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));
+ }
+
+}