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
3 changes: 3 additions & 0 deletions kuri/api/jvm/kuri.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions kuri/api/kuri.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final enum class org.dexpace.kuri.error/HostError : kotlin/Enum<org.dexpace.kuri

final enum class org.dexpace.kuri.error/ValidationError : kotlin/Enum<org.dexpace.kuri.error/ValidationError> { // 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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ 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). 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,
}
8 changes: 7 additions & 1 deletion kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 33 additions & 14 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String>(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)
}

/**
Expand Down
81 changes: 56 additions & 25 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -136,8 +138,10 @@ internal object Resolver {
reference: ParsedComponents,
): ParseResult<ParsedComponents> {
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. */
Expand All @@ -153,46 +157,48 @@ internal object Resolver {
private fun transformReferences(
base: UriParts,
ref: UriParts,
): UriParts {
): ParseResult<UriParts> {
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<UriParts> =
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)
}

/** §5.2.2 with neither reference scheme nor authority: the base authority is inherited. */
private fun resolveNoAuthority(
base: UriParts,
ref: UriParts,
): UriParts {
val (path, query) = resolvePathAndQuery(base, ref)
return UriParts(base.scheme, base.authority, path, query, null)
}
): ParseResult<UriParts> =
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<String, String?> =
): ParseResult<Pair<String, String?>> =
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 -----------------------------------------------------------------------
Expand Down Expand Up @@ -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<String> {
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. */
Expand Down Expand Up @@ -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" }
Expand All @@ -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<String> {
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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]) -------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ internal object UriNormalizer {
Host.Empty -> host
}

/** Lowercases reg-name letters ([NORM-5]) then applies the shared triplet rules ([NORM-7]). */
private fun normalizeRegName(value: String): String = normalizeText(lowercaseRegNameLetters(value))
/** 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]. */
private fun lowercaseRegNameLetters(text: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading