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
54 changes: 23 additions & 31 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.dexpace.kuri.parser.ParsedComponents
import org.dexpace.kuri.parser.Resolver
import org.dexpace.kuri.parser.UriParser
import org.dexpace.kuri.parser.UrlPath
import org.dexpace.kuri.parser.decodedSegments
import org.dexpace.kuri.parser.fileExtensionOf
import org.dexpace.kuri.parser.fileNameOf
import org.dexpace.kuri.parser.toUriPathString
Expand Down Expand Up @@ -540,10 +541,7 @@ public class Uri internal constructor(

/** The decoded segments backing [pathSegments]; an opaque path yields its single decoded value. */
private fun computeDecodedPathSegments(): List<String> =
when (val storedPath = components.path) {
is UrlPath.Opaque -> listOf(PercentCodec.decode(storedPath.path))
is UrlPath.Segments -> storedPath.segments.map { PercentCodec.decode(it) }
}
decodedSegments(components.path) { PercentCodec.decode(it) }

/**
* True for the RFC 3986 opaque shape: an absolute URI with no authority and a rootless path.
Expand Down Expand Up @@ -960,7 +958,7 @@ public class Uri internal constructor(
*/
public fun build(): Uri {
val path = effectivePath()
validateComposable(path)
composabilityError(path)?.let { throw IllegalArgumentException(it) }
return buildResult(path).getOrThrow()
}

Expand All @@ -977,7 +975,7 @@ public class Uri internal constructor(
*/
public fun buildOrNull(): Uri? {
val path = effectivePath()
if (!isComposable(path)) return null
if (composabilityError(path) != null) return null
return buildResult(path).getOrNull()
}

Expand All @@ -993,34 +991,28 @@ public class Uri internal constructor(
UriParser.parse(recompose(path), options).map { Uri(it) }

/**
* Rejects component combinations no RFC 3986 recomposition can represent (RFC 3986 §3.2/§3.3).
* The RFC 3986 §3.2/§3.3 message for the first component combination no recomposition can
* represent, or `null` when the accumulated components are composable.
*
* `userinfo`/`port` are authority sub-components, so they require a host; a caller wanting an
* empty authority passes `host("")`. A present authority forbids a rootless [path], which
* would otherwise merge into the authority on re-parse; a segment-built path is already rooted
* by [effectivePath] when a host is present, so only a verbatim rootless path can trip this.
* The single ordered source of the composability rules [build] and [buildOrNull] share, so a
* rule cannot drift between the throwing and the non-throwing path. `userinfo`/`port` are
* authority sub-components, so they require a host; a caller wanting an empty authority passes
* `host("")`. A present authority forbids a rootless [path], which would otherwise merge into
* the authority on re-parse; a segment-built path is already rooted by [effectivePath] when a
* host is present, so only a verbatim rootless path can trip that rule.
*/
private fun validateComposable(path: String) {
require(authorityHasHost()) {
"userInfo/port require a host: set host(\"\") for an empty-authority URI, or drop them"
}
require(pathFitsAuthority(path)) {
"a path with an authority must be empty or start with '/': $path"
}
require(host != null || !path.startsWith("//")) {
"an authority-less path cannot begin with '//': $path"
private fun composabilityError(path: String): String? =
when {
!authorityHasHost() ->
"userInfo/port require a host: set host(\"\") for an empty-authority URI, or drop them"
!pathFitsAuthority(path) ->
"a path with an authority must be empty or start with '/': $path"
host == null && path.startsWith("//") ->
"an authority-less path cannot begin with '//': $path"
!this.path.wellFormed() ->
"a rootless path cannot begin with an empty segment; the edit would re-root it: $path"
else -> null
}
require(this.path.wellFormed()) {
"a rootless path cannot begin with an empty segment; the edit would re-root it: $path"
}
}

/** True iff [build] would pass [validateComposable] for [path] without throwing; for [buildOrNull]. */
private fun isComposable(path: String): Boolean =
authorityHasHost() &&
pathFitsAuthority(path) &&
(host != null || !path.startsWith("//")) &&
this.path.wellFormed()

/** True when a [host] is present, or neither [userInfo] nor [port] (which require one) is set. */
private fun authorityHasHost(): Boolean = host != null || (userInfo.isNullOrEmpty() && port == null)
Expand Down
49 changes: 25 additions & 24 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.dexpace.kuri.parser.BuilderPath
import org.dexpace.kuri.parser.ParsedComponents
import org.dexpace.kuri.parser.UrlParser
import org.dexpace.kuri.parser.UrlPath
import org.dexpace.kuri.parser.decodedSegments
import org.dexpace.kuri.parser.fileExtensionOf
import org.dexpace.kuri.parser.fileNameOf
import org.dexpace.kuri.percent.PercentCodec
Expand Down Expand Up @@ -136,11 +137,7 @@ public class Url internal constructor(
/** The decoded path segments in order (read-only); an opaque path yields its single decoded value. */
@get:JvmName("pathSegments")
public val pathSegments: List<String>
get() =
when (val path = components.path) {
is UrlPath.Opaque -> listOf(PercentCodec.decode(path.path))
is UrlPath.Segments -> path.segments.map { PercentCodec.decode(it) }
}
get() = decodedSegments(components.path) { PercentCodec.decode(it) }

/** The canonical encoded path string (e.g. `/a/b`, or the opaque path verbatim). */
@get:JvmName("encodedPath")
Expand Down Expand Up @@ -806,18 +803,7 @@ public class Url internal constructor(
*/
public fun build(): Url {
val path = effectivePath()
require(hasRequiredHost()) {
"a special scheme requires a host: set host(...) before build(): $scheme"
}
require(pathMatchesAuthority(path)) {
"a path with an authority must be empty or start with '/': $path"
}
require(pathAllowedWithoutAuthority(path)) {
"an authority-less path cannot begin with '//': $path"
}
require(this.path.wellFormed()) {
"a rootless path cannot begin with an empty segment; the edit would re-root it: $path"
}
composabilityError(path)?.let { throw IllegalArgumentException(it) }
return buildResult(path).getOrThrow()
}

Expand All @@ -835,7 +821,7 @@ public class Url internal constructor(
*/
public fun buildOrNull(): Url? {
val path = effectivePath()
if (!isComposable(path)) return null
if (composabilityError(path) != null) return null
return buildResult(path).getOrNull()
}

Expand Down Expand Up @@ -891,11 +877,26 @@ public class Url internal constructor(
*/
private fun pathAllowedWithoutAuthority(path: String): Boolean = host != null || !path.startsWith("//")

/** True iff [build] would pass its composability requires for [path] without throwing; for [buildOrNull]. */
private fun isComposable(path: String): Boolean =
hasRequiredHost() &&
pathMatchesAuthority(path) &&
pathAllowedWithoutAuthority(path) &&
this.path.wellFormed()
/**
* The message for the first component combination that cannot form a valid URL, or `null` when
* the accumulated components are composable.
*
* The single ordered source of the composability rules [build] and [buildOrNull] share, so a
* rule cannot drift between the throwing and the non-throwing path: a special (non-`file`)
* scheme needs a host (whose absence the parser would misread as the first path segment), a
* present authority forbids a rootless [path], and a rootless path must not re-root at build.
*/
private fun composabilityError(path: String): String? =
when {
!hasRequiredHost() ->
"a special scheme requires a host: set host(...) before build(): $scheme"
!pathMatchesAuthority(path) ->
"a path with an authority must be empty or start with '/': $path"
!pathAllowedWithoutAuthority(path) ->
"an authority-less path cannot begin with '//': $path"
!this.path.wellFormed() ->
"a rootless path cannot begin with an empty segment; the edit would re-root it: $path"
else -> null
}
}
}
17 changes: 17 additions & 0 deletions kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlPath.kt
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,23 @@ internal fun fileExtensionOf(name: String): String {
return if (hasNonDotStem) name.substring(dot + 1) else ""
}

/**
* The decoded path segments of [path], each percent-decoded through [decode]; an
* [Opaque][UrlPath.Opaque] path has no segment structure and yields its single decoded value as a
* one-element list. Shared by the `Uri`/`Url` `pathSegments` projections.
*
* Takes the decoder as a lambda so this helper stays free of the percent-codec dependency, the same
* convention [appendPathSegments] follows with its `encode` lambda.
*/
internal fun decodedSegments(
path: UrlPath,
decode: (String) -> String,
): List<String> =
when (path) {
is UrlPath.Opaque -> listOf(decode(path.path))
is UrlPath.Segments -> path.segments.map(decode)
}

/**
* Appends the '/'-separated [input] onto [current] as decoded path segments (OkHttp
* `HttpUrl.Builder.addPathSegments` semantics), encoding each piece with [encode].
Expand Down
Loading