From 5f48c4406ba926b1c70e3de72344225926e394ee Mon Sep 17 00:00:00 2001 From: John Cormie Date: Tue, 6 Jan 2026 16:05:19 -0800 Subject: [PATCH 1/3] api: improve Uri's API for the path component. Add examples to the getPathSegments() javadoc. Introduce RFC 3986 concepts of absolute and rootless paths. --- api/src/main/java/io/grpc/Uri.java | 47 ++++++++++++++++++++++++++ api/src/test/java/io/grpc/UriTest.java | 30 ++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/api/src/main/java/io/grpc/Uri.java b/api/src/main/java/io/grpc/Uri.java index 0ef6212d35a..3034211752b 100644 --- a/api/src/main/java/io/grpc/Uri.java +++ b/api/src/main/java/io/grpc/Uri.java @@ -481,6 +481,15 @@ public String getPath() { *

Prefer this method over {@link #getPath()} because it preserves the distinction between * segment separators and literal '/'s within a path segment. * + *

A trailing '/' delimiter in the path results in the empty string as the last element in the + * returned list. For example, file://localhost/foo/bar/ has path segments + * ["foo", "bar", ""] + * + *

A leading '/' delimiter cannot be detected using this method. For example, both + * dns:example.com and dns:///example.com have the same list of path segments: + * ["example.com"]. Use {@link #isPathAbsolute()} or {@link #isPathRootless()} to + * distinguish these cases. + * *

The returned list is immutable. */ public List getPathSegments() { @@ -490,6 +499,44 @@ public List getPathSegments() { return segmentsBuilder.build(); } + /** + * Returns true iff this URI's path component starts with a path segment (rather than the '/' + * segment delimiter). + * + *

The path of an RFC 3986 URI is either empty, absolute (starts with the '/' segment + * delimiter) or rootless (starts with a path segment). For example, tel:+1-206-555-1212 + * , mailto:me@example.com and urn:isbn:978-1492082798 all have + * rootless paths. mailto:%2Fdev%2Fnull@example.com is also rootless because its + * percent-encoded slashes are not segment delimiters but rather part of the first and only path + * segment. + * + *

Contrast rootless paths with absolute ones (see {@link #isPathAbsolute()}. + */ + public boolean isPathRootless() { + return !path.isEmpty() && !path.startsWith("/"); + } + + /** + * Returns true iff this URI's path component starts with the '/' segment delimiter (rather than a + * path segment). + * + *

The path of an RFC 3986 URI is either empty, absolute (starts with the '/' segment + * delimiter) or rootless (starts with a path segment). For example, file:///resume.txt + * , file:/resume.txt and file://localhost/ all have absolute + * paths while tel:+1-206-555-1212's path is not absolute. + * mailto:%2Fdev%2Fnull@example.com is also not absolute because its percent-encoded + * slashes are not segment delimiters but rather part of the first and only path segment. + * + *

Contrast absolute paths with rootless ones (see {@link #isPathRootless()}. + * + *

NB: The term "absolute" has two different meanings in RFC 3986 which are easily confused. + * This method tests for a property of this URI's path component. Contrast with {@link + * #isAbsolute()} which tests the URI itself for a different property. + */ + public boolean isPathAbsolute() { + return path.startsWith("/"); + } + /** * Returns the path component of this URI in its originally parsed, possibly percent-encoded form. */ diff --git a/api/src/test/java/io/grpc/UriTest.java b/api/src/test/java/io/grpc/UriTest.java index 12fc9813b60..e34319e8910 100644 --- a/api/src/test/java/io/grpc/UriTest.java +++ b/api/src/test/java/io/grpc/UriTest.java @@ -46,6 +46,8 @@ public void parse_allComponents() throws URISyntaxException { assertThat(uri.getFragment()).isEqualTo("fragment"); assertThat(uri.toString()).isEqualTo("scheme://user@host:0443/path?query#fragment"); assertThat(uri.isAbsolute()).isFalse(); // Has a fragment. + assertThat(uri.isPathAbsolute()).isTrue(); + assertThat(uri.isPathRootless()).isFalse(); } @Test @@ -127,6 +129,8 @@ public void parse_emptyPathWithAuthority() throws URISyntaxException { assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("scheme://authority"); assertThat(uri.isAbsolute()).isTrue(); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isFalse(); } @Test @@ -139,6 +143,8 @@ public void parse_rootless() throws URISyntaxException { assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("mailto:ceo@company.com?subject=raise"); assertThat(uri.isAbsolute()).isTrue(); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isTrue(); } @Test @@ -151,6 +157,8 @@ public void parse_emptyPath() throws URISyntaxException { assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("scheme:"); assertThat(uri.isAbsolute()).isTrue(); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isFalse(); } @Test @@ -348,12 +356,34 @@ public void parse_onePathSegment_trailingSlash() throws URISyntaxException { assertThat(uri.getPathSegments()).containsExactly("foo", ""); } + @Test + public void parse_onePathSegment_rootless() throws URISyntaxException { + Uri uri = Uri.create("dns:www.example.com"); + assertThat(uri.getPathSegments()).containsExactly("www.example.com"); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isTrue(); + } + @Test public void parse_twoPathSegments() throws URISyntaxException { Uri uri = Uri.create("file:/foo/bar"); assertThat(uri.getPathSegments()).containsExactly("foo", "bar"); } + @Test + public void parse_twoPathSegments_rootless() throws URISyntaxException { + Uri uri = Uri.create("file:foo/bar"); + assertThat(uri.getPathSegments()).containsExactly("foo", "bar"); + } + + @Test + public void parse_percentEncodedPathSegment_rootless() throws URISyntaxException { + Uri uri = Uri.create("mailto:%2Fdev%2Fnull@example.com"); + assertThat(uri.getPathSegments()).containsExactly("/dev/null@example.com"); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isTrue(); + } + @Test public void toString_percentEncoding() throws URISyntaxException { Uri uri = From 15c892bed28c451779ffc218c98a9ce8d22ff0f6 Mon Sep 17 00:00:00 2001 From: John Cormie Date: Thu, 8 Jan 2026 13:57:41 -0800 Subject: [PATCH 2/3] core: add javadoc and tests to enshrine some existing dns behavior Update javadoc/tests to match reality w.r.t target URI path: path isn't actually the name to resolve, only the first path segment is actually used. This is a side effect of authority parsing later, in DnsNameResolver.java. Any additional path segments are silently ignored. Replace discussion of the leading path / by making explicit the requirement that the input java.net.URI be hierarchical. This requirement isn't new -- checkNotNull(targetUri.getPath()) throws today if the target is opaque. Also fix sloppy language in javadoc wrt service authority port: it isn't actually an input to the DNS layer, rather it's copied forward as a property of our output addresses at the io.grpc.NameResolver layer. --- .../internal/DnsNameResolverProvider.java | 11 ++++-- .../internal/DnsNameResolverProviderTest.java | 38 +++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java b/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java index c977fbb0cca..c7f6bce7c69 100644 --- a/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java +++ b/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java @@ -31,15 +31,18 @@ * A provider for {@link DnsNameResolver}. * *

It resolves a target URI whose scheme is {@code "dns"}. The (optional) authority of the target - * URI is reserved for the address of alternative DNS server (not implemented yet). The path of the - * target URI, excluding the leading slash {@code '/'}, is treated as the host name and the optional - * port to be resolved by DNS. Example target URIs: + * URI is reserved for the address of alternative DNS server (not implemented yet). The first path + * segment of the hierarchical target URI is interpreted as an RFC 2396 "server-based" authority and + * used as the "service authority" of the resulting {@link NameResolver}. The "host" part of this + * authority is the name to be resolved by DNS. The "port" part of this authority (if present) will + * become the port number for all {@link InetSocketAddress} produced by this resolver. For example: * *

*/ public final class DnsNameResolverProvider extends NameResolverProvider { diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java index aff10ce9337..f1f4b8f3211 100644 --- a/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java +++ b/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java @@ -16,8 +16,7 @@ package io.grpc.internal; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; +import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -59,10 +58,35 @@ public void isAvailable() { } @Test - public void newNameResolver() { - assertSame(DnsNameResolver.class, - provider.newNameResolver(URI.create("dns:///localhost:443"), args).getClass()); - assertNull( - provider.newNameResolver(URI.create("notdns:///localhost:443"), args)); + public void newNameResolver_acceptsHostAndPort() { + NameResolver nameResolver = provider.newNameResolver(URI.create("dns:///localhost:443"), args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443"); + } + + @Test + public void newNameResolver_rejectsNonDnsScheme() { + NameResolver nameResolver = + provider.newNameResolver(URI.create("notdns:///localhost:443"), args); + assertThat(nameResolver).isNull(); + } + + @Test + public void newNameResolver_toleratesTrailingPathSegments() { + NameResolver nameResolver = + provider.newNameResolver(URI.create("dns:///foo.googleapis.com/ig/nor/ed"), args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); + } + + @Test + public void newNameResolver_toleratesAuthority() { + NameResolver nameResolver = + provider.newNameResolver(URI.create("dns://8.8.8.8/foo.googleapis.com"), args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); } } From c65f5a2d1f136144c442d86943a861341c02ed9e Mon Sep 17 00:00:00 2001 From: John Cormie Date: Thu, 8 Jan 2026 14:18:36 -0800 Subject: [PATCH 3/3] core: Add RFC 3986 support for 'dns' target URIs Accept both absolute (e.g. 'dns:///hostname') and rootless (e.g. 'dns:hostname') paths as specified by https://github.com/grpc/grpc/blob/master/doc/naming.md and matching the behavior of grpc core and grpc-go. --- .../internal/DnsNameResolverProvider.java | 22 ++++++++++ .../internal/DnsNameResolverProviderTest.java | 42 +++++++++++++++---- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java b/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java index c7f6bce7c69..16edf767901 100644 --- a/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java +++ b/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java @@ -21,11 +21,13 @@ import io.grpc.InternalServiceProviders; import io.grpc.NameResolver; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; import java.util.Collection; import java.util.Collections; +import java.util.List; /** * A provider for {@link DnsNameResolver}. @@ -54,6 +56,7 @@ public final class DnsNameResolverProvider extends NameResolverProvider { @Override public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + // TODO(jdcormie): Remove once RFC 3986 migration is complete. if (SCHEME.equals(targetUri.getScheme())) { String targetPath = Preconditions.checkNotNull(targetUri.getPath(), "targetPath"); Preconditions.checkArgument(targetPath.startsWith("/"), @@ -71,6 +74,25 @@ public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { } } + @Override + public NameResolver newNameResolver(Uri targetUri, final NameResolver.Args args) { + if (SCHEME.equals(targetUri.getScheme())) { + List pathSegments = targetUri.getPathSegments(); + Preconditions.checkArgument(!pathSegments.isEmpty(), + "expected 1 path segment in target %s but found %s", targetUri, pathSegments); + String domainNameToResolve = pathSegments.get(0); + return new DnsNameResolver( + targetUri.getAuthority(), + domainNameToResolve, + args, + GrpcUtil.SHARED_CHANNEL_EXECUTOR, + Stopwatch.createUnstarted(), + IS_ANDROID); + } else { + return null; + } + } + @Override public String getDefaultScheme() { return SCHEME; diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java index f1f4b8f3211..fabecea0bad 100644 --- a/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java +++ b/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java @@ -17,6 +17,7 @@ package io.grpc.internal; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.TruthJUnit.assume; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -24,16 +25,27 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import java.net.URI; +import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; /** Unit tests for {@link DnsNameResolverProvider}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class DnsNameResolverProviderTest { private final FakeClock fakeClock = new FakeClock(); + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + private final SynchronizationContext syncContext = new SynchronizationContext( new Thread.UncaughtExceptionHandler() { @Override @@ -59,7 +71,16 @@ public void isAvailable() { @Test public void newNameResolver_acceptsHostAndPort() { - NameResolver nameResolver = provider.newNameResolver(URI.create("dns:///localhost:443"), args); + NameResolver nameResolver = newNameResolver("dns:///localhost:443", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443"); + } + + @Test + public void newNameResolver_acceptsRootless() { + assume().that(enableRfc3986UrisParam).isTrue(); + NameResolver nameResolver = newNameResolver("dns:localhost:443", args); assertThat(nameResolver).isNotNull(); assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443"); @@ -67,15 +88,13 @@ public void newNameResolver_acceptsHostAndPort() { @Test public void newNameResolver_rejectsNonDnsScheme() { - NameResolver nameResolver = - provider.newNameResolver(URI.create("notdns:///localhost:443"), args); + NameResolver nameResolver = newNameResolver("notdns:///localhost:443", args); assertThat(nameResolver).isNull(); } @Test public void newNameResolver_toleratesTrailingPathSegments() { - NameResolver nameResolver = - provider.newNameResolver(URI.create("dns:///foo.googleapis.com/ig/nor/ed"), args); + NameResolver nameResolver = newNameResolver("dns:///foo.googleapis.com/ig/nor/ed", args); assertThat(nameResolver).isNotNull(); assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); @@ -83,10 +102,15 @@ public void newNameResolver_toleratesTrailingPathSegments() { @Test public void newNameResolver_toleratesAuthority() { - NameResolver nameResolver = - provider.newNameResolver(URI.create("dns://8.8.8.8/foo.googleapis.com"), args); + NameResolver nameResolver = newNameResolver("dns://8.8.8.8/foo.googleapis.com", args); assertThat(nameResolver).isNotNull(); assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); } + + private NameResolver newNameResolver(String uriString, NameResolver.Args args) { + return enableRfc3986UrisParam + ? provider.newNameResolver(Uri.create(uriString), args) + : provider.newNameResolver(URI.create(uriString), args); + } }