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
45 changes: 37 additions & 8 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,11 @@ public class Uri internal constructor(
/**
* The RFC 3986 `userinfo` component, reconstructed from the parsed credentials and preserved verbatim.
*
* `null` when the URI has no authority or no userinfo; otherwise `user` or `user:password`. An empty
* userinfo (e.g. `//@h`) carries no credentials and is therefore reported as absent (`null`).
* `null` when the URI has no authority or no userinfo at all — no `@` in the source; otherwise the
* verbatim text between the authority's `//` and its `@`: `""` for a present-but-empty userinfo (e.g.
* `//@h`), `user` when no `:` was present, or `user:password` (either half possibly empty) when a `:`
* was. The absent-vs-present-empty and no-colon-vs-empty-password distinctions are preserved exactly
* as parsed ([MODEL-11], [MODEL-12]).
*/
@get:JvmName("userInfo")
public val userInfo: String?
Expand Down Expand Up @@ -548,12 +551,19 @@ public class Uri internal constructor(
/** The hash of the canonical [uriString], consistent with [equals]. */
override fun hashCode(): Int = uriString.hashCode()

/** Reconstructs the raw `userinfo` from the decoded credentials, or `null` when none is present. */
/**
* Reconstructs the raw `userinfo` from the decoded credentials, or `null` when none is present.
*
* Presence is tracked by nullability, not emptiness ([MODEL-11], [MODEL-12]): a `username` of
* `""` (an `@` present with nothing before it) still yields `""`, not `null`, and a `password` of
* `""` (a `:` present with nothing after it) still contributes its `:` — so `//@h` and `//u:@h`
* round-trip distinctly from `//h` and `//u@h`.
*/
private fun reconstructUserInfo(): String? =
when {
components.host == null -> null
components.username.isEmpty() && components.password.isEmpty() -> null
components.password.isEmpty() -> components.username
components.username == null -> null
components.password == null -> components.username
else -> "${components.username}:${components.password}"
}

Expand Down Expand Up @@ -751,7 +761,12 @@ public class Uri internal constructor(
* [password] call made alone (without re-setting the other) starts from `""` rather than
* leaking the value this call just discarded.
*
* @param userInfo the encoded userinfo (e.g. `user` or `user:password`), or `null` to clear it.
* `null` and `""` are distinct here: `null` omits the userinfo entirely (no `@`), while `""`
* produces a present-but-empty userinfo (e.g. `//@h`) — matching [Uri.userInfo]'s own
* absent-vs-present-empty distinction ([MODEL-11]).
*
* @param userInfo the encoded userinfo (e.g. `user` or `user:password`), `""` for a
* present-but-empty userinfo, or `null` to omit it entirely.
*/
public fun userInfo(userInfo: String?): Builder {
this.userInfo = userInfo
Expand Down Expand Up @@ -791,6 +806,12 @@ public class Uri internal constructor(
* priority and combination rules the two setters share, and for the literal-`%` escape
* this setter also applies.
*
* Calling this setter alone, without a preceding or following [username] call, still
* normalizes the built username to `""` (present-but-empty) rather than leaving it
* absent — a non-null password never pairs with a `null` username ([MODEL-13]). For
* example, `Uri.Builder().password("pw").host("h").build().userInfo` is `":pw"`, not a
* password with no username at all.
*
* @param password the decoded password; `""` clears the password.
* @return this builder, for chaining.
*/
Expand Down Expand Up @@ -1164,12 +1185,20 @@ public class Uri internal constructor(
return sb.toString()
}

/** Appends `//[userinfo@]host[:port]` for a present authority (RFC 3986 §3.2). */
/**
* Appends `//[userinfo@]host[:port]` for a present authority (RFC 3986 §3.2).
*
* Emits `@` whenever [effectiveUserInfo] is non-`null`, even `""` — a present-but-empty
* verbatim [userInfo] (e.g. from `userInfo("")`, or pre-filled by [newBuilder] from a
* parsed `//@h`) must still round-trip to `//@host` ([MODEL-11], [NORM-27]). A split-mode
* [combinedUserInfo] never yields non-null `""`: [username]/[password] alone cannot express
* a present-but-empty userinfo, so this only changes behaviour for verbatim mode.
*/
private fun appendAuthority(sb: StringBuilder) {
val authorityHost = requireNotNull(host) { "authority requires a host" }
val effectiveUserInfo = if (usesSplitUserInfo) combinedUserInfo() else userInfo
sb.append(SLASH).append(SLASH)
if (!effectiveUserInfo.isNullOrEmpty()) sb.append(effectiveUserInfo).append('@')
if (effectiveUserInfo != null) sb.append(effectiveUserInfo).append('@')
sb.append(authorityHost)
if (port != null) sb.append(':').append(port)
}
Expand Down
19 changes: 15 additions & 4 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,16 @@ public class Url internal constructor(
public val scheme: String
get() = requireNotNull(components.scheme) { "a parsed Url always carries a scheme" }

/** The percent-encoded userinfo username, or `""` when no credentials are present. */
/**
* The percent-encoded userinfo username, or `""` when no credentials are present.
*
* The `Url` profile has no absent-vs-present-empty distinction ([NORM-30]): [components] never
* actually stores a `null` username for a parsed `Url`, but the `?:` guard keeps this accessor
* total against the shared, nullable [ParsedComponents] shape.
*/
@get:JvmName("username")
public val username: String
get() = components.username
get() = components.username ?: ""

/**
* The decoded userinfo username (percent-decoded [username]), `""` when absent or empty.
Expand All @@ -143,10 +149,15 @@ public class Url internal constructor(
public val decodedUsername: String
get() = decodedUsernameValue

/** The percent-encoded userinfo password, or `""` when absent or empty. */
/**
* The percent-encoded userinfo password, or `""` when absent or empty.
*
* As [username], the `?:` guard keeps this accessor total; [components] never actually stores
* a `null` password for a parsed `Url` ([NORM-30]).
*/
@get:JvmName("password")
public val password: String
get() = components.password
get() = components.password ?: ""

/**
* The decoded userinfo password (percent-decoded [password]), `""` when absent or empty.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,32 @@ import org.dexpace.kuri.host.Host
* - [query] `null` = no `?`; `""` = `?` present with empty content ([MODEL-30]).
* - [fragment] `null` = no `#`; `""` = `#` present with empty content ([PARSE-8]).
*
* [username]/[password] default to `""` (absent userinfo) rather than `null`; the
* `Uri`/`Url` facades map them to their nullable public accessors ([MODEL-13]). Their
* encoding is profile-specific: for `Uri` they are a raw, unmodified pass-through of
* whatever the input contained — split off the userinfo span verbatim, with no decode
* and no encode, the same treatment as every other `Uri`-profile component; for `Url`
* they are already percent-encoded under the userinfo percent-encode set at parse time
* [username]/[password] are independently nullable ([MODEL-10], [MODEL-11]) so that an
* absent userinfo/password stays distinct from a present-but-empty one all the way
* through to serialization: `username == null` means no userinfo at all (no `@` in the
* source), `username == ""` means an `@` was present with nothing before it; likewise
* `password == null` means no `:` was present within the userinfo, `password == ""`
* means a `:` was present with nothing after it. A non-null `password` therefore never
* pairs with a `null` `username` ([MODEL-13]) — the `Uri`/`Url` facades map the fields to
* their nullable/non-null public accessors respectively. Their encoding is
* profile-specific: for `Uri` they are a raw, unmodified pass-through of whatever the
* input contained — split off the userinfo span verbatim, with no decode and no encode,
* the same treatment as every other `Uri`-profile component; for `Url` they are never
* `null` (the WHATWG profile has no absent/present-empty userinfo distinction, [NORM-30])
* and are already percent-encoded under the userinfo percent-encode set at parse time
* (§5), matching `Url.username`/`Url.password`'s own documented contract.
*
* As a value record this type performs no inline validation — the §7/§8 modules that
* populate it own the component invariants (mirroring [Host] and [ComponentPath]).
*
* @property scheme the parsed scheme (lowercased for storage in the `Url` profile),
* or `null` for a `Uri` relative reference.
* @property username the userinfo user (raw for `Uri`, percent-encoded for `Url`),
* `""` when no userinfo is present.
* @property password the userinfo password (raw for `Uri`, percent-encoded for `Url`),
* `""` when absent or empty.
* @property username the userinfo user (raw for `Uri`, percent-encoded and never `null`
* for `Url`); `null` when no userinfo (no `@`) is present, `""` when an `@` was
* present with an empty user.
* @property password the userinfo password (raw for `Uri`, percent-encoded and never
* `null` for `Url`); `null` when no `:` was present in the userinfo, `""` when a `:`
* was present with nothing after it.
* @property host the parsed host, `null` when there is no authority, [Host.Empty]
* for an empty authority.
* @property port the explicit port, or `null` when unspecified / default-elided.
Expand All @@ -58,12 +67,18 @@ import org.dexpace.kuri.host.Host
*/
internal data class ParsedComponents(
val scheme: String? = null,
val username: String = "",
val password: String = "",
val username: String? = null,
val password: String? = null,
val host: Host? = null,
val port: Int? = null,
val path: ComponentPath = ComponentPath.Segments(emptyList()),
val query: String? = null,
val fragment: String? = null,
val validationErrors: List<ValidationError> = emptyList(),
)
) {
init {
check(username != null || password == null) {
"a non-null password requires a non-null username (MODEL-13)"
}
}
}
20 changes: 13 additions & 7 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -407,13 +407,19 @@ internal object Resolver {
return userinfoPrefix(c) + serializeHost(host) + port
}

/** Builds the `userinfo@` prefix from the decoded credentials, or `""` when no userinfo is present. */
private fun userinfoPrefix(c: ParsedComponents): String =
when {
c.username.isEmpty() && c.password.isEmpty() -> ""
c.password.isEmpty() -> "${c.username}@"
else -> "${c.username}:${c.password}@"
}
/**
* Builds the `userinfo@` prefix from the decoded credentials, or `""` when no userinfo is
* present. `Resolver` serves only the `Uri` profile, so this follows [NORM-16]'s null/empty
* rule: presence is tracked by nullability, not emptiness, so a present-but-empty username
* or password still contributes its `@` or `:` ([MODEL-11]).
*/
private fun userinfoPrefix(c: ParsedComponents): String {
val username = c.username
val password = c.password
if (username == null && password == null) return ""
val passwordPart = if (password != null) ":$password" else ""
return "${username.orEmpty()}$passwordPart@"
}

/** The behaviour-free five-component view the §5 algorithm operates on (each component nullable per §5.3). */
private data class UriParts(
Expand Down
40 changes: 26 additions & 14 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,25 +238,29 @@ internal object UriParser {
else -> parsePresentAuthority(authority, sections.authorityStart, options)
}

/** Splits userinfo from host:port at the LAST `@` ([PARSE-44]); a forbidden userinfo unit is fatal. */
/**
* Splits userinfo from host:port at the LAST `@` ([PARSE-44]); a forbidden userinfo unit is
* fatal. [userinfo] stays `null` when no `@` was found at all — distinct from an `@` found
* with nothing before it, which yields the present-but-empty `""` ([MODEL-11]).
*/
private fun parsePresentAuthority(
authority: String,
authorityStart: Int,
options: ParseOptions,
): ParseResult<Authority?> {
require(authorityStart >= 0) { "authority start must be known: $authorityStart" }
val at = authority.lastIndexOf('@')
val userinfo = if (at >= 0) authority.substring(0, at) else ""
val userinfo = if (at >= 0) authority.substring(0, at) else null
val hostPort = if (at >= 0) authority.substring(at + 1) else authority
return when (val error = rawError(userinfo, authorityStart)) {
return when (val error = userinfo?.let { rawError(it, authorityStart) }) {
null -> buildAuthority(userinfo, hostPort, options)
else -> ParseResult.Err(error)
}
}

/** Parses host (§7, `Uri` profile) and port, combining them with the split userinfo credentials. */
private fun buildAuthority(
userinfo: String,
userinfo: String?,
hostPort: String,
options: ParseOptions,
): ParseResult<Authority?> {
Expand All @@ -273,8 +277,8 @@ internal object UriParser {

/** Validates the optional port ([PARSE-32]/[PARSE-33]) and assembles the [Authority]. */
private fun attachPort(
username: String,
password: String,
username: String?,
password: String?,
host: Host,
portText: String?,
): ParseResult<Authority?> =
Expand All @@ -283,11 +287,16 @@ internal object UriParser {
is ParseResult.Ok -> ParseResult.Ok(Authority(username, password, host, port.value))
}

/** Splits userinfo into username and password at the FIRST `:` ([PARSE-45]); later `:` stay in the password. */
private fun splitUserinfo(userinfo: String): Pair<String, String> {
/**
* Splits present [userinfo] into username and password at the FIRST `:` ([PARSE-45]); later
* `:` stay in the password. A `null` [userinfo] (no `@` at all) yields `Pair(null, null)`; a
* non-null [userinfo] with no `:` yields a `null` password ([MODEL-11]).
*/
private fun splitUserinfo(userinfo: String?): Pair<String?, String?> {
if (userinfo == null) return Pair(null, null)
val colon = userinfo.indexOf(':')
return when {
colon < 0 -> Pair(userinfo, "")
colon < 0 -> Pair(userinfo, null)
else -> Pair(userinfo.substring(0, colon), userinfo.substring(colon + 1))
}
}
Expand Down Expand Up @@ -431,8 +440,8 @@ internal object UriParser {
): ParsedComponents =
ParsedComponents(
scheme = sections.scheme,
username = authority?.username ?: "",
password = authority?.password ?: "",
username = authority?.username,
password = authority?.password,
host = authority?.host,
port = authority?.port,
path = splitUriPath(sections.path),
Expand Down Expand Up @@ -484,10 +493,13 @@ internal object UriParser {
val fragmentStart: Int,
)

/** The parsed authority pieces: decoded-form credentials, the §7 [host], and the optional [port]. */
/**
* The parsed authority pieces: decoded-form credentials (nullable per [MODEL-11] — see
* [splitUserinfo]), the §7 [host], and the optional [port].
*/
private data class Authority(
val username: String,
val password: String,
val username: String?,
val password: String?,
val host: Host,
val port: Int?,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,11 @@ internal class UrlParserState(
) : this(value, base = null, fragmentRaw = null, errors = errors) {
scheme = seed.scheme
special = seed.scheme?.let { Scheme.isSpecial(it) } ?: false
username = seed.username
password = seed.password
// A Url-profile seed never actually carries a null username/password (the Url profile has
// no absent-vs-present-empty userinfo distinction, NORM-30); the `?:` guard keeps this
// assignment total against the shared, nullable ParsedComponents shape.
username = seed.username ?: ""
password = seed.password ?: ""
host = seed.host
port = seed.port
when (val seedPath = seed.path) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,18 @@ internal object UrlParserStates {
return UrlTransition.Reconsume(UrlState.PATH)
}

/** Copies the base's username/password/host/port into [state]. */
/**
* Copies the base's username/password/host/port into [state].
*
* As [UrlParserState]'s seed constructor, the `?:` guard on username/password is total-only:
* a `Url`-profile base never actually carries a null credential (`NORM-30`).
*/
private fun copyBaseAuthority(
state: UrlParserState,
base: ParsedComponents,
) {
state.username = base.username
state.password = base.password
state.username = base.username ?: ""
state.password = base.password ?: ""
state.host = base.host
state.port = base.port
}
Expand Down
Loading