From 6936ce228b339e7814a29c8f2acb1907c9d703d7 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 18 Jul 2026 04:05:00 +0300 Subject: [PATCH 1/2] feat: add Uri/Url redact() and isDirectory()/hasTrailingSlash() SPEC.md documented these as MUST-level requirements (CONF-120, CONF-85, PATH-3) but neither was ever implemented, so callers had no way to strip credentials/query/fragment before logging a URL, or to detect a directory-style path, without falling back to manual string inspection. redact() removes userinfo (or username/password for Url), query, and fragment while leaving scheme, host, port, and path untouched, mirroring the existing withoutFragment() builder-rebuild pattern. isDirectory() and hasTrailingSlash() are aliases that report whether the path's serialization ends in "/" (a trailing empty segment), covering the root path, a genuine trailing slash, and opaque paths uniformly. Closes #111 --- kuri/api/android/kuri.api | 6 ++ kuri/api/jvm/kuri.api | 6 ++ kuri/api/kuri.klib.api | 6 ++ .../commonMain/kotlin/org/dexpace/kuri/Uri.kt | 41 ++++++++++++ .../commonMain/kotlin/org/dexpace/kuri/Url.kt | 45 +++++++++++++ .../org/dexpace/kuri/parser/ComponentPath.kt | 17 +++++ .../kotlin/org/dexpace/kuri/UriDxTest.kt | 65 +++++++++++++++++++ .../kotlin/org/dexpace/kuri/UrlDxTest.kt | 62 ++++++++++++++++++ .../org/dexpace/kuri/PublicApiDxJavaTest.java | 43 ++++++++++++ 9 files changed, 291 insertions(+) diff --git a/kuri/api/android/kuri.api b/kuri/api/android/kuri.api index 31dbcc5..7408097 100644 --- a/kuri/api/android/kuri.api +++ b/kuri/api/android/kuri.api @@ -41,10 +41,12 @@ public final class org/dexpace/kuri/Uri { public final fun fileExtension ()Ljava/lang/String; public final fun fileName ()Ljava/lang/String; public final fun fragment ()Ljava/lang/String; + public final fun hasTrailingSlash ()Z public fun hashCode ()I public final fun host ()Lorg/dexpace/kuri/host/Host; public final fun hostName ()Ljava/lang/String; public final fun isAbsolute ()Z + public final fun isDirectory ()Z public final fun isOpaquePath ()Z public final fun newBuilder ()Lorg/dexpace/kuri/Uri$Builder; public final fun normalized ()Lorg/dexpace/kuri/Uri; @@ -60,6 +62,7 @@ public final class org/dexpace/kuri/Uri { public final fun port ()Ljava/lang/Integer; public final fun query ()Ljava/lang/String; public final fun queryParameters ()Lorg/dexpace/kuri/query/QueryParameters; + public final fun redact ()Lorg/dexpace/kuri/Uri; public final fun relativize (Lorg/dexpace/kuri/Uri;)Lorg/dexpace/kuri/Uri; public final fun resolve (Ljava/lang/String;)Lorg/dexpace/kuri/error/ParseResult; public final fun resolve (Ljava/lang/String;Lorg/dexpace/kuri/ParseOptions;)Lorg/dexpace/kuri/error/ParseResult; @@ -138,10 +141,12 @@ public final class org/dexpace/kuri/Url { public final fun fileName ()Ljava/lang/String; public final fun fragment ()Ljava/lang/String; public final fun hasOpaqueOrigin ()Z + public final fun hasTrailingSlash ()Z public fun hashCode ()I public final fun host ()Lorg/dexpace/kuri/host/Host; public final fun hostName ()Ljava/lang/String; public final fun href ()Ljava/lang/String; + public final fun isDirectory ()Z public final fun isSpecial ()Z public final fun newBuilder ()Lorg/dexpace/kuri/Url$Builder; public final fun origin ()Ljava/lang/String; @@ -156,6 +161,7 @@ public final class org/dexpace/kuri/Url { public final fun port ()Ljava/lang/Integer; public final fun query ()Ljava/lang/String; public final fun queryParameters ()Lorg/dexpace/kuri/query/QueryParameters; + public final fun redact ()Lorg/dexpace/kuri/Url; public final fun relativize (Lorg/dexpace/kuri/Url;)Ljava/lang/String; public final fun resolve (Ljava/lang/String;)Lorg/dexpace/kuri/error/ParseResult; public final fun resolveOrNull (Ljava/lang/String;)Lorg/dexpace/kuri/Url; diff --git a/kuri/api/jvm/kuri.api b/kuri/api/jvm/kuri.api index 11e1b68..005ba17 100644 --- a/kuri/api/jvm/kuri.api +++ b/kuri/api/jvm/kuri.api @@ -52,10 +52,12 @@ public final class org/dexpace/kuri/Uri { public final fun fileExtension ()Ljava/lang/String; public final fun fileName ()Ljava/lang/String; public final fun fragment ()Ljava/lang/String; + public final fun hasTrailingSlash ()Z public fun hashCode ()I public final fun host ()Lorg/dexpace/kuri/host/Host; public final fun hostName ()Ljava/lang/String; public final fun isAbsolute ()Z + public final fun isDirectory ()Z public final fun isOpaquePath ()Z public final fun newBuilder ()Lorg/dexpace/kuri/Uri$Builder; public final fun normalized ()Lorg/dexpace/kuri/Uri; @@ -71,6 +73,7 @@ public final class org/dexpace/kuri/Uri { public final fun port ()Ljava/lang/Integer; public final fun query ()Ljava/lang/String; public final fun queryParameters ()Lorg/dexpace/kuri/query/QueryParameters; + public final fun redact ()Lorg/dexpace/kuri/Uri; public final fun relativize (Lorg/dexpace/kuri/Uri;)Lorg/dexpace/kuri/Uri; public final fun resolve (Ljava/lang/String;)Lorg/dexpace/kuri/error/ParseResult; public final fun resolve (Ljava/lang/String;Lorg/dexpace/kuri/ParseOptions;)Lorg/dexpace/kuri/error/ParseResult; @@ -149,10 +152,12 @@ public final class org/dexpace/kuri/Url { public final fun fileName ()Ljava/lang/String; public final fun fragment ()Ljava/lang/String; public final fun hasOpaqueOrigin ()Z + public final fun hasTrailingSlash ()Z public fun hashCode ()I public final fun host ()Lorg/dexpace/kuri/host/Host; public final fun hostName ()Ljava/lang/String; public final fun href ()Ljava/lang/String; + public final fun isDirectory ()Z public final fun isSpecial ()Z public final fun newBuilder ()Lorg/dexpace/kuri/Url$Builder; public final fun origin ()Ljava/lang/String; @@ -167,6 +172,7 @@ public final class org/dexpace/kuri/Url { public final fun port ()Ljava/lang/Integer; public final fun query ()Ljava/lang/String; public final fun queryParameters ()Lorg/dexpace/kuri/query/QueryParameters; + public final fun redact ()Lorg/dexpace/kuri/Url; public final fun relativize (Lorg/dexpace/kuri/Url;)Ljava/lang/String; public final fun resolve (Ljava/lang/String;)Lorg/dexpace/kuri/error/ParseResult; public final fun resolveOrNull (Ljava/lang/String;)Lorg/dexpace/kuri/Url; diff --git a/kuri/api/kuri.klib.api b/kuri/api/kuri.klib.api index 40d9e11..a4ba97c 100644 --- a/kuri/api/kuri.klib.api +++ b/kuri/api/kuri.klib.api @@ -427,13 +427,16 @@ final class org.dexpace.kuri/Uri { // org.dexpace.kuri/Uri|null[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.dexpace.kuri/Uri.equals|equals(kotlin.Any?){}[0] final fun fileExtension(): kotlin/String // org.dexpace.kuri/Uri.fileExtension|fileExtension(){}[0] final fun fileName(): kotlin/String // org.dexpace.kuri/Uri.fileName|fileName(){}[0] + final fun hasTrailingSlash(): kotlin/Boolean // org.dexpace.kuri/Uri.hasTrailingSlash|hasTrailingSlash(){}[0] final fun hashCode(): kotlin/Int // org.dexpace.kuri/Uri.hashCode|hashCode(){}[0] final fun isAbsolute(): kotlin/Boolean // org.dexpace.kuri/Uri.isAbsolute|isAbsolute(){}[0] + final fun isDirectory(): kotlin/Boolean // org.dexpace.kuri/Uri.isDirectory|isDirectory(){}[0] final fun isOpaquePath(): kotlin/Boolean // org.dexpace.kuri/Uri.isOpaquePath|isOpaquePath(){}[0] final fun newBuilder(): org.dexpace.kuri/Uri.Builder // org.dexpace.kuri/Uri.newBuilder|newBuilder(){}[0] final fun normalized(): org.dexpace.kuri/Uri // org.dexpace.kuri/Uri.normalized|normalized(){}[0] final fun normalizedEquals(org.dexpace.kuri/Uri): kotlin/Boolean // org.dexpace.kuri/Uri.normalizedEquals|normalizedEquals(org.dexpace.kuri.Uri){}[0] final fun queryParameters(): org.dexpace.kuri.query/QueryParameters // org.dexpace.kuri/Uri.queryParameters|queryParameters(){}[0] + final fun redact(): org.dexpace.kuri/Uri // org.dexpace.kuri/Uri.redact|redact(){}[0] final fun relativize(org.dexpace.kuri/Uri): org.dexpace.kuri/Uri? // org.dexpace.kuri/Uri.relativize|relativize(org.dexpace.kuri.Uri){}[0] final fun resolve(kotlin/String, org.dexpace.kuri/ParseOptions = ...): org.dexpace.kuri.error/ParseResult // org.dexpace.kuri/Uri.resolve|resolve(kotlin.String;org.dexpace.kuri.ParseOptions){}[0] final fun resolveOrNull(kotlin/String, org.dexpace.kuri/ParseOptions = ...): org.dexpace.kuri/Uri? // org.dexpace.kuri/Uri.resolveOrNull|resolveOrNull(kotlin.String;org.dexpace.kuri.ParseOptions){}[0] @@ -524,9 +527,12 @@ final class org.dexpace.kuri/Url { // org.dexpace.kuri/Url|null[0] final fun fileExtension(): kotlin/String // org.dexpace.kuri/Url.fileExtension|fileExtension(){}[0] final fun fileName(): kotlin/String // org.dexpace.kuri/Url.fileName|fileName(){}[0] final fun hasOpaqueOrigin(): kotlin/Boolean // org.dexpace.kuri/Url.hasOpaqueOrigin|hasOpaqueOrigin(){}[0] + final fun hasTrailingSlash(): kotlin/Boolean // org.dexpace.kuri/Url.hasTrailingSlash|hasTrailingSlash(){}[0] final fun hashCode(): kotlin/Int // org.dexpace.kuri/Url.hashCode|hashCode(){}[0] + final fun isDirectory(): kotlin/Boolean // org.dexpace.kuri/Url.isDirectory|isDirectory(){}[0] final fun isSpecial(): kotlin/Boolean // org.dexpace.kuri/Url.isSpecial|isSpecial(){}[0] final fun newBuilder(): org.dexpace.kuri/Url.Builder // org.dexpace.kuri/Url.newBuilder|newBuilder(){}[0] + final fun redact(): org.dexpace.kuri/Url // org.dexpace.kuri/Url.redact|redact(){}[0] final fun relativize(org.dexpace.kuri/Url): kotlin/String? // org.dexpace.kuri/Url.relativize|relativize(org.dexpace.kuri.Url){}[0] final fun resolve(kotlin/String): org.dexpace.kuri.error/ParseResult // org.dexpace.kuri/Url.resolve|resolve(kotlin.String){}[0] final fun resolveOrNull(kotlin/String): org.dexpace.kuri/Url? // org.dexpace.kuri/Url.resolveOrNull|resolveOrNull(kotlin.String){}[0] diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt index f570414..eb66941 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt @@ -16,6 +16,7 @@ import org.dexpace.kuri.parser.UriParser import org.dexpace.kuri.parser.decodedSegments import org.dexpace.kuri.parser.fileExtensionOf import org.dexpace.kuri.parser.fileNameOf +import org.dexpace.kuri.parser.isDirectoryPath import org.dexpace.kuri.parser.toUriPathString import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.percent.PercentEncodeSet @@ -499,6 +500,46 @@ public class Uri internal constructor( */ public fun withoutFragment(): Uri = withFragment(null) + /** + * Returns a copy of this URI with its userinfo, query, and fragment removed, leaving the + * [scheme], [host], [port], and [path] intact (SPEC [CONF-120]). + * + * A convenience for logging or telemetry: it strips exactly the three components RFC 3986 + * treats as sensitive or context-dependent (credentials, the query string, and the fragment) + * while every other component — including a userinfo-less authority — is preserved verbatim. + * A URI that already carries none of the three is returned equal in value (though [newBuilder] + * always rebuilds, so the result is not necessarily the same reference). + * + * @return a new `Uri` with no userinfo, query, or fragment. + */ + public fun redact(): Uri = + newBuilder() + .userInfo(null) + .query(null) + .fragment(null) + .build() + + /** + * Reports whether this URI's path denotes a directory — its [encodedPath] ends in `/` (SPEC + * [PATH-3], [CONF-85]). + * + * True for a trailing-slash path such as `/a/` and for the root path `/` (a single empty + * segment); `false` for a path with content after its last segment (`/a`) and for a wholly + * empty path, which has no trailing slash to report. [hasTrailingSlash] is an exact alias, for + * a call site that prefers the WHATWG "trailing slash" phrasing over the filesystem-style + * "directory" term. + * + * @return `true` iff [encodedPath] ends in `/`. + */ + public fun isDirectory(): Boolean = components.path.isDirectoryPath() + + /** + * Alias of [isDirectory] (SPEC [PATH-3], [CONF-85]); both accessors report the same condition. + * + * @return `true` iff [encodedPath] ends in `/`. + */ + public fun hasTrailingSlash(): Boolean = isDirectory() + /** The canonical [uriString]; a parsed `Uri` round-trips through `toString` then [parse]. */ override fun toString(): String = uriString diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt index e7b452c..902ed71 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Url.kt @@ -17,6 +17,7 @@ import org.dexpace.kuri.parser.UrlParser import org.dexpace.kuri.parser.decodedSegments import org.dexpace.kuri.parser.fileExtensionOf import org.dexpace.kuri.parser.fileNameOf +import org.dexpace.kuri.parser.isDirectoryPath import org.dexpace.kuri.percent.PercentCodec import org.dexpace.kuri.percent.PercentEncodeSet import org.dexpace.kuri.percent.PercentEncodeSets @@ -517,6 +518,50 @@ public class Url internal constructor( */ public fun withoutFragment(): Url = withFragment(null) + /** + * Returns a copy of this URL with its userinfo (username and password), query, and fragment + * removed, leaving the [scheme], [host], [port], and [encodedPath] intact (SPEC [CONF-120]). + * + * A convenience for logging or telemetry: it strips exactly the components WHATWG/RFC 3986 + * treat as sensitive or context-dependent (credentials, the query string, and the fragment) + * while every other component — including a credential-less authority — is preserved + * verbatim. For an opaque-path URL such as `mailto:user@example.com`, the `user@` text is part + * of the opaque path rather than real userinfo (there is no authority at all) and so is left + * untouched; only the actual query and fragment are stripped. A URL that already carries none + * of the three is returned equal in value (though [newBuilder] always rebuilds, so the result + * is not necessarily the same reference). + * + * @return a new `Url` with no username, password, query, or fragment. + */ + public fun redact(): Url = + newBuilder() + .username("") + .password("") + .query(null) + .fragment(null) + .build() + + /** + * Reports whether this URL's path denotes a directory — its [encodedPath] ends in `/` (SPEC + * [PATH-3], [CONF-85]). + * + * True for a trailing-slash path such as `/a/` and for the root path `/` (a single empty + * segment) — which every special-scheme URL with an otherwise-empty path canonicalizes to, so + * e.g. `https://h` and `https://h/` both report `true`. `false` for a path with content after + * its last segment (`/a`). [hasTrailingSlash] is an exact alias, for a call site that prefers + * the WHATWG "trailing slash" phrasing over the filesystem-style "directory" term. + * + * @return `true` iff [encodedPath] ends in `/`. + */ + public fun isDirectory(): Boolean = components.path.isDirectoryPath() + + /** + * Alias of [isDirectory] (SPEC [PATH-3], [CONF-85]); both accessors report the same condition. + * + * @return `true` iff [encodedPath] ends in `/`. + */ + public fun hasTrailingSlash(): Boolean = isDirectory() + /** * The WHATWG `protocol` setter (URL §5): returns a copy with [value]'s scheme, or this URL * unchanged when the change is not permitted (special↔non-special, or an invalid `file` diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt index 0db3738..a31351a 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/ComponentPath.kt @@ -132,6 +132,23 @@ internal fun splitUriPath(path: String): ComponentPath.Segments = else -> ComponentPath.Segments(path.split('/'), rooted = false) } +/** + * Whether [path] denotes a directory-style path — one whose serialization ends in `/` (SPEC + * [PATH-3], [CONF-85]). Shared by the `Uri`/`Url` `isDirectory`/`hasTrailingSlash` projections. + * + * For a [Segments][ComponentPath.Segments] path this is exactly "the last segment is the empty + * string": a non-empty list whose final element is `""`, which covers both the root path `/` + * (`[""]`) and any explicitly trailing-slashed path (`/a/`, `["a", ""]`). A wholly empty path + * (`emptyList()`, serialized `""`) has no trailing slash to report. For an + * [Opaque][ComponentPath.Opaque] path — which has no segment structure — the equivalent + * serialization-ends-in-`/` test is applied directly to its verbatim text. + */ +internal fun ComponentPath.isDirectoryPath(): Boolean = + when (this) { + is ComponentPath.Segments -> segments.isNotEmpty() && segments.last().isEmpty() + is ComponentPath.Opaque -> path.endsWith(URI_PATH_SEPARATOR) + } + /** * The "file name" of decoded path [segments]: the last non-empty segment, or `""` when there is * none (SPEC §3.3). Shared by the `Uri`/`Url` `fileName()` projections. diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt index a4721bd..ef1af1d 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt @@ -578,6 +578,71 @@ class UriDxTest { assertNull(parseOk("http://h/p#x").withoutFragment().fragment) } + // --- redact --- + + @Test + fun `redact strips userinfo query and fragment but keeps scheme host port and path`() { + val uri = parseOk("http://user:pass@h:8080/p/a?q=1#frag") + + val redacted = uri.redact() + + assertEquals("http://h:8080/p/a", redacted.uriString) + assertNull(redacted.userInfo) + assertNull(redacted.query) + assertNull(redacted.fragment) + assertEquals("h", redacted.hostName) + assertEquals(8080, redacted.port) + assertEquals("/p/a", redacted.encodedPath) + } + + @Test + fun `redact is a no-op in value when there is no userinfo query or fragment already`() { + val uri = parseOk("http://h:8080/p") + + assertEquals(uri, uri.redact()) + } + + @Test + fun `redact strips only the credential-bearing components on a relative reference`() { + val uri = parseOk("/a/b?q#f") + + val redacted = uri.redact() + + assertEquals("/a/b", redacted.uriString) + assertNull(redacted.query) + assertNull(redacted.fragment) + } + + // --- isDirectory / hasTrailingSlash --- + + @Test + fun `isDirectory and hasTrailingSlash agree on a trailing empty segment`() { + val trailingSlash = parseOk("http://h/a/") + + assertTrue(trailingSlash.isDirectory()) + assertTrue(trailingSlash.hasTrailingSlash()) + + val noTrailingSlash = parseOk("http://h/a") + + assertFalse(noTrailingSlash.isDirectory()) + assertFalse(noTrailingSlash.hasTrailingSlash()) + } + + @Test + fun `isDirectory is true for the root path`() { + assertTrue(parseOk("http://h/").isDirectory()) + } + + @Test + fun `isDirectory is false for a wholly empty path`() { + assertFalse(parseOk("http://h").isDirectory()) + } + + @Test + fun `isDirectory is false for an opaque path with no trailing slash`() { + assertFalse(parseOk("mailto:a@example.com").isDirectory()) + } + @Test fun `relativize returns null across different schemes`() { // The authorities match, but a differing scheme fails the shared-hierarchy check, so there is no diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt index 473142c..0c569c5 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt @@ -388,6 +388,68 @@ class UrlDxTest { assertNull(base.withFragment("top").withoutFragment().fragment) } + @Test + fun `redact strips userinfo query and fragment but keeps scheme host port and path`() { + val url = parseOk("https://user:pass@h:8443/p/a?q=1#frag") + + val redacted = url.redact() + + assertEquals("https://h:8443/p/a", redacted.href) + assertEquals("", redacted.username) + assertEquals("", redacted.password) + assertNull(redacted.query) + assertNull(redacted.fragment) + assertEquals("h", redacted.hostName) + assertEquals(8443, redacted.port) + assertEquals("/p/a", redacted.encodedPath) + } + + @Test + fun `redact is a no-op in value when there is no userinfo query or fragment already`() { + val url = parseOk("https://h:8443/p") + + assertEquals(url, url.redact()) + } + + @Test + fun `redact on an opaque-path URL clears only its query and fragment`() { + // mailto: has no authority at all, so its "userinfo" is really part of the opaque path and is + // untouched; only the real query and fragment components are stripped. + val url = parseOk("mailto:a@example.com?subject=hi#top") + + val redacted = url.redact() + + assertEquals("mailto:a@example.com", redacted.href) + assertNull(redacted.query) + assertNull(redacted.fragment) + } + + @Test + fun `isDirectory and hasTrailingSlash agree on a trailing empty segment`() { + val trailingSlash = parseOk("https://h/a/") + + assertTrue(trailingSlash.isDirectory()) + assertTrue(trailingSlash.hasTrailingSlash()) + + val noTrailingSlash = parseOk("https://h/a") + + assertFalse(noTrailingSlash.isDirectory()) + assertFalse(noTrailingSlash.hasTrailingSlash()) + } + + @Test + fun `isDirectory is true for the root path`() { + assertTrue(parseOk("https://h/").isDirectory()) + // A special-scheme empty path is WHATWG-canonicalized to the root "/", which is itself a + // directory path, so this also exercises the empty-input edge case. + assertTrue(parseOk("https://h").isDirectory()) + } + + @Test + fun `isDirectory is false for an opaque path with no trailing slash`() { + assertFalse(parseOk("mailto:a@example.com").isDirectory()) + } + @Test fun `isSpecial reflects whether the scheme is a WHATWG special scheme`() { val special = listOf("https://h/", "ws://h/", "ftp://h/", "file:///x") diff --git a/kuri/src/jvmTest/java/org/dexpace/kuri/PublicApiDxJavaTest.java b/kuri/src/jvmTest/java/org/dexpace/kuri/PublicApiDxJavaTest.java index f1dea07..b6c9731 100644 --- a/kuri/src/jvmTest/java/org/dexpace/kuri/PublicApiDxJavaTest.java +++ b/kuri/src/jvmTest/java/org/dexpace/kuri/PublicApiDxJavaTest.java @@ -146,6 +146,27 @@ public void uriWithPortAndWithoutFragment() { Assert.assertNull(Uri.parseOrThrow("http://h/p#x").withoutFragment().fragment()); } + @Test + public void uriRedactStripsUserinfoQueryAndFragment() { + Uri redacted = Uri.parseOrThrow("http://user:pass@h:8080/p?q=1#frag").redact(); + + Assert.assertEquals("http://h:8080/p", redacted.uriString()); + Assert.assertNull(redacted.userInfo()); + Assert.assertNull(redacted.query()); + Assert.assertNull(redacted.fragment()); + } + + @Test + public void uriIsDirectoryAndHasTrailingSlashAgree() { + Uri directory = Uri.parseOrThrow("http://h/a/"); + Assert.assertTrue(directory.isDirectory()); + Assert.assertTrue(directory.hasTrailingSlash()); + + Uri file = Uri.parseOrThrow("http://h/a"); + Assert.assertFalse(file.isDirectory()); + Assert.assertFalse(file.hasTrailingSlash()); + } + @Test public void uriResolveAndConvertToUrl() { Assert.assertEquals("http://h/x", Uri.parseOrThrow("http://h/a/b").resolveOrThrow("../x").uriString()); @@ -230,6 +251,28 @@ public void urlWithPortAndWithoutFragment() { Assert.assertNull(Url.parseOrThrow("https://h/p#x").withoutFragment().fragment()); } + @Test + public void urlRedactStripsUserinfoQueryAndFragment() { + Url redacted = Url.parseOrThrow("https://user:pass@h:8443/p?q=1#frag").redact(); + + Assert.assertEquals("https://h:8443/p", redacted.href()); + Assert.assertEquals("", redacted.username()); + Assert.assertEquals("", redacted.password()); + Assert.assertNull(redacted.query()); + Assert.assertNull(redacted.fragment()); + } + + @Test + public void urlIsDirectoryAndHasTrailingSlashAgree() { + Url directory = Url.parseOrThrow("https://h/a/"); + Assert.assertTrue(directory.isDirectory()); + Assert.assertTrue(directory.hasTrailingSlash()); + + Url file = Url.parseOrThrow("https://h/a"); + Assert.assertFalse(file.isDirectory()); + Assert.assertFalse(file.hasTrailingSlash()); + } + // --- Query + ParseResult + Host --- @Test From f3002f1216493d43f51ce3ce894152a992cdd730 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 19 Jul 2026 20:04:38 +0300 Subject: [PATCH 2/2] test: cover the opaque-path true branch of Url.isDirectory() ComponentPath.Opaque's trailing-slash check only had a test asserting the false case (a mailto: address with no trailing slash). Add a case for an opaque path that does end in a slash so both outcomes of that branch are exercised. --- .../commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt index 0c569c5..57e3328 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UrlDxTest.kt @@ -450,6 +450,18 @@ class UrlDxTest { assertFalse(parseOk("mailto:a@example.com").isDirectory()) } + @Test + fun `isDirectory is true for an opaque path ending in a slash`() { + val url = parseOk("urn:example:a/") + + // Confirms the path really is opaque (verbatim, colon-bearing text) rather than having + // been reinterpreted as segments, so the assertions below can't pass vacuously. + assertEquals("urn:example:a/", url.href) + + assertTrue(url.isDirectory()) + assertTrue(url.hasTrailingSlash()) + } + @Test fun `isSpecial reflects whether the scheme is a WHATWG special scheme`() { val special = listOf("https://h/", "ws://h/", "ftp://h/", "file:///x")