diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt index a864b7d..ed8e0bb 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt @@ -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? @@ -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}" } @@ -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 @@ -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. */ @@ -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) } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index 25e5a2d..5dbcc2c 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -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. @@ -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. diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt index 4bda3be..9fe7cfd 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ParsedComponents.kt @@ -28,12 +28,19 @@ 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 @@ -41,10 +48,12 @@ import org.dexpace.kuri.host.Host * * @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. @@ -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 = emptyList(), -) +) { + init { + check(username != null || password == null) { + "a non-null password requires a non-null username (MODEL-13)" + } + } +} 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 e421948..febd505 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt @@ -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( diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt index e3f7513..98aa3c2 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UriParser.kt @@ -238,7 +238,11 @@ 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, @@ -246,9 +250,9 @@ internal object UriParser { ): ParseResult { 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) } @@ -256,7 +260,7 @@ internal object UriParser { /** 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 { @@ -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 = @@ -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 { + /** + * 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 { + 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)) } } @@ -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), @@ -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?, ) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt index 8282ec9..207bbd8 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserState.kt @@ -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) { diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt index df09d5f..0b616e4 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserStates.kt @@ -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 } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt index 84a2e8b..efb0151 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/SerializeShared.kt @@ -22,29 +22,63 @@ internal const val DOUBLE_SLASH: String = "//" internal const val LEADING_DOT_GUARD: String = "/." /** - * §11.2 [NORM-16] authority serializer `[userinfo "@"] host [":" port]`; the single home shared by - * both profiles' authority rendering ([UriSerializer], [UrlSerializer], and `Url.authority`). + * §11.2 [NORM-16]/[NORM-30] authority serializer `[userinfo "@"] host [":" port]`; the single home + * shared by both profiles' authority rendering ([UriSerializer], [UrlSerializer], and `Url.authority`). * * @param components the stored components whose authority is rendered; MUST carry a non-null host. + * @param preserveEmptyUserinfo `true` for the `Uri` profile's [NORM-16] null/empty rule, which + * distinguishes an absent userinfo (`username == null`) from a present-but-empty one + * (`username == ""`); `false` (the default) for the `Url` profile's [NORM-30] WHATWG rule, which + * has no such distinction and collapses both to no credentials. * @return the authority text `[userinfo@]host[:port]`. */ -internal fun serializeAuthority(components: ParsedComponents): String { +internal fun serializeAuthority( + components: ParsedComponents, + preserveEmptyUserinfo: Boolean = false, +): String { val host = requireNotNull(components.host) { "authority serialization requires a host" } val port = if (components.port != null) ":${components.port}" else "" - return credentialsPrefix(components) + serializeHost(host) + port + return credentialsPrefix(components, preserveEmptyUserinfo) + serializeHost(host) + port } /** * The `userinfo@` prefix ([NORM-16] / [NORM-30]); empty for a value with no credentials. * - * The WHATWG "includes credentials" rule and the RFC null/empty rule coincide here because - * [ParsedComponents] holds the credentials as (possibly empty) strings, never null: the prefix - * appears iff `username` or `password` is non-empty, and `:password` iff `password` is non-empty. + * Under [preserveEmptyUserinfo] (the `Uri` profile), presence is tracked by nullability: the + * prefix appears iff `username` or `password` is non-null, and `:password` appears iff `password` + * is non-null — so a present-but-empty username or password still renders its `@` or `:` + * ([MODEL-11]). Otherwise (the `Url` profile, [NORM-30]), [ParsedComponents] never holds a `null` + * credential and the WHATWG "includes credentials" rule applies instead: the prefix appears iff + * `username` or `password` is non-empty, and `:password` iff `password` is non-empty. */ -private fun credentialsPrefix(components: ParsedComponents): String { - if (components.username.isEmpty() && components.password.isEmpty()) return "" - val password = if (components.password.isNotEmpty()) ":${components.password}" else "" - return "${components.username}$password@" +private fun credentialsPrefix( + components: ParsedComponents, + preserveEmptyUserinfo: Boolean, +): String = + if (preserveEmptyUserinfo) { + preservedCredentialsPrefix(components.username, components.password) + } else { + collapsedCredentialsPrefix(components.username, components.password) + } + +/** [NORM-16]: emits `@` iff either credential is non-`null`; `username` defaults to `""` when absent. */ +private fun preservedCredentialsPrefix( + username: String?, + password: String?, +): String { + if (username == null && password == null) return "" + val passwordPart = if (password != null) ":$password" else "" + return "${username.orEmpty()}$passwordPart@" +} + +/** [NORM-30]: emits `@` iff either credential is non-empty (a `Url` credential is never `null`). */ +private fun collapsedCredentialsPrefix( + username: String?, + password: String?, +): String { + if (username.isNullOrEmpty() && password.isNullOrEmpty()) return "" + val passwordPart = if (!password.isNullOrEmpty()) ":$password" else "" + return "${username.orEmpty()}$passwordPart@" } /** diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt index d3e5066..5ca9fb1 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt @@ -49,8 +49,8 @@ internal object UriNormalizer { check(scheme == null || scheme.none { it in 'A'..'Z' }) { "scheme normalization left upper-case" } return c.copy( scheme = scheme, - username = normalizeText(c.username), - password = normalizeText(c.password), + username = c.username?.let { normalizeText(it) }, + password = c.password?.let { normalizeText(it) }, host = host, port = normalizePort(scheme, c.port), path = normalizePath(c.path, hasAuthority = c.host != null), diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt index d8ae647..9522ea4 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriSerializer.kt @@ -34,7 +34,7 @@ internal object UriSerializer { ): String { val sb = StringBuilder() if (c.scheme != null) sb.append(c.scheme).append(':') - if (c.host != null) sb.append(DOUBLE_SLASH).append(serializeAuthority(c)) + if (c.host != null) sb.append(DOUBLE_SLASH).append(serializeAuthority(c, preserveEmptyUserinfo = true)) sb.append(guardRecomposedUriPath(c.scheme, c.host != null, c.path.toUriPathString())) appendQueryFragment(sb, c, excludeFragment) check(c.host == null || sb.contains(DOUBLE_SLASH)) { "an authority must emit //" } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderTest.kt index 76f5f66..73fa23a 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderTest.kt @@ -67,6 +67,32 @@ class UriBuilderTest { assertEquals("a/b/c", rebuilt.uriString) } + @Test + fun `newBuilder then build reproduces an empty-but-present userinfo`() { + // Regression for #104: newBuilder() pre-fills its verbatim userInfo field from source.userInfo, + // which is now "" (not null) for a present-but-empty userinfo, so the rebuild must still emit + // the "@" rather than silently dropping it. + val original = parseOk("http://@h/") + + val rebuilt = original.newBuilder().build() + + assertEquals(original, rebuilt) + assertEquals("http://@h/", rebuilt.uriString) + } + + @Test + fun `newBuilder then build reproduces an empty-but-present password`() { + // Regression for #104: newBuilder() pre-fills its verbatim userInfo field from source.userInfo, + // which is now "u:" (not "u") for a present-but-empty password, so the rebuild must still emit + // the trailing ":" rather than silently dropping it. + val original = parseOk("http://u:@h/") + + val rebuilt = original.newBuilder().build() + + assertEquals(original, rebuilt) + assertEquals("http://u:@h/", rebuilt.uriString) + } + @Test fun `builds a rootless relative reference from a bare encoded path`() { val uri = Uri.Builder().encodedPath("a/b").build() diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderUserInfoTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderUserInfoTest.kt index 7839a0b..79be559 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderUserInfoTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriBuilderUserInfoTest.kt @@ -108,12 +108,11 @@ class UriBuilderUserInfoTest { } @Test - fun `a trailing colon in raw userInfo is unaffected by the split-mode addition`() { - // Uri.userInfo collapses an empty password to no colon regardless of which mode produced - // it (Uri.reconstructUserInfo / SerializeShared.serializeUserinfo, both pre-existing and - // untouched by this change) — so "user:" reads back as "user". The point of this case is - // that verbatim mode still recomposes and re-parses to that same pre-existing value; split - // mode was never engaged for it to regress. + fun `a trailing colon in raw userInfo is preserved as an empty-but-present password`() { + // The empty password after the trailing ':' is a present-but-empty field, distinct from no + // password at all (Uri.reconstructUserInfo / SerializeShared.preservedCredentialsPrefix), so + // "user:" reads back as "user:" — the point of this case is that verbatim mode recomposes + // and re-parses to that same value rather than collapsing the trailing colon away. val uri = Uri .Builder() @@ -122,7 +121,7 @@ class UriBuilderUserInfoTest { .encodedPath("/p") .build() - assertEquals("user", uri.userInfo) + assertEquals("user:", uri.userInfo) } @Test diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriTest.kt index 3d7e87b..23e0f2b 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriTest.kt @@ -266,6 +266,35 @@ class UriTest { assertEquals(left.hashCode(), right.hashCode()) } + @Test + fun `an empty-but-present userinfo round-trips the at-sign and compares unequal to no userinfo`() { + // Regression for #104: username/password used to collapse "absent" and "present-but-empty" + // into the same "" value before serialization, so the "@" was silently dropped on re-serialize + // and the two forms compared equal — violating the PRESERVE-profile equality contract. + val withEmptyUserinfo = parseOk("http://@h/") + val withoutUserinfo = parseOk("http://h/") + + assertEquals("http://@h/", withEmptyUserinfo.uriString) + assertEquals("http://h/", withoutUserinfo.uriString) + assertEquals("", withEmptyUserinfo.userInfo) + assertNull(withoutUserinfo.userInfo) + assertFalse(withEmptyUserinfo == withoutUserinfo) + } + + @Test + fun `an empty-but-present password round-trips the trailing colon and compares unequal to no password`() { + // Regression for #104: the second reported repro case — a trailing ':' with an empty + // password used to collapse to the same value as no password at all. + val withEmptyPassword = parseOk("http://u:@h/") + val withoutPassword = parseOk("http://u@h/") + + assertEquals("http://u:@h/", withEmptyPassword.uriString) + assertEquals("http://u@h/", withoutPassword.uriString) + assertEquals("u:", withEmptyPassword.userInfo) + assertEquals("u", withoutPassword.userInfo) + assertFalse(withEmptyPassword == withoutPassword) + } + @Test fun `is usable as a hash set element`() { val set = hashSetOf(parseOk("foo://h/p")) 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 efffc06..f2c27fe 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt @@ -12,6 +12,7 @@ import org.dexpace.kuri.serialize.UriSerializer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNull /** * RFC 3986 §5 reference-resolution tests: the §5.2.4 remove_dot_segments unit cases and the full @@ -211,14 +212,15 @@ internal class ResolverTest { @Test fun `structured resolve inherits a base username-only userinfo`() { // Pins the userinfoPrefix username-only branch: a base with a username but no password is - // inherited by a relative reference that supplies neither scheme nor authority. + // inherited by a relative reference that supplies neither scheme nor authority. No ':' was + // present, so the resolved password stays absent (null), not merely empty. val base = UriParser.parse("http://user@host/a/b").getOrThrow() val reference = UriParser.parse("x").getOrThrow() val resolved = Resolver.resolve(base, reference).getOrThrow() assertEquals("user", resolved.username) - assertEquals("", resolved.password) + assertNull(resolved.password) assertEquals("http://user@host/a/x", UriSerializer.serialize(resolved)) } diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt index 6ab85b5..629247e 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UriParserTest.kt @@ -268,6 +268,34 @@ class UriParserTest { assertEquals(Host.RegName("h"), components.host) } + @Test + fun `parse leaves username and password null when no at-sign is present`() { + val components = parsed("http://h/p") + + assertNull(components.username) + assertNull(components.password) + } + + @Test + fun `parse distinguishes an empty-but-present userinfo from no userinfo`() { + // Regression for #104: an at-sign with nothing before it is a present, empty userinfo + // (username == ""), distinct from no at-sign at all (username == null). + val components = parsed("http://@h/") + + assertEquals("", components.username) + assertNull(components.password) + } + + @Test + fun `parse distinguishes an empty-but-present password from no password`() { + // Regression for #104: a colon with nothing after it is a present, empty password + // (password == ""), distinct from no colon at all (password == null). + val components = parsed("http://u:@h/") + + assertEquals("u", components.username) + assertEquals("", components.password) + } + @Test fun `parse rejects a DEL code point in the path`() { val result = UriParser.parse("http://h/p\u007Fq")