From f399e000e42f598fb0784debcd1422216186e3ce Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sat, 15 Aug 2026 17:16:59 +0200 Subject: [PATCH] Add a model and parser for the RFC 9209 Proxy-Status response field Proxy-Status is a Structured Fields (RFC 8941) List whose members carry an intermediary identity and a set of parameters. ProxyStatus is an immutable holder for a single member, ProxyStatusError enumerates the standardized error tokens, and ProxyStatusSupport parses the field value. Parameter values keep their structured-field types, the standardized and error-specific parameter types are validated, received-status is checked as an HTTP status code, and byte-sequence values are defensively copied. The code only decodes the field; it neither interprets nor acts on the reported values. --- .../org/apache/hc/core5/http/ProxyStatus.java | 228 +++++++++++ .../hc/core5/http/ProxyStatusError.java | 104 +++++ .../http/support/ProxyStatusSupport.java | 383 ++++++++++++++++++ .../http/support/TestProxyStatusSupport.java | 345 ++++++++++++++++ 4 files changed, 1060 insertions(+) create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatus.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatusError.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/support/ProxyStatusSupport.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/support/TestProxyStatusSupport.java diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatus.java b/httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatus.java new file mode 100644 index 0000000000..59c83e20a7 --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatus.java @@ -0,0 +1,228 @@ +/* + * ==================================================================== + * 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; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.util.Args; + +/** + * A single member of the {@code Proxy-Status} response field defined by RFC 9209. Each member + * identifies one intermediary and carries the parameters that describe how that intermediary + * handled the request. + *

+ * This type is a passive, immutable data holder. It neither interprets nor acts on the reported + * values; callers decide whether and how to use them. Parameter values keep their Structured + * Fields (RFC 8941) types: a {@link Token} for tokens, a {@link String} for strings, a + * {@link Long} for integers, a {@link java.math.BigDecimal} for decimals, a {@link Boolean} for + * booleans and a {@code byte[]} for byte sequences. Byte-sequence values are defensively copied + * on the way in and out, so instances remain immutable. Parsing of the field value is performed + * by {@link org.apache.hc.core5.http.support.ProxyStatusSupport}. + * + * @since 5.5 + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +public final class ProxyStatus { + + /** + * An RFC 8941 Token value, kept distinct from {@link String} so that Token and String + * parameter values are not conflated. + */ + @Contract(threading = ThreadingBehavior.IMMUTABLE) + public static final class Token { + + private final String value; + + public Token(final String value) { + this.value = Args.notNull(value, "Token value"); + } + + public String getValue() { + return this.value; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + return obj instanceof Token && this.value.equals(((Token) obj).value); + } + + @Override + public int hashCode() { + return this.value.hashCode(); + } + + @Override + public String toString() { + return this.value; + } + } + + private static final String ERROR = "error"; + private static final String NEXT_HOP = "next-hop"; + private static final String NEXT_PROTOCOL = "next-protocol"; + private static final String RECEIVED_STATUS = "received-status"; + private static final String DETAILS = "details"; + + private final String name; + private final Map parameters; + + /** + * Creates a member with the given intermediary identity and parameters. The parameter map is + * deep-copied and {@code byte[]} values are cloned, so the instance cannot be mutated through + * the supplied map. To keep the instance immutable, each value must be one of the supported + * Structured Fields types ({@link Token}, {@link String}, {@link Long}, {@link BigDecimal}, + * {@link Boolean} or {@code byte[]}); any other value is rejected. + * + * @param name the identity of the intermediary; must not be {@code null}. + * @param parameters the member parameters, or {@code null} for none. + * @throws IllegalArgumentException if a parameter value is not a supported type. + */ + public ProxyStatus(final String name, final Map parameters) { + this.name = Args.notNull(name, "Proxy identity"); + this.parameters = Collections.unmodifiableMap(copy(parameters)); + } + + private static Map copy(final Map source) { + final Map target = new LinkedHashMap<>(); + if (source != null) { + for (final Map.Entry entry : source.entrySet()) { + target.put(entry.getKey(), copyValue(entry.getValue())); + } + } + return target; + } + + private static Object copyValue(final Object value) { + if (value instanceof byte[]) { + return ((byte[]) value).clone(); + } + if (value instanceof String + || value instanceof Token + || value instanceof Long + || value instanceof BigDecimal + || value instanceof Boolean) { + return value; + } + throw new IllegalArgumentException("Unsupported Proxy-Status parameter value type: " + + (value != null ? value.getClass().getName() : "null")); + } + + /** + * Returns the identity of the intermediary, that is, the value of the list member. + */ + public String getName() { + return this.name; + } + + /** + * Returns the parameters attached to this member as an unmodifiable map in parsed order. Any + * {@code byte[]} value is cloned, so mutating it does not affect this instance. + */ + public Map getParameters() { + return Collections.unmodifiableMap(copy(this.parameters)); + } + + /** + * Returns the value of the named parameter, or {@code null} when it is absent. A {@code byte[]} + * value is cloned before being returned. + */ + public Object getParameter(final String name) { + return copyValue(this.parameters.get(name)); + } + + /** + * Returns the raw {@code error} token, or {@code null} when the parameter is absent or not a + * token. + */ + public String getErrorToken() { + final Object value = this.parameters.get(ERROR); + return value instanceof Token ? ((Token) value).getValue() : null; + } + + /** + * Returns the standardized {@code error}, or {@code null} when the parameter is absent or its + * token is not registered by RFC 9209. + */ + public ProxyStatusError getError() { + return ProxyStatusError.fromToken(getErrorToken()); + } + + /** + * Returns the {@code next-hop} value, or {@code null} when the parameter is absent. The + * parameter may be a string or a token; both are returned as their character value. + */ + public String getNextHop() { + final Object value = this.parameters.get(NEXT_HOP); + if (value instanceof String) { + return (String) value; + } + return value instanceof Token ? ((Token) value).getValue() : null; + } + + /** + * Returns the {@code next-protocol} ALPN identifier when it is expressed as a token, or + * {@code null} when the parameter is absent or expressed as a byte sequence. A byte-sequence + * identifier is available as a {@code byte[]} through {@link #getParameter(String)}. + */ + public String getNextProtocol() { + final Object value = this.parameters.get(NEXT_PROTOCOL); + return value instanceof Token ? ((Token) value).getValue() : null; + } + + /** + * Returns the {@code received-status} code, or {@code null} when the parameter is absent or + * not an integer. The value is validated as an HTTP status code during parsing, so it always + * fits in an {@code int}. + */ + public Integer getReceivedStatus() { + final Object value = this.parameters.get(RECEIVED_STATUS); + return value instanceof Long ? Integer.valueOf(((Long) value).intValue()) : null; + } + + /** + * Returns the free-form {@code details} string, or {@code null} when the parameter is absent. + */ + public String getDetails() { + final Object value = this.parameters.get(DETAILS); + return value instanceof String ? (String) value : null; + } + + @Override + public String toString() { + return this.name + this.parameters; + } + +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatusError.java b/httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatusError.java new file mode 100644 index 0000000000..8fb27b5cda --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/ProxyStatusError.java @@ -0,0 +1,104 @@ +/* + * ==================================================================== + * 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; + +/** + * Standardized {@code error} values of the {@code Proxy-Status} response field as defined by + * RFC 9209. The constants correspond to the initial contents of the IANA "HTTP Proxy-Status + * Error Types" registry. + *

+ * The {@code error} parameter may also carry unregistered extension tokens, which this + * enumeration does not model. {@link #fromToken(String)} returns {@code null} for such tokens, + * leaving the raw value available through {@link ProxyStatus#getErrorToken()}. + * + * @since 5.5 + */ +public enum ProxyStatusError { + + DNS_TIMEOUT("dns_timeout"), + DNS_ERROR("dns_error"), + DESTINATION_NOT_FOUND("destination_not_found"), + DESTINATION_UNAVAILABLE("destination_unavailable"), + DESTINATION_IP_PROHIBITED("destination_ip_prohibited"), + DESTINATION_IP_UNROUTABLE("destination_ip_unroutable"), + CONNECTION_REFUSED("connection_refused"), + CONNECTION_TERMINATED("connection_terminated"), + CONNECTION_TIMEOUT("connection_timeout"), + CONNECTION_READ_TIMEOUT("connection_read_timeout"), + CONNECTION_WRITE_TIMEOUT("connection_write_timeout"), + CONNECTION_LIMIT_REACHED("connection_limit_reached"), + TLS_PROTOCOL_ERROR("tls_protocol_error"), + TLS_CERTIFICATE_ERROR("tls_certificate_error"), + TLS_ALERT_RECEIVED("tls_alert_received"), + HTTP_REQUEST_ERROR("http_request_error"), + HTTP_REQUEST_DENIED("http_request_denied"), + HTTP_RESPONSE_INCOMPLETE("http_response_incomplete"), + HTTP_RESPONSE_HEADER_SECTION_SIZE("http_response_header_section_size"), + HTTP_RESPONSE_HEADER_SIZE("http_response_header_size"), + HTTP_RESPONSE_BODY_SIZE("http_response_body_size"), + HTTP_RESPONSE_TRAILER_SECTION_SIZE("http_response_trailer_section_size"), + HTTP_RESPONSE_TRAILER_SIZE("http_response_trailer_size"), + HTTP_RESPONSE_TRANSFER_CODING("http_response_transfer_coding"), + HTTP_RESPONSE_CONTENT_CODING("http_response_content_coding"), + HTTP_RESPONSE_TIMEOUT("http_response_timeout"), + HTTP_UPGRADE_FAILED("http_upgrade_failed"), + HTTP_PROTOCOL_ERROR("http_protocol_error"), + PROXY_INTERNAL_RESPONSE("proxy_internal_response"), + PROXY_INTERNAL_ERROR("proxy_internal_error"), + PROXY_CONFIGURATION_ERROR("proxy_configuration_error"), + PROXY_LOOP_DETECTED("proxy_loop_detected"); + + private final String token; + + ProxyStatusError(final String token) { + this.token = token; + } + + /** + * Returns the lowercase token used on the wire for this error. + */ + public String getToken() { + return this.token; + } + + /** + * Returns the standardized error matching the given token, or {@code null} when the token is + * {@code null} or not one of the values registered by RFC 9209. + */ + public static ProxyStatusError fromToken(final String token) { + if (token != null) { + for (final ProxyStatusError error : values()) { + if (error.token.equals(token)) { + return error; + } + } + } + return null; + } + +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/support/ProxyStatusSupport.java b/httpcore5/src/main/java/org/apache/hc/core5/http/support/ProxyStatusSupport.java new file mode 100644 index 0000000000..058dc76f6b --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/support/ProxyStatusSupport.java @@ -0,0 +1,383 @@ +/* + * ==================================================================== + * 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.support; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.ProxyStatus; +import org.apache.hc.core5.http.message.ParserCursor; +import org.apache.hc.core5.util.Args; + +/** + * Parser for the {@code Proxy-Status} response field defined by RFC 9209. The field is a + * Structured Fields (RFC 8941) List whose members are Items: an intermediary identity followed + * by parameters. + *

+ * Parsing is strict. Input that does not conform to the grammar is rejected with a + * {@link ParseException}, since RFC 8941 requires a field that fails to parse to be treated as + * absent rather than partially applied. The parser only decodes the field; it does not interpret + * or act on the reported values. Parameter values are returned by their structured-field types: + * {@link String} for tokens and strings, {@link Long} for integers, {@link BigDecimal} for + * decimals, {@link Boolean} for booleans and {@code byte[]} for byte sequences. + * + * @since 5.5 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public final class ProxyStatusSupport { + + private ProxyStatusSupport() { + // no instances + } + + /** + * Parses a {@code Proxy-Status} field value into its ordered list of members. + * + * @param value the field value; must not be {@code null}. + * @return the parsed members, empty when the value contains no members. + * @throws ParseException if the value does not conform to RFC 9209 / RFC 8941. + */ + public static List parse(final CharSequence value) throws ParseException { + Args.notNull(value, "Proxy-Status value"); + final ParserCursor cursor = new ParserCursor(0, value.length()); + final List members = new ArrayList<>(); + skipOws(value, cursor); + if (cursor.atEnd()) { + return members; + } + for (;;) { + members.add(parseMember(value, cursor)); + skipOws(value, cursor); + if (cursor.atEnd()) { + break; + } + if (value.charAt(cursor.getPos()) != ',') { + throw malformed("Malformed Proxy-Status: expected ','", value, cursor.getPos()); + } + cursor.updatePos(cursor.getPos() + 1); + skipOws(value, cursor); + if (cursor.atEnd()) { + throw malformed("Malformed Proxy-Status: trailing comma", value, cursor.getPos()); + } + } + return members; + } + + private static ProxyStatus parseMember(final CharSequence value, final ParserCursor cursor) throws ParseException { + final int start = cursor.getPos(); + final Object identity = parseBareItem(value, cursor); + final String name; + if (identity instanceof ProxyStatus.Token) { + name = ((ProxyStatus.Token) identity).getValue(); + } else if (identity instanceof String) { + name = (String) identity; + } else { + throw malformed("Malformed Proxy-Status: intermediary identity must be a token or string", value, start); + } + return new ProxyStatus(name, parseParameters(value, cursor)); + } + + private static Map parseParameters(final CharSequence value, final ParserCursor cursor) + throws ParseException { + final Map params = new LinkedHashMap<>(); + while (!cursor.atEnd() && value.charAt(cursor.getPos()) == ';') { + final int paramStart = cursor.getPos(); + cursor.updatePos(cursor.getPos() + 1); + skipSp(value, cursor); + final String key = parseKey(value, cursor); + final Object paramValue; + if (!cursor.atEnd() && value.charAt(cursor.getPos()) == '=') { + cursor.updatePos(cursor.getPos() + 1); + paramValue = parseBareItem(value, cursor); + } else { + paramValue = Boolean.TRUE; + } + enforceParameterType(key, paramValue, value, paramStart); + params.put(key, paramValue); + } + return params; + } + + private static void enforceParameterType(final String key, final Object value, final CharSequence text, + final int errorOffset) throws ParseException { + switch (key) { + case "error": + case "coding": + requireType(value instanceof ProxyStatus.Token, key, "a Token", text, errorOffset); + break; + case "details": + case "rcode": + case "status-phrase": + case "header-name": + case "trailer-name": + requireType(value instanceof String, key, "a String", text, errorOffset); + break; + case "info-code": + case "alert-id": + case "status-code": + case "header-section-size": + case "header-size": + case "body-size": + case "trailer-section-size": + case "trailer-size": + requireType(value instanceof Long, key, "an Integer", text, errorOffset); + break; + case "next-hop": + case "alert-message": + requireType(value instanceof String || value instanceof ProxyStatus.Token, key, + "a String or Token", text, errorOffset); + break; + case "next-protocol": + requireType(value instanceof ProxyStatus.Token || value instanceof byte[], key, + "a Token or Byte Sequence", text, errorOffset); + break; + case "received-status": { + requireType(value instanceof Long, key, "an Integer", text, errorOffset); + final long status = (Long) value; + if (status < 100 || status > 599) { + throw malformed("Malformed Proxy-Status: 'received-status' is not a valid HTTP status code", + text, errorOffset); + } + break; + } + default: + break; + } + } + + private static void requireType(final boolean satisfied, final String key, final String expected, + final CharSequence text, final int errorOffset) throws ParseException { + if (!satisfied) { + throw malformed("Malformed Proxy-Status: '" + key + "' must be " + expected, text, errorOffset); + } + } + + private static Object parseBareItem(final CharSequence value, final ParserCursor cursor) throws ParseException { + if (cursor.atEnd()) { + throw malformed("Malformed Proxy-Status: expected a value", value, cursor.getPos()); + } + final char c = value.charAt(cursor.getPos()); + if (c == '"') { + return parseString(value, cursor); + } + if (c == '?') { + return parseBoolean(value, cursor); + } + if (c == ':') { + return parseByteSequence(value, cursor); + } + if (c == '-' || isDigit(c)) { + return parseNumber(value, cursor); + } + if (c == '*' || isAlpha(c)) { + return parseToken(value, cursor); + } + throw malformed("Malformed Proxy-Status: unexpected character", value, cursor.getPos()); + } + + private static String parseString(final CharSequence value, final ParserCursor cursor) throws ParseException { + cursor.updatePos(cursor.getPos() + 1); + final StringBuilder sb = new StringBuilder(); + while (!cursor.atEnd()) { + final char c = value.charAt(cursor.getPos()); + cursor.updatePos(cursor.getPos() + 1); + if (c == '\\') { + if (cursor.atEnd()) { + throw malformed("Malformed Proxy-Status: truncated escape in string", value, cursor.getPos()); + } + final char esc = value.charAt(cursor.getPos()); + cursor.updatePos(cursor.getPos() + 1); + if (esc != '"' && esc != '\\') { + throw malformed("Malformed Proxy-Status: invalid escape in string", value, cursor.getPos() - 1); + } + sb.append(esc); + } else if (c == '"') { + return sb.toString(); + } else if (c < ' ' || c > '~') { + throw malformed("Malformed Proxy-Status: invalid character in string", value, cursor.getPos() - 1); + } else { + sb.append(c); + } + } + throw malformed("Malformed Proxy-Status: unterminated string", value, cursor.getPos()); + } + + private static ProxyStatus.Token parseToken(final CharSequence value, final ParserCursor cursor) { + final int start = cursor.getPos(); + cursor.updatePos(cursor.getPos() + 1); + while (!cursor.atEnd() && isTokenChar(value.charAt(cursor.getPos()))) { + cursor.updatePos(cursor.getPos() + 1); + } + return new ProxyStatus.Token(value.subSequence(start, cursor.getPos()).toString()); + } + + private static String parseKey(final CharSequence value, final ParserCursor cursor) throws ParseException { + if (cursor.atEnd()) { + throw malformed("Malformed Proxy-Status: expected a parameter name", value, cursor.getPos()); + } + final char first = value.charAt(cursor.getPos()); + if (first != '*' && !isLcAlpha(first)) { + throw malformed("Malformed Proxy-Status: invalid parameter name", value, cursor.getPos()); + } + final int start = cursor.getPos(); + cursor.updatePos(cursor.getPos() + 1); + while (!cursor.atEnd() && isKeyChar(value.charAt(cursor.getPos()))) { + cursor.updatePos(cursor.getPos() + 1); + } + return value.subSequence(start, cursor.getPos()).toString(); + } + + private static Object parseNumber(final CharSequence value, final ParserCursor cursor) throws ParseException { + final int start = cursor.getPos(); + if (value.charAt(cursor.getPos()) == '-') { + cursor.updatePos(cursor.getPos() + 1); + } + if (cursor.atEnd() || !isDigit(value.charAt(cursor.getPos()))) { + throw malformed("Malformed Proxy-Status: invalid number", value, start); + } + boolean decimal = false; + int intDigits = 0; + int fracDigits = 0; + while (!cursor.atEnd()) { + final char c = value.charAt(cursor.getPos()); + if (isDigit(c)) { + if (decimal) { + if (++fracDigits > 3) { + throw malformed("Malformed Proxy-Status: too many fractional digits", value, cursor.getPos()); + } + } else if (++intDigits > 15) { + throw malformed("Malformed Proxy-Status: integer too long", value, cursor.getPos()); + } + cursor.updatePos(cursor.getPos() + 1); + } else if (c == '.' && !decimal) { + if (intDigits > 12) { + throw malformed("Malformed Proxy-Status: too many integer digits in decimal", value, cursor.getPos()); + } + decimal = true; + cursor.updatePos(cursor.getPos() + 1); + } else { + break; + } + } + final String text = value.subSequence(start, cursor.getPos()).toString(); + if (decimal) { + if (fracDigits == 0) { + throw malformed("Malformed Proxy-Status: decimal requires a fractional part", value, cursor.getPos()); + } + return new BigDecimal(text); + } + return Long.valueOf(Long.parseLong(text)); + } + + private static Boolean parseBoolean(final CharSequence value, final ParserCursor cursor) throws ParseException { + cursor.updatePos(cursor.getPos() + 1); + if (cursor.atEnd()) { + throw malformed("Malformed Proxy-Status: truncated boolean", value, cursor.getPos()); + } + final char c = value.charAt(cursor.getPos()); + cursor.updatePos(cursor.getPos() + 1); + if (c == '1') { + return Boolean.TRUE; + } + if (c == '0') { + return Boolean.FALSE; + } + throw malformed("Malformed Proxy-Status: invalid boolean", value, cursor.getPos() - 1); + } + + private static byte[] parseByteSequence(final CharSequence value, final ParserCursor cursor) throws ParseException { + cursor.updatePos(cursor.getPos() + 1); + final int start = cursor.getPos(); + while (!cursor.atEnd() && value.charAt(cursor.getPos()) != ':') { + cursor.updatePos(cursor.getPos() + 1); + } + if (cursor.atEnd()) { + throw malformed("Malformed Proxy-Status: unterminated byte sequence", value, start); + } + final String encoded = value.subSequence(start, cursor.getPos()).toString(); + cursor.updatePos(cursor.getPos() + 1); + try { + return Base64.getDecoder().decode(encoded); + } catch (final IllegalArgumentException ex) { + throw malformed("Malformed Proxy-Status: invalid byte sequence", value, start); + } + } + + private static void skipOws(final CharSequence value, final ParserCursor cursor) { + while (!cursor.atEnd()) { + final char c = value.charAt(cursor.getPos()); + if (c == ' ' || c == '\t') { + cursor.updatePos(cursor.getPos() + 1); + } else { + break; + } + } + } + + private static void skipSp(final CharSequence value, final ParserCursor cursor) { + while (!cursor.atEnd() && value.charAt(cursor.getPos()) == ' ') { + cursor.updatePos(cursor.getPos() + 1); + } + } + + private static boolean isDigit(final char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isAlpha(final char c) { + return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'; + } + + private static boolean isLcAlpha(final char c) { + return c >= 'a' && c <= 'z'; + } + + private static boolean isTchar(final char c) { + return isDigit(c) || isAlpha(c) || "!#$%&'*+-.^_`|~".indexOf(c) >= 0; + } + + private static boolean isTokenChar(final char c) { + return isTchar(c) || c == ':' || c == '/'; + } + + private static boolean isKeyChar(final char c) { + return isLcAlpha(c) || isDigit(c) || c == '_' || c == '-' || c == '.' || c == '*'; + } + + private static ParseException malformed(final String message, final CharSequence value, final int errorOffset) { + return new ParseException(message, value, 0, value.length(), errorOffset); + } + +} diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/support/TestProxyStatusSupport.java b/httpcore5/src/test/java/org/apache/hc/core5/http/support/TestProxyStatusSupport.java new file mode 100644 index 0000000000..f387a50068 --- /dev/null +++ b/httpcore5/src/test/java/org/apache/hc/core5/http/support/TestProxyStatusSupport.java @@ -0,0 +1,345 @@ +/* + * ==================================================================== + * 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.support; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.ProxyStatus; +import org.apache.hc.core5.http.ProxyStatusError; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestProxyStatusSupport { + + @Test + void testValidParameters() throws Exception { + final List members = ProxyStatusSupport.parse( + "ExampleProxy; error=connection_timeout; next-hop=\"backend.example.net\"; " + + "next-protocol=h2; received-status=504; details=\"timed out\""); + Assertions.assertEquals(1, members.size()); + final ProxyStatus member = members.get(0); + Assertions.assertEquals("ExampleProxy", member.getName()); + Assertions.assertEquals(ProxyStatusError.CONNECTION_TIMEOUT, member.getError()); + Assertions.assertEquals("connection_timeout", member.getErrorToken()); + Assertions.assertEquals("backend.example.net", member.getNextHop()); + Assertions.assertEquals("h2", member.getNextProtocol()); + Assertions.assertEquals(Integer.valueOf(504), member.getReceivedStatus()); + Assertions.assertEquals("timed out", member.getDetails()); + } + + @Test + void testMultipleListMembers() throws Exception { + final List members = ProxyStatusSupport.parse( + "cdn.example.org; error=http_request_denied, \"proxy.example.net\"; received-status=503"); + Assertions.assertEquals(2, members.size()); + Assertions.assertEquals("cdn.example.org", members.get(0).getName()); + Assertions.assertEquals(ProxyStatusError.HTTP_REQUEST_DENIED, members.get(0).getError()); + Assertions.assertEquals("proxy.example.net", members.get(1).getName()); + Assertions.assertEquals(Integer.valueOf(503), members.get(1).getReceivedStatus()); + } + + @Test + void testUnknownExtensionParameters() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse( + "ExampleProxy; x-token=custom; x-str=\"custom\"; x-count=3; x-ratio=1.5; x-flag; x-off=?0").get(0); + Assertions.assertEquals(new ProxyStatus.Token("custom"), member.getParameter("x-token")); + Assertions.assertEquals("custom", member.getParameter("x-str")); + Assertions.assertEquals(Long.valueOf(3), member.getParameter("x-count")); + Assertions.assertEquals(new BigDecimal("1.5"), member.getParameter("x-ratio")); + Assertions.assertEquals(Boolean.TRUE, member.getParameter("x-flag")); + Assertions.assertEquals(Boolean.FALSE, member.getParameter("x-off")); + Assertions.assertNull(member.getError()); + Assertions.assertNull(member.getReceivedStatus()); + } + + @Test + void testStandardizedErrors() throws Exception { + Assertions.assertEquals(ProxyStatusError.DNS_TIMEOUT, + ProxyStatusSupport.parse("p; error=dns_timeout").get(0).getError()); + Assertions.assertEquals(ProxyStatusError.TLS_CERTIFICATE_ERROR, + ProxyStatusSupport.parse("p; error=tls_certificate_error").get(0).getError()); + Assertions.assertEquals(ProxyStatusError.PROXY_LOOP_DETECTED, + ProxyStatusSupport.parse("p; error=proxy_loop_detected").get(0).getError()); + } + + @Test + void testUnregisteredErrorTokenPreserved() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse("p; error=some_new_error").get(0); + Assertions.assertNull(member.getError()); + Assertions.assertEquals("some_new_error", member.getErrorToken()); + } + + @Test + void testProxyStatusErrorFromToken() { + Assertions.assertEquals(ProxyStatusError.CONNECTION_REFUSED, ProxyStatusError.fromToken("connection_refused")); + Assertions.assertEquals("connection_refused", ProxyStatusError.CONNECTION_REFUSED.getToken()); + Assertions.assertNull(ProxyStatusError.fromToken("not_a_registered_error")); + Assertions.assertNull(ProxyStatusError.fromToken(null)); + } + + // Fix 1: the 8 error values added from RFC 9209. + + @Test + void testNewlyAddedErrorValues() throws Exception { + Assertions.assertEquals(32, ProxyStatusError.values().length); + Assertions.assertEquals(ProxyStatusError.HTTP_RESPONSE_HEADER_SECTION_SIZE, + ProxyStatusSupport.parse("p; error=http_response_header_section_size").get(0).getError()); + Assertions.assertEquals(ProxyStatusError.HTTP_RESPONSE_BODY_SIZE, + ProxyStatusSupport.parse("p; error=http_response_body_size").get(0).getError()); + Assertions.assertEquals(ProxyStatusError.HTTP_RESPONSE_CONTENT_CODING, + ProxyStatusSupport.parse("p; error=http_response_content_coding").get(0).getError()); + Assertions.assertEquals(ProxyStatusError.HTTP_RESPONSE_TIMEOUT, + ProxyStatusSupport.parse("p; error=http_response_timeout").get(0).getError()); + } + + // Fix 2: Token and String kept as distinct Java types, standardized parameter types enforced. + + @Test + void testTokenAndStringPreservedAsDistinctTypes() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse("p; x-a=token; x-b=\"token\"").get(0); + Assertions.assertTrue(member.getParameter("x-a") instanceof ProxyStatus.Token); + Assertions.assertTrue(member.getParameter("x-b") instanceof String); + Assertions.assertNotEquals(member.getParameter("x-a"), member.getParameter("x-b")); + } + + @Test + void testErrorMustBeToken() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; error=\"connection_refused\"")); + } + + @Test + void testDetailsMustBeString() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; details=plain")); + } + + @Test + void testNextProtocolMustBeTokenOrByteSequence() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; next-protocol=\"h2\"")); + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; next-protocol=3")); + } + + // Fix 3: next-protocol Byte Sequence values. + + @Test + void testNextProtocolToken() throws Exception { + Assertions.assertEquals("h2", ProxyStatusSupport.parse("p; next-protocol=h2").get(0).getNextProtocol()); + } + + @Test + void testNextProtocolByteSequence() throws Exception { + // ":AAEC:" is the base64 of the bytes {0, 1, 2}, which cannot be expressed as an ASCII Token. + final byte[] expected = {0, 1, 2}; + final ProxyStatus member = ProxyStatusSupport.parse("p; next-protocol=:AAEC:").get(0); + Assertions.assertNull(member.getNextProtocol()); + Assertions.assertArrayEquals(expected, (byte[]) member.getParameter("next-protocol")); + } + + // Fix 4: received-status validated as an HTTP status code, no integer overflow. + + @Test + void testReceivedStatusValid() throws Exception { + Assertions.assertEquals(Integer.valueOf(200), + ProxyStatusSupport.parse("p; received-status=200").get(0).getReceivedStatus()); + } + + @Test + void testReceivedStatusBelowRangeRejected() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; received-status=99")); + } + + @Test + void testReceivedStatusAboveRangeRejected() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; received-status=600")); + } + + @Test + void testReceivedStatusOverflowRejected() { + // 4294967596 truncates to 300 as an int; it must be rejected, not silently narrowed. + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; received-status=4294967596")); + } + + // Fix 5: genuine immutability, including defensive handling of byte[] values. + + @Test + void testByteSequenceValuesAreDefensivelyCopied() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse("p; next-protocol=:AAEC:").get(0); + final byte[] expected = {0, 1, 2}; + + final byte[] fromGetParameter = (byte[]) member.getParameter("next-protocol"); + fromGetParameter[0] = 9; + Assertions.assertArrayEquals(expected, (byte[]) member.getParameter("next-protocol")); + + final byte[] fromGetParameters = (byte[]) member.getParameters().get("next-protocol"); + fromGetParameters[0] = 9; + Assertions.assertArrayEquals(expected, (byte[]) member.getParameter("next-protocol")); + } + + @Test + void testConstructorCopiesByteArray() { + final Map params = new LinkedHashMap<>(); + final byte[] raw = {1, 2, 3}; + params.put("x-bin", raw); + final ProxyStatus member = new ProxyStatus("p", params); + raw[0] = 9; + Assertions.assertArrayEquals(new byte[] {1, 2, 3}, (byte[]) member.getParameter("x-bin")); + } + + @Test + void testParametersMapIsUnmodifiable() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse("p; x-a=1").get(0); + Assertions.assertThrows(UnsupportedOperationException.class, () -> member.getParameters().put("x-b", "v")); + } + + // Fix (this round) 1: RFC-defined error-specific parameter types are validated. + + @Test + void testErrorSpecificParametersAccepted() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse( + "p; error=dns_error; rcode=\"NXDOMAIN\"; info-code=15").get(0); + Assertions.assertEquals("NXDOMAIN", member.getParameter("rcode")); + Assertions.assertEquals(Long.valueOf(15), member.getParameter("info-code")); + Assertions.assertEquals(new ProxyStatus.Token("gzip"), + ProxyStatusSupport.parse("p; coding=gzip").get(0).getParameter("coding")); + // alert-message accepts either a Token or a String + Assertions.assertEquals(new ProxyStatus.Token("close_notify"), + ProxyStatusSupport.parse("p; alert-message=close_notify").get(0).getParameter("alert-message")); + Assertions.assertEquals("close notify", + ProxyStatusSupport.parse("p; alert-message=\"close notify\"").get(0).getParameter("alert-message")); + } + + @Test + void testErrorSpecificParameterTypesEnforced() { + // rcode is a String; a Token is rejected + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; rcode=NXDOMAIN")); + // info-code is an Integer; a String is rejected + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; info-code=\"15\"")); + // coding is a Token; a String is rejected + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; coding=\"gzip\"")); + // status-code is an Integer; a decimal is rejected + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; status-code=2.0")); + // header-name is a String; a Token is rejected + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; header-name=Location")); + } + + @Test + void testUnrelatedExtensionParametersStillIgnored() throws Exception { + // extension parameters are not type-checked, whatever their value type + final ProxyStatus member = ProxyStatusSupport.parse("p; x-size=\"not-an-int\"; x-flag=maybe").get(0); + Assertions.assertEquals("not-an-int", member.getParameter("x-size")); + Assertions.assertEquals(new ProxyStatus.Token("maybe"), member.getParameter("x-flag")); + } + + // Fix (this round) 2: only supported Structured Fields value types are accepted. + + @Test + void testConstructorRejectsUnsupportedMutableValue() { + final Map params = new LinkedHashMap<>(); + params.put("x-bad", new StringBuilder("mutable")); + Assertions.assertThrows(IllegalArgumentException.class, () -> new ProxyStatus("p", params)); + } + + @Test + void testConstructorRejectsIntegerInsteadOfLong() { + final Map params = new LinkedHashMap<>(); + params.put("x-num", Integer.valueOf(3)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new ProxyStatus("p", params)); + } + + @Test + void testConstructorAcceptsSupportedTypes() { + final Map params = new LinkedHashMap<>(); + params.put("a", "s"); + params.put("b", new ProxyStatus.Token("t")); + params.put("c", Long.valueOf(1)); + params.put("d", new BigDecimal("1.5")); + params.put("e", Boolean.TRUE); + params.put("f", new byte[] {1, 2}); + Assertions.assertEquals(6, new ProxyStatus("p", params).getParameters().size()); + } + + // Structured Fields grammar and error handling. + + @Test + void testQuotedStringIdentityWithEscapes() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse("\"a \\\"quoted\\\" proxy\"").get(0); + Assertions.assertEquals("a \"quoted\" proxy", member.getName()); + Assertions.assertTrue(member.getParameters().isEmpty()); + } + + @Test + void testBooleanParameterWithoutValue() throws Exception { + final ProxyStatus member = ProxyStatusSupport.parse("p; cached").get(0); + Assertions.assertEquals(Boolean.TRUE, member.getParameter("cached")); + } + + @Test + void testEmptyValueYieldsEmptyList() throws Exception { + Assertions.assertTrue(ProxyStatusSupport.parse("").isEmpty()); + Assertions.assertTrue(ProxyStatusSupport.parse(" ").isEmpty()); + } + + @Test + void testMalformedTrailingComma() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p,")); + } + + @Test + void testMalformedUnterminatedString() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; details=\"open")); + } + + @Test + void testMalformedIdentityNotTokenOrString() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("504")); + } + + @Test + void testMalformedInvalidBoolean() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; flag=?2")); + } + + @Test + void testMalformedGarbageAfterMember() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p q")); + } + + @Test + void testMalformedMissingParameterName() { + Assertions.assertThrows(ParseException.class, () -> ProxyStatusSupport.parse("p; =bad")); + } + + @Test + void testNullValueRejected() { + Assertions.assertThrows(NullPointerException.class, () -> ProxyStatusSupport.parse(null)); + } + +}