Skip to content

Commit 46ea05f

Browse files
authored
fix: enforce RFC 3987 IRI rules and close IDNA, WPT, and BCV gaps (#71)
PR: #71
1 parent d00b6b9 commit 46ea05f

24 files changed

Lines changed: 1716 additions & 64 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,9 @@ kuri implements the standards below; per-standard conformance is measured in [Co
473473
| [RFC 3987][rfc3987] | IRIs — one-way `Iri` mapping, not a validating parser* | Supported | Default |
474474
| [WHATWG URL Standard][whatwg-url] | the `Url` model — parser, special schemes, canonical serialization | Conformant | Default |
475475

476-
\* kuri maps IRIs to URIs one-way (RFC 3987 §3.1/§3.2); it does not implement §4 validation (bidi, repertoire checks).
476+
\* kuri maps IRIs to URIs one-way (RFC 3987 §3.1/§3.2), rejecting a §2.2 `ucschar`/`iprivate`
477+
repertoire violation and a §4.1 bidi formatting character; it does not enforce §4.2's per-component
478+
directionality restriction, which the RFC itself states as a SHOULD, not a MUST.
477479

478480
**Hosts, internationalization, and IP addresses**
479481

@@ -491,7 +493,7 @@ kuri implements the standards below; per-standard conformance is measured in [Co
491493

492494
| Standard | Governs | Compliance | Support |
493495
|------------------------------------------------|----------------------------------------------|------------|---------|
494-
| [`application/x-www-form-urlencoded`][formenc] | Form-encoded query parsing and serialization | Supported | Default |
496+
| [`application/x-www-form-urlencoded`][formenc] | Form-encoded query parsing and serialization | Conformant | Default |
495497

496498
**Notation and requirement levels**
497499

@@ -548,6 +550,7 @@ Behavior is checked against the conformance corpora the standards ship with:
548550
| IDNA `IdnaTestV2` + `toascii` | 2756 / 2760 |
549551
| Unicode `NormalizationTest.txt` (NFC) | 20 034 / 20 034 |
550552
| RFC 3986 §5.4 reference resolution | all rows |
553+
| WHATWG `urlencoded-parser.any.js` — form parsing | 35 / 35 |
551554

552555
Any case that does not yet pass is pinned in a checked-in known-failures baseline; the build fails if a passing case
553556
later regresses.

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ val generators: List<Triple<String, String, String>> =
7575
"percent-encoding",
7676
"Regenerate the WPT percent-encoding conformance fixture from the vendored ada WPT corpus.",
7777
),
78+
Triple(
79+
"generateUrlEncodedTestData",
80+
"urlencoded",
81+
"Regenerate the WPT urlencoded-parser conformance fixture from tools/urlencoded/urlencoded-parser.json.",
82+
),
7883
)
7984

8085
val codegenTasks: List<TaskProvider<Exec>> =

docs/idna-unicode-update.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ The generators are Go programs under `tools/` (module
3030
`github.com/dexpace/kuri/tools`), driven by `tools/internal/codegen`. That
3131
package reads raw UCD via `tools/internal/ucd` and derives the conformance
3232
baseline via `tools/internal/idnaref`, a faithful Go port of kuri's runtime IDNA
33-
engine.
33+
engine — a second implementation of kuri's own algorithm, not an independent
34+
oracle. WPT (below) is the independent oracle; idnaref only narrows the
35+
known-failures residual within whatever WPT doesn't already exercise.
3436

3537
## Source of truth
3638

gradle/libs.versions.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ kover = "0.9.8"
77
binary-compat = "0.18.1"
88
dokka = "2.0.0"
99
maven-publish = "0.30.0"
10+
icu4j = "77.1"
11+
# Pinned to match the ASM version binary-compatibility-validator 0.18.1 wires into its own
12+
# jvm/native apiBuild classpath internally, so the hand-wired android apiBuild leg (build.gradle.kts)
13+
# parses class files with the same ASM release the rest of the apiCheck gate uses.
14+
asm = "9.6"
1015

1116
[libraries]
1217
# kotlin-test is pulled in via the kotlin("test") MPP helper; listed for reference.
1318
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
19+
# jvmTest-only: an independent third-party UTS-46 implementation to cross-check kuri's IDNA
20+
# engine against (see IdnaIcu4jDifferentialTest). No multiplatform artifact exists, so this
21+
# must never be referenced from commonMain/commonTest.
22+
icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
23+
# Runtime classpath for the manually wired android apiBuild/apiCheck tasks (build.gradle.kts).
24+
asm-core = { module = "org.ow2.asm:asm", version.ref = "asm" }
25+
asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" }
26+
kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" }
1427

1528
[plugins]
1629
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

kuri/api/android/kuri.api

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,34 @@ public final class org/dexpace/kuri/error/UriParseError$InvalidScheme : org/dexp
362362
public fun toString ()Ljava/lang/String;
363363
}
364364

365+
public final class org/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter : org/dexpace/kuri/error/UriParseError {
366+
public fun <init> (II)V
367+
public final fun component1 ()I
368+
public final fun component2 ()I
369+
public final fun copy (II)Lorg/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter;
370+
public static synthetic fun copy$default (Lorg/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter;IIILjava/lang/Object;)Lorg/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter;
371+
public fun equals (Ljava/lang/Object;)Z
372+
public final fun getAt ()I
373+
public final fun getCodePoint ()I
374+
public fun getMessage ()Ljava/lang/String;
375+
public fun hashCode ()I
376+
public fun toString ()Ljava/lang/String;
377+
}
378+
379+
public final class org/dexpace/kuri/error/UriParseError$IriInvalidCodePoint : org/dexpace/kuri/error/UriParseError {
380+
public fun <init> (II)V
381+
public final fun component1 ()I
382+
public final fun component2 ()I
383+
public final fun copy (II)Lorg/dexpace/kuri/error/UriParseError$IriInvalidCodePoint;
384+
public static synthetic fun copy$default (Lorg/dexpace/kuri/error/UriParseError$IriInvalidCodePoint;IIILjava/lang/Object;)Lorg/dexpace/kuri/error/UriParseError$IriInvalidCodePoint;
385+
public fun equals (Ljava/lang/Object;)Z
386+
public final fun getAt ()I
387+
public final fun getCodePoint ()I
388+
public fun getMessage ()Ljava/lang/String;
389+
public fun hashCode ()I
390+
public fun toString ()Ljava/lang/String;
391+
}
392+
365393
public final class org/dexpace/kuri/error/UriParseError$MissingScheme : org/dexpace/kuri/error/UriParseError {
366394
public static final field INSTANCE Lorg/dexpace/kuri/error/UriParseError$MissingScheme;
367395
public fun equals (Ljava/lang/Object;)Z
@@ -376,6 +404,7 @@ public final class org/dexpace/kuri/error/UriSyntaxException : java/lang/Illegal
376404

377405
public final class org/dexpace/kuri/error/ValidationError : java/lang/Enum {
378406
public static final field BACKSLASH_AS_SOLIDUS Lorg/dexpace/kuri/error/ValidationError;
407+
public static final field INVALID_CREDENTIALS Lorg/dexpace/kuri/error/ValidationError;
379408
public static final field INVALID_URL_UNIT Lorg/dexpace/kuri/error/ValidationError;
380409
public static final field LEADING_OR_TRAILING_C0_CONTROL_OR_SPACE Lorg/dexpace/kuri/error/ValidationError;
381410
public static final field MISSING_AUTHORITY_SLASHES Lorg/dexpace/kuri/error/ValidationError;
@@ -519,6 +548,7 @@ public final class org/dexpace/kuri/query/QueryParameters : java/lang/Iterable,
519548
public static final fun parse (Ljava/lang/String;)Lorg/dexpace/kuri/query/QueryParameters;
520549
public static final fun parseForm (Ljava/lang/String;)Lorg/dexpace/kuri/query/QueryParameters;
521550
public final fun size ()I
551+
public final fun split (Ljava/lang/String;C)Ljava/util/List;
522552
public final fun toFormUrlEncoded ()Ljava/lang/String;
523553
public final fun toMap ()Ljava/util/Map;
524554
public final fun toQueryString ()Ljava/lang/String;

kuri/api/jvm/kuri.api

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,34 @@ public final class org/dexpace/kuri/error/UriParseError$InvalidScheme : org/dexp
373373
public fun toString ()Ljava/lang/String;
374374
}
375375

376+
public final class org/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter : org/dexpace/kuri/error/UriParseError {
377+
public fun <init> (II)V
378+
public final fun component1 ()I
379+
public final fun component2 ()I
380+
public final fun copy (II)Lorg/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter;
381+
public static synthetic fun copy$default (Lorg/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter;IIILjava/lang/Object;)Lorg/dexpace/kuri/error/UriParseError$IriBidiFormattingCharacter;
382+
public fun equals (Ljava/lang/Object;)Z
383+
public final fun getAt ()I
384+
public final fun getCodePoint ()I
385+
public fun getMessage ()Ljava/lang/String;
386+
public fun hashCode ()I
387+
public fun toString ()Ljava/lang/String;
388+
}
389+
390+
public final class org/dexpace/kuri/error/UriParseError$IriInvalidCodePoint : org/dexpace/kuri/error/UriParseError {
391+
public fun <init> (II)V
392+
public final fun component1 ()I
393+
public final fun component2 ()I
394+
public final fun copy (II)Lorg/dexpace/kuri/error/UriParseError$IriInvalidCodePoint;
395+
public static synthetic fun copy$default (Lorg/dexpace/kuri/error/UriParseError$IriInvalidCodePoint;IIILjava/lang/Object;)Lorg/dexpace/kuri/error/UriParseError$IriInvalidCodePoint;
396+
public fun equals (Ljava/lang/Object;)Z
397+
public final fun getAt ()I
398+
public final fun getCodePoint ()I
399+
public fun getMessage ()Ljava/lang/String;
400+
public fun hashCode ()I
401+
public fun toString ()Ljava/lang/String;
402+
}
403+
376404
public final class org/dexpace/kuri/error/UriParseError$MissingScheme : org/dexpace/kuri/error/UriParseError {
377405
public static final field INSTANCE Lorg/dexpace/kuri/error/UriParseError$MissingScheme;
378406
public fun equals (Ljava/lang/Object;)Z

kuri/api/kuri.klib.api

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,38 @@ sealed interface org.dexpace.kuri.error/UriParseError { // org.dexpace.kuri.erro
160160
final fun toString(): kotlin/String // org.dexpace.kuri.error/UriParseError.InvalidScheme.toString|toString(){}[0]
161161
}
162162

163+
final class IriBidiFormattingCharacter : org.dexpace.kuri.error/UriParseError { // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter|null[0]
164+
constructor <init>(kotlin/Int, kotlin/Int) // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.<init>|<init>(kotlin.Int;kotlin.Int){}[0]
165+
166+
final val at // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.at|{}at[0]
167+
final fun <get-at>(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.at.<get-at>|<get-at>(){}[0]
168+
final val codePoint // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.codePoint|{}codePoint[0]
169+
final fun <get-codePoint>(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.codePoint.<get-codePoint>|<get-codePoint>(){}[0]
170+
171+
final fun component1(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.component1|component1(){}[0]
172+
final fun component2(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.component2|component2(){}[0]
173+
final fun copy(kotlin/Int = ..., kotlin/Int = ...): org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.copy|copy(kotlin.Int;kotlin.Int){}[0]
174+
final fun equals(kotlin/Any?): kotlin/Boolean // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.equals|equals(kotlin.Any?){}[0]
175+
final fun hashCode(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.hashCode|hashCode(){}[0]
176+
final fun toString(): kotlin/String // org.dexpace.kuri.error/UriParseError.IriBidiFormattingCharacter.toString|toString(){}[0]
177+
}
178+
179+
final class IriInvalidCodePoint : org.dexpace.kuri.error/UriParseError { // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint|null[0]
180+
constructor <init>(kotlin/Int, kotlin/Int) // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.<init>|<init>(kotlin.Int;kotlin.Int){}[0]
181+
182+
final val at // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.at|{}at[0]
183+
final fun <get-at>(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.at.<get-at>|<get-at>(){}[0]
184+
final val codePoint // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.codePoint|{}codePoint[0]
185+
final fun <get-codePoint>(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.codePoint.<get-codePoint>|<get-codePoint>(){}[0]
186+
187+
final fun component1(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.component1|component1(){}[0]
188+
final fun component2(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.component2|component2(){}[0]
189+
final fun copy(kotlin/Int = ..., kotlin/Int = ...): org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.copy|copy(kotlin.Int;kotlin.Int){}[0]
190+
final fun equals(kotlin/Any?): kotlin/Boolean // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.equals|equals(kotlin.Any?){}[0]
191+
final fun hashCode(): kotlin/Int // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.hashCode|hashCode(){}[0]
192+
final fun toString(): kotlin/String // org.dexpace.kuri.error/UriParseError.IriInvalidCodePoint.toString|toString(){}[0]
193+
}
194+
163195
final object EmptyHost : org.dexpace.kuri.error/UriParseError { // org.dexpace.kuri.error/UriParseError.EmptyHost|null[0]
164196
final fun equals(kotlin/Any?): kotlin/Boolean // org.dexpace.kuri.error/UriParseError.EmptyHost.equals|equals(kotlin.Any?){}[0]
165197
final fun hashCode(): kotlin/Int // org.dexpace.kuri.error/UriParseError.EmptyHost.hashCode|hashCode(){}[0]

kuri/build.gradle.kts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.vanniktech.maven.publish.JavadocJar
77
import com.vanniktech.maven.publish.KotlinMultiplatform
88
import com.vanniktech.maven.publish.SonatypeHost
99
import kotlinx.kover.gradle.plugin.dsl.CoverageUnit
10+
import kotlinx.validation.KotlinApiBuildTask
11+
import kotlinx.validation.KotlinApiCompareTask
1012
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
1113
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
1214

@@ -160,6 +162,10 @@ kotlin {
160162
// on the Java test's compile classpath, at the version the Kotlin release was built for.
161163
jvmTest.dependencies {
162164
implementation(kotlin("test-junit"))
165+
// Independent third-party UTS-46 oracle for IdnaIcu4jDifferentialTest (issue #66):
166+
// icu4j has no multiplatform artifact, so it is scoped to the JVM test classpath only
167+
// and must never be referenced from commonMain/commonTest.
168+
implementation(libs.icu4j)
163169
}
164170
// Instrumented tests run under AndroidJUnitRunner (JUnit4), so bind kotlin.test to JUnit4
165171
// and pull in the AndroidX test runtime that provides the runner.
@@ -242,6 +248,72 @@ apiValidation {
242248
}
243249
}
244250

251+
// binary-compatibility-validator's built-in multiplatform auto-configuration only recognizes an
252+
// Android target compilation literally named "release" (the classic AGP `com.android.library` +
253+
// `androidTarget()` build-variant naming). The AGP KMP library plugin used here
254+
// (`com.android.kotlin.multiplatform.library`) is single-variant and names its production
255+
// compilation "main" instead, so that auto-configuration never matches it, `androidApiBuild`/
256+
// `androidApiCheck`/`androidApiDump` tasks never get created, and `apiCheck`/`apiDump` silently
257+
// skip the android surface entirely (see the upstream gap tracked as KT-71172; verified locally
258+
// by inspecting the plugin's target-matching source — it hasn't changed as of BCV 0.18.1). That
259+
// silent skip is exactly how `kuri/api/android/kuri.api` went stale: nothing ever regenerated or
260+
// checked it. Wire an equivalent android leg by hand using BCV's own public task classes,
261+
// `KotlinApiBuildTask`/`KotlinApiCompareTask`, mirroring the classpath and directory layout
262+
// (`api/android/kuri.api`) its Kotlin-target legs already use, and hook the result into the same
263+
// `apiDump`/`apiCheck` entry points so android gets no special-cased invocation.
264+
val androidAbiRuntimeClasspath: NamedDomainObjectProvider<Configuration> =
265+
configurations.register("androidAbiRuntimeClasspath") {
266+
description = "Runtime classpath for the manually wired android apiBuild/apiCheck tasks."
267+
isCanBeConsumed = false
268+
isCanBeResolved = true
269+
isVisible = false
270+
}
271+
272+
dependencies.apply {
273+
add(androidAbiRuntimeClasspath.name, libs.asm.core.get())
274+
add(androidAbiRuntimeClasspath.name, libs.asm.tree.get())
275+
add(
276+
androidAbiRuntimeClasspath.name,
277+
libs.kotlin.metadata.jvm
278+
.get(),
279+
)
280+
}
281+
282+
val androidApiBuild: TaskProvider<KotlinApiBuildTask> =
283+
tasks.register<KotlinApiBuildTask>("androidApiBuild") {
284+
group = "verification"
285+
description = "Builds the Kotlin API dump for the android target's 'main' compilation of kuri."
286+
inputClassesDirs.from(tasks.named("compileAndroidMain").map { it.outputs.files })
287+
inputDependencies.from(
288+
kotlin.targets
289+
.getByName("android")
290+
.compilations
291+
.getByName("main")
292+
.compileDependencyFiles,
293+
)
294+
outputApiFile.set(layout.buildDirectory.file("kotlin/abi-android/kuri.api"))
295+
runtimeClasspath.from(androidAbiRuntimeClasspath)
296+
}
297+
298+
val androidApiCheck: TaskProvider<KotlinApiCompareTask> =
299+
tasks.register<KotlinApiCompareTask>("androidApiCheck") {
300+
group = "verification"
301+
description = "Checks the android target's public API against api/android/kuri.api."
302+
projectApiFile.set(layout.projectDirectory.file("api/android/kuri.api"))
303+
generatedApiFile.set(androidApiBuild.flatMap { it.outputApiFile })
304+
}
305+
306+
val androidApiDump: TaskProvider<Copy> =
307+
tasks.register<Copy>("androidApiDump") {
308+
group = "other"
309+
description = "Copies the android target's generated API dump into api/android/kuri.api."
310+
from(androidApiBuild.flatMap { it.outputApiFile })
311+
into(layout.projectDirectory.dir("api/android"))
312+
}
313+
314+
tasks.named("apiCheck") { dependsOn(androidApiCheck) }
315+
tasks.named("apiDump") { dependsOn(androidApiDump) }
316+
245317
// Publish every Kotlin Multiplatform target (plus the root `kotlinMultiplatform` publication) to Maven
246318
// Central through the Central Portal. The vanniktech plugin wires the per-target publications, a
247319
// Dokka-generated Javadoc jar and GPG signing; coordinates derive from the module `group` and the

0 commit comments

Comments
 (0)