Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

}
Original file line number Diff line number Diff line change
@@ -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
* <http://www.apache.org/>.
*
*/

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<String> 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<String> parseValue(final String value) {
if (value == null || value.isEmpty()) {
return Collections.emptyList();
}
final List<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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
* <http://www.apache.org/>.
*
*/
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<String> 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
}
}