ZoneID = 1*( unreserved / pct-encoded )+ * Throws {@link IllegalArgumentException} on invalid input. + */ + public static void validateZoneIdEncoded(final CharSequence enc) { + if (enc == null || enc.length() == 0) { + throw new IllegalArgumentException("ZoneID must not be empty"); + } + for (int i = 0; i < enc.length(); i++) { + final char ch = enc.charAt(i); + if (unreserved(ch)) { + continue; + } + if (ch == '%' && i + 2 < enc.length() + && TextUtils.isHex(enc.charAt(i + 1)) && TextUtils.isHex(enc.charAt(i + 2))) { + i += 2; + continue; + } + throw new IllegalArgumentException("Illegal character in ZoneID"); + } + } + + /** + * Heuristic: returns {@code true} if {@code host} looks like an IPv6 address-part + * (i.e., before any ZoneID) by counting colons. We do not parse/validate IPv6; + * this keeps our surface minimal while still bracketing correctly. + *
Rule: if the address-part (up to '%', if present) contains >= 2 colons, + * treat it as IPv6-like.
+ */ + public static boolean looksLikeIPv6AddressPart(final CharSequence host) { + if (host == null) { + return false; + } + int end = host.length(); + for (int i = 0; i < end; i++) { + if (host.charAt(i) == '%') { + end = i; + break; + } + } + int colons = 0; + for (int i = 0; i < end; i++) { + if (host.charAt(i) == ':') { + colons++; + if (colons >= 2) { + return true; + } + } + } + return false; + } + + /** + * Appends a bracketed IPv6 literal to {@code buf} if {@code host} looks like IPv6. + * If a ZoneID is present (after '%'), it is written as {@code "%25"} followed by the + * RFC 6874-encoded ZoneID. Returns {@code true} iff it wrote the bracketed literal. + */ + public static boolean appendBracketedIPv6(final StringBuilder buf, final CharSequence host) { + if (!looksLikeIPv6AddressPart(host)) { + return false; + } + // address part + int zoneIdx = -1; + for (int i = 0; i < host.length(); i++) { + if (host.charAt(i) == '%') { + zoneIdx = i; + break; + } + } + buf.append('['); + if (zoneIdx >= 0) { + buf.append(host, 0, zoneIdx); + } else { + buf.append(host); + } + // zone part + if (zoneIdx >= 0) { + final CharSequence zone = host.subSequence(zoneIdx + 1, host.length()); + buf.append("%25").append(encodeZoneIdRfc6874(zone)); + } + buf.append(']'); + return true; + } + + /** + * RFC 3986 unreserved characters. + */ + private static boolean unreserved(final char ch) { + return ch >= 'A' && ch <= 'Z' + || ch >= 'a' && ch <= 'z' + || ch >= '0' && ch <= '9' + || ch == '-' || ch == '.' || ch == '_' || ch == '~'; + } +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/util/TextUtils.java b/httpcore5/src/main/java/org/apache/hc/core5/util/TextUtils.java index dffeaf58f3..e36ab710d3 100644 --- a/httpcore5/src/main/java/org/apache/hc/core5/util/TextUtils.java +++ b/httpcore5/src/main/java/org/apache/hc/core5/util/TextUtils.java @@ -188,4 +188,21 @@ public static byte castAsByte(final int c) { return '?'; } + /** + * Tests whether the given character is an ASCII hexadecimal digit. + *+ * Accepts {@code '0'..'9'}, {@code 'A'..'F'}, and {@code 'a'..'f'} only. + * This method does not consider non-ASCII numerals or fullwidth forms. + * + * @param c the character to test + * @return {@code true} if {@code c} is an ASCII hex digit, {@code false} otherwise + * @since 5.4 + */ + public static boolean isHex(final char c) { + return c >= '0' && c <= '9' + || c >= 'A' && c <= 'F' + || c >= 'a' && c <= 'f'; + } + + } diff --git a/httpcore5/src/test/java/org/apache/hc/core5/net/TestInetAddressUtils.java b/httpcore5/src/test/java/org/apache/hc/core5/net/TestInetAddressUtils.java index 3471c2d92c..3c1541fce3 100644 --- a/httpcore5/src/test/java/org/apache/hc/core5/net/TestInetAddressUtils.java +++ b/httpcore5/src/test/java/org/apache/hc/core5/net/TestInetAddressUtils.java @@ -27,6 +27,10 @@ package org.apache.hc.core5.net; +import java.net.URI; +import java.net.URISyntaxException; + +import org.apache.hc.core5.http.HttpHost; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -215,5 +219,69 @@ void testInvalidIPv4MappedIPv6AddressWithBadOctets() { Assertions.assertFalse(InetAddressUtils.isIPv4MappedIPv6("::ffff:0255.000.000.000")); } + @Test + void parseAuthorityWithZoneId_decodesDelimiter() throws URISyntaxException { + final URIAuthority a = URIAuthority.parse("[fe80::1%25eth0]:8080"); + Assertions.assertNotNull(a); + Assertions.assertEquals("fe80::1%eth0", a.getHostName()); + Assertions.assertEquals(8080, a.getPort()); + Assertions.assertNull(a.getUserInfo()); + } + + @Test + void formatAuthorityWithZoneId_emitsPercent25() { + final URIAuthority a = new URIAuthority(null, "fe80::1%eth0", 8080); + Assertions.assertEquals("[fe80::1%25eth0]:8080", a.toString()); + } + + @Test + void httpHost_toURI_formatsZoneId() { + final HttpHost h = new HttpHost("http", "fe80::1%eth0", 8080); + Assertions.assertEquals("http://[fe80::1%25eth0]:8080", h.toURI()); + } + + @Test + void uriBuilder_roundTrip_zoneId() throws Exception { + final URI u = new URI("http://[fe80::1%25eth0]:8080/path?q=1"); + final URIBuilder b = new URIBuilder(u); + Assertions.assertEquals("fe80::1%eth0", b.getHost()); + final URI rebuilt = b.build(); + Assertions.assertEquals("http://[fe80::1%25eth0]:8080/path?q=1", rebuilt.toASCIIString()); + } + + @Test + void zoneId_validation_rejects_bad_pct() { + // empty zone — invalid + Assertions.assertThrows(IllegalArgumentException.class, + () -> URIAuthority.parse("[fe80::1%25]:80")); + + // dangling percent-triplet — invalid + Assertions.assertThrows(IllegalArgumentException.class, + () -> URIAuthority.parse("[fe80::1%25%]:80")); + + // non-hex in percent-triplet — invalid + Assertions.assertThrows(IllegalArgumentException.class, + () -> URIAuthority.parse("[fe80::1%25%G1]:80")); + + // character not in RFC 3986 "unreserved" — invalid + Assertions.assertThrows(IllegalArgumentException.class, + () -> URIAuthority.parse("[fe80::1%25!]:80")); + + } + + @Test + void zoneId_allows_unreserved_and_pct() throws URISyntaxException { + final URIAuthority a = URIAuthority.parse("[fe80::1%25en1-._~x%20]:443"); + Assertions.assertNotNull(a); + Assertions.assertEquals("fe80::1%en1-._~x ", a.getHostName()); + Assertions.assertEquals("[fe80::1%25en1-._~x%20]:443", a.toString()); + } + + @Test + void inetAddressUtils_helper_accepts_zone() { + Assertions.assertTrue(ZoneIdSupport.looksLikeIPv6AddressPart("fe80::1%eth0")); + Assertions.assertTrue(ZoneIdSupport.looksLikeIPv6AddressPart("fe80::1234:0:0:0:0:0%en1")); + Assertions.assertFalse(ZoneIdSupport.looksLikeIPv6AddressPart("not-an-ip")); + } }