diff --git a/servicetalk-http-api/src/test/java/io/servicetalk/http/api/AbstractHttpRequestMetaDataTest.java b/servicetalk-http-api/src/test/java/io/servicetalk/http/api/AbstractHttpRequestMetaDataTest.java index fbc95956a7..9c7b6854f1 100644 --- a/servicetalk-http-api/src/test/java/io/servicetalk/http/api/AbstractHttpRequestMetaDataTest.java +++ b/servicetalk-http-api/src/test/java/io/servicetalk/http/api/AbstractHttpRequestMetaDataTest.java @@ -29,6 +29,7 @@ import static io.servicetalk.http.api.HttpHeaderNames.AUTHORIZATION; import static io.servicetalk.http.api.HttpHeaderNames.HOST; import static io.servicetalk.http.api.HttpRequestMethod.CONNECT; +import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV6Address; import static java.lang.System.lineSeparator; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Arrays.asList; @@ -39,6 +40,7 @@ import static java.util.Spliterators.spliteratorUnknownSize; import static java.util.stream.Collectors.toList; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.lessThan; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -208,19 +210,21 @@ void testParseHttpUriAbsoluteFormWithHost() { } @Test + @SuppressWarnings("PMD.AvoidUsingHardCodedIP") void testEffectiveHostIPv6NoPort() { createFixture("some/path?foo=bar&abc=def&foo=baz"); fixture.headers().set(HOST, "[1:2:3::5]"); - assertEffectiveHostAndPort("[1:2:3::5]"); + assertEffectiveHostAndPort("1:2:3::5"); } @Test + @SuppressWarnings("PMD.AvoidUsingHardCodedIP") void testEffectiveHostIPv6WithPort() { createFixture("some/path?foo=bar&abc=def&foo=baz"); fixture.headers().set(HOST, "[1:2:3::5]:8080"); - assertEffectiveHostAndPort("[1:2:3::5]", 8080); + assertEffectiveHostAndPort("1:2:3::5", 8080); } @Test @@ -839,6 +843,9 @@ private void assertEffectiveHostAndPort(String hostName, int port) { assertNotNull(effectiveHostAndPort); assertEquals(hostName, effectiveHostAndPort.hostName()); assertEquals(port, effectiveHostAndPort.port()); + assertThat(effectiveHostAndPort.toString(), isValidIpV6Address(hostName) ? + equalTo('[' + hostName + "]:" + port) : + equalTo(hostName + ':' + port)); } @SuppressWarnings("unchecked") diff --git a/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/DefaultHostAndPort.java b/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/DefaultHostAndPort.java index 0fc0a46a60..bef75231bf 100644 --- a/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/DefaultHostAndPort.java +++ b/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/DefaultHostAndPort.java @@ -15,13 +15,29 @@ */ package io.servicetalk.transport.api; +import javax.annotation.Nullable; + +import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV4Address; +import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV6Address; +import static java.lang.Integer.parseInt; import static java.util.Objects.requireNonNull; /** * A default immutable implementation of {@link HostAndPort}. */ final class DefaultHostAndPort implements HostAndPort { + /** + * {@code xxx.xxx.xxx.xxx:yyyyy} + */ + private static final int MAX_IPV4_LEN = 21; + /** + * {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} = 47 chars w/out zone id + */ + private static final int MAX_IPV6_LEN = 47 + 12 /* some limit for zone id length */; + private static final String STR_IPV6 = "_ipv6_"; private final String hostName; + @Nullable + private String toString; private final int port; /** @@ -30,8 +46,86 @@ final class DefaultHostAndPort implements HostAndPort { * @param port the port. */ DefaultHostAndPort(String hostName, int port) { + if (isValidIpV6Address(requireNonNull(hostName))) { // Normalize ipv6 so equals/hashCode works as expected + this.hostName = hostName.charAt(0) == '[' ? + compressIPv6(hostName, 1, hostName.length() - 1) : compressIPv6(hostName, 0, hostName.length()); + this.toString = STR_IPV6; + } else { + this.hostName = hostName; + } + this.port = port; + } + + DefaultHostAndPort(String hostName, int port, boolean isIPv6) { this.hostName = requireNonNull(hostName); this.port = port; + this.toString = isIPv6 ? STR_IPV6 : null; + } + + /** + * Parse IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} and IPv6 {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} style + * addresses. + * @param ipPort An IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} or IPv6 + * {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} addresses. + * @param startIndex The index at which the address parsing starts. + * @return A {@link HostAndPort} where the hostname is the IP address and the port is parsed from the string. + */ + static HostAndPort parseFromIpPort(String ipPort, int startIndex) { + String inetAddress; + final boolean isv6; + int i; + if (ipPort.charAt(startIndex) == '[') { // check if ipv6 + if (ipPort.length() - startIndex > MAX_IPV6_LEN) { + throw new IllegalArgumentException("Invalid IPv6 address: " + ipPort.substring(startIndex)); + } + i = ipPort.indexOf(']'); + if (i <= startIndex) { + throw new IllegalArgumentException("unable to find end ']' of IPv6 address: " + + ipPort.substring(startIndex)); + } + inetAddress = ipPort.substring(startIndex + 1, i); + ++i; + isv6 = true; + if (i >= ipPort.length()) { + throw new IllegalArgumentException("no port found after ']' of IPv6 address: " + + ipPort.substring(startIndex)); + } else if (ipPort.charAt(i) != ':') { + throw new IllegalArgumentException("':' expected after ']' for IPv6 address: " + + ipPort.substring(startIndex)); + } + } else { + if (ipPort.length() - startIndex > MAX_IPV4_LEN) { + throw new IllegalArgumentException("Invalid IPv4 address: " + ipPort.substring(startIndex)); + } + i = ipPort.lastIndexOf(':'); + if (i < 0) { + throw new IllegalArgumentException("no port found: " + ipPort.substring(startIndex)); + } + inetAddress = ipPort.substring(startIndex, i); + isv6 = false; + } + + final int port; + try { + port = parseInt(ipPort.substring(i + 1)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid port " + ipPort.substring(startIndex), e); + } + if (!isValidPort(port) || ipPort.charAt(i + 1) == '+') { // parseInt allows '+' but we don't want this + throw new IllegalArgumentException("invalid port " + ipPort.substring(startIndex)); + } + + if (isv6) { + if (!isValidIpV6Address(inetAddress)) { + throw new IllegalArgumentException("Invalid IPv6 address: " + inetAddress); + } + inetAddress = compressIPv6(inetAddress, 0, inetAddress.length()); + return new DefaultHostAndPort(inetAddress, port, true); + } + if (!isValidIpV4Address(inetAddress)) { + throw new IllegalArgumentException("Invalid IPv4 address: " + inetAddress); + } + return new DefaultHostAndPort(inetAddress, port, false); } @Override @@ -46,7 +140,13 @@ public int port() { @Override public String toString() { - return hostName + ':' + port; + String str = toString; + if (str == null) { + toString = str = hostName + ':' + port; + } else if (str == STR_IPV6) { + toString = str = '[' + hostName + "]:" + port; + } + return str; } @Override @@ -62,4 +162,68 @@ public boolean equals(Object o) { public int hashCode() { return 31 * (31 + port) + hostName.hashCode(); } + + private static boolean isValidPort(int port) { + return port >= 0 && port <= 65535; + } + + private static String compressIPv6(String rawIp, int start, int end) { + if (end - start <= 0) { + throw new IllegalArgumentException("Empty IPv6 address"); + } + // https://datatracker.ietf.org/doc/html/rfc5952#section-2 + // JDK doesn't do IPv6 compression, or remove leading 0s. This may lead to inconsistent String representation + // which will yield different hash-codes and equals comparisons to fail when it shouldn't. + int longestZerosCount = 0; + int longestZerosBegin = -1; + int longestZerosEnd = -1; + int zerosCount = 0; + int zerosBegin = rawIp.charAt(start) != '0' ? -1 : 0; + int zerosEnd = -1; + boolean isCompressed = false; + char prevChar = '\0'; + StringBuilder compressedIPv6Builder = new StringBuilder(end - start); + for (int i = start; i < end; ++i) { + final char c = rawIp.charAt(i); + switch (c) { + case '0': + if (zerosBegin < 0 || i == end - 1) { + compressedIPv6Builder.append('0'); + } + break; + case ':': + if (prevChar == ':') { + isCompressed = true; + compressedIPv6Builder.append(':'); + } else if (zerosBegin >= 0) { + ++zerosCount; + compressedIPv6Builder.append("0:"); + zerosEnd = compressedIPv6Builder.length(); + } else { + compressedIPv6Builder.append(':'); + zerosBegin = compressedIPv6Builder.length(); + } + break; + default: + // https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.3 + // if there is a tie in the longest length, we must choose the first to compress. + if (zerosEnd > 0 && zerosCount > longestZerosCount) { + longestZerosCount = zerosCount; + longestZerosBegin = zerosBegin; + longestZerosEnd = zerosEnd; + } + zerosBegin = zerosEnd = -1; + zerosCount = 0; + compressedIPv6Builder.append(c); + break; + } + prevChar = c; + } + // https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.2 + // The symbol "::" MUST NOT be used to shorten just one 16-bit 0 field. + if (!isCompressed && longestZerosBegin >= 0 && longestZerosCount > 1) { + compressedIPv6Builder.replace(longestZerosBegin, longestZerosEnd, longestZerosBegin == 0 ? "::" : ":"); + } + return compressedIPv6Builder.toString(); + } } diff --git a/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/HostAndPort.java b/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/HostAndPort.java index 95a0b96ec3..4a2ccd1574 100644 --- a/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/HostAndPort.java +++ b/servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/HostAndPort.java @@ -15,6 +15,7 @@ */ package io.servicetalk.transport.api; +import java.net.Inet6Address; import java.net.InetSocketAddress; /** @@ -55,6 +56,31 @@ static HostAndPort of(String host, int port) { * @return the {@link HostAndPort}. */ static HostAndPort of(InetSocketAddress address) { - return new DefaultHostAndPort(address.getHostString(), address.getPort()); + return new DefaultHostAndPort(address.getHostString(), address.getPort(), + address.getAddress() instanceof Inet6Address); + } + + /** + * Parse IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} and IPv6 {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} style + * addresses. + * @param ipPort An IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} or IPv6 + * {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} addresses. + * @return A {@link HostAndPort} where the hostname is the IP address and the port is parsed from the string. + * @see #ofIpPort(String, int) + */ + static HostAndPort ofIpPort(String ipPort) { + return DefaultHostAndPort.parseFromIpPort(ipPort, 0); + } + + /** + * Parse IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} and IPv6 {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} style + * addresses. + * @param ipPort An IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} or IPv6 + * {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} addresses. + * @param startIndex The index at which the address parsing starts. + * @return A {@link HostAndPort} where the hostname is the IP address and the port is parsed from the string. + */ + static HostAndPort ofIpPort(String ipPort, int startIndex) { + return DefaultHostAndPort.parseFromIpPort(ipPort, startIndex); } } diff --git a/servicetalk-transport-api/src/test/java/io/servicetalk/transport/api/HostAndPortTest.java b/servicetalk-transport-api/src/test/java/io/servicetalk/transport/api/HostAndPortTest.java new file mode 100644 index 0000000000..9e549d109e --- /dev/null +++ b/servicetalk-transport-api/src/test/java/io/servicetalk/transport/api/HostAndPortTest.java @@ -0,0 +1,265 @@ +/* + * Copyright © 2022 Apple Inc. and the ServiceTalk project authors + * + * Licensed 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. + */ +package io.servicetalk.transport.api; + +import org.junit.jupiter.api.Test; + +import java.net.Inet6Address; +import java.net.InetSocketAddress; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings("PMD.AvoidUsingHardCodedIP") +final class HostAndPortTest { + @Test + void hostConstructorNormalizesIpv6() { + HostAndPort hp1 = HostAndPort.of("[::1]", 9999); + HostAndPort hp2 = HostAndPort.of("::1", 9999); + HostAndPort hp3 = HostAndPort.of("[0000:0000:0000:0000:0000:0000:0000:0001]", 9999); + HostAndPort hp4 = HostAndPort.of("0000:0000:0000:0000:0000:0000:0000:0001", 9999); + + assertHpEqualTo(hp1, hp2); + assertHpEqualTo(hp2, hp3); + assertHpEqualTo(hp3, hp4); + } + + @Test + void IPv6LoopBack() { + assertIP("[::1]:9999", "::1", 9999); + } + + @Test + void IPv6Compressed() { + assertIP("[2001:1234::1b12:0:0:1a13]:0", "2001:1234::1b12:0:0:1a13", 0); + } + + @Test + void IPv6MappedIPv4() { + assertIP("[::FFFF:129.144.52.38]:443", "::FFFF:129.144.52.38", 443); + } + + @Test + void IPv6WithScope() { + assertIP("[::FFFF:129.144.52.38%2]:65535", "::FFFF:129.144.52.38%2", 65535); + } + + @Test + void IPv4() { + assertIP("1.2.3.4:8080", "1.2.3.4", 8080); + } + + @Test + void IPv4MissingComponents() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3:80", "1.2.3", 80)); + } + + @Test + void IPv4NoAddress() { + assertThrows(IllegalArgumentException.class, () -> assertIP(":80", "", 80)); + } + + @Test + void IPv4NoPort() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3.4", "1.2.3.4", 0)); + } + + @Test + void IPv6NoPort() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]", "[::1]", 0)); + } + + @Test + void IPv6NoAddress() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[]:80", "[]", 80)); + } + + @Test + void IPv6SingleCharAddress() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[a]:80", "[a]", 80)); + } + + @Test + void IPv4NegativePort() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3.4:-22", "1.2.3.4", 0)); + } + + @Test + void IPv6NegativePort() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]-1", "[::1]", 0)); + } + + @Test + void IPv4PlusPort() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3.4:+22", "1.2.3.4", 0)); + } + + @Test + void IPv6PlusPort() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]:+22", "[::1]", 0)); + } + + @Test + void IPv4PortTooHigh() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3.4:65536", "1.2.3.4", 0)); + } + + @Test + void IPv6PortTooHigh() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]:65536", "[::1]", 0)); + } + + @Test + void IPv4PortTooLow() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3.4:-1", "1.2.3.4", 0)); + } + + @Test + void IPv6PortTooLow() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]:-3", "[::1]", 0)); + } + + @Test + void IPv4PortInvalidChar() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.3.4:12x", "1.2.3.4", 0)); + } + + @Test + void IPv6PortInvalidChar() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]:x", "[::1]", 0)); + } + + @Test + void IPv6HalfBracketFirst() { + assertThrows(IllegalArgumentException.class, () -> assertIP("::1]:80", "[::1]", 80)); + } + + @Test + void IPv6HalfBracketLast() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1:80", "[::1]", 80)); + } + + @Test + void IPv6DoubleBracketFirst() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[[::1]:80", "[::1]", 80)); + } + + @Test + void IPv6DoubleBracketLast() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::1]]:80", "[::1]", 80)); + } + + @Test + void IPv6ChineseChar() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[\u4F60\u597D]:8080", "", 8080)); + } + + @Test + void IPv4ChineseChar() { + assertThrows(IllegalArgumentException.class, () -> assertIP("\u4F60.2.3.4:8080", "", 8080)); + } + + @Test + void IPv6UTF8() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::❤]:8080", "", 8080)); + } + + @Test + void IPv4UTF8() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.❤.3.4:8080", "", 8080)); + } + + @Test + void IPv6Latin1AsUTF8() { + assertThrows(IllegalArgumentException.class, () -> assertIP("[::ö]:8080", "", 8080)); + } + + @Test + void IPv4Latin1AsUTF8() { + assertThrows(IllegalArgumentException.class, () -> assertIP("1.2.ö.4:8080", "", 8080)); + } + + @Test + void IPv6CompressAndRemoveLeadingZeros() { + assertIP("[0000:0000:0000:0000:0000:0000:0000:0001]:234", "::1", 234); + } + + @Test + void IPv6CompressAndRemoveLeadingZerosPreserveLastZero() { + assertIP("[1000:0200:0030:0004:0000:0000:0050:0000]:234", "1000:200:30:4::50:0", 234); + } + + @Test + void IPv6CompressAndRemoveLeadingZeroTwo() { + assertIP("[00:000:0030:0004:0001:2000:0050:0000]:234", "::30:4:1:2000:50:0", 234); + } + + @Test + void IPv6RemoveLeadingZerosEvenIfAlreadyCompressed() { + // https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.1 + assertIP("[2001:0db8::0001]:1234", "2001:db8::1", 1234); + } + + @Test + void IPv6NoCompressOneBitField() { + // https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.2 + assertIP("[2001:db8:0:1:1:1:1:1]:1234", "2001:db8:0:1:1:1:1:1", 1234); + assertIP("[2001:db8:0000:1:1:1:1:1]:1234", "2001:db8:0:1:1:1:1:1", 1234); + assertIP("[0000:0db8:0000:01:01:01:01:01]:1234", "0:db8:0:1:1:1:1:1", 1234); + } + + @Test + void IPv6CompressLongestStreak() { + // https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.3 + assertIP("[2001:0:0:1:0:0:0:1]:1234", "2001:0:0:1::1", 1234); + } + + @Test + void IPv6CompressFirstIfTie() { + // https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.3 + assertIP("[2001:db8:0:0:1:0:0:1]:1234", "2001:db8::1:0:0:1", 1234); + } + + private static void assertIP(String ipPort, String expectedAddress, int expectedPort) { + assertIP(HostAndPort.ofIpPort(ipPort), expectedAddress, expectedPort); + + for (int i = 1; i <= 3; ++i) { + StringBuilder prefix = new StringBuilder(i); + for (int x = 0; x < i; ++x) { + prefix.append('a'); + } + assertIP(HostAndPort.ofIpPort(prefix + ipPort, prefix.length()), expectedAddress, expectedPort); + } + } + + private static void assertIP(HostAndPort result, String expectedAddress, int expectedPort) { + assertThat(result.hostName(), equalTo(expectedAddress)); + assertThat(result.port(), equalTo(expectedPort)); + InetSocketAddress socketAddress = new InetSocketAddress(expectedAddress, expectedPort); + if (socketAddress.getAddress() instanceof Inet6Address || expectedAddress.startsWith("::FFFF:")) { + assertThat(result.toString(), equalTo('[' + expectedAddress + "]:" + expectedPort)); + } else { + assertThat(result.toString(), equalTo(expectedAddress + ':' + expectedPort)); + } + } + + private static void assertHpEqualTo(HostAndPort hp1, HostAndPort hp2) { + assertThat(hp1, equalTo(hp2)); + assertThat(hp1.hashCode(), equalTo(hp2.hashCode())); + assertThat(hp1.toString(), equalTo(hp2.toString())); + } +} diff --git a/servicetalk-utils-internal/src/main/java/io/servicetalk/utils/internal/NetworkUtils.java b/servicetalk-utils-internal/src/main/java/io/servicetalk/utils/internal/NetworkUtils.java index 8b801126df..98345bfd7b 100644 --- a/servicetalk-utils-internal/src/main/java/io/servicetalk/utils/internal/NetworkUtils.java +++ b/servicetalk-utils-internal/src/main/java/io/servicetalk/utils/internal/NetworkUtils.java @@ -55,6 +55,14 @@ public static boolean isValidIpV4Address(final CharSequence ip) { return isValidIpV4Address(ip, 0, ip.length()); } + /** + * Takes a string and parses it to see if it is a valid IPV4 address. + * + * @param ip the IP-address to validate + * @param from the index in {@code ip} to start validation (inclusive). + * @param toExclusive the index in {@code ip} to end validation (exclusive). + * @return true, if the string represents an IPV4 address in dotted notation, false otherwise. + */ private static boolean isValidIpV4Address(final CharSequence ip, int from, int toExclusive) { int len = toExclusive - from; int i;