Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 5 additions & 16 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/host/HostParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import org.dexpace.kuri.error.ParseResult
import org.dexpace.kuri.error.UriParseError
import org.dexpace.kuri.idna.Idna
import org.dexpace.kuri.percent.PercentCodec
import org.dexpace.kuri.text.isAsciiAlphanumeric
import org.dexpace.kuri.text.hasPercentHexPairAt
import org.dexpace.kuri.text.isAsciiHexDigit
import org.dexpace.kuri.text.isUnreserved

/** Opening delimiter of an IP-literal; a host beginning with it dispatches to §7.2/§7.8. */
private const val BRACKET_OPEN: Char = '['
Expand All @@ -31,9 +32,6 @@ private const val MIN_IP_FUTURE_DOT_INDEX: Int = 2
/** The `.` separating the IPvFuture version digits from their payload. */
private const val IP_FUTURE_DOT: Char = '.'

/** RFC 3986 `unreserved` symbol characters beyond ALPHA/DIGIT (`ALPHA / DIGIT / "-._~"`). */
private const val UNRESERVED_SYMBOLS: String = "-._~"

/** RFC 3986 `sub-delims` set (`"!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="`). */
private const val SUB_DELIMS: String = "!\$&'()*+,;="

Expand Down Expand Up @@ -256,8 +254,8 @@ internal object HostParser {
require(index in input.indices) { "index out of range: $index" }
val c = input[index]
return when {
c == '%' && isPctEncodedAt(input, index) -> PCT_TRIPLET_LENGTH
isUnreserved(c) || isSubDelim(c) -> 1
c == '%' && hasPercentHexPairAt(input, index) -> PCT_TRIPLET_LENGTH
c.isUnreserved() || isSubDelim(c) -> 1
else -> 0
}
}
Expand All @@ -274,18 +272,9 @@ internal object HostParser {
private fun bracketError(input: String): ParseResult<Host> =
ParseResult.Err(UriParseError.InvalidHost(input, HostError.Ipv6Malformed))

/** True when a `%` at [index] introduces a valid `pct-encoded` triplet (`%` then two HEXDIG). */
private fun isPctEncodedAt(
input: String,
index: Int,
): Boolean = index + 2 < input.length && input[index + 1].isAsciiHexDigit() && input[index + 2].isAsciiHexDigit()

/** True when [c] is an RFC 3986 `unreserved` code point. */
private fun isUnreserved(c: Char): Boolean = c.isAsciiAlphanumeric() || c in UNRESERVED_SYMBOLS

/** True when [c] is an RFC 3986 `sub-delims` code point. */
private fun isSubDelim(c: Char): Boolean = c in SUB_DELIMS

/** True when [c] is admissible in an IPvFuture payload (`unreserved / sub-delims / ":"`). */
private fun isIpFuturePayloadChar(c: Char): Boolean = isUnreserved(c) || isSubDelim(c) || c == ':'
private fun isIpFuturePayloadChar(c: Char): Boolean = c.isUnreserved() || isSubDelim(c) || c == ':'
}
25 changes: 4 additions & 21 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ package org.dexpace.kuri.host
import org.dexpace.kuri.error.HostError
import org.dexpace.kuri.error.ParseResult
import org.dexpace.kuri.error.UriParseError
import org.dexpace.kuri.text.hasPercentHexPairAt
import org.dexpace.kuri.text.hexDigitToInt
import org.dexpace.kuri.text.isAsciiAlphanumeric
import org.dexpace.kuri.text.isAsciiDigit
import org.dexpace.kuri.text.isAsciiHexDigit
import org.dexpace.kuri.text.isUnreserved

/** Number of 16-bit pieces in an IPv6 address (§7.2). */
private const val PIECE_COUNT: Int = 8
Expand Down Expand Up @@ -54,9 +55,6 @@ private const val ZONE_INTRODUCER: String = "%25"
/** Length of a `pct-encoded` triplet, `%HH`, consumed whole by the `ZoneID` scanner ([HOST-18]). */
private const val PCT_TRIPLET_LENGTH: Int = 3

/** RFC 3986 `unreserved` symbol characters beyond ALPHA/DIGIT (`ALPHA / DIGIT / "-._~"`). */
private const val ZONE_UNRESERVED_SYMBOLS: String = "-._~"

/**
* The IPv6 literal parser and RFC 5952 serializer (SPEC §7.2).
*
Expand Down Expand Up @@ -161,27 +159,12 @@ internal object Ipv6 {
require(index in rawZone.indices) { "zone index out of range: $index" }
val c = rawZone[index]
return when {
c == '%' && isPctTriplet(rawZone, index) -> PCT_TRIPLET_LENGTH
isZoneUnreserved(c) -> 1
c == '%' && hasPercentHexPairAt(rawZone, index) -> PCT_TRIPLET_LENGTH
c.isUnreserved() -> 1
else -> 0
}
}

/** True when [c] is an RFC 3986 `unreserved` code point (`ALPHA / DIGIT / "-._~"`). */
private fun isZoneUnreserved(c: Char): Boolean = c.isAsciiAlphanumeric() || c in ZONE_UNRESERVED_SYMBOLS

/** True when a `%` at [index] introduces a valid `pct-encoded` triplet (`%` then two HEXDIG). */
private fun isPctTriplet(
rawZone: String,
index: Int,
): Boolean {
require(index in rawZone.indices) { "pct index out of range: $index" }
require(rawZone[index] == '%') { "pct triplet must start with '%'" }
return index + 2 < rawZone.length &&
rawZone[index + 1].isAsciiHexDigit() &&
rawZone[index + 2].isAsciiHexDigit()
}

/**
* Serializes eight 16-bit pieces to their RFC 5952 canonical form ([HOST-15],
* [HOST-16]): lowercase hex, suppressed leading zeros, and the single longest
Expand Down
18 changes: 6 additions & 12 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ 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.NON_ASCII_MIN
import org.dexpace.kuri.text.appendCodePoint
import org.dexpace.kuri.text.asciiLowercased
import org.dexpace.kuri.text.codePointsOf
import org.dexpace.kuri.text.isAllAscii

/** ACE prefix marking a Punycode-encoded (`xn--`) label (RFC 5890 §2.3.2.1). */
private const val ACE_PREFIX: String = "xn--"
Expand All @@ -21,12 +24,6 @@ private const val ACE_PREFIX_LENGTH: Int = 4
/** Label separator inside a domain (U+002E FULL STOP); also the join separator. */
private const val LABEL_SEPARATOR: String = "."

/** First non-ASCII code point; a label with any point `>=` this needs ACE encoding. */
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

/**
* 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`,
Expand Down Expand Up @@ -88,7 +85,7 @@ internal object Idna {
*/
internal fun domainToAsciiForUrl(domain: String): ParseResult<String> {
val result =
if (isAsciiString(domain)) {
if (domain.isAllAscii()) {
asciiLowercase(domain)
} else {
when (val ascii = domainToAscii(domain)) {
Expand Down Expand Up @@ -240,7 +237,7 @@ internal object Idna {
label: String,
domain: String,
): ParseResult<String> {
if (label.all { it.code < NON_ASCII_MIN }) {
if (label.isAllAscii()) {
return ParseResult.Ok(label)
}
return when (val encoded = Punycode.encode(label)) {
Expand All @@ -264,12 +261,9 @@ internal object Idna {
private fun idnaError(domain: String): ParseResult.Err =
ParseResult.Err(UriParseError.InvalidHost(domain, HostError.IdnaFailed))

/** True when every UTF-16 unit of [s] is ASCII (`< 0x80`); an ASCII domain skips UTS-46 ToASCII. */
private fun isAsciiString(s: String): Boolean = s.all { it.code < NON_ASCII_MIN }

/** ASCII-lowercases [s] (`A`–`Z` -> `a`–`z`), leaving every other code unit unchanged. */
private fun asciiLowercase(s: String): String =
buildString(s.length) {
for (c in s) append(if (c in 'A'..'Z') c + ASCII_CASE_OFFSET else c)
for (c in s) append(c.asciiLowercased())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import org.dexpace.kuri.percent.PercentCodec
import org.dexpace.kuri.percent.PercentEncodeSets
import org.dexpace.kuri.scheme.Scheme
import org.dexpace.kuri.scheme.schemeColonIndex

/** First non-ASCII code point; a host carrying any point `>=` this is routed through IDNA. */
private const val NON_ASCII_MIN: Int = 0x80
import org.dexpace.kuri.text.isAllAscii

/** The authority-introducing `//` and the number of code units it spans (RFC 3986 §3.2). */
private const val DOUBLE_SLASH: String = "//"
Expand Down Expand Up @@ -67,7 +65,7 @@ internal object IriMapping {
* UTS-46 [Idna.domainToAscii], whose failure becomes the conversion failure.
*/
private fun mapHost(host: String): ParseResult<String> {
val verbatim = host.startsWith('[') || isAllAscii(host)
val verbatim = host.startsWith('[') || host.isAllAscii()
return when {
verbatim -> ParseResult.Ok(host)
else -> Idna.domainToAscii(host)
Expand Down Expand Up @@ -190,9 +188,6 @@ internal object IriMapping {
return Authority(userInfo, host, port)
}

/** True when every UTF-16 unit of [value] is ASCII (`< 0x80`); an ASCII host skips IDNA ToASCII. */
private fun isAllAscii(value: String): Boolean = value.all { it.code < NON_ASCII_MIN }

/** The located structural components of an IRI, each carried raw (pre-transform). */
private data class IriParts(
val scheme: String?,
Expand Down
17 changes: 4 additions & 13 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import org.dexpace.kuri.error.UriParseError
import org.dexpace.kuri.host.Host
import org.dexpace.kuri.host.HostParser
import org.dexpace.kuri.scheme.Scheme
import org.dexpace.kuri.scheme.isSchemeContinuationChar
import org.dexpace.kuri.scheme.schemeColonIndex
import org.dexpace.kuri.text.hasPercentHexPairAt
import org.dexpace.kuri.text.isAsciiAlpha
import org.dexpace.kuri.text.isAsciiAlphanumeric
import org.dexpace.kuri.text.isAsciiDigit
import org.dexpace.kuri.text.isAsciiHexDigit
import org.dexpace.kuri.text.isC0Control

/** Sentinel returned by the index scanners when no offending position exists. */
Expand Down Expand Up @@ -168,12 +168,9 @@ internal object UriParser {
when {
candidate.isEmpty() -> 0
!candidate[0].isAsciiAlpha() -> 0
else -> (1 until candidate.length).firstOrNull { !isSchemeTailChar(candidate[it]) } ?: 0
else -> (1 until candidate.length).firstOrNull { !isSchemeContinuationChar(candidate[it]) } ?: 0
}

/** True when [c] may follow the first code point of a scheme (§6.2; ALPHA / DIGIT / `+` / `-` / `.`). */
private fun isSchemeTailChar(c: Char): Boolean = c.isAsciiAlphanumeric() || c == '+' || c == '-' || c == '.'

/** Assembles the [Sections] record, splitting the post-scheme remainder into authority and path. */
private fun buildSections(
frag: FragmentSplit,
Expand Down Expand Up @@ -410,19 +407,13 @@ internal object UriParser {
var index = 0
var bad = NOT_FOUND
while (index < text.length && bad == NOT_FOUND) {
if (text[index] == '%' && !isHexPairAt(text, index)) bad = index
if (text[index] == '%' && !hasPercentHexPairAt(text, index)) bad = index
index++
}
check(bad == NOT_FOUND || bad in text.indices) { "bad percent index out of range: $bad" }
return bad
}

/** True when a `%` at [index] is followed by two in-bounds ASCII hex digits. */
private fun isHexPairAt(
text: String,
index: Int,
): Boolean = index + 2 < text.length && text[index + 1].isAsciiHexDigit() && text[index + 2].isAsciiHexDigit()

/** The smaller non-negative index of [a] and [b], or the present one, or [NOT_FOUND] when both are absent. */
private fun earliest(
a: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import org.dexpace.kuri.error.ValidationError
import org.dexpace.kuri.percent.PercentCodec
import org.dexpace.kuri.percent.PercentEncodeSets
import org.dexpace.kuri.scheme.Scheme
import org.dexpace.kuri.scheme.isSchemeContinuationChar
import org.dexpace.kuri.text.asciiLowercased
import org.dexpace.kuri.text.isAsciiAlpha
import org.dexpace.kuri.text.isAsciiAlphanumeric

/** The canonical `file` scheme, special-cased throughout the §8 state machine. */
private const val FILE_SCHEME: String = "file"
Expand Down Expand Up @@ -38,7 +39,7 @@ internal object UrlParserStates {
internal fun schemeStartState(state: UrlParserState): UrlTransition {
val c = state.currentChar()
if (c != null && c.isAsciiAlpha()) {
state.buffer.append(c.lowercaseChar())
state.buffer.append(c.asciiLowercased())
return UrlTransition.Advance(UrlState.SCHEME)
}
return UrlTransition.Reconsume(UrlState.NO_SCHEME)
Expand All @@ -54,18 +55,15 @@ internal object UrlParserStates {
internal fun schemeState(state: UrlParserState): UrlTransition {
val c = state.currentChar()
return when {
c != null && isSchemeTailChar(c) -> {
state.buffer.append(c.lowercaseChar())
c != null && isSchemeContinuationChar(c) -> {
state.buffer.append(c.asciiLowercased())
UrlTransition.Advance(UrlState.SCHEME)
}
c == ':' -> schemeColon(state)
else -> schemeRestart(state)
}
}

/** True when [c] may continue a scheme: ASCII alphanumeric, `+`, `-`, or `.` ([PARSE-14]). */
private fun isSchemeTailChar(c: Char): Boolean = c.isAsciiAlphanumeric() || c == '+' || c == '-' || c == '.'

/** Resets the scan to NO_SCHEME from the first code point ([PARSE-16]; the only `pos` reset). */
private fun schemeRestart(state: UrlParserState): UrlTransition {
state.buffer.setLength(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,21 @@
*/
package org.dexpace.kuri.percent

import org.dexpace.kuri.text.hexDigitToInt
import org.dexpace.kuri.text.isAsciiHexDigit
import org.dexpace.kuri.text.NON_ASCII_MIN
import org.dexpace.kuri.text.hasPercentHexPairAt
import org.dexpace.kuri.text.isSurrogatePairAt
import org.dexpace.kuri.text.percentByteAt
import org.dexpace.kuri.text.toPercentEncodedByte

/** Mask isolating the low eight bits of an `Int`, turning a signed `Byte` into an octet `0..255`. */
private const val BYTE_MASK: Int = 0xFF

/** Bit width of one hex nibble; shifting the high hex digit left by this composes a triplet octet. */
private const val HEX_SHIFT: Int = 4

/** Length of a percent-encoded triplet, `%XX` (SPEC §5). */
private const val TRIPLET_LENGTH: Int = 3

/** U+FFFD, substituted for a lone surrogate that cannot be UTF-8 encoded ([PCT-21]). */
private const val REPLACEMENT_CHARACTER: String = "\uFFFD"

/** First non-ASCII octet value; a triplet run decodes to display text only when every octet is `>=` this. */
private const val NON_ASCII_MIN: Int = 0x80

/**
* Percent-encoding ([encode], SPEC §5.2) and percent-decoding ([decode], SPEC §5.3).
*
Expand Down Expand Up @@ -99,7 +94,7 @@ internal object PercentCodec {
val c = input[i]
i =
when {
c == '%' && hasHexPairAt(input, i) -> appendTripletRun(out, input, i)
c == '%' && hasPercentHexPairAt(input, i) -> appendTripletRun(out, input, i)
plusAsSpace && c == '+' -> appendLiteral(out, ' ', i)
else -> appendLiteral(out, c, i)
}
Expand Down Expand Up @@ -129,7 +124,7 @@ internal object PercentCodec {
val c = input[i]
i =
when {
c == '%' && hasHexPairAt(input, i) -> appendNonAsciiRun(out, input, i)
c == '%' && hasPercentHexPairAt(input, i) -> appendNonAsciiRun(out, input, i)
else -> appendLiteral(out, c, i)
}
}
Expand Down Expand Up @@ -165,7 +160,7 @@ internal object PercentCodec {
var source = start
var target = 0
while (target < count) {
bytes[target] = tripletByte(input, source).toByte()
bytes[target] = percentByteAt(input, source).toByte()
source += TRIPLET_LENGTH
target++
}
Expand All @@ -185,7 +180,7 @@ internal object PercentCodec {
private fun isNonAsciiTripletAt(
input: String,
i: Int,
): Boolean = input[i] == '%' && hasHexPairAt(input, i) && tripletByte(input, i) >= NON_ASCII_MIN
): Boolean = input[i] == '%' && hasPercentHexPairAt(input, i) && percentByteAt(input, i) >= NON_ASCII_MIN

/**
* Returns the index of the first code unit that requires encoding under [set], or `-1` when
Expand Down Expand Up @@ -256,7 +251,7 @@ internal object PercentCodec {
): Int {
require(input[start] == '%') { "triplet run must start at a percent sign" }
var end = start
while (end < input.length && input[end] == '%' && hasHexPairAt(input, end)) {
while (end < input.length && input[end] == '%' && hasPercentHexPairAt(input, end)) {
end += TRIPLET_LENGTH
}
val count = (end - start) / TRIPLET_LENGTH
Expand All @@ -265,32 +260,11 @@ internal object PercentCodec {
var source = start
var target = 0
while (target < count) {
bytes[target] = tripletByte(input, source).toByte()
bytes[target] = percentByteAt(input, source).toByte()
source += TRIPLET_LENGTH
target++
}
out.append(bytes.decodeToString())
return end
}

/** True when [i] holds a `%` followed by two ASCII hex digits ([PCT-22], [PCT-24]). */
private fun hasHexPairAt(
input: String,
i: Int,
): Boolean {
val high = i + 1
val low = i + 2
return low < input.length && input[high].isAsciiHexDigit() && input[low].isAsciiHexDigit()
}

/** Decodes the two hex digits following the `%` at [i] into one octet `0..255`. */
private fun tripletByte(
input: String,
i: Int,
): Int {
val high = hexDigitToInt(input[i + 1])
val low = hexDigitToInt(input[i + 2])
check(high >= 0 && low >= 0) { "triplet bytes must follow a verified hex pair" }
return (high shl HEX_SHIFT) or low
}
}
Loading
Loading