From 08a615b153b2ea222cd82e5704bd9a752079b678 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 9 Jan 2026 18:52:22 +0100 Subject: [PATCH 1/3] Add RFC6724-based DNS resolver with configurable family preference. Define INTERLEAVE as no-bias and align tests and debug output. --- .../Rfc6724AddressSelectingDnsResolver.java | 595 ++++++++++++++++++ .../http/config/ProtocolFamilyPreference.java | 67 ++ ...fc6724AddressSelectingDnsResolverTest.java | 170 +++++ 3 files changed, 832 insertions(+) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java b/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java new file mode 100644 index 0000000000..357b0dcf60 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java @@ -0,0 +1,595 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http; + +import java.net.DatagramSocket; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.apache.hc.client5.http.config.ProtocolFamilyPreference; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@code Rfc6724AddressSelectingDnsResolver} wraps a delegate {@link DnsResolver} + * and applies RFC 6724 destination address selection rules to the returned + * addresses. It can also enforce or bias a protocol family preference. + * + *

The canonical hostname lookup is delegated unchanged.

+ * + *

+ * {@link ProtocolFamilyPreference#INTERLEAVE} is treated as "no family bias": + * the resolver keeps the RFC 6724 sorted order intact. Family interleaving, if + * desired, should be handled at dial time (e.g. Happy Eyeballs). + *

+ * + * @since 5.6 + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +public final class Rfc6724AddressSelectingDnsResolver implements DnsResolver { + + private static final Logger LOG = LoggerFactory.getLogger(Rfc6724AddressSelectingDnsResolver.class); + + private static final int PROBE_PORT = 53; // UDP connect trick; no packets sent + + private final DnsResolver delegate; + private final ProtocolFamilyPreference familyPreference; + + /** + * Creates a new resolver that applies RFC 6724 ordering with no family bias (INTERLEAVE). + * + * @param delegate underlying resolver to use. + */ + public Rfc6724AddressSelectingDnsResolver(final DnsResolver delegate) { + this(delegate, ProtocolFamilyPreference.INTERLEAVE); + } + + /** + * Creates a new resolver that applies RFC 6724 ordering and a specific protocol family preference. + * + * @param delegate underlying resolver to use. + * @param familyPreference family preference to apply (e.g. PREFER_IPV6, IPV4_ONLY). + */ + public Rfc6724AddressSelectingDnsResolver( + final DnsResolver delegate, + final ProtocolFamilyPreference familyPreference) { + this.delegate = java.util.Objects.requireNonNull(delegate, "delegate"); + this.familyPreference = familyPreference != null ? familyPreference : ProtocolFamilyPreference.INTERLEAVE; + } + + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + if (LOG.isDebugEnabled()) { + LOG.debug("{} resolving host '{}' via delegate {}", simpleName(), host, delegate.getClass().getName()); + LOG.debug("{} familyPreference={}", simpleName(), familyPreference); + } + + final InetAddress[] resolved = delegate.resolve(host); + if (resolved == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("{} delegate returned null for '{}'", simpleName(), host); + } + return null; + } + if (LOG.isDebugEnabled()) { + LOG.debug("{} delegate returned {} addresses for '{}': {}", simpleName(), resolved.length, host, fmt(resolved)); + } + if (resolved.length <= 1) { + if (LOG.isDebugEnabled()) { + LOG.debug("{} nothing to sort/filter (<=1 address). Returning as-is.", simpleName()); + } + return resolved; + } + + // 1) Filter by family if forced + final List candidates = new ArrayList<>(resolved.length); + switch (familyPreference) { + case IPV4_ONLY: { + for (final InetAddress a : resolved) { + if (a instanceof Inet4Address) { + candidates.add(a); + } + } + break; + } + case IPV6_ONLY: { + for (final InetAddress a : resolved) { + if (a instanceof Inet6Address) { + candidates.add(a); + } + } + break; + } + default: { + candidates.addAll(Arrays.asList(resolved)); + break; + } + } + + if (LOG.isDebugEnabled()) { + LOG.debug("{} after family filter {} -> {} candidate(s): {}", simpleName(), familyPreference, candidates.size(), fmt(candidates)); + } + + if (candidates.isEmpty()) { + if (LOG.isDebugEnabled()) { + LOG.debug("{} no address of requested family; returning empty for '{}'", simpleName(), host); + } + return new InetAddress[0]; + } + + // 2) RFC 6724 sort (uses UDP connect to infer source addresses; no packets sent) + final List rfcSorted = sortByRfc6724(candidates); + + // 3) Apply preference bias + final List ordered = applyFamilyPreference(rfcSorted, familyPreference); + + if (LOG.isDebugEnabled()) { + LOG.debug("{} final ordered list for '{}': {}", simpleName(), host, fmt(ordered)); + } + + return ordered.toArray(new InetAddress[0]); + } + + @Override + public String resolveCanonicalHostname(final String host) throws UnknownHostException { + if (LOG.isDebugEnabled()) { + LOG.debug("{} resolveCanonicalHostname('{}') via delegate {}", simpleName(), host, delegate.getClass().getName()); + } + return delegate.resolveCanonicalHostname(host); + } + + // --- RFC 6724 helpers --- + + private static List sortByRfc6724(final List addrs) { + if (addrs.size() < 2) { + return addrs; + } + if (LOG.isDebugEnabled()) { + LOG.debug("RFC6724 input candidates: {}", fmt(addrs)); + } + + final List sockAddrs = new ArrayList<>(addrs.size()); + for (final InetAddress a : addrs) { + sockAddrs.add(new InetSocketAddress(a, PROBE_PORT)); + } + final List srcs = srcAddrs(sockAddrs); + + final List infos = new ArrayList<>(addrs.size()); + for (int i = 0; i < addrs.size(); i++) { + final InetAddress dst = addrs.get(i); + final InetAddress src = srcs.get(i); + infos.add(new Info(dst, src, ipAttrOf(dst), ipAttrOf(src))); + } + + if (LOG.isDebugEnabled()) { + for (final Info info : infos) { + LOG.debug("RFC6724 candidate dst={} src={} dst[scope={},prec={},label={}] src[scope={},prec={},label={}]", + addr(info.dst), addr(info.src), + info.dstAttr.scope, info.dstAttr.precedence, info.dstAttr.label, + info.srcAttr.scope, info.srcAttr.precedence, info.srcAttr.label); + } + } + + infos.sort(RFC6724_COMPARATOR); + + final List out = new ArrayList<>(infos.size()); + for (final Info info : infos) { + out.add(info.dst); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("RFC6724 output order: {}", fmt(out)); + } + return out; + } + + private static List applyFamilyPreference( + final List rfcSorted, + final ProtocolFamilyPreference pref) { + + if (rfcSorted.size() <= 1) { + return rfcSorted; + } + + switch (pref) { + case PREFER_IPV6: + case PREFER_IPV4: { + final boolean preferV6 = pref == ProtocolFamilyPreference.PREFER_IPV6; + final List first = new ArrayList<>(); + final List second = new ArrayList<>(); + for (final InetAddress a : rfcSorted) { + final boolean isV6 = a instanceof Inet6Address; + if (preferV6 && isV6 || !preferV6 && !isV6) { + first.add(a); + } else { + second.add(a); + } + } + final List merged = new ArrayList<>(rfcSorted.size()); + merged.addAll(first); + merged.addAll(second); + if (LOG.isDebugEnabled()) { + LOG.debug("Family preference {} applied. First bucket={}, second bucket={}", pref, fmt(first), fmt(second)); + LOG.debug("Family preference output: {}", fmt(merged)); + } + return merged; + } + case IPV4_ONLY: + case IPV6_ONLY: { + // already filtered earlier + if (LOG.isDebugEnabled()) { + LOG.debug("Family preference {} enforced earlier. Order unchanged: {}", pref, fmt(rfcSorted)); + } + return rfcSorted; + } + case INTERLEAVE: + default: { + // No family bias. Keep RFC 6724 order intact. + if (LOG.isDebugEnabled()) { + LOG.debug("INTERLEAVE treated as no-bias. Order unchanged: {}", fmt(rfcSorted)); + } + return rfcSorted; + } + } + } + + private static List srcAddrs(final List addrs) { + final List srcs = new ArrayList<>(addrs.size()); + for (final InetSocketAddress dest : addrs) { + InetAddress src = null; + try (final DatagramSocket s = new DatagramSocket()) { + s.connect(dest); // does not send packets; OS picks source addr/if + src = s.getLocalAddress(); + } catch (final SocketException ignore) { + if (LOG.isDebugEnabled()) { + LOG.debug("RFC6724 could not infer source address for {}: {}", dest, ignore.toString()); + } + } + srcs.add(src); + } + if (LOG.isDebugEnabled()) { + final List printable = new ArrayList<>(srcs.size()); + for (final InetAddress a : srcs) { + printable.add(addr(a)); + } + LOG.debug("RFC6724 inferred source addresses: {}", printable); + } + return srcs; + } + + // --- RFC 6724 score structs --- + + private static final class Info { + final InetAddress dst; + final InetAddress src; + final Attr dstAttr; + final Attr srcAttr; + + Info(final InetAddress dst, final InetAddress src, final Attr dstAttr, final Attr srcAttr) { + this.dst = dst; + this.src = src; + this.dstAttr = dstAttr; + this.srcAttr = srcAttr; + } + } + + private static final class Attr { + final Scope scope; + final int precedence; + final int label; + + Attr(final Scope scope, final int precedence, final int label) { + this.scope = scope; + this.precedence = precedence; + this.label = label; + } + } + + private enum Scope { + INTERFACE_LOCAL(0x1), + LINK_LOCAL(0x2), + ADMIN_LOCAL(0x4), + SITE_LOCAL(0x5), + ORG_LOCAL(0x8), + GLOBAL(0xe); + + final int value; + + Scope(final int v) { + this.value = v; + } + + static Scope fromValue(final int v) { + switch (v) { + case 0x1: { + return INTERFACE_LOCAL; + } + case 0x2: { + return LINK_LOCAL; + } + case 0x4: { + return ADMIN_LOCAL; + } + case 0x5: { + return SITE_LOCAL; + } + case 0x8: { + return ORG_LOCAL; + } + default: { + return GLOBAL; + } + } + } + } + + private static Attr ipAttrOf(final InetAddress ip) { + if (ip == null) { + return new Attr(Scope.GLOBAL, 0, 0); + } + final PolicyEntry e = classify(ip); + return new Attr(classifyScope(ip), e.precedence, e.label); + } + + private static Scope classifyScope(final InetAddress ip) { + if (ip.isLoopbackAddress()) { + return Scope.INTERFACE_LOCAL; + } + if (ip.isLinkLocalAddress()) { + return Scope.LINK_LOCAL; + } + if (ip.isMulticastAddress()) { + if (ip instanceof Inet6Address) { + // RFC 4291: low 4 bits of second byte are scope. + return Scope.fromValue(ip.getAddress()[1] & 0x0f); + } + return Scope.GLOBAL; + } + if (ip.isSiteLocalAddress()) { + return Scope.SITE_LOCAL; + } + return Scope.GLOBAL; + } + + private static final class PolicyEntry { + final Network prefix; + final int precedence; + final int label; + + PolicyEntry(final Network prefix, final int precedence, final int label) { + this.prefix = prefix; + this.precedence = precedence; + this.label = label; + } + } + + private static final class Network { + final byte[] ip; + final int bits; + + Network(final byte[] ip, final int bits) { + this.ip = ip; + this.bits = bits; + } + + boolean contains(final InetAddress addr) { + final byte[] a = addr instanceof Inet4Address ? v4toMapped(addr.getAddress()) : addr.getAddress(); + if (a.length != ip.length) { + return false; + } + final int fullBytes = bits / 8; + for (int i = 0; i < fullBytes; i++) { + if (a[i] != ip[i]) { + return false; + } + } + final int rem = bits % 8; + if (rem == 0) { + return true; + } + final int mask = 0xff << 8 - rem; + return (a[fullBytes] & mask) == (ip[fullBytes] & mask); + } + + private static byte[] v4toMapped(final byte[] v4) { + final byte[] mapped = new byte[16]; + mapped[10] = (byte) 0xff; + mapped[11] = (byte) 0xff; + System.arraycopy(v4, 0, mapped, 12, 4); + return mapped; + } + } + + private static Network toPrefix(final String text, final int bits) { + try { + return new Network(InetAddress.getByName(text).getAddress(), bits); + } catch (final UnknownHostException ex) { + throw new IllegalArgumentException(ex); + } + } + + private static final List POLICY_TABLE = + Collections.unmodifiableList(Arrays.asList( + new PolicyEntry(toPrefix("::1", 128), 50, 0), + new PolicyEntry(toPrefix("::ffff:0:0", 96), 35, 4), + new PolicyEntry(toPrefix("::", 96), 1, 3), + new PolicyEntry(toPrefix("2001::", 32), 5, 5), + new PolicyEntry(toPrefix("2002::", 16), 30, 2), + new PolicyEntry(toPrefix("3ffe::", 16), 1, 12), + new PolicyEntry(toPrefix("fec0::", 10), 1, 11), + new PolicyEntry(toPrefix("fc00::", 7), 3, 13), + new PolicyEntry(toPrefix("::", 0), 40, 1) + )); + + private static PolicyEntry classify(final InetAddress ip) { + for (final PolicyEntry e : POLICY_TABLE) { + if (e.prefix.contains(ip)) { + return e; + } + } + return new PolicyEntry(null, 40, 1); + } + + private static final Comparator RFC6724_COMPARATOR = (a, b) -> { + final InetAddress DA = a.dst; + final InetAddress DB = b.dst; + final InetAddress SourceDA = a.src; + final InetAddress SourceDB = b.src; + final Attr attrDA = a.dstAttr; + final Attr attrDB = b.dstAttr; + final Attr attrSourceDA = a.srcAttr; + final Attr attrSourceDB = b.srcAttr; + + final int preferDA = -1; + final int preferDB = 1; + + // Rule 1: Avoid unusable destinations. + final boolean validA = SourceDA != null && !SourceDA.isAnyLocalAddress(); + final boolean validB = SourceDB != null && !SourceDB.isAnyLocalAddress(); + if (!validA && !validB) { + return 0; + } + if (!validB) { + return preferDA; + } + if (!validA) { + return preferDB; + } + + // Rule 2: Prefer matching scope. + if (attrDA.scope == attrSourceDA.scope && attrDB.scope != attrSourceDB.scope) { + return preferDA; + } + if (attrDA.scope != attrSourceDA.scope && attrDB.scope == attrSourceDB.scope) { + return preferDB; + } + + // Rule 5: Prefer matching label. + if (attrSourceDA.label == attrDA.label && attrSourceDB.label != attrDB.label) { + return preferDA; + } + if (attrSourceDA.label != attrDA.label && attrSourceDB.label == attrDB.label) { + return preferDB; + } + + // Rule 6: Prefer higher precedence. + if (attrDA.precedence > attrDB.precedence) { + return preferDA; + } + if (attrDA.precedence < attrDB.precedence) { + return preferDB; + } + + // Rule 8: Prefer smaller scope. + if (attrDA.scope.value < attrDB.scope.value) { + return preferDA; + } + if (attrDA.scope.value > attrDB.scope.value) { + return preferDB; + } + + // Rule 9: Longest common prefix (IPv6 only). + if (DA instanceof Inet6Address && DB instanceof Inet6Address) { + final int commonA = commonPrefixLen(SourceDA, DA); + final int commonB = commonPrefixLen(SourceDB, DB); + if (commonA > commonB) { + return preferDA; + } + if (commonA < commonB) { + return preferDB; + } + } + + // Rule 10: Otherwise equal (original order preserved by stable sort). + return 0; + }; + + private static int commonPrefixLen(final InetAddress a, final InetAddress b) { + if (a == null || b == null || a.getClass() != b.getClass()) { + return 0; + } + final byte[] aa = a.getAddress(); + final byte[] bb = b.getAddress(); + final int len = Math.min(aa.length, bb.length); + int bits = 0; + for (int i = 0; i < len; i++) { + final int x = (aa[i] ^ bb[i]) & 0xFF; + if (x == 0) { + bits += 8; + } else { + for (int j = 7; j >= 0; j--) { + if ((x & 1 << j) != 0) { + return bits; + } + bits++; + } + return bits; + } + } + return bits; + } + + private static String addr(final InetAddress a) { + if (a == null) { + return "null"; + } + final String family = a instanceof Inet6Address ? "IPv6" : "IPv4"; + return family + "(" + a.getHostAddress() + ")"; + } + + private static List fmt(final InetAddress[] arr) { + final List out = new ArrayList<>(arr.length); + for (final InetAddress a : arr) { + out.add(addr(a)); + } + return out; + } + + private static List fmt(final List arr) { + final List out = new ArrayList<>(arr.size()); + for (final InetAddress a : arr) { + out.add(addr(a)); + } + return out; + } + + private static String simpleName() { + return "Rfc6724Resolver"; + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java new file mode 100644 index 0000000000..705ad6f8fd --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java @@ -0,0 +1,67 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.config; + +/** + * Protocol family preference for outbound connections. + * + *

Used by connection initiation code to filter or order destination + * addresses and, when enabled, to interleave families during staggered attempts. + * + * @since 5.7 + */ +public enum ProtocolFamilyPreference { + /** Keep families as returned (or RFC 6724 ordered). */ + DEFAULT, + /** + * Prefer IPv4 addresses but allow IPv6 as a fallback. + */ + PREFER_IPV4, + + /** + * Prefer IPv6 addresses but allow IPv4 as a fallback. + */ + PREFER_IPV6, + + /** + * Use only IPv4 addresses. + */ + IPV4_ONLY, + + /** + * Use only IPv6 addresses. + */ + IPV6_ONLY, + + /** + * Interleave address families (v6, then v4, then v6, …) when multiple + * addresses are available. When staggered connects are enabled, the first + * address of the other family is delayed by a small offset. + */ + INTERLEAVE +} + diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java new file mode 100644 index 0000000000..63f46833c9 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java @@ -0,0 +1,170 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.Mockito.when; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.hc.client5.http.config.ProtocolFamilyPreference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class Rfc6724AddressSelectingDnsResolverTest { + + private DnsResolver delegate; + + @BeforeEach + void setUp() { + delegate = Mockito.mock(DnsResolver.class); + } + + @Test + void ipv4Only_filtersOutIPv6() throws Exception { + final InetAddress v4 = InetAddress.getByName("203.0.113.10"); // TEST-NET-3 + final InetAddress v6 = InetAddress.getByName("2001:db8::10"); // documentation prefix + + when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v6, v4}); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY); + + final InetAddress[] ordered = r.resolve("dual.example"); + Assertions.assertNotNull(ordered); + assertEquals(1, ordered.length); + assertInstanceOf(Inet4Address.class, ordered[0]); + assertEquals(v4, ordered[0]); + } + + @Test + void ipv6Only_filtersOutIPv4() throws Exception { + final InetAddress v4 = InetAddress.getByName("192.0.2.1"); // TEST-NET-1 + final InetAddress v6 = InetAddress.getByName("2001:db8::1"); + + when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v4, v6}); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV6_ONLY); + + final InetAddress[] ordered = r.resolve("dual.example"); + Assertions.assertNotNull(ordered); + assertEquals(1, ordered.length); + assertInstanceOf(Inet6Address.class, ordered[0]); + assertEquals(v6, ordered[0]); + } + + @Test + void ipv4Only_emptyWhenNoIPv4Candidates() throws Exception { + final InetAddress v6a = InetAddress.getByName("2001:db8::1"); + final InetAddress v6b = InetAddress.getByName("2001:db8::2"); + + when(delegate.resolve("v6only.example")).thenReturn(new InetAddress[]{v6a, v6b}); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY); + + final InetAddress[] ordered = r.resolve("v6only.example"); + Assertions.assertNotNull(ordered); + assertEquals(0, ordered.length); + } + + @Test + void interleave_isDefault_and_hasNoFamilyBias() throws Exception { + final InetAddress v6a = InetAddress.getByName("2001:db8::1"); + final InetAddress v6b = InetAddress.getByName("2001:db8::2"); + final InetAddress v4a = InetAddress.getByName("192.0.2.1"); + final InetAddress v4b = InetAddress.getByName("203.0.113.10"); + + when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v6a, v6b, v4a, v4b}); + + final Rfc6724AddressSelectingDnsResolver rDefault = new Rfc6724AddressSelectingDnsResolver(delegate); + final Rfc6724AddressSelectingDnsResolver rInterleave = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE); + + final InetAddress[] outDefault = rDefault.resolve("dual.example"); + final InetAddress[] outInterleave = rInterleave.resolve("dual.example"); + + assertArrayEquals(outDefault, outInterleave); + Assertions.assertNotNull(outInterleave); + assertEquals(4, outInterleave.length); + } + + @Test + void preferIpv6_groupsAllV6First_preservingRelativeOrder() throws Exception { + final InetAddress v4a = InetAddress.getByName("192.0.2.1"); + final InetAddress v6a = InetAddress.getByName("2001:db8::1"); + final InetAddress v4b = InetAddress.getByName("203.0.113.10"); + final InetAddress v6b = InetAddress.getByName("2001:db8::2"); + + when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v4a, v6a, v4b, v6b}); + + final Rfc6724AddressSelectingDnsResolver baseline = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE); + final InetAddress[] baseOut = baseline.resolve("dual.example"); + + final Rfc6724AddressSelectingDnsResolver preferV6 = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.PREFER_IPV6); + final InetAddress[] out = preferV6.resolve("dual.example"); + + // Expected: stable partition of the RFC-sorted baseline. + final List baseV6 = new ArrayList<>(); + final List baseV4 = new ArrayList<>(); + for (final InetAddress a : baseOut) { + if (a instanceof Inet6Address) { + baseV6.add(a); + } else { + baseV4.add(a); + } + } + final List expected = new ArrayList<>(baseOut.length); + expected.addAll(baseV6); + expected.addAll(baseV4); + + assertEquals(expected, Arrays.asList(out)); + assertInstanceOf(Inet6Address.class, out[0]); + } + + @Test + void canonicalHostname_delegates() throws Exception { + when(delegate.resolveCanonicalHostname("example.org")).thenReturn("canon.example.org"); + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE); + assertEquals("canon.example.org", r.resolveCanonicalHostname("example.org")); + Mockito.verify(delegate).resolveCanonicalHostname("example.org"); + } + +} From 0f91122066f297236b09e3a05ab368f5d2dccd06 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 13 Jan 2026 12:01:53 +0100 Subject: [PATCH 2/3] Implement true INTERLEAVE ordering. Add manual gated IT to dump DEFAULT vs INTERLEAVE results. Expand unit coverage for scope mapping and core RFC comparison rules. --- .../testing/ManualRfc6724ResolverIT.java | 87 +++++ .../Rfc6724AddressSelectingDnsResolver.java | 365 +++++++++++------- .../http/config/ProtocolFamilyPreference.java | 19 +- ...fc6724AddressSelectingDnsResolverTest.java | 345 +++++++++++++---- .../http/examples/Rfc6724ResolverExample.java | 67 ++++ 5 files changed, 666 insertions(+), 217 deletions(-) create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java new file mode 100644 index 0000000000..7503e8c12e --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java @@ -0,0 +1,87 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.testing; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.net.Inet6Address; +import java.net.InetAddress; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.Rfc6724AddressSelectingDnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.config.ProtocolFamilyPreference; +import org.junit.jupiter.api.Test; + +class ManualRfc6724ResolverIT { + + private static final String PROP_ENABLE = "httpclient.rfc6724.it"; + private static final String PROP_HOST = "httpclient.rfc6724.host"; + + @Test + void resolve_and_dump_order() throws Exception { + assumeTrue(Boolean.getBoolean(PROP_ENABLE), + "Enable with -Dhttpclient.rfc6724.it=true"); + + final String host = System.getProperty(PROP_HOST, "localhost"); + + final DnsResolver base = SystemDefaultDnsResolver.INSTANCE; + + System.out.println("Host: " + host); + + dump(base, host, ProtocolFamilyPreference.DEFAULT); + System.out.println(); + dump(base, host, ProtocolFamilyPreference.INTERLEAVE); + } + + private static void dump( + final DnsResolver base, + final String host, + final ProtocolFamilyPreference pref) throws Exception { + + final Rfc6724AddressSelectingDnsResolver resolver = + new Rfc6724AddressSelectingDnsResolver(base, pref); + + final InetAddress[] out = resolver.resolve(host); + + int v4 = 0; + int v6 = 0; + + System.out.println("Preference: " + pref); + for (final InetAddress a : out) { + if (a instanceof Inet6Address) { + v6++; + System.out.println(" IPv6 " + a.getHostAddress()); + } else { + v4++; + System.out.println(" IPv4 " + a.getHostAddress()); + } + } + System.out.println("Counts: IPv4=" + v4 + " IPv6=" + v6); + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java b/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java index 357b0dcf60..48f0e061f7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java @@ -39,6 +39,8 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; import org.apache.hc.client5.http.config.ProtocolFamilyPreference; import org.apache.hc.core5.annotation.Contract; @@ -48,18 +50,18 @@ /** * {@code Rfc6724AddressSelectingDnsResolver} wraps a delegate {@link DnsResolver} - * and applies RFC 6724 destination address selection rules to the returned - * addresses. It can also enforce or bias a protocol family preference. + * and applies RFC 6724 destination address selection rules (RFC 6724 §6) + * to the returned addresses. It can also enforce or bias a protocol family preference. * *

The canonical hostname lookup is delegated unchanged.

* *

- * {@link ProtocolFamilyPreference#INTERLEAVE} is treated as "no family bias": - * the resolver keeps the RFC 6724 sorted order intact. Family interleaving, if - * desired, should be handled at dial time (e.g. Happy Eyeballs). + * {@link ProtocolFamilyPreference#DEFAULT} keeps the RFC 6724 sorted order intact (no family bias). + * {@link ProtocolFamilyPreference#INTERLEAVE} interleaves IPv6 and IPv4 addresses (v6, v4, v6, …), + * preserving the relative order within each family as produced by RFC 6724 sorting. *

* - * @since 5.6 + * @since 5.7 */ @Contract(threading = ThreadingBehavior.IMMUTABLE) public final class Rfc6724AddressSelectingDnsResolver implements DnsResolver { @@ -68,16 +70,29 @@ public final class Rfc6724AddressSelectingDnsResolver implements DnsResolver { private static final int PROBE_PORT = 53; // UDP connect trick; no packets sent + @FunctionalInterface + interface SourceAddressResolver { + InetAddress resolveSource(final InetSocketAddress destination) throws SocketException; + } + + private static final SourceAddressResolver DEFAULT_SOURCE_ADDRESS_RESOLVER = destination -> { + try (final DatagramSocket socket = new DatagramSocket()) { + socket.connect(destination); + return socket.getLocalAddress(); + } + }; + private final DnsResolver delegate; private final ProtocolFamilyPreference familyPreference; + private final SourceAddressResolver sourceAddressResolver; /** - * Creates a new resolver that applies RFC 6724 ordering with no family bias (INTERLEAVE). + * Creates a new resolver that applies RFC 6724 ordering with no family bias (DEFAULT). * * @param delegate underlying resolver to use. */ public Rfc6724AddressSelectingDnsResolver(final DnsResolver delegate) { - this(delegate, ProtocolFamilyPreference.INTERLEAVE); + this(delegate, ProtocolFamilyPreference.DEFAULT); } /** @@ -89,78 +104,57 @@ public Rfc6724AddressSelectingDnsResolver(final DnsResolver delegate) { public Rfc6724AddressSelectingDnsResolver( final DnsResolver delegate, final ProtocolFamilyPreference familyPreference) { - this.delegate = java.util.Objects.requireNonNull(delegate, "delegate"); - this.familyPreference = familyPreference != null ? familyPreference : ProtocolFamilyPreference.INTERLEAVE; + this(delegate, familyPreference, DEFAULT_SOURCE_ADDRESS_RESOLVER); + } + + // Package-private for unit tests: allows deterministic source address inference. + Rfc6724AddressSelectingDnsResolver( + final DnsResolver delegate, + final ProtocolFamilyPreference familyPreference, + final SourceAddressResolver sourceAddressResolver) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.familyPreference = familyPreference != null ? familyPreference : ProtocolFamilyPreference.DEFAULT; + this.sourceAddressResolver = sourceAddressResolver != null ? sourceAddressResolver : DEFAULT_SOURCE_ADDRESS_RESOLVER; } @Override public InetAddress[] resolve(final String host) throws UnknownHostException { - if (LOG.isDebugEnabled()) { - LOG.debug("{} resolving host '{}' via delegate {}", simpleName(), host, delegate.getClass().getName()); - LOG.debug("{} familyPreference={}", simpleName(), familyPreference); - } - final InetAddress[] resolved = delegate.resolve(host); + if (resolved == null) { if (LOG.isDebugEnabled()) { - LOG.debug("{} delegate returned null for '{}'", simpleName(), host); + LOG.debug("{} resolved '{}' -> null", simpleName(), host); } return null; } - if (LOG.isDebugEnabled()) { - LOG.debug("{} delegate returned {} addresses for '{}': {}", simpleName(), resolved.length, host, fmt(resolved)); - } + if (resolved.length <= 1) { if (LOG.isDebugEnabled()) { - LOG.debug("{} nothing to sort/filter (<=1 address). Returning as-is.", simpleName()); + LOG.debug("{} resolved '{}' -> {}", simpleName(), host, fmt(resolved)); } return resolved; } - // 1) Filter by family if forced - final List candidates = new ArrayList<>(resolved.length); - switch (familyPreference) { - case IPV4_ONLY: { - for (final InetAddress a : resolved) { - if (a instanceof Inet4Address) { - candidates.add(a); - } - } - break; - } - case IPV6_ONLY: { - for (final InetAddress a : resolved) { - if (a instanceof Inet6Address) { - candidates.add(a); - } - } - break; - } - default: { - candidates.addAll(Arrays.asList(resolved)); - break; - } + if (LOG.isTraceEnabled()) { + LOG.trace("{} resolving host '{}' via delegate {}", simpleName(), host, delegate.getClass().getName()); + LOG.trace("{} familyPreference={}", simpleName(), familyPreference); + LOG.trace("{} delegate returned {} addresses for '{}': {}", simpleName(), resolved.length, host, fmt(resolved)); } - if (LOG.isDebugEnabled()) { - LOG.debug("{} after family filter {} -> {} candidate(s): {}", simpleName(), familyPreference, candidates.size(), fmt(candidates)); - } + final List candidates = filterCandidates(resolved, familyPreference); if (candidates.isEmpty()) { if (LOG.isDebugEnabled()) { - LOG.debug("{} no address of requested family; returning empty for '{}'", simpleName(), host); + LOG.debug("{} resolved '{}' -> []", simpleName(), host); } return new InetAddress[0]; } - // 2) RFC 6724 sort (uses UDP connect to infer source addresses; no packets sent) final List rfcSorted = sortByRfc6724(candidates); - - // 3) Apply preference bias final List ordered = applyFamilyPreference(rfcSorted, familyPreference); if (LOG.isDebugEnabled()) { - LOG.debug("{} final ordered list for '{}': {}", simpleName(), host, fmt(ordered)); + LOG.debug("{} resolved '{}' -> {}", simpleName(), host, fmt(ordered)); } return ordered.toArray(new InetAddress[0]); @@ -168,27 +162,74 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { @Override public String resolveCanonicalHostname(final String host) throws UnknownHostException { - if (LOG.isDebugEnabled()) { - LOG.debug("{} resolveCanonicalHostname('{}') via delegate {}", simpleName(), host, delegate.getClass().getName()); + if (LOG.isTraceEnabled()) { + LOG.trace("{} resolveCanonicalHostname('{}') via delegate {}", simpleName(), host, delegate.getClass().getName()); } return delegate.resolveCanonicalHostname(host); } + private static boolean isUsableDestination(final InetAddress ip) { + if (ip == null) { + return false; + } + if (ip.isAnyLocalAddress()) { + return false; + } + // HTTP/TCP is for unicast destinations; multicast is not a valid connect target. + if (ip.isMulticastAddress()) { + return false; + } + return true; + } + + private static List filterCandidates( + final InetAddress[] resolved, + final ProtocolFamilyPreference pref) { + + final List out = new ArrayList<>(resolved.length); + for (final InetAddress a : resolved) { + if (!isUsableDestination(a)) { + continue; + } + switch (pref) { + case IPV4_ONLY: { + if (a instanceof Inet4Address) { + out.add(a); + } + break; + } + case IPV6_ONLY: { + if (a instanceof Inet6Address) { + out.add(a); + } + break; + } + default: { + out.add(a); + break; + } + } + } + return out; + } + // --- RFC 6724 helpers --- - private static List sortByRfc6724(final List addrs) { + private List sortByRfc6724(final List addrs) { if (addrs.size() < 2) { return addrs; } - if (LOG.isDebugEnabled()) { - LOG.debug("RFC6724 input candidates: {}", fmt(addrs)); + + if (LOG.isTraceEnabled()) { + LOG.trace("RFC6724 input candidates: {}", fmt(addrs)); } - final List sockAddrs = new ArrayList<>(addrs.size()); + final List socketAddresses = new ArrayList<>(addrs.size()); for (final InetAddress a : addrs) { - sockAddrs.add(new InetSocketAddress(a, PROBE_PORT)); + socketAddresses.add(new InetSocketAddress(a, PROBE_PORT)); } - final List srcs = srcAddrs(sockAddrs); + + final List srcs = inferSourceAddresses(socketAddresses); final List infos = new ArrayList<>(addrs.size()); for (int i = 0; i < addrs.size(); i++) { @@ -197,9 +238,9 @@ private static List sortByRfc6724(final List addrs) { infos.add(new Info(dst, src, ipAttrOf(dst), ipAttrOf(src))); } - if (LOG.isDebugEnabled()) { + if (LOG.isTraceEnabled()) { for (final Info info : infos) { - LOG.debug("RFC6724 candidate dst={} src={} dst[scope={},prec={},label={}] src[scope={},prec={},label={}]", + LOG.trace("RFC6724 candidate dst={} src={} dst[scope={},prec={},label={}] src[scope={},prec={},label={}]", addr(info.dst), addr(info.src), info.dstAttr.scope, info.dstAttr.precedence, info.dstAttr.label, info.srcAttr.scope, info.srcAttr.precedence, info.srcAttr.label); @@ -213,12 +254,39 @@ private static List sortByRfc6724(final List addrs) { out.add(info.dst); } - if (LOG.isDebugEnabled()) { - LOG.debug("RFC6724 output order: {}", fmt(out)); + if (LOG.isTraceEnabled()) { + LOG.trace("RFC6724 output order: {}", fmt(out)); } + return out; } + private List inferSourceAddresses(final List destinations) { + final List srcs = new ArrayList<>(destinations.size()); + + for (final InetSocketAddress dest : destinations) { + InetAddress src = null; + try { + src = sourceAddressResolver.resolveSource(dest); + } catch (final SocketException ignore) { + if (LOG.isTraceEnabled()) { + LOG.trace("RFC6724 could not infer source address for {}: {}", dest, ignore.toString()); + } + } + srcs.add(src); + } + + if (LOG.isTraceEnabled()) { + final List printable = new ArrayList<>(srcs.size()); + for (final InetAddress a : srcs) { + printable.add(addr(a)); + } + LOG.trace("RFC6724 inferred source addresses: {}", printable); + } + + return srcs; + } + private static List applyFamilyPreference( final List rfcSorted, final ProtocolFamilyPreference pref) { @@ -231,66 +299,69 @@ private static List applyFamilyPreference( case PREFER_IPV6: case PREFER_IPV4: { final boolean preferV6 = pref == ProtocolFamilyPreference.PREFER_IPV6; - final List first = new ArrayList<>(); - final List second = new ArrayList<>(); - for (final InetAddress a : rfcSorted) { - final boolean isV6 = a instanceof Inet6Address; - if (preferV6 && isV6 || !preferV6 && !isV6) { - first.add(a); - } else { - second.add(a); - } + + // Stable: preserves the RFC6724 order within each family. + final List out = rfcSorted.stream() + .sorted(Comparator.comparingInt(a -> ((a instanceof Inet6Address) == preferV6) ? 0 : 1)) + .collect(Collectors.toList()); + + if (LOG.isTraceEnabled()) { + LOG.trace("Family preference {} applied. Output: {}", pref, fmt(out)); } - final List merged = new ArrayList<>(rfcSorted.size()); - merged.addAll(first); - merged.addAll(second); - if (LOG.isDebugEnabled()) { - LOG.debug("Family preference {} applied. First bucket={}, second bucket={}", pref, fmt(first), fmt(second)); - LOG.debug("Family preference output: {}", fmt(merged)); + return out; + } + case INTERLEAVE: { + final List out = interleaveFamilies(rfcSorted); + if (LOG.isTraceEnabled()) { + LOG.trace("Family preference {} applied. Output: {}", pref, fmt(out)); } - return merged; + return out; } case IPV4_ONLY: case IPV6_ONLY: { // already filtered earlier - if (LOG.isDebugEnabled()) { - LOG.debug("Family preference {} enforced earlier. Order unchanged: {}", pref, fmt(rfcSorted)); - } return rfcSorted; } - case INTERLEAVE: + case DEFAULT: default: { // No family bias. Keep RFC 6724 order intact. - if (LOG.isDebugEnabled()) { - LOG.debug("INTERLEAVE treated as no-bias. Order unchanged: {}", fmt(rfcSorted)); - } return rfcSorted; } } } - private static List srcAddrs(final List addrs) { - final List srcs = new ArrayList<>(addrs.size()); - for (final InetSocketAddress dest : addrs) { - InetAddress src = null; - try (final DatagramSocket s = new DatagramSocket()) { - s.connect(dest); // does not send packets; OS picks source addr/if - src = s.getLocalAddress(); - } catch (final SocketException ignore) { - if (LOG.isDebugEnabled()) { - LOG.debug("RFC6724 could not infer source address for {}: {}", dest, ignore.toString()); - } + private static List interleaveFamilies(final List rfcSorted) { + final List v6 = new ArrayList<>(); + final List v4 = new ArrayList<>(); + + for (final InetAddress a : rfcSorted) { + if (a instanceof Inet6Address) { + v6.add(a); + } else { + v4.add(a); } - srcs.add(src); } - if (LOG.isDebugEnabled()) { - final List printable = new ArrayList<>(srcs.size()); - for (final InetAddress a : srcs) { - printable.add(addr(a)); + + if (v6.isEmpty() || v4.isEmpty()) { + return rfcSorted; + } + + final boolean startWithV6 = rfcSorted.get(0) instanceof Inet6Address; + final List first = startWithV6 ? v6 : v4; + final List second = startWithV6 ? v4 : v6; + + final List out = new ArrayList<>(rfcSorted.size()); + int i = 0; + int j = 0; + while (i < first.size() || j < second.size()) { + if (i < first.size()) { + out.add(first.get(i++)); + } + if (j < second.size()) { + out.add(second.get(j++)); } - LOG.debug("RFC6724 inferred source addresses: {}", printable); } - return srcs; + return out; } // --- RFC 6724 score structs --- @@ -376,7 +447,7 @@ private static Scope classifyScope(final InetAddress ip) { } if (ip.isMulticastAddress()) { if (ip instanceof Inet6Address) { - // RFC 4291: low 4 bits of second byte are scope. + // RFC 6724 §3.1 and RFC 4291: low 4 bits of second byte are scope for IPv6 multicast. return Scope.fromValue(ip.getAddress()[1] & 0x0f); } return Scope.GLOBAL; @@ -423,8 +494,10 @@ boolean contains(final InetAddress addr) { if (rem == 0) { return true; } - final int mask = 0xff << 8 - rem; - return (a[fullBytes] & mask) == (ip[fullBytes] & mask); + final int mask = 0xff << (8 - rem); + final int aByte = a[fullBytes] & 0xff; + final int ipByte = ip[fullBytes] & 0xff; + return (aByte & mask) == (ipByte & mask); } private static byte[] v4toMapped(final byte[] v4) { @@ -467,72 +540,74 @@ private static PolicyEntry classify(final InetAddress ip) { } private static final Comparator RFC6724_COMPARATOR = (a, b) -> { - final InetAddress DA = a.dst; - final InetAddress DB = b.dst; - final InetAddress SourceDA = a.src; - final InetAddress SourceDB = b.src; - final Attr attrDA = a.dstAttr; - final Attr attrDB = b.dstAttr; - final Attr attrSourceDA = a.srcAttr; - final Attr attrSourceDB = b.srcAttr; - - final int preferDA = -1; - final int preferDB = 1; + final InetAddress aDst = a.dst; + final InetAddress bDst = b.dst; + final InetAddress aSrc = a.src; + final InetAddress bSrc = b.src; + final Attr aDstAttr = a.dstAttr; + final Attr bDstAttr = b.dstAttr; + final Attr aSrcAttr = a.srcAttr; + final Attr bSrcAttr = b.srcAttr; + + final int preferA = -1; + final int preferB = 1; + + // RFC 6724 §6: destination address selection rules. // Rule 1: Avoid unusable destinations. - final boolean validA = SourceDA != null && !SourceDA.isAnyLocalAddress(); - final boolean validB = SourceDB != null && !SourceDB.isAnyLocalAddress(); + final boolean validA = aSrc != null && !aSrc.isAnyLocalAddress(); + final boolean validB = bSrc != null && !bSrc.isAnyLocalAddress(); if (!validA && !validB) { return 0; } if (!validB) { - return preferDA; + return preferA; } if (!validA) { - return preferDB; + return preferB; } // Rule 2: Prefer matching scope. - if (attrDA.scope == attrSourceDA.scope && attrDB.scope != attrSourceDB.scope) { - return preferDA; + if (aDstAttr.scope == aSrcAttr.scope && bDstAttr.scope != bSrcAttr.scope) { + return preferA; } - if (attrDA.scope != attrSourceDA.scope && attrDB.scope == attrSourceDB.scope) { - return preferDB; + if (aDstAttr.scope != aSrcAttr.scope && bDstAttr.scope == bSrcAttr.scope) { + return preferB; } // Rule 5: Prefer matching label. - if (attrSourceDA.label == attrDA.label && attrSourceDB.label != attrDB.label) { - return preferDA; + if (aSrcAttr.label == aDstAttr.label && bSrcAttr.label != bDstAttr.label) { + return preferA; } - if (attrSourceDA.label != attrDA.label && attrSourceDB.label == attrDB.label) { - return preferDB; + if (aSrcAttr.label != aDstAttr.label && bSrcAttr.label == bDstAttr.label) { + return preferB; } // Rule 6: Prefer higher precedence. - if (attrDA.precedence > attrDB.precedence) { - return preferDA; + if (aDstAttr.precedence > bDstAttr.precedence) { + return preferA; } - if (attrDA.precedence < attrDB.precedence) { - return preferDB; + if (aDstAttr.precedence < bDstAttr.precedence) { + return preferB; } // Rule 8: Prefer smaller scope. - if (attrDA.scope.value < attrDB.scope.value) { - return preferDA; + if (aDstAttr.scope.value < bDstAttr.scope.value) { + return preferA; } - if (attrDA.scope.value > attrDB.scope.value) { - return preferDB; + if (aDstAttr.scope.value > bDstAttr.scope.value) { + return preferB; } - // Rule 9: Longest common prefix (IPv6 only). - if (DA instanceof Inet6Address && DB instanceof Inet6Address) { - final int commonA = commonPrefixLen(SourceDA, DA); - final int commonB = commonPrefixLen(SourceDB, DB); + // Rule 9: Longest matching prefix (IPv6 only). + if (aDst instanceof Inet6Address && bDst instanceof Inet6Address) { + final int commonA = commonPrefixLen(aSrc, aDst); + final int commonB = commonPrefixLen(bSrc, bDst); if (commonA > commonB) { - return preferDA; + return preferA; } if (commonA < commonB) { - return preferDB; + return preferB; } } @@ -554,7 +629,7 @@ private static int commonPrefixLen(final InetAddress a, final InetAddress b) { bits += 8; } else { for (int j = 7; j >= 0; j--) { - if ((x & 1 << j) != 0) { + if ((x & (1 << j)) != 0) { return bits; } bits++; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java index 705ad6f8fd..7ad272e3f6 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java @@ -35,33 +35,38 @@ * @since 5.7 */ public enum ProtocolFamilyPreference { - /** Keep families as returned (or RFC 6724 ordered). */ + + /** + * No family bias. Preserve RFC 6724 order. + */ DEFAULT, + /** - * Prefer IPv4 addresses but allow IPv6 as a fallback. + * Prefer IPv4 addresses (stable: preserves RFC order within each family). */ PREFER_IPV4, /** - * Prefer IPv6 addresses but allow IPv4 as a fallback. + * Prefer IPv6 addresses (stable: preserves RFC order within each family). */ PREFER_IPV6, /** - * Use only IPv4 addresses. + * Filter out all non-IPv4 addresses. */ IPV4_ONLY, /** - * Use only IPv6 addresses. + * Filter out all non-IPv6 addresses. */ IPV6_ONLY, /** * Interleave address families (v6, then v4, then v6, …) when multiple - * addresses are available. When staggered connects are enabled, the first - * address of the other family is delayed by a small offset. + * addresses are available, preserving the relative order within each family + * as produced by RFC 6724 sorting. */ INTERLEAVE + } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java index 63f46833c9..3690a99aeb 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java @@ -24,47 +24,51 @@ * . * */ + package org.apache.hc.client5.http; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.lang.reflect.Method; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; -import java.util.ArrayList; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; import java.util.Arrays; import java.util.List; import org.apache.hc.client5.http.config.ProtocolFamilyPreference; -import org.junit.jupiter.api.Assertions; +import org.apache.hc.client5.http.impl.InMemoryDnsResolver; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; class Rfc6724AddressSelectingDnsResolverTest { - private DnsResolver delegate; + private static final Rfc6724AddressSelectingDnsResolver.SourceAddressResolver NO_SOURCE_ADDR = + (final InetSocketAddress dest) -> null; + + private InMemoryDnsResolver delegate; @BeforeEach void setUp() { - delegate = Mockito.mock(DnsResolver.class); + delegate = new InMemoryDnsResolver(); } @Test void ipv4Only_filtersOutIPv6() throws Exception { - final InetAddress v4 = InetAddress.getByName("203.0.113.10"); // TEST-NET-3 - final InetAddress v6 = InetAddress.getByName("2001:db8::10"); // documentation prefix + final InetAddress v4 = inet("203.0.113.10"); // TEST-NET-3 + final InetAddress v6 = inet("2001:db8::10"); // documentation prefix - when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v6, v4}); + delegate.add("dual.example", v6, v4); final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY); + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); final InetAddress[] ordered = r.resolve("dual.example"); - Assertions.assertNotNull(ordered); assertEquals(1, ordered.length); assertInstanceOf(Inet4Address.class, ordered[0]); assertEquals(v4, ordered[0]); @@ -72,16 +76,15 @@ void ipv4Only_filtersOutIPv6() throws Exception { @Test void ipv6Only_filtersOutIPv4() throws Exception { - final InetAddress v4 = InetAddress.getByName("192.0.2.1"); // TEST-NET-1 - final InetAddress v6 = InetAddress.getByName("2001:db8::1"); + final InetAddress v4 = inet("192.0.2.1"); // TEST-NET-1 + final InetAddress v6 = inet("2001:db8::1"); - when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v4, v6}); + delegate.add("dual.example", v4, v6); final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV6_ONLY); + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV6_ONLY, NO_SOURCE_ADDR); final InetAddress[] ordered = r.resolve("dual.example"); - Assertions.assertNotNull(ordered); assertEquals(1, ordered.length); assertInstanceOf(Inet6Address.class, ordered[0]); assertEquals(v6, ordered[0]); @@ -89,82 +92,294 @@ void ipv6Only_filtersOutIPv4() throws Exception { @Test void ipv4Only_emptyWhenNoIPv4Candidates() throws Exception { - final InetAddress v6a = InetAddress.getByName("2001:db8::1"); - final InetAddress v6b = InetAddress.getByName("2001:db8::2"); + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v6b = inet("2001:db8::2"); - when(delegate.resolve("v6only.example")).thenReturn(new InetAddress[]{v6a, v6b}); + delegate.add("v6only.example", v6a, v6b); final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY); + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); final InetAddress[] ordered = r.resolve("v6only.example"); - Assertions.assertNotNull(ordered); assertEquals(0, ordered.length); } @Test - void interleave_isDefault_and_hasNoFamilyBias() throws Exception { - final InetAddress v6a = InetAddress.getByName("2001:db8::1"); - final InetAddress v6b = InetAddress.getByName("2001:db8::2"); - final InetAddress v4a = InetAddress.getByName("192.0.2.1"); - final InetAddress v4b = InetAddress.getByName("203.0.113.10"); + void default_hasNoFamilyBias() throws Exception { + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v6b = inet("2001:db8::2"); + final InetAddress v4a = inet("192.0.2.1"); + final InetAddress v4b = inet("203.0.113.10"); - when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v6a, v6b, v4a, v4b}); + delegate.add("dual.example", v6a, v6b, v4a, v4b); - final Rfc6724AddressSelectingDnsResolver rDefault = new Rfc6724AddressSelectingDnsResolver(delegate); - final Rfc6724AddressSelectingDnsResolver rInterleave = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE); + final Rfc6724AddressSelectingDnsResolver r1 = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); + final Rfc6724AddressSelectingDnsResolver r2 = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); - final InetAddress[] outDefault = rDefault.resolve("dual.example"); - final InetAddress[] outInterleave = rInterleave.resolve("dual.example"); + final InetAddress[] out1 = r1.resolve("dual.example"); + final InetAddress[] out2 = r2.resolve("dual.example"); - assertArrayEquals(outDefault, outInterleave); - Assertions.assertNotNull(outInterleave); - assertEquals(4, outInterleave.length); + assertArrayEquals(out1, out2); + assertEquals(4, out1.length); } @Test - void preferIpv6_groupsAllV6First_preservingRelativeOrder() throws Exception { - final InetAddress v4a = InetAddress.getByName("192.0.2.1"); - final InetAddress v6a = InetAddress.getByName("2001:db8::1"); - final InetAddress v4b = InetAddress.getByName("203.0.113.10"); - final InetAddress v6b = InetAddress.getByName("2001:db8::2"); + void interleave_alternatesFamilies_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception { + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v6b = inet("2001:db8::2"); + final InetAddress v4a = inet("192.0.2.1"); + final InetAddress v4b = inet("203.0.113.10"); + + // With NO_SOURCE_ADDR, RFC sort becomes a stable no-op; deterministic interleave. + delegate.add("dual.example", v6a, v6b, v4a, v4b); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE, NO_SOURCE_ADDR); + + final InetAddress[] out = r.resolve("dual.example"); + assertEquals(Arrays.asList(v6a, v4a, v6b, v4b), Arrays.asList(out)); + } - when(delegate.resolve("dual.example")).thenReturn(new InetAddress[]{v4a, v6a, v4b, v6b}); + @Test + void preferIpv6_groupsAllV6First_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception { + final InetAddress v4a = inet("192.0.2.1"); + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v4b = inet("203.0.113.10"); + final InetAddress v6b = inet("2001:db8::2"); - final Rfc6724AddressSelectingDnsResolver baseline = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE); - final InetAddress[] baseOut = baseline.resolve("dual.example"); + delegate.add("dual.example", v4a, v6a, v4b, v6b); final Rfc6724AddressSelectingDnsResolver preferV6 = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.PREFER_IPV6); + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.PREFER_IPV6, NO_SOURCE_ADDR); + final InetAddress[] out = preferV6.resolve("dual.example"); + assertEquals(Arrays.asList(v6a, v6b, v4a, v4b), Arrays.asList(out)); + assertInstanceOf(Inet6Address.class, out[0]); + } - // Expected: stable partition of the RFC-sorted baseline. - final List baseV6 = new ArrayList<>(); - final List baseV4 = new ArrayList<>(); - for (final InetAddress a : baseOut) { - if (a instanceof Inet6Address) { - baseV6.add(a); - } else { - baseV4.add(a); - } - } - final List expected = new ArrayList<>(baseOut.length); - expected.addAll(baseV6); - expected.addAll(baseV4); + @Test + void filtersOutMulticastDestinations() throws Exception { + final InetAddress multicastV6 = inet("ff02::1"); + final InetAddress v6 = inet("2001:db8::1"); - assertEquals(expected, Arrays.asList(out)); - assertInstanceOf(Inet6Address.class, out[0]); + delegate.add("mcast.example", multicastV6, v6); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); + + final InetAddress[] out = r.resolve("mcast.example"); + assertEquals(1, out.length); + assertEquals(v6, out[0]); + } + + // ------------------------------------------------------------------------- + // New: direct tests for classifyScope(..) and Scope.fromValue(..) via reflection + // (Scope and classifyScope are private in the resolver). + // ------------------------------------------------------------------------- + + @Test + void classifyScope_loopback_linkLocal_siteLocal_global() throws Exception { + final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; + + assertEquals("INTERFACE_LOCAL", classifyScope(resolverClass, inet("127.0.0.1"))); + assertEquals("INTERFACE_LOCAL", classifyScope(resolverClass, inet("::1"))); + + assertEquals("LINK_LOCAL", classifyScope(resolverClass, inet("169.254.0.1"))); + assertEquals("LINK_LOCAL", classifyScope(resolverClass, inet("fe80::1"))); + + assertEquals("SITE_LOCAL", classifyScope(resolverClass, inet("10.0.0.1"))); + + assertEquals("GLOBAL", classifyScope(resolverClass, inet("8.8.8.8"))); + assertEquals("GLOBAL", classifyScope(resolverClass, inet("2003::1"))); + } + + @Test + void classifyScope_ipv6Multicast_usesLowNibbleScope() throws Exception { + final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; + + // ff01::1 -> scope 0x1 -> INTERFACE_LOCAL + assertEquals("INTERFACE_LOCAL", classifyScope(resolverClass, inet("ff01::1"))); + // ff02::1 -> scope 0x2 -> LINK_LOCAL + assertEquals("LINK_LOCAL", classifyScope(resolverClass, inet("ff02::1"))); + // ff04::1 -> scope 0x4 -> ADMIN_LOCAL + assertEquals("ADMIN_LOCAL", classifyScope(resolverClass, inet("ff04::1"))); + // ff05::1 -> scope 0x5 -> SITE_LOCAL + assertEquals("SITE_LOCAL", classifyScope(resolverClass, inet("ff05::1"))); + // ff08::1 -> scope 0x8 -> ORG_LOCAL + assertEquals("ORG_LOCAL", classifyScope(resolverClass, inet("ff08::1"))); + // ff0e::1 -> scope 0xe -> GLOBAL (default branch) + assertEquals("GLOBAL", classifyScope(resolverClass, inet("ff0e::1"))); + } + + @Test + void scopeFromValue_mapsKnownConstants_andDefaultsToGlobal() throws Exception { + final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; + final Class scopeClass = findDeclaredClass(resolverClass, "Scope"); + assertNotNull(scopeClass); + + assertEquals("INTERFACE_LOCAL", scopeFromValue(scopeClass, 0x1)); + assertEquals("LINK_LOCAL", scopeFromValue(scopeClass, 0x2)); + assertEquals("ADMIN_LOCAL", scopeFromValue(scopeClass, 0x4)); + assertEquals("SITE_LOCAL", scopeFromValue(scopeClass, 0x5)); + assertEquals("ORG_LOCAL", scopeFromValue(scopeClass, 0x8)); + + assertEquals("GLOBAL", scopeFromValue(scopeClass, 0x0)); + assertEquals("GLOBAL", scopeFromValue(scopeClass, 0xe)); + assertEquals("GLOBAL", scopeFromValue(scopeClass, 0xf)); } @Test - void canonicalHostname_delegates() throws Exception { - when(delegate.resolveCanonicalHostname("example.org")).thenReturn("canon.example.org"); + void rfcRule2_prefersMatchingScope() throws Exception { + final InetAddress aDst = inet("2001:db8::1"); + final InetAddress bDst = inet("2001:db8::2"); + + // A matches scope (GLOBAL == GLOBAL); B mismatches (GLOBAL != LINK_LOCAL) + final InetAddress aSrc = inet("2001:db8::abcd"); + final InetAddress bSrc = inet("fe80::1"); + + delegate.add("t.example", bDst, aDst); + final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE); - assertEquals("canon.example.org", r.resolveCanonicalHostname("example.org")); - Mockito.verify(delegate).resolveCanonicalHostname("example.org"); + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); } + @Test + void rfcRule5_prefersMatchingLabel() throws Exception { + final InetAddress aDst = inet("2001:db8::1"); // label 5 (2001::/32) + final InetAddress bDst = inet("2001:db8::2"); // label 5 + + final InetAddress aSrc = inet("2001:db8::abcd"); // label 5 -> matches A + final InetAddress bSrc = inet("::ffff:192.0.2.1"); // label 4 -> does not match B + + delegate.add("t.example", bDst, aDst); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void rfcRule6_prefersHigherPrecedence() throws Exception { + final InetAddress aDst = inet("::1"); // precedence 50 (policy ::1) + final InetAddress bDst = inet("2001:db8::1"); // precedence 5 (policy 2001::/32) + + final InetAddress aSrc = inet("::1"); + final InetAddress bSrc = inet("2001:db8::abcd"); + + delegate.add("t.example", bDst, aDst); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void rfcRule8_prefersSmallerScope_whenPrecedenceAndLabelTie() throws Exception { + // Both fall to ::/0 policy -> precedence 40, label 1, but different scopes. + final InetAddress aDst = inet("fe80::1"); // LINK_LOCAL scope (0x2) + final InetAddress bDst = inet("2003::1"); // GLOBAL scope (0xe) + + final InetAddress aSrc = inet("fe80::2"); // LINK_LOCAL, label 1 + final InetAddress bSrc = inet("2003::2"); // GLOBAL, label 1 + + delegate.add("t.example", bDst, aDst); + + final Rfc6724AddressSelectingDnsResolver r = + new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void addr_fmt_simpleName() throws Exception { + final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; + + final Method addr = resolverClass.getDeclaredMethod("addr", InetAddress.class); + addr.setAccessible(true); + + final Method fmtArr = resolverClass.getDeclaredMethod("fmt", InetAddress[].class); + fmtArr.setAccessible(true); + + final Method fmtList = resolverClass.getDeclaredMethod("fmt", List.class); + fmtList.setAccessible(true); + + final Method simpleName = resolverClass.getDeclaredMethod("simpleName"); + simpleName.setAccessible(true); + + assertEquals("null", (String) addr.invoke(null, new Object[]{null})); + + final InetAddress v4 = inet("192.0.2.1"); + final InetAddress v6 = inet("2001:db8::1"); + + final String s4 = (String) addr.invoke(null, v4); + final String s6 = (String) addr.invoke(null, v6); + + assertEquals("IPv4(" + v4.getHostAddress() + ")", s4); + assertEquals("IPv6(" + v6.getHostAddress() + ")", s6); + + @SuppressWarnings("unchecked") final List arrOut = (List) fmtArr.invoke(null, new Object[]{new InetAddress[]{v6, v4}}); + assertEquals(Arrays.asList("IPv6(" + v6.getHostAddress() + ")", "IPv4(" + v4.getHostAddress() + ")"), arrOut); + + @SuppressWarnings("unchecked") final List listOut = (List) fmtList.invoke(null, Arrays.asList(v4, v6)); + assertEquals(Arrays.asList("IPv4(" + v4.getHostAddress() + ")", "IPv6(" + v6.getHostAddress() + ")"), listOut); + + assertNotNull((String) simpleName.invoke(null)); + assertEquals("Rfc6724Resolver", (String) simpleName.invoke(null)); + } + + private static InetAddress inet(final String s) { + try { + return InetAddress.getByName(s); + } catch (final UnknownHostException ex) { + throw new AssertionError(ex); + } + } + + private static Rfc6724AddressSelectingDnsResolver.SourceAddressResolver sourceMap( + final InetAddress aDst, final InetAddress aSrc, + final InetAddress bDst, final InetAddress bSrc) { + return (final InetSocketAddress dest) -> { + final InetAddress d = dest.getAddress(); + if (aDst.equals(d)) { + return aSrc; + } + if (bDst.equals(d)) { + return bSrc; + } + return null; + }; + } + + private static String classifyScope(final Class resolverClass, final InetAddress ip) throws Exception { + final Method m = resolverClass.getDeclaredMethod("classifyScope", InetAddress.class); + m.setAccessible(true); + final Object scope = m.invoke(null, ip); + return scope != null ? scope.toString() : null; + } + + private static String scopeFromValue(final Class scopeClass, final int v) throws Exception { + final Method m = scopeClass.getDeclaredMethod("fromValue", int.class); + m.setAccessible(true); + final Object scope = m.invoke(null, v); + return scope != null ? scope.toString() : null; + } + + private static Class findDeclaredClass(final Class outer, final String simpleName) { + for (final Class c : outer.getDeclaredClasses()) { + if (simpleName.equals(c.getSimpleName())) { + return c; + } + } + return null; + } } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java new file mode 100644 index 0000000000..4113bcc4b8 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java @@ -0,0 +1,67 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.examples; + +import java.net.InetAddress; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.Rfc6724AddressSelectingDnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.config.ProtocolFamilyPreference; + +public final class Rfc6724ResolverExample { + + public static void main(final String[] args) throws Exception { + final String host = args.length > 0 ? args[0] : "localhost"; + final ProtocolFamilyPreference pref = args.length > 1 + ? ProtocolFamilyPreference.valueOf(args[1]) + : ProtocolFamilyPreference.DEFAULT; + + final DnsResolver resolver = new Rfc6724AddressSelectingDnsResolver(SystemDefaultDnsResolver.INSTANCE, pref); + + final InetAddress[] out = resolver.resolve(host); + + System.out.println("Host: " + host); + System.out.println("Preference: " + pref); + if (out == null) { + System.out.println("Result: null"); + return; + } + if (out.length == 0) { + System.out.println("Result: []"); + return; + } + for (final InetAddress a : out) { + final String family = a instanceof java.net.Inet6Address ? "IPv6" : "IPv4"; + System.out.println(" " + family + " " + a.getHostAddress()); + } + } + + private Rfc6724ResolverExample() { + } +} From d8d2dabbfc11fc4b93a995f81e017054ea0b6ffd Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 15 Jan 2026 20:29:30 +0100 Subject: [PATCH 3/3] Apply RFC 6724 destination ordering and family preferences to DNS results. Tighten resolver semantics and filtering for HTTP/TCP connect targets. --- .../AddressSelectingDnsResolverIT.java | 254 +++++++++ .../testing/ManualRfc6724ResolverIT.java | 87 --- ....java => AddressSelectingDnsResolver.java} | 266 +++++---- .../http/AddressSelectingDnsResolverTest.java | 532 ++++++++++++++++++ ...fc6724AddressSelectingDnsResolverTest.java | 385 ------------- ...> AddressSelectingDnsResolverExample.java} | 34 +- 6 files changed, 956 insertions(+), 602 deletions(-) create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/AddressSelectingDnsResolverIT.java delete mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java rename httpclient5/src/main/java/org/apache/hc/client5/http/{Rfc6724AddressSelectingDnsResolver.java => AddressSelectingDnsResolver.java} (71%) create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java delete mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java rename httpclient5/src/test/java/org/apache/hc/client5/http/examples/{Rfc6724ResolverExample.java => AddressSelectingDnsResolverExample.java} (67%) diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/AddressSelectingDnsResolverIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/AddressSelectingDnsResolverIT.java new file mode 100644 index 0000000000..9f365669fa --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/AddressSelectingDnsResolverIT.java @@ -0,0 +1,254 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.hc.client5.http.AddressSelectingDnsResolver; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.config.ProtocolFamilyPreference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +@EnabledIfSystemProperty(named = "httpclient.rfc6724.it", matches = "true") +class AddressSelectingDnsResolverIT { + + private static final String PROP_HOST = "httpclient.rfc6724.host"; + + @Test + void interleave_isStableInterleavingOfDefault_forSameResolution() throws Exception { + final String host = System.getProperty(PROP_HOST, "localhost"); + + final InetAddress[] resolvedOnce = SystemDefaultDnsResolver.INSTANCE.resolve(host); + + final DnsResolver captured = new DnsResolver() { + @Override + public InetAddress[] resolve(final String h) throws UnknownHostException { + if (!host.equals(h)) { + throw new UnknownHostException(h); + } + return resolvedOnce.clone(); + } + + @Override + public String resolveCanonicalHostname(final String h) throws UnknownHostException { + return SystemDefaultDnsResolver.INSTANCE.resolveCanonicalHostname(h); + } + }; + + final AddressSelectingDnsResolver rDefault = + new AddressSelectingDnsResolver(captured, ProtocolFamilyPreference.DEFAULT); + + final AddressSelectingDnsResolver rInterleave = + new AddressSelectingDnsResolver(captured, ProtocolFamilyPreference.INTERLEAVE); + + final InetAddress[] outDefault = rDefault.resolve(host); + final InetAddress[] outInterleave = rInterleave.resolve(host); + + // 0) Both outputs must be permutations of the captured, single resolution. + Assertions.assertNotNull(outDefault); + assertSameElements(Arrays.asList(resolvedOnce), Arrays.asList(outDefault)); + Assertions.assertNotNull(outInterleave); + assertSameElements(Arrays.asList(resolvedOnce), Arrays.asList(outInterleave)); + + // 1) Same elements between DEFAULT and INTERLEAVE (no drops, no additions). + assertSameElements(Arrays.asList(outDefault), Arrays.asList(outInterleave)); + + // 2) Family counts must match between DEFAULT and INTERLEAVE. + final Counts cDefault = countFamilies(outDefault); + final Counts cInterleave = countFamilies(outInterleave); + assertEquals(cDefault.v4, cInterleave.v4); + assertEquals(cDefault.v6, cInterleave.v6); + + // 3) INTERLEAVE must be the stable interleaving of the DEFAULT-ordered list. + final List expected = expectedInterleaveFromBaseline(Arrays.asList(outDefault)); + assertEquals(expected, Arrays.asList(outInterleave)); + + // 4) If both families are present, the first 2*min(v4,v6) addresses must alternate by family. + final int pairs = Math.min(cInterleave.v4, cInterleave.v6); + if (pairs > 0) { + assertAlternatingPrefix(outInterleave, 2 * pairs); + } + + // 5) Relative order within each family is preserved from DEFAULT. + assertFamilyRelativeOrderPreserved(outDefault, outInterleave); + + // Diagnostics for manual runs. + System.out.println("Host: " + host); + dump("DEFAULT", outDefault); + dump("INTERLEAVE", outInterleave); + } + + private static void assertSameElements(final List a, final List b) { + assertEquals(a.size(), b.size()); + + final List remaining = new ArrayList<>(b); + for (final InetAddress x : a) { + assertTrue(remaining.remove(x), "Missing address: " + x); + } + assertTrue(remaining.isEmpty(), "Extra addresses: " + remaining); + } + + private static final class Counts { + final int v4; + final int v6; + + Counts(final int v4, final int v6) { + this.v4 = v4; + this.v6 = v6; + } + } + + private static Counts countFamilies(final InetAddress[] out) { + int v4 = 0; + int v6 = 0; + for (final InetAddress a : out) { + if (a instanceof Inet6Address) { + v6++; + } else { + v4++; + } + } + return new Counts(v4, v6); + } + + private static void assertAlternatingPrefix(final InetAddress[] out, final int length) { + if (length <= 1) { + return; + } + final boolean firstV6 = out[0] instanceof Inet6Address; + for (int i = 0; i < length; i++) { + final boolean expectV6 = firstV6 == (i % 2 == 0); + final boolean isV6 = out[i] instanceof Inet6Address; + assertEquals(expectV6, isV6, "Not alternating at index " + i); + } + } + + private static void assertFamilyRelativeOrderPreserved( + final InetAddress[] base, + final InetAddress[] interleaved) { + + final List baseV6 = new ArrayList<>(); + final List baseV4 = new ArrayList<>(); + for (final InetAddress a : base) { + if (a instanceof Inet6Address) { + baseV6.add(a); + } else { + baseV4.add(a); + } + } + + final List gotV6 = new ArrayList<>(); + final List gotV4 = new ArrayList<>(); + for (final InetAddress a : interleaved) { + if (a instanceof Inet6Address) { + gotV6.add(a); + } else { + gotV4.add(a); + } + } + + assertEquals(baseV6, gotV6, "IPv6 relative order changed"); + assertEquals(baseV4, gotV4, "IPv4 relative order changed"); + } + + private static List expectedInterleaveFromBaseline(final List baseline) { + if (baseline.size() <= 1) { + return baseline; + } + + final List v6 = new ArrayList<>(); + final List v4 = new ArrayList<>(); + + for (final InetAddress a : baseline) { + if (a instanceof Inet6Address) { + v6.add(a); + } else { + v4.add(a); + } + } + + if (v6.isEmpty() || v4.isEmpty()) { + return baseline; + } + + final boolean startV6 = baseline.get(0) instanceof Inet6Address; + + final List out = new ArrayList<>(baseline.size()); + int i6 = 0; + int i4 = 0; + + while (i6 < v6.size() || i4 < v4.size()) { + if (startV6) { + if (i6 < v6.size()) { + out.add(v6.get(i6++)); + } + if (i4 < v4.size()) { + out.add(v4.get(i4++)); + } + } else { + if (i4 < v4.size()) { + out.add(v4.get(i4++)); + } + if (i6 < v6.size()) { + out.add(v6.get(i6++)); + } + } + } + + return out; + } + + private static void dump(final String label, final InetAddress[] out) { + int v4 = 0; + int v6 = 0; + + System.out.println("Preference: " + label); + for (final InetAddress a : out) { + if (a instanceof Inet6Address) { + v6++; + System.out.println(" IPv6 " + a.getHostAddress()); + } else { + v4++; + System.out.println(" IPv4 " + a.getHostAddress()); + } + } + System.out.println("Counts: IPv4=" + v4 + " IPv6=" + v6); + System.out.println(); + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java deleted file mode 100644 index 7503e8c12e..0000000000 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/ManualRfc6724ResolverIT.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ - -package org.apache.hc.client5.testing; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import java.net.Inet6Address; -import java.net.InetAddress; - -import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.Rfc6724AddressSelectingDnsResolver; -import org.apache.hc.client5.http.SystemDefaultDnsResolver; -import org.apache.hc.client5.http.config.ProtocolFamilyPreference; -import org.junit.jupiter.api.Test; - -class ManualRfc6724ResolverIT { - - private static final String PROP_ENABLE = "httpclient.rfc6724.it"; - private static final String PROP_HOST = "httpclient.rfc6724.host"; - - @Test - void resolve_and_dump_order() throws Exception { - assumeTrue(Boolean.getBoolean(PROP_ENABLE), - "Enable with -Dhttpclient.rfc6724.it=true"); - - final String host = System.getProperty(PROP_HOST, "localhost"); - - final DnsResolver base = SystemDefaultDnsResolver.INSTANCE; - - System.out.println("Host: " + host); - - dump(base, host, ProtocolFamilyPreference.DEFAULT); - System.out.println(); - dump(base, host, ProtocolFamilyPreference.INTERLEAVE); - } - - private static void dump( - final DnsResolver base, - final String host, - final ProtocolFamilyPreference pref) throws Exception { - - final Rfc6724AddressSelectingDnsResolver resolver = - new Rfc6724AddressSelectingDnsResolver(base, pref); - - final InetAddress[] out = resolver.resolve(host); - - int v4 = 0; - int v6 = 0; - - System.out.println("Preference: " + pref); - for (final InetAddress a : out) { - if (a instanceof Inet6Address) { - v6++; - System.out.println(" IPv6 " + a.getHostAddress()); - } else { - v4++; - System.out.println(" IPv4 " + a.getHostAddress()); - } - } - System.out.println("Counts: IPv4=" + v4 + " IPv6=" + v6); - } -} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java b/httpclient5/src/main/java/org/apache/hc/client5/http/AddressSelectingDnsResolver.java similarity index 71% rename from httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java rename to httpclient5/src/main/java/org/apache/hc/client5/http/AddressSelectingDnsResolver.java index 48f0e061f7..052ba90045 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolver.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/AddressSelectingDnsResolver.java @@ -38,6 +38,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -49,7 +50,7 @@ import org.slf4j.LoggerFactory; /** - * {@code Rfc6724AddressSelectingDnsResolver} wraps a delegate {@link DnsResolver} + * {@code AddressSelectingDnsResolver} wraps a delegate {@link DnsResolver} * and applies RFC 6724 destination address selection rules (RFC 6724 §6) * to the returned addresses. It can also enforce or bias a protocol family preference. * @@ -64,9 +65,9 @@ * @since 5.7 */ @Contract(threading = ThreadingBehavior.IMMUTABLE) -public final class Rfc6724AddressSelectingDnsResolver implements DnsResolver { +public final class AddressSelectingDnsResolver implements DnsResolver { - private static final Logger LOG = LoggerFactory.getLogger(Rfc6724AddressSelectingDnsResolver.class); + private static final Logger LOG = LoggerFactory.getLogger(AddressSelectingDnsResolver.class); private static final int PROBE_PORT = 53; // UDP connect trick; no packets sent @@ -91,7 +92,7 @@ interface SourceAddressResolver { * * @param delegate underlying resolver to use. */ - public Rfc6724AddressSelectingDnsResolver(final DnsResolver delegate) { + public AddressSelectingDnsResolver(final DnsResolver delegate) { this(delegate, ProtocolFamilyPreference.DEFAULT); } @@ -101,14 +102,14 @@ public Rfc6724AddressSelectingDnsResolver(final DnsResolver delegate) { * @param delegate underlying resolver to use. * @param familyPreference family preference to apply (e.g. PREFER_IPV6, IPV4_ONLY). */ - public Rfc6724AddressSelectingDnsResolver( + public AddressSelectingDnsResolver( final DnsResolver delegate, final ProtocolFamilyPreference familyPreference) { this(delegate, familyPreference, DEFAULT_SOURCE_ADDRESS_RESOLVER); } // Package-private for unit tests: allows deterministic source address inference. - Rfc6724AddressSelectingDnsResolver( + AddressSelectingDnsResolver( final DnsResolver delegate, final ProtocolFamilyPreference familyPreference, final SourceAddressResolver sourceAddressResolver) { @@ -123,47 +124,62 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { if (resolved == null) { if (LOG.isDebugEnabled()) { - LOG.debug("{} resolved '{}' -> null", simpleName(), host); + LOG.debug("resolved '{}' -> null", host); } return null; } - if (resolved.length <= 1) { - if (LOG.isDebugEnabled()) { - LOG.debug("{} resolved '{}' -> {}", simpleName(), host, fmt(resolved)); - } - return resolved; - } - if (LOG.isTraceEnabled()) { - LOG.trace("{} resolving host '{}' via delegate {}", simpleName(), host, delegate.getClass().getName()); - LOG.trace("{} familyPreference={}", simpleName(), familyPreference); - LOG.trace("{} delegate returned {} addresses for '{}': {}", simpleName(), resolved.length, host, fmt(resolved)); + LOG.trace("resolving host '{}' via delegate {}", host, delegate.getClass().getName()); + LOG.trace("familyPreference={}", familyPreference); + LOG.trace("delegate returned {} addresses for '{}': {}", resolved.length, host, fmt(resolved)); } + // Always filter, even for a single address — the family preference may exclude it. final List candidates = filterCandidates(resolved, familyPreference); if (candidates.isEmpty()) { if (LOG.isDebugEnabled()) { - LOG.debug("{} resolved '{}' -> []", simpleName(), host); + LOG.debug("resolved '{}' -> []", host); + } + return null; + } + + if (candidates.size() == 1) { + if (LOG.isDebugEnabled()) { + LOG.debug("resolved '{}' -> {}", host, fmt(candidates)); } - return new InetAddress[0]; + return candidates.toArray(new InetAddress[0]); } final List rfcSorted = sortByRfc6724(candidates); final List ordered = applyFamilyPreference(rfcSorted, familyPreference); if (LOG.isDebugEnabled()) { - LOG.debug("{} resolved '{}' -> {}", simpleName(), host, fmt(ordered)); + LOG.debug("resolved '{}' -> {}", host, fmt(ordered)); } return ordered.toArray(new InetAddress[0]); } + @Override + public List resolve(final String host, final int port) throws UnknownHostException { + final InetAddress[] ordered = resolve(host); + if (ordered == null) { + // Do NOT fall back to createUnresolved: an unresolved address would let + // downstream code re-resolve the hostname and bypass family filtering + // (e.g. IPV4_ONLY / IPV6_ONLY). + throw new UnknownHostException(host); + } + return Arrays.stream(ordered) + .map(a -> new InetSocketAddress(a, port)) + .collect(Collectors.toList()); + } + @Override public String resolveCanonicalHostname(final String host) throws UnknownHostException { if (LOG.isTraceEnabled()) { - LOG.trace("{} resolveCanonicalHostname('{}') via delegate {}", simpleName(), host, delegate.getClass().getName()); + LOG.trace("resolveCanonicalHostname('{}') via delegate {}", host, delegate.getClass().getName()); } return delegate.resolveCanonicalHostname(host); } @@ -176,43 +192,32 @@ private static boolean isUsableDestination(final InetAddress ip) { return false; } // HTTP/TCP is for unicast destinations; multicast is not a valid connect target. - if (ip.isMulticastAddress()) { - return false; - } - return true; + return !ip.isMulticastAddress(); } + private static List filterCandidates( final InetAddress[] resolved, final ProtocolFamilyPreference pref) { - final List out = new ArrayList<>(resolved.length); - for (final InetAddress a : resolved) { - if (!isUsableDestination(a)) { - continue; - } - switch (pref) { - case IPV4_ONLY: { - if (a instanceof Inet4Address) { - out.add(a); - } - break; - } - case IPV6_ONLY: { - if (a instanceof Inet6Address) { - out.add(a); - } - break; - } - default: { - out.add(a); - break; - } - } + return Arrays.stream(resolved) + .filter(AddressSelectingDnsResolver::isUsableDestination) + .filter(a -> shouldInclude(pref, a)) + .collect(Collectors.toList()); + } + + private static boolean shouldInclude(final ProtocolFamilyPreference pref, final InetAddress a) { + switch (pref) { + case IPV4_ONLY: + return a instanceof Inet4Address; + case IPV6_ONLY: + return a instanceof Inet6Address; + default: + return true; } - return out; } + // --- RFC 6724 helpers --- private List sortByRfc6724(final List addrs) { @@ -224,17 +229,9 @@ private List sortByRfc6724(final List addrs) { LOG.trace("RFC6724 input candidates: {}", fmt(addrs)); } - final List socketAddresses = new ArrayList<>(addrs.size()); - for (final InetAddress a : addrs) { - socketAddresses.add(new InetSocketAddress(a, PROBE_PORT)); - } - - final List srcs = inferSourceAddresses(socketAddresses); - final List infos = new ArrayList<>(addrs.size()); - for (int i = 0; i < addrs.size(); i++) { - final InetAddress dst = addrs.get(i); - final InetAddress src = srcs.get(i); + for (final InetAddress dst : addrs) { + final InetAddress src = inferSourceAddress(new InetSocketAddress(dst, PROBE_PORT)); infos.add(new Info(dst, src, ipAttrOf(dst), ipAttrOf(src))); } @@ -249,10 +246,9 @@ private List sortByRfc6724(final List addrs) { infos.sort(RFC6724_COMPARATOR); - final List out = new ArrayList<>(infos.size()); - for (final Info info : infos) { - out.add(info.dst); - } + final List out = infos.stream() + .map(info -> info.dst) + .collect(Collectors.toList()); if (LOG.isTraceEnabled()) { LOG.trace("RFC6724 output order: {}", fmt(out)); @@ -261,30 +257,15 @@ private List sortByRfc6724(final List addrs) { return out; } - private List inferSourceAddresses(final List destinations) { - final List srcs = new ArrayList<>(destinations.size()); - - for (final InetSocketAddress dest : destinations) { - InetAddress src = null; - try { - src = sourceAddressResolver.resolveSource(dest); - } catch (final SocketException ignore) { - if (LOG.isTraceEnabled()) { - LOG.trace("RFC6724 could not infer source address for {}: {}", dest, ignore.toString()); - } - } - srcs.add(src); - } - - if (LOG.isTraceEnabled()) { - final List printable = new ArrayList<>(srcs.size()); - for (final InetAddress a : srcs) { - printable.add(addr(a)); + private InetAddress inferSourceAddress(final InetSocketAddress destination) { + try { + return sourceAddressResolver.resolveSource(destination); + } catch (final SocketException ex) { + if (LOG.isTraceEnabled()) { + LOG.trace("RFC6724 could not infer source address for {}: {}", destination, ex.toString()); } - LOG.trace("RFC6724 inferred source addresses: {}", printable); + return null; } - - return srcs; } private static List applyFamilyPreference( @@ -351,14 +332,14 @@ private static List interleaveFamilies(final List rfcS final List second = startWithV6 ? v4 : v6; final List out = new ArrayList<>(rfcSorted.size()); - int i = 0; - int j = 0; - while (i < first.size() || j < second.size()) { - if (i < first.size()) { - out.add(first.get(i++)); + final Iterator it1 = first.iterator(); + final Iterator it2 = second.iterator(); + while (it1.hasNext() || it2.hasNext()) { + if (it1.hasNext()) { + out.add(it1.next()); } - if (j < second.size()) { - out.add(second.get(j++)); + if (it2.hasNext()) { + out.add(it2.next()); } } return out; @@ -392,7 +373,8 @@ private static final class Attr { } } - private enum Scope { + // Package-private for unit testing. + enum Scope { INTERFACE_LOCAL(0x1), LINK_LOCAL(0x2), ADMIN_LOCAL(0x4), @@ -423,9 +405,12 @@ static Scope fromValue(final int v) { case 0x8: { return ORG_LOCAL; } - default: { + case 0xe: { return GLOBAL; } + default: { + throw new IllegalArgumentException("Unknown scope value: 0x" + Integer.toHexString(v)); + } } } } @@ -438,19 +423,49 @@ private static Attr ipAttrOf(final InetAddress ip) { return new Attr(classifyScope(ip), e.precedence, e.label); } - private static Scope classifyScope(final InetAddress ip) { + // Package-private for unit testing. + // + // RFC 6724 §3.1 scope assignment: + // IPv6 ::1 → link-local (interface-local is multicast-only per RFC 4291 §2.7) + // IPv6 link-local → link-local + // IPv6 site-local → site-local (deprecated but still defined) + // IPv6 multicast → scope nibble from second byte (RFC 4291) + // IPv6 ULA (fc00::/7) → global (handled via policy table, not scope) + // IPv6 everything else → global + // + // IPv4 127/8 → link-local + // IPv4 169.254/16 → link-local + // IPv4 everything else → global (including RFC1918 and 100.64/10) + // + static Scope classifyScope(final InetAddress ip) { + if (ip instanceof Inet4Address) { + // IPv4 scope rules per RFC 6724 §3.1: + // Only loopback (127/8) and link-local (169.254/16) get link-local scope; + // all other IPv4 addresses (including RFC1918 private and 100.64/10) are global. + if (ip.isLoopbackAddress() || ip.isLinkLocalAddress()) { + return Scope.LINK_LOCAL; + } + return Scope.GLOBAL; + } + + // IPv6 scope rules. if (ip.isLoopbackAddress()) { - return Scope.INTERFACE_LOCAL; + // Interface-local scope (0x1) is a multicast-only concept (RFC 4291 §2.7). + // For unicast, the smallest meaningful scope is link-local. + return Scope.LINK_LOCAL; } if (ip.isLinkLocalAddress()) { return Scope.LINK_LOCAL; } if (ip.isMulticastAddress()) { - if (ip instanceof Inet6Address) { - // RFC 6724 §3.1 and RFC 4291: low 4 bits of second byte are scope for IPv6 multicast. - return Scope.fromValue(ip.getAddress()[1] & 0x0f); + // RFC 6724 §3.1 and RFC 4291: low 4 bits of second byte encode scope for IPv6 multicast. + // Not all nibble values map to a known Scope constant; treat unknown values as GLOBAL. + final int nibble = ip.getAddress()[1] & 0x0f; + try { + return Scope.fromValue(nibble); + } catch (final IllegalArgumentException e) { + return Scope.GLOBAL; } - return Scope.GLOBAL; } if (ip.isSiteLocalAddress()) { return Scope.SITE_LOCAL; @@ -470,7 +485,8 @@ private static final class PolicyEntry { } } - private static final class Network { + // Package-private for unit testing. + static final class Network { final byte[] ip; final int bits; @@ -553,6 +569,11 @@ private static PolicyEntry classify(final InetAddress ip) { final int preferB = 1; // RFC 6724 §6: destination address selection rules. + // + // Rules 3, 4 and 7 are not implementable with standard JDK APIs: + // Rule 3 (Avoid deprecated source addresses) — InetAddress exposes no deprecation state. + // Rule 4 (Prefer home addresses) — Mobile IPv6 concept; not applicable to an HTTP client. + // Rule 7 (Prefer native transport) — JDK provides no encapsulation/tunneling info. // Rule 1: Avoid unusable destinations. final boolean validA = aSrc != null && !aSrc.isAnyLocalAddress(); @@ -575,6 +596,10 @@ private static PolicyEntry classify(final InetAddress ip) { return preferB; } + // Rule 3: Avoid deprecated addresses — skipped (see above). + + // Rule 4: Prefer home addresses — skipped (see above). + // Rule 5: Prefer matching label. if (aSrcAttr.label == aDstAttr.label && bSrcAttr.label != bDstAttr.label) { return preferA; @@ -591,6 +616,8 @@ private static PolicyEntry classify(final InetAddress ip) { return preferB; } + // Rule 7: Prefer native transport — skipped (see above). + // Rule 8: Prefer smaller scope. if (aDstAttr.scope.value < bDstAttr.scope.value) { return preferA; @@ -599,8 +626,16 @@ private static PolicyEntry classify(final InetAddress ip) { return preferB; } - // Rule 9: Longest matching prefix (IPv6 only). - if (aDst instanceof Inet6Address && bDst instanceof Inet6Address) { + // Rule 9: Use longest matching prefix (RFC 6724 §6). + // Applies when DA and DB belong to the same address family. + // + // Note: this is an approximation. A fully correct implementation would + // use the source address's on-link prefix length (from the interface + // configuration / routing table), not the bit-wise common-prefix of + // source and destination. The JDK does not expose interface prefix + // lengths for source address selection, so we fall back to + // CommonPrefixLen(Source(D), D) as a reasonable heuristic. + if (aDst.getClass() == bDst.getClass()) { final int commonA = commonPrefixLen(aSrc, aDst); final int commonB = commonPrefixLen(bSrc, bDst); if (commonA > commonB) { @@ -640,7 +675,7 @@ private static int commonPrefixLen(final InetAddress a, final InetAddress b) { return bits; } - private static String addr(final InetAddress a) { + static String addr(final InetAddress a) { if (a == null) { return "null"; } @@ -648,23 +683,16 @@ private static String addr(final InetAddress a) { return family + "(" + a.getHostAddress() + ")"; } - private static List fmt(final InetAddress[] arr) { - final List out = new ArrayList<>(arr.length); - for (final InetAddress a : arr) { - out.add(addr(a)); - } - return out; + static List fmt(final InetAddress[] arr) { + return Arrays.stream(arr) + .map(AddressSelectingDnsResolver::addr) + .collect(Collectors.toList()); } - private static List fmt(final List arr) { - final List out = new ArrayList<>(arr.size()); - for (final InetAddress a : arr) { - out.add(addr(a)); - } - return out; + static List fmt(final List arr) { + return arr.stream() + .map(AddressSelectingDnsResolver::addr) + .collect(Collectors.toList()); } - private static String simpleName() { - return "Rfc6724Resolver"; - } } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java new file mode 100644 index 0000000000..80510db647 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java @@ -0,0 +1,532 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +import org.apache.hc.client5.http.config.ProtocolFamilyPreference; +import org.apache.hc.client5.http.impl.InMemoryDnsResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AddressSelectingDnsResolverTest { + + private static final AddressSelectingDnsResolver.SourceAddressResolver NO_SOURCE_ADDR = + (final InetSocketAddress dest) -> null; + + private InMemoryDnsResolver delegate; + + @BeforeEach + void setUp() { + delegate = new InMemoryDnsResolver(); + } + + @Test + void ipv4Only_filtersOutIPv6() throws Exception { + final InetAddress v4 = inet("203.0.113.10"); // TEST-NET-3 + final InetAddress v6 = inet("2001:db8::10"); // documentation prefix + + delegate.add("dual.example", v6, v4); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); + + final InetAddress[] ordered = r.resolve("dual.example"); + assertEquals(1, ordered.length); + assertInstanceOf(Inet4Address.class, ordered[0]); + assertEquals(v4, ordered[0]); + } + + @Test + void ipv6Only_filtersOutIPv4() throws Exception { + final InetAddress v4 = inet("192.0.2.1"); // TEST-NET-1 + final InetAddress v6 = inet("2001:db8::1"); + + delegate.add("dual.example", v4, v6); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV6_ONLY, NO_SOURCE_ADDR); + + final InetAddress[] ordered = r.resolve("dual.example"); + assertEquals(1, ordered.length); + assertInstanceOf(Inet6Address.class, ordered[0]); + assertEquals(v6, ordered[0]); + } + + @Test + void ipv4Only_emptyWhenNoIPv4Candidates() throws Exception { + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v6b = inet("2001:db8::2"); + + delegate.add("v6only.example", v6a, v6b); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); + + final InetAddress[] ordered = r.resolve("v6only.example"); + assertNull(ordered); + } + + @Test + void default_hasNoFamilyBias() throws Exception { + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v6b = inet("2001:db8::2"); + final InetAddress v4a = inet("192.0.2.1"); + final InetAddress v4b = inet("203.0.113.10"); + + delegate.add("dual.example", v6a, v6b, v4a, v4b); + + final AddressSelectingDnsResolver r1 = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); + final AddressSelectingDnsResolver r2 = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); + + final InetAddress[] out1 = r1.resolve("dual.example"); + final InetAddress[] out2 = r2.resolve("dual.example"); + + assertArrayEquals(out1, out2); + assertEquals(4, out1.length); + } + + @Test + void interleave_alternatesFamilies_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception { + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v6b = inet("2001:db8::2"); + final InetAddress v4a = inet("192.0.2.1"); + final InetAddress v4b = inet("203.0.113.10"); + + // With NO_SOURCE_ADDR, RFC sort becomes a stable no-op; deterministic interleave. + delegate.add("dual.example", v6a, v6b, v4a, v4b); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE, NO_SOURCE_ADDR); + + final InetAddress[] out = r.resolve("dual.example"); + assertEquals(Arrays.asList(v6a, v4a, v6b, v4b), Arrays.asList(out)); + } + + @Test + void preferIpv6_groupsAllV6First_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception { + final InetAddress v4a = inet("192.0.2.1"); + final InetAddress v6a = inet("2001:db8::1"); + final InetAddress v4b = inet("203.0.113.10"); + final InetAddress v6b = inet("2001:db8::2"); + + delegate.add("dual.example", v4a, v6a, v4b, v6b); + + final AddressSelectingDnsResolver preferV6 = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.PREFER_IPV6, NO_SOURCE_ADDR); + + final InetAddress[] out = preferV6.resolve("dual.example"); + assertEquals(Arrays.asList(v6a, v6b, v4a, v4b), Arrays.asList(out)); + assertInstanceOf(Inet6Address.class, out[0]); + } + + @Test + void filtersOutMulticastDestinations() throws Exception { + final InetAddress multicastV6 = inet("ff02::1"); + final InetAddress v6 = inet("2001:db8::1"); + + delegate.add("mcast.example", multicastV6, v6); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); + + final InetAddress[] out = r.resolve("mcast.example"); + assertEquals(1, out.length); + assertEquals(v6, out[0]); + } + + // ------------------------------------------------------------------------- + // Direct tests for classifyScope(..) and Scope.fromValue(..) + // ------------------------------------------------------------------------- + + @Test + void classifyScope_ipv6Loopback_isLinkLocal() throws Exception { + // ::1 maps to link-local scope; interface-local (0x1) is multicast-only (RFC 4291 §2.7). + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("::1"))); + } + + @Test + void classifyScope_ipv4Loopback_isLinkLocal() throws Exception { + // IPv4 127/8 maps to link-local, NOT interface-local (RFC 6724 §3.1). + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("127.0.0.1"))); + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("127.255.255.255"))); + } + + @Test + void classifyScope_linkLocal() throws Exception { + // IPv4 169.254/16 → link-local. + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("169.254.0.1"))); + // IPv6 fe80::/10 → link-local. + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("fe80::1"))); + } + + @Test + void classifyScope_ipv4Private_isGlobal() throws Exception { + // RFC 6724: all IPv4 except 127/8 and 169.254/16 are global, + // including RFC1918 (10/8, 172.16/12, 192.168/16) and 100.64/10. + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("10.0.0.1"))); + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("172.16.0.1"))); + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("192.168.1.1"))); + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("100.64.0.1"))); + } + + @Test + void classifyScope_ipv6SiteLocal() throws Exception { + // IPv6 deprecated site-local (fec0::/10) still classified as site-local per RFC 6724. + assertEquals(AddressSelectingDnsResolver.Scope.SITE_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("fec0::1"))); + } + + @Test + void classifyScope_global() throws Exception { + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("8.8.8.8"))); + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("2003::1"))); + } + + @Test + void classifyScope_ipv6Multicast_usesLowNibbleScope() throws Exception { + // ff01::1 -> scope 0x1 -> INTERFACE_LOCAL + assertEquals(AddressSelectingDnsResolver.Scope.INTERFACE_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("ff01::1"))); + // ff02::1 -> scope 0x2 -> LINK_LOCAL + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("ff02::1"))); + // ff04::1 -> scope 0x4 -> ADMIN_LOCAL + assertEquals(AddressSelectingDnsResolver.Scope.ADMIN_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("ff04::1"))); + // ff05::1 -> scope 0x5 -> SITE_LOCAL + assertEquals(AddressSelectingDnsResolver.Scope.SITE_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("ff05::1"))); + // ff08::1 -> scope 0x8 -> ORG_LOCAL + assertEquals(AddressSelectingDnsResolver.Scope.ORG_LOCAL, + AddressSelectingDnsResolver.classifyScope(inet("ff08::1"))); + // ff0e::1 -> scope 0xe -> GLOBAL + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("ff0e::1"))); + } + + @Test + void scopeFromValue_mapsKnownConstants() { + assertEquals(AddressSelectingDnsResolver.Scope.INTERFACE_LOCAL, + AddressSelectingDnsResolver.Scope.fromValue(0x1)); + assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL, + AddressSelectingDnsResolver.Scope.fromValue(0x2)); + assertEquals(AddressSelectingDnsResolver.Scope.ADMIN_LOCAL, + AddressSelectingDnsResolver.Scope.fromValue(0x4)); + assertEquals(AddressSelectingDnsResolver.Scope.SITE_LOCAL, + AddressSelectingDnsResolver.Scope.fromValue(0x5)); + assertEquals(AddressSelectingDnsResolver.Scope.ORG_LOCAL, + AddressSelectingDnsResolver.Scope.fromValue(0x8)); + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.Scope.fromValue(0xe)); + } + + @Test + void scopeFromValue_throwsOnUnknownValue() { + assertThrows(IllegalArgumentException.class, + () -> AddressSelectingDnsResolver.Scope.fromValue(0x0)); + assertThrows(IllegalArgumentException.class, + () -> AddressSelectingDnsResolver.Scope.fromValue(0x3)); + assertThrows(IllegalArgumentException.class, + () -> AddressSelectingDnsResolver.Scope.fromValue(0xf)); + } + + @Test + void rfcRule2_prefersMatchingScope() throws Exception { + final InetAddress aDst = inet("2001:db8::1"); + final InetAddress bDst = inet("2001:db8::2"); + + // A matches scope (GLOBAL == GLOBAL); B mismatches (GLOBAL != LINK_LOCAL) + final InetAddress aSrc = inet("2001:db8::abcd"); + final InetAddress bSrc = inet("fe80::1"); + + delegate.add("t.example", bDst, aDst); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void rfcRule5_prefersMatchingLabel() throws Exception { + final InetAddress aDst = inet("2001:db8::1"); // label 5 (2001::/32) + final InetAddress bDst = inet("2001:db8::2"); // label 5 + + final InetAddress aSrc = inet("2001:db8::abcd"); // label 5 -> matches A + final InetAddress bSrc = inet("::ffff:192.0.2.1"); // label 4 -> does not match B + + delegate.add("t.example", bDst, aDst); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void rfcRule6_prefersHigherPrecedence() throws Exception { + final InetAddress aDst = inet("::1"); // precedence 50 (policy ::1) + final InetAddress bDst = inet("2001:db8::1"); // precedence 5 (policy 2001::/32) + + final InetAddress aSrc = inet("::1"); + final InetAddress bSrc = inet("2001:db8::abcd"); + + delegate.add("t.example", bDst, aDst); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void rfcRule8_prefersSmallerScope_whenPrecedenceAndLabelTie() throws Exception { + // Both fall to ::/0 policy -> precedence 40, label 1, but different scopes. + final InetAddress aDst = inet("fe80::1"); // LINK_LOCAL scope (0x2) + final InetAddress bDst = inet("2003::1"); // GLOBAL scope (0xe) + + final InetAddress aSrc = inet("fe80::2"); // LINK_LOCAL, label 1 + final InetAddress bSrc = inet("2003::2"); // GLOBAL, label 1 + + delegate.add("t.example", bDst, aDst); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void rfcRule9_prefersLongestMatchingPrefix_ipv6Only() throws Exception { + // Both in same policy (::/0 -> prec 40, label 1), same scope (GLOBAL). + // A's source shares a longer prefix with its destination than B's. + final InetAddress aDst = inet("2003::1"); + final InetAddress bDst = inet("2003::1:0:0:1"); + + // aSrc shares a full /64 with aDst; bSrc only shares /48. + final InetAddress aSrc = inet("2003::2"); + final InetAddress bSrc = inet("2003::2:0:0:1"); + + delegate.add("t.example", bDst, aDst); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + // A has longer common prefix (src=2003::2, dst=2003::1 share 127 bits) + // B has shorter common prefix (src=2003::2:0:0:1, dst=2003::1:0:0:1 share 79 bits) + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void classifyScope_unknownMulticastNibble_fallsBackToGlobal() throws Exception { + // ff03::1 -> scope nibble 0x3, which is not a known Scope constant. + assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL, + AddressSelectingDnsResolver.classifyScope(inet("ff03::1"))); + } + + @Test + void addr_fmt_simpleName() throws Exception { + assertEquals("null", AddressSelectingDnsResolver.addr(null)); + + final InetAddress v4 = inet("192.0.2.1"); + final InetAddress v6 = inet("2001:db8::1"); + + assertEquals("IPv4(" + v4.getHostAddress() + ")", AddressSelectingDnsResolver.addr(v4)); + assertEquals("IPv6(" + v6.getHostAddress() + ")", AddressSelectingDnsResolver.addr(v6)); + + assertEquals(Arrays.asList("IPv6(" + v6.getHostAddress() + ")", "IPv4(" + v4.getHostAddress() + ")"), + AddressSelectingDnsResolver.fmt(new InetAddress[]{v6, v4})); + + assertEquals(Arrays.asList("IPv4(" + v4.getHostAddress() + ")", "IPv6(" + v6.getHostAddress() + ")"), + AddressSelectingDnsResolver.fmt(Arrays.asList(v4, v6))); + + } + + private static InetAddress inet(final String s) { + try { + return InetAddress.getByName(s); + } catch (final UnknownHostException ex) { + throw new AssertionError(ex); + } + } + + private static AddressSelectingDnsResolver.SourceAddressResolver sourceMap( + final InetAddress aDst, final InetAddress aSrc, + final InetAddress bDst, final InetAddress bSrc) { + return (final InetSocketAddress dest) -> { + final InetAddress d = dest.getAddress(); + if (aDst.equals(d)) { + return aSrc; + } + if (bDst.equals(d)) { + return bSrc; + } + return null; + }; + } + + + @Test + void ipv4Only_filtersSingleV6Address() throws Exception { + // Regression: a single IPv6 address must still be filtered when IPV4_ONLY is set. + final InetAddress v6 = inet("2001:db8::1"); + delegate.add("v6.example", v6); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); + + assertNull(r.resolve("v6.example")); + } + + @Test + void resolveHostPort_appliesRfc6724Ordering() throws Exception { + final InetAddress v4 = inet("192.0.2.1"); + final InetAddress v6 = inet("2001:db8::1"); + + delegate.add("dual.example", v4, v6); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); + + final List out = r.resolve("dual.example", 443); + assertEquals(1, out.size()); + assertEquals(new InetSocketAddress(v4, 443), out.get(0)); + } + + @Test + void resolveHostPort_throwsWhenAllFilteredOut() throws Exception { + // Only IPv6 addresses, but IPV4_ONLY is requested. + // Must throw rather than returning an unresolved address that bypasses filtering. + delegate.add("v6.example", inet("2001:db8::1")); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); + + assertThrows(UnknownHostException.class, () -> r.resolve("v6.example", 443)); + } + + @Test + void rfcRule9_appliesToIpv4Pairs() throws Exception { + // Both IPv4, same policy (::ffff:0:0/96 → prec 35, label 4), same scope (GLOBAL). + // Rule 9 should prefer the address whose source shares a longer prefix. + final InetAddress aDst = inet("192.0.2.1"); + final InetAddress bDst = inet("203.0.113.1"); + + // aSrc shares 24 bits with aDst (192.0.2.x); bSrc shares only 8 bits with bDst (203 vs 203). + final InetAddress aSrc = inet("192.0.2.100"); + final InetAddress bSrc = inet("203.0.114.1"); + + delegate.add("t.example", bDst, aDst); + + final AddressSelectingDnsResolver r = + new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); + + final InetAddress[] out = r.resolve("t.example"); + // aSrc-aDst share more prefix bits than bSrc-bDst, so aDst should come first. + assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); + } + + @Test + void networkContains_ipv6Prefix32() throws Exception { + final AddressSelectingDnsResolver.Network p32 = + new AddressSelectingDnsResolver.Network(inet("2001:db8::").getAddress(), 32); + + assertTrue(p32.contains(inet("2001:db8::1"))); + assertTrue(p32.contains(inet("2001:db8:ffff::1"))); + + assertFalse(p32.contains(inet("2001:db9::1"))); + assertFalse(p32.contains(inet("2000:db8::1"))); + } + + @Test + void networkContains_ipv4IsMatchedViaV4MappedWhenPrefixIsV6Mapped96() throws Exception { + // Build ::ffff:0:0 as raw 16 bytes. Do NOT use InetAddress.getByName(..) here: + // the JDK may normalize it to an Inet4Address, yielding a 4-byte array. + final byte[] v6mapped = new byte[16]; + v6mapped[10] = (byte) 0xff; + v6mapped[11] = (byte) 0xff; + + final AddressSelectingDnsResolver.Network p96 = + new AddressSelectingDnsResolver.Network(v6mapped, 96); + + assertTrue(p96.contains(inet("192.0.2.1"))); + assertTrue(p96.contains(inet("203.0.113.10"))); + + // A pure IPv6 address must not match that v4-mapped prefix. + assertFalse(p96.contains(inet("2001:db8::1"))); + } + + @Test + void networkContains_nonByteAlignedPrefix7Boundary() throws Exception { + // fc00::/7 (ULA) is in the policy table. + final AddressSelectingDnsResolver.Network p7 = + new AddressSelectingDnsResolver.Network(inet("fc00::").getAddress(), 7); + + // Inside /7: fc00:: and fd00:: (since /7 covers fc00..fdff) + assertTrue(p7.contains(inet("fc00::1"))); + assertTrue(p7.contains(inet("fd00::1"))); + + // Just outside /7: fe00:: (top bits 11111110 vs 1111110x) + assertFalse(p7.contains(inet("fe00::1"))); + assertFalse(p7.contains(inet("2001:db8::1"))); + } + +} \ No newline at end of file diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java deleted file mode 100644 index 3690a99aeb..0000000000 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/Rfc6724AddressSelectingDnsResolverTest.java +++ /dev/null @@ -1,385 +0,0 @@ -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ - -package org.apache.hc.client5.http; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.lang.reflect.Method; -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.List; - -import org.apache.hc.client5.http.config.ProtocolFamilyPreference; -import org.apache.hc.client5.http.impl.InMemoryDnsResolver; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class Rfc6724AddressSelectingDnsResolverTest { - - private static final Rfc6724AddressSelectingDnsResolver.SourceAddressResolver NO_SOURCE_ADDR = - (final InetSocketAddress dest) -> null; - - private InMemoryDnsResolver delegate; - - @BeforeEach - void setUp() { - delegate = new InMemoryDnsResolver(); - } - - @Test - void ipv4Only_filtersOutIPv6() throws Exception { - final InetAddress v4 = inet("203.0.113.10"); // TEST-NET-3 - final InetAddress v6 = inet("2001:db8::10"); // documentation prefix - - delegate.add("dual.example", v6, v4); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); - - final InetAddress[] ordered = r.resolve("dual.example"); - assertEquals(1, ordered.length); - assertInstanceOf(Inet4Address.class, ordered[0]); - assertEquals(v4, ordered[0]); - } - - @Test - void ipv6Only_filtersOutIPv4() throws Exception { - final InetAddress v4 = inet("192.0.2.1"); // TEST-NET-1 - final InetAddress v6 = inet("2001:db8::1"); - - delegate.add("dual.example", v4, v6); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV6_ONLY, NO_SOURCE_ADDR); - - final InetAddress[] ordered = r.resolve("dual.example"); - assertEquals(1, ordered.length); - assertInstanceOf(Inet6Address.class, ordered[0]); - assertEquals(v6, ordered[0]); - } - - @Test - void ipv4Only_emptyWhenNoIPv4Candidates() throws Exception { - final InetAddress v6a = inet("2001:db8::1"); - final InetAddress v6b = inet("2001:db8::2"); - - delegate.add("v6only.example", v6a, v6b); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR); - - final InetAddress[] ordered = r.resolve("v6only.example"); - assertEquals(0, ordered.length); - } - - @Test - void default_hasNoFamilyBias() throws Exception { - final InetAddress v6a = inet("2001:db8::1"); - final InetAddress v6b = inet("2001:db8::2"); - final InetAddress v4a = inet("192.0.2.1"); - final InetAddress v4b = inet("203.0.113.10"); - - delegate.add("dual.example", v6a, v6b, v4a, v4b); - - final Rfc6724AddressSelectingDnsResolver r1 = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); - final Rfc6724AddressSelectingDnsResolver r2 = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); - - final InetAddress[] out1 = r1.resolve("dual.example"); - final InetAddress[] out2 = r2.resolve("dual.example"); - - assertArrayEquals(out1, out2); - assertEquals(4, out1.length); - } - - @Test - void interleave_alternatesFamilies_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception { - final InetAddress v6a = inet("2001:db8::1"); - final InetAddress v6b = inet("2001:db8::2"); - final InetAddress v4a = inet("192.0.2.1"); - final InetAddress v4b = inet("203.0.113.10"); - - // With NO_SOURCE_ADDR, RFC sort becomes a stable no-op; deterministic interleave. - delegate.add("dual.example", v6a, v6b, v4a, v4b); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE, NO_SOURCE_ADDR); - - final InetAddress[] out = r.resolve("dual.example"); - assertEquals(Arrays.asList(v6a, v4a, v6b, v4b), Arrays.asList(out)); - } - - @Test - void preferIpv6_groupsAllV6First_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception { - final InetAddress v4a = inet("192.0.2.1"); - final InetAddress v6a = inet("2001:db8::1"); - final InetAddress v4b = inet("203.0.113.10"); - final InetAddress v6b = inet("2001:db8::2"); - - delegate.add("dual.example", v4a, v6a, v4b, v6b); - - final Rfc6724AddressSelectingDnsResolver preferV6 = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.PREFER_IPV6, NO_SOURCE_ADDR); - - final InetAddress[] out = preferV6.resolve("dual.example"); - assertEquals(Arrays.asList(v6a, v6b, v4a, v4b), Arrays.asList(out)); - assertInstanceOf(Inet6Address.class, out[0]); - } - - @Test - void filtersOutMulticastDestinations() throws Exception { - final InetAddress multicastV6 = inet("ff02::1"); - final InetAddress v6 = inet("2001:db8::1"); - - delegate.add("mcast.example", multicastV6, v6); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR); - - final InetAddress[] out = r.resolve("mcast.example"); - assertEquals(1, out.length); - assertEquals(v6, out[0]); - } - - // ------------------------------------------------------------------------- - // New: direct tests for classifyScope(..) and Scope.fromValue(..) via reflection - // (Scope and classifyScope are private in the resolver). - // ------------------------------------------------------------------------- - - @Test - void classifyScope_loopback_linkLocal_siteLocal_global() throws Exception { - final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; - - assertEquals("INTERFACE_LOCAL", classifyScope(resolverClass, inet("127.0.0.1"))); - assertEquals("INTERFACE_LOCAL", classifyScope(resolverClass, inet("::1"))); - - assertEquals("LINK_LOCAL", classifyScope(resolverClass, inet("169.254.0.1"))); - assertEquals("LINK_LOCAL", classifyScope(resolverClass, inet("fe80::1"))); - - assertEquals("SITE_LOCAL", classifyScope(resolverClass, inet("10.0.0.1"))); - - assertEquals("GLOBAL", classifyScope(resolverClass, inet("8.8.8.8"))); - assertEquals("GLOBAL", classifyScope(resolverClass, inet("2003::1"))); - } - - @Test - void classifyScope_ipv6Multicast_usesLowNibbleScope() throws Exception { - final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; - - // ff01::1 -> scope 0x1 -> INTERFACE_LOCAL - assertEquals("INTERFACE_LOCAL", classifyScope(resolverClass, inet("ff01::1"))); - // ff02::1 -> scope 0x2 -> LINK_LOCAL - assertEquals("LINK_LOCAL", classifyScope(resolverClass, inet("ff02::1"))); - // ff04::1 -> scope 0x4 -> ADMIN_LOCAL - assertEquals("ADMIN_LOCAL", classifyScope(resolverClass, inet("ff04::1"))); - // ff05::1 -> scope 0x5 -> SITE_LOCAL - assertEquals("SITE_LOCAL", classifyScope(resolverClass, inet("ff05::1"))); - // ff08::1 -> scope 0x8 -> ORG_LOCAL - assertEquals("ORG_LOCAL", classifyScope(resolverClass, inet("ff08::1"))); - // ff0e::1 -> scope 0xe -> GLOBAL (default branch) - assertEquals("GLOBAL", classifyScope(resolverClass, inet("ff0e::1"))); - } - - @Test - void scopeFromValue_mapsKnownConstants_andDefaultsToGlobal() throws Exception { - final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; - final Class scopeClass = findDeclaredClass(resolverClass, "Scope"); - assertNotNull(scopeClass); - - assertEquals("INTERFACE_LOCAL", scopeFromValue(scopeClass, 0x1)); - assertEquals("LINK_LOCAL", scopeFromValue(scopeClass, 0x2)); - assertEquals("ADMIN_LOCAL", scopeFromValue(scopeClass, 0x4)); - assertEquals("SITE_LOCAL", scopeFromValue(scopeClass, 0x5)); - assertEquals("ORG_LOCAL", scopeFromValue(scopeClass, 0x8)); - - assertEquals("GLOBAL", scopeFromValue(scopeClass, 0x0)); - assertEquals("GLOBAL", scopeFromValue(scopeClass, 0xe)); - assertEquals("GLOBAL", scopeFromValue(scopeClass, 0xf)); - } - - @Test - void rfcRule2_prefersMatchingScope() throws Exception { - final InetAddress aDst = inet("2001:db8::1"); - final InetAddress bDst = inet("2001:db8::2"); - - // A matches scope (GLOBAL == GLOBAL); B mismatches (GLOBAL != LINK_LOCAL) - final InetAddress aSrc = inet("2001:db8::abcd"); - final InetAddress bSrc = inet("fe80::1"); - - delegate.add("t.example", bDst, aDst); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); - - final InetAddress[] out = r.resolve("t.example"); - assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); - } - - @Test - void rfcRule5_prefersMatchingLabel() throws Exception { - final InetAddress aDst = inet("2001:db8::1"); // label 5 (2001::/32) - final InetAddress bDst = inet("2001:db8::2"); // label 5 - - final InetAddress aSrc = inet("2001:db8::abcd"); // label 5 -> matches A - final InetAddress bSrc = inet("::ffff:192.0.2.1"); // label 4 -> does not match B - - delegate.add("t.example", bDst, aDst); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); - - final InetAddress[] out = r.resolve("t.example"); - assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); - } - - @Test - void rfcRule6_prefersHigherPrecedence() throws Exception { - final InetAddress aDst = inet("::1"); // precedence 50 (policy ::1) - final InetAddress bDst = inet("2001:db8::1"); // precedence 5 (policy 2001::/32) - - final InetAddress aSrc = inet("::1"); - final InetAddress bSrc = inet("2001:db8::abcd"); - - delegate.add("t.example", bDst, aDst); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); - - final InetAddress[] out = r.resolve("t.example"); - assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); - } - - @Test - void rfcRule8_prefersSmallerScope_whenPrecedenceAndLabelTie() throws Exception { - // Both fall to ::/0 policy -> precedence 40, label 1, but different scopes. - final InetAddress aDst = inet("fe80::1"); // LINK_LOCAL scope (0x2) - final InetAddress bDst = inet("2003::1"); // GLOBAL scope (0xe) - - final InetAddress aSrc = inet("fe80::2"); // LINK_LOCAL, label 1 - final InetAddress bSrc = inet("2003::2"); // GLOBAL, label 1 - - delegate.add("t.example", bDst, aDst); - - final Rfc6724AddressSelectingDnsResolver r = - new Rfc6724AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc)); - - final InetAddress[] out = r.resolve("t.example"); - assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out)); - } - - @Test - void addr_fmt_simpleName() throws Exception { - final Class resolverClass = Rfc6724AddressSelectingDnsResolver.class; - - final Method addr = resolverClass.getDeclaredMethod("addr", InetAddress.class); - addr.setAccessible(true); - - final Method fmtArr = resolverClass.getDeclaredMethod("fmt", InetAddress[].class); - fmtArr.setAccessible(true); - - final Method fmtList = resolverClass.getDeclaredMethod("fmt", List.class); - fmtList.setAccessible(true); - - final Method simpleName = resolverClass.getDeclaredMethod("simpleName"); - simpleName.setAccessible(true); - - assertEquals("null", (String) addr.invoke(null, new Object[]{null})); - - final InetAddress v4 = inet("192.0.2.1"); - final InetAddress v6 = inet("2001:db8::1"); - - final String s4 = (String) addr.invoke(null, v4); - final String s6 = (String) addr.invoke(null, v6); - - assertEquals("IPv4(" + v4.getHostAddress() + ")", s4); - assertEquals("IPv6(" + v6.getHostAddress() + ")", s6); - - @SuppressWarnings("unchecked") final List arrOut = (List) fmtArr.invoke(null, new Object[]{new InetAddress[]{v6, v4}}); - assertEquals(Arrays.asList("IPv6(" + v6.getHostAddress() + ")", "IPv4(" + v4.getHostAddress() + ")"), arrOut); - - @SuppressWarnings("unchecked") final List listOut = (List) fmtList.invoke(null, Arrays.asList(v4, v6)); - assertEquals(Arrays.asList("IPv4(" + v4.getHostAddress() + ")", "IPv6(" + v6.getHostAddress() + ")"), listOut); - - assertNotNull((String) simpleName.invoke(null)); - assertEquals("Rfc6724Resolver", (String) simpleName.invoke(null)); - } - - private static InetAddress inet(final String s) { - try { - return InetAddress.getByName(s); - } catch (final UnknownHostException ex) { - throw new AssertionError(ex); - } - } - - private static Rfc6724AddressSelectingDnsResolver.SourceAddressResolver sourceMap( - final InetAddress aDst, final InetAddress aSrc, - final InetAddress bDst, final InetAddress bSrc) { - return (final InetSocketAddress dest) -> { - final InetAddress d = dest.getAddress(); - if (aDst.equals(d)) { - return aSrc; - } - if (bDst.equals(d)) { - return bSrc; - } - return null; - }; - } - - private static String classifyScope(final Class resolverClass, final InetAddress ip) throws Exception { - final Method m = resolverClass.getDeclaredMethod("classifyScope", InetAddress.class); - m.setAccessible(true); - final Object scope = m.invoke(null, ip); - return scope != null ? scope.toString() : null; - } - - private static String scopeFromValue(final Class scopeClass, final int v) throws Exception { - final Method m = scopeClass.getDeclaredMethod("fromValue", int.class); - m.setAccessible(true); - final Object scope = m.invoke(null, v); - return scope != null ? scope.toString() : null; - } - - private static Class findDeclaredClass(final Class outer, final String simpleName) { - for (final Class c : outer.getDeclaredClasses()) { - if (simpleName.equals(c.getSimpleName())) { - return c; - } - } - return null; - } -} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AddressSelectingDnsResolverExample.java similarity index 67% rename from httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java rename to httpclient5/src/test/java/org/apache/hc/client5/http/examples/AddressSelectingDnsResolverExample.java index 4113bcc4b8..a7c6656da7 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/Rfc6724ResolverExample.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AddressSelectingDnsResolverExample.java @@ -30,11 +30,11 @@ import java.net.InetAddress; import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.Rfc6724AddressSelectingDnsResolver; +import org.apache.hc.client5.http.AddressSelectingDnsResolver; import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.config.ProtocolFamilyPreference; -public final class Rfc6724ResolverExample { +public final class AddressSelectingDnsResolverExample { public static void main(final String[] args) throws Exception { final String host = args.length > 0 ? args[0] : "localhost"; @@ -42,26 +42,38 @@ public static void main(final String[] args) throws Exception { ? ProtocolFamilyPreference.valueOf(args[1]) : ProtocolFamilyPreference.DEFAULT; - final DnsResolver resolver = new Rfc6724AddressSelectingDnsResolver(SystemDefaultDnsResolver.INSTANCE, pref); + System.out.println("Host: " + host); + System.out.println("Preference: " + pref); + System.out.println(); + + // Before: raw system resolver output (no RFC 6724 ordering). + final InetAddress[] raw = SystemDefaultDnsResolver.INSTANCE.resolve(host); + System.out.println("Before (system resolver):"); + printAddresses(raw); + // After: RFC 6724 ordered + family preference applied. + final DnsResolver resolver = new AddressSelectingDnsResolver(SystemDefaultDnsResolver.INSTANCE, pref); final InetAddress[] out = resolver.resolve(host); + System.out.println("After (AddressSelectingDnsResolver, " + pref + "):"); + printAddresses(out); + } - System.out.println("Host: " + host); - System.out.println("Preference: " + pref); - if (out == null) { - System.out.println("Result: null"); + private static void printAddresses(final InetAddress[] addresses) { + if (addresses == null) { + System.out.println(" null"); return; } - if (out.length == 0) { - System.out.println("Result: []"); + if (addresses.length == 0) { + System.out.println(" []"); return; } - for (final InetAddress a : out) { + for (final InetAddress a : addresses) { final String family = a instanceof java.net.Inet6Address ? "IPv6" : "IPv4"; System.out.println(" " + family + " " + a.getHostAddress()); } + System.out.println(); } - private Rfc6724ResolverExample() { + private AddressSelectingDnsResolverExample() { } }