diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java b/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java index c8a966a7c9..981f059583 100644 --- a/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java @@ -204,4 +204,12 @@ private HttpHeaders() { public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + /** + * RFC 7639 — ALPN HTTP header field used with CONNECT to advertise the + * application protocols intended to run inside the tunnel (e.g. {@code h2}, {@code http/1.1}). + * + * @since 5.4 + */ + public static final String ALPN = "ALPN"; + } diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/message/AlpnHeaderSupport.java b/httpcore5/src/main/java/org/apache/hc/core5/http/message/AlpnHeaderSupport.java new file mode 100644 index 0000000000..204dc5e85c --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/message/AlpnHeaderSupport.java @@ -0,0 +1,181 @@ +/* + * ==================================================================== + * 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.core5.http.message; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.util.Args; + +/** + * Codec for the HTTP {@code ALPN} header field (RFC 7639). + * + * @since 5.4 + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +@Internal +public final class AlpnHeaderSupport { + + private static final char[] HEXADECIMAL = "0123456789ABCDEF".toCharArray(); + + private AlpnHeaderSupport() { + } + + /** + * Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header value. + */ + public static String formatValue(final List protocolIds) { + Args.notEmpty(protocolIds, "protocolIds"); + final StringBuilder sb = new StringBuilder(); + boolean first = true; + for (final String id : protocolIds) { + if (!first) { + sb.append(", "); + } + sb.append(encodeId(id)); + first = false; + } + return sb.toString(); + } + + /** + * Parses an {@code ALPN} header value into decoded protocol IDs. + */ + public static List parseValue(final String value) { + if (value == null || value.isEmpty()) { + return Collections.emptyList(); + } + final List out = new ArrayList<>(); + final ParserCursor cursor = new ParserCursor(0, value.length()); + MessageSupport.parseTokens(value, cursor, token -> { + if (!token.isEmpty()) { + out.add(decodeId(token)); + } + }); + return out; + } + + /** + * Encodes a single raw protocol ID to canonical token form. + */ + public static String encodeId(final String id) { + Args.notBlank(id, "id"); + final byte[] bytes = id.getBytes(StandardCharsets.UTF_8); + final StringBuilder sb = new StringBuilder(bytes.length); + for (final byte b0 : bytes) { + final int b = b0 & 0xFF; + if (b == '%' || !isTchar(b)) { + appendPctEncoded(b, sb); + } else { + sb.append((char) b); + } + } + return sb.toString(); + } + + /** + * Decodes percent-encoded token to raw ID using UTF-8. + * Accepts lowercase hex; malformed/incomplete sequences are left literal. + */ + public static String decodeId(final String token) { + Args.notBlank(token, "token"); + final byte[] buf = new byte[token.length()]; + int bi = 0; + for (int i = 0; i < token.length(); ) { + final char c = token.charAt(i); + if (c == '%' && i + 2 < token.length()) { + final int hi = hexVal(token.charAt(i + 1)); + final int lo = hexVal(token.charAt(i + 2)); + if (hi >= 0 && lo >= 0) { + buf[bi++] = (byte) ((hi << 4) | lo); + i += 3; + continue; + } + } + buf[bi++] = (byte) c; + i++; + } + return new String(buf, 0, bi, StandardCharsets.UTF_8); + } + + // RFC7230 tchar minus '%' (RFC7639 requires '%' be percent-encoded) + private static boolean isTchar(final int c) { + if (c >= '0' && c <= '9') { + return true; + } + if (c >= 'A' && c <= 'Z') { + return true; + } + if (c >= 'a' && c <= 'z') { + return true; + } + switch (c) { + case '!': + case '#': + case '$': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } + } + + private static void appendPctEncoded(final int b, final StringBuilder sb) { + sb.append('%'); + sb.append(HEXADECIMAL[(b >>> 4) & 0x0F]); + sb.append(HEXADECIMAL[b & 0x0F]); + } + + private static int hexVal(final char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'A' && c <= 'F') { + return 10 + (c - 'A'); + } + if (c >= 'a' && c <= 'f') { + return 10 + (c - 'a'); + } + return -1; + } +} diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/message/AlpnHeaderSupportTest.java b/httpcore5/src/test/java/org/apache/hc/core5/http/message/AlpnHeaderSupportTest.java new file mode 100644 index 0000000000..cf990d4deb --- /dev/null +++ b/httpcore5/src/test/java/org/apache/hc/core5/http/message/AlpnHeaderSupportTest.java @@ -0,0 +1,132 @@ +/* + * ==================================================================== + * 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.core5.http.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AlpnHeaderSupportTest { + + @Test + void encodes_slash_and_percent_and_space() { + assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); + assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%")); + assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar")); + } + + @Test + void encodes_unicode_utf8() { + final String raw = "ws/é"; // \u00E9 -> C3 A9 + final String enc = AlpnHeaderSupport.encodeId(raw); + assertEquals("ws%2F%C3%A9", enc); + assertEquals(raw, AlpnHeaderSupport.decodeId(enc)); + } + + @Test + void keeps_tchar_plain_and_upper_hex() { + assertEquals("h2", AlpnHeaderSupport.encodeId("h2")); + assertEquals("A1+B", AlpnHeaderSupport.encodeId("A1+B")); // '+' is a tchar → stays literal + assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // slash encoded, hex uppercase + } + + @Test + void decode_is_liberal_on_hex_case_and_incomplete_sequences() { + assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1")); + // incomplete % — treat as literal + assertEquals("h2%", AlpnHeaderSupport.decodeId("h2%")); + assertEquals("h2%G1", AlpnHeaderSupport.decodeId("h2%G1")); + } + + @Test + void format_and_parse_roundtrip_with_ows() { + final String v = "h2, http%2F1.1 ,ws"; + final List ids = AlpnHeaderSupport.parseValue(v); + assertEquals(Arrays.asList("h2", "http/1.1", "ws"), ids); + assertEquals("h2, http%2F1.1, ws", AlpnHeaderSupport.formatValue(ids)); + } + + @Test + void parse_empty_and_blank() { + assertTrue(AlpnHeaderSupport.parseValue(null).isEmpty()); + assertTrue(AlpnHeaderSupport.parseValue("").isEmpty()); + assertTrue(AlpnHeaderSupport.parseValue(" , \t ").isEmpty()); + } + + @Test + void all_tchar_pass_through() { + // digits + for (char c = '0'; c <= '9'; c++) { + assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c))); + } + // uppercase letters + for (char c = 'A'; c <= 'Z'; c++) { + assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c))); + } + // lowercase letters + for (char c = 'a'; c <= 'z'; c++) { + assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c))); + } + // the symbol set (minus '%' which must be encoded) + final String symbols = "!#$&'*+-.^_`|~"; + for (int i = 0; i < symbols.length(); i++) { + final String s = String.valueOf(symbols.charAt(i)); + assertEquals(s, AlpnHeaderSupport.encodeId(s)); + } + } + + @Test + void percent_is_always_encoded_and_uppercase_hex() { + assertEquals("%25", AlpnHeaderSupport.encodeId("%")); // '%' must be encoded + assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%")); // stays uppercase hex + } + + @Test + void non_tchar_bytes_are_percent_encoded_uppercase() { + assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // 'F' uppercase + assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar")); // space → %20 + } + + @Test + void utf8_roundtrip_works() { + final String raw = "ws/é"; + final String enc = AlpnHeaderSupport.encodeId(raw); + assertEquals("ws%2F%C3%A9", enc); + assertEquals(raw, AlpnHeaderSupport.decodeId(enc)); + } + + @Test + void decoder_is_liberal() { + assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1")); // lower hex ok + assertEquals("h2%", AlpnHeaderSupport.decodeId("h2%")); // incomplete stays literal + assertEquals("h2%G1", AlpnHeaderSupport.decodeId("h2%G1")); // invalid hex stays literal + } +}