Skip to content

Commit 16d1af6

Browse files
authored
HostAndPort to support parsing ip+port string (apple#2412)
Motivation: Parsing HostAndPort from 'ip+port' string is a common case, but we don't have tooling to support this.
1 parent 7551fd7 commit 16d1af6

File tree

5 files changed

+474
-4
lines changed

5 files changed

+474
-4
lines changed

servicetalk-http-api/src/test/java/io/servicetalk/http/api/AbstractHttpRequestMetaDataTest.java

+9-2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import static io.servicetalk.http.api.HttpHeaderNames.AUTHORIZATION;
3030
import static io.servicetalk.http.api.HttpHeaderNames.HOST;
3131
import static io.servicetalk.http.api.HttpRequestMethod.CONNECT;
32+
import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV6Address;
3233
import static java.lang.System.lineSeparator;
3334
import static java.nio.charset.StandardCharsets.UTF_8;
3435
import static java.util.Arrays.asList;
@@ -39,6 +40,7 @@
3940
import static java.util.Spliterators.spliteratorUnknownSize;
4041
import static java.util.stream.Collectors.toList;
4142
import static org.hamcrest.MatcherAssert.assertThat;
43+
import static org.hamcrest.Matchers.equalTo;
4244
import static org.hamcrest.Matchers.hasSize;
4345
import static org.hamcrest.Matchers.lessThan;
4446
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -208,19 +210,21 @@ void testParseHttpUriAbsoluteFormWithHost() {
208210
}
209211

210212
@Test
213+
@SuppressWarnings("PMD.AvoidUsingHardCodedIP")
211214
void testEffectiveHostIPv6NoPort() {
212215
createFixture("some/path?foo=bar&abc=def&foo=baz");
213216
fixture.headers().set(HOST, "[1:2:3::5]");
214217

215-
assertEffectiveHostAndPort("[1:2:3::5]");
218+
assertEffectiveHostAndPort("1:2:3::5");
216219
}
217220

218221
@Test
222+
@SuppressWarnings("PMD.AvoidUsingHardCodedIP")
219223
void testEffectiveHostIPv6WithPort() {
220224
createFixture("some/path?foo=bar&abc=def&foo=baz");
221225
fixture.headers().set(HOST, "[1:2:3::5]:8080");
222226

223-
assertEffectiveHostAndPort("[1:2:3::5]", 8080);
227+
assertEffectiveHostAndPort("1:2:3::5", 8080);
224228
}
225229

226230
@Test
@@ -839,6 +843,9 @@ private void assertEffectiveHostAndPort(String hostName, int port) {
839843
assertNotNull(effectiveHostAndPort);
840844
assertEquals(hostName, effectiveHostAndPort.hostName());
841845
assertEquals(port, effectiveHostAndPort.port());
846+
assertThat(effectiveHostAndPort.toString(), isValidIpV6Address(hostName) ?
847+
equalTo('[' + hostName + "]:" + port) :
848+
equalTo(hostName + ':' + port));
842849
}
843850

844851
@SuppressWarnings("unchecked")

servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/DefaultHostAndPort.java

+165-1
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,29 @@
1515
*/
1616
package io.servicetalk.transport.api;
1717

18+
import javax.annotation.Nullable;
19+
20+
import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV4Address;
21+
import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV6Address;
22+
import static java.lang.Integer.parseInt;
1823
import static java.util.Objects.requireNonNull;
1924

2025
/**
2126
* A default immutable implementation of {@link HostAndPort}.
2227
*/
2328
final class DefaultHostAndPort implements HostAndPort {
29+
/**
30+
* {@code xxx.xxx.xxx.xxx:yyyyy}
31+
*/
32+
private static final int MAX_IPV4_LEN = 21;
33+
/**
34+
* {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} = 47 chars w/out zone id
35+
*/
36+
private static final int MAX_IPV6_LEN = 47 + 12 /* some limit for zone id length */;
37+
private static final String STR_IPV6 = "_ipv6_";
2438
private final String hostName;
39+
@Nullable
40+
private String toString;
2541
private final int port;
2642

2743
/**
@@ -30,8 +46,86 @@ final class DefaultHostAndPort implements HostAndPort {
3046
* @param port the port.
3147
*/
3248
DefaultHostAndPort(String hostName, int port) {
49+
if (isValidIpV6Address(requireNonNull(hostName))) { // Normalize ipv6 so equals/hashCode works as expected
50+
this.hostName = hostName.charAt(0) == '[' ?
51+
compressIPv6(hostName, 1, hostName.length() - 1) : compressIPv6(hostName, 0, hostName.length());
52+
this.toString = STR_IPV6;
53+
} else {
54+
this.hostName = hostName;
55+
}
56+
this.port = port;
57+
}
58+
59+
DefaultHostAndPort(String hostName, int port, boolean isIPv6) {
3360
this.hostName = requireNonNull(hostName);
3461
this.port = port;
62+
this.toString = isIPv6 ? STR_IPV6 : null;
63+
}
64+
65+
/**
66+
* Parse IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} and IPv6 {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} style
67+
* addresses.
68+
* @param ipPort An IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} or IPv6
69+
* {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} addresses.
70+
* @param startIndex The index at which the address parsing starts.
71+
* @return A {@link HostAndPort} where the hostname is the IP address and the port is parsed from the string.
72+
*/
73+
static HostAndPort parseFromIpPort(String ipPort, int startIndex) {
74+
String inetAddress;
75+
final boolean isv6;
76+
int i;
77+
if (ipPort.charAt(startIndex) == '[') { // check if ipv6
78+
if (ipPort.length() - startIndex > MAX_IPV6_LEN) {
79+
throw new IllegalArgumentException("Invalid IPv6 address: " + ipPort.substring(startIndex));
80+
}
81+
i = ipPort.indexOf(']');
82+
if (i <= startIndex) {
83+
throw new IllegalArgumentException("unable to find end ']' of IPv6 address: " +
84+
ipPort.substring(startIndex));
85+
}
86+
inetAddress = ipPort.substring(startIndex + 1, i);
87+
++i;
88+
isv6 = true;
89+
if (i >= ipPort.length()) {
90+
throw new IllegalArgumentException("no port found after ']' of IPv6 address: " +
91+
ipPort.substring(startIndex));
92+
} else if (ipPort.charAt(i) != ':') {
93+
throw new IllegalArgumentException("':' expected after ']' for IPv6 address: " +
94+
ipPort.substring(startIndex));
95+
}
96+
} else {
97+
if (ipPort.length() - startIndex > MAX_IPV4_LEN) {
98+
throw new IllegalArgumentException("Invalid IPv4 address: " + ipPort.substring(startIndex));
99+
}
100+
i = ipPort.lastIndexOf(':');
101+
if (i < 0) {
102+
throw new IllegalArgumentException("no port found: " + ipPort.substring(startIndex));
103+
}
104+
inetAddress = ipPort.substring(startIndex, i);
105+
isv6 = false;
106+
}
107+
108+
final int port;
109+
try {
110+
port = parseInt(ipPort.substring(i + 1));
111+
} catch (NumberFormatException e) {
112+
throw new IllegalArgumentException("invalid port " + ipPort.substring(startIndex), e);
113+
}
114+
if (!isValidPort(port) || ipPort.charAt(i + 1) == '+') { // parseInt allows '+' but we don't want this
115+
throw new IllegalArgumentException("invalid port " + ipPort.substring(startIndex));
116+
}
117+
118+
if (isv6) {
119+
if (!isValidIpV6Address(inetAddress)) {
120+
throw new IllegalArgumentException("Invalid IPv6 address: " + inetAddress);
121+
}
122+
inetAddress = compressIPv6(inetAddress, 0, inetAddress.length());
123+
return new DefaultHostAndPort(inetAddress, port, true);
124+
}
125+
if (!isValidIpV4Address(inetAddress)) {
126+
throw new IllegalArgumentException("Invalid IPv4 address: " + inetAddress);
127+
}
128+
return new DefaultHostAndPort(inetAddress, port, false);
35129
}
36130

37131
@Override
@@ -46,7 +140,13 @@ public int port() {
46140

47141
@Override
48142
public String toString() {
49-
return hostName + ':' + port;
143+
String str = toString;
144+
if (str == null) {
145+
toString = str = hostName + ':' + port;
146+
} else if (str == STR_IPV6) {
147+
toString = str = '[' + hostName + "]:" + port;
148+
}
149+
return str;
50150
}
51151

52152
@Override
@@ -62,4 +162,68 @@ public boolean equals(Object o) {
62162
public int hashCode() {
63163
return 31 * (31 + port) + hostName.hashCode();
64164
}
165+
166+
private static boolean isValidPort(int port) {
167+
return port >= 0 && port <= 65535;
168+
}
169+
170+
private static String compressIPv6(String rawIp, int start, int end) {
171+
if (end - start <= 0) {
172+
throw new IllegalArgumentException("Empty IPv6 address");
173+
}
174+
// https://datatracker.ietf.org/doc/html/rfc5952#section-2
175+
// JDK doesn't do IPv6 compression, or remove leading 0s. This may lead to inconsistent String representation
176+
// which will yield different hash-codes and equals comparisons to fail when it shouldn't.
177+
int longestZerosCount = 0;
178+
int longestZerosBegin = -1;
179+
int longestZerosEnd = -1;
180+
int zerosCount = 0;
181+
int zerosBegin = rawIp.charAt(start) != '0' ? -1 : 0;
182+
int zerosEnd = -1;
183+
boolean isCompressed = false;
184+
char prevChar = '\0';
185+
StringBuilder compressedIPv6Builder = new StringBuilder(end - start);
186+
for (int i = start; i < end; ++i) {
187+
final char c = rawIp.charAt(i);
188+
switch (c) {
189+
case '0':
190+
if (zerosBegin < 0 || i == end - 1) {
191+
compressedIPv6Builder.append('0');
192+
}
193+
break;
194+
case ':':
195+
if (prevChar == ':') {
196+
isCompressed = true;
197+
compressedIPv6Builder.append(':');
198+
} else if (zerosBegin >= 0) {
199+
++zerosCount;
200+
compressedIPv6Builder.append("0:");
201+
zerosEnd = compressedIPv6Builder.length();
202+
} else {
203+
compressedIPv6Builder.append(':');
204+
zerosBegin = compressedIPv6Builder.length();
205+
}
206+
break;
207+
default:
208+
// https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.3
209+
// if there is a tie in the longest length, we must choose the first to compress.
210+
if (zerosEnd > 0 && zerosCount > longestZerosCount) {
211+
longestZerosCount = zerosCount;
212+
longestZerosBegin = zerosBegin;
213+
longestZerosEnd = zerosEnd;
214+
}
215+
zerosBegin = zerosEnd = -1;
216+
zerosCount = 0;
217+
compressedIPv6Builder.append(c);
218+
break;
219+
}
220+
prevChar = c;
221+
}
222+
// https://datatracker.ietf.org/doc/html/rfc5952#section-4.2.2
223+
// The symbol "::" MUST NOT be used to shorten just one 16-bit 0 field.
224+
if (!isCompressed && longestZerosBegin >= 0 && longestZerosCount > 1) {
225+
compressedIPv6Builder.replace(longestZerosBegin, longestZerosEnd, longestZerosBegin == 0 ? "::" : ":");
226+
}
227+
return compressedIPv6Builder.toString();
228+
}
65229
}

servicetalk-transport-api/src/main/java/io/servicetalk/transport/api/HostAndPort.java

+27-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.servicetalk.transport.api;
1717

18+
import java.net.Inet6Address;
1819
import java.net.InetSocketAddress;
1920

2021
/**
@@ -55,6 +56,31 @@ static HostAndPort of(String host, int port) {
5556
* @return the {@link HostAndPort}.
5657
*/
5758
static HostAndPort of(InetSocketAddress address) {
58-
return new DefaultHostAndPort(address.getHostString(), address.getPort());
59+
return new DefaultHostAndPort(address.getHostString(), address.getPort(),
60+
address.getAddress() instanceof Inet6Address);
61+
}
62+
63+
/**
64+
* Parse IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} and IPv6 {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} style
65+
* addresses.
66+
* @param ipPort An IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} or IPv6
67+
* {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} addresses.
68+
* @return A {@link HostAndPort} where the hostname is the IP address and the port is parsed from the string.
69+
* @see #ofIpPort(String, int)
70+
*/
71+
static HostAndPort ofIpPort(String ipPort) {
72+
return DefaultHostAndPort.parseFromIpPort(ipPort, 0);
73+
}
74+
75+
/**
76+
* Parse IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} and IPv6 {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} style
77+
* addresses.
78+
* @param ipPort An IPv4 {@code xxx.xxx.xxx.xxx:yyyyy} or IPv6
79+
* {@code [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:yyyyy} addresses.
80+
* @param startIndex The index at which the address parsing starts.
81+
* @return A {@link HostAndPort} where the hostname is the IP address and the port is parsed from the string.
82+
*/
83+
static HostAndPort ofIpPort(String ipPort, int startIndex) {
84+
return DefaultHostAndPort.parseFromIpPort(ipPort, startIndex);
5985
}
6086
}

0 commit comments

Comments
 (0)