IuWebUtils.java
/*
* Copyright © 2025 Indiana University
* All rights reserved.
*
* BSD 3-Clause License
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* - Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package edu.iu;
import java.net.HttpCookie;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.function.Function;
/**
* Provides useful utility methods for low-level web client and server
* interactions.
*/
public final class IuWebUtils {
private static final Map<String, InetAddress> IP_CACHE = new IuCacheMap<>(Duration.ofSeconds(5L));
private static char DQUOTE = 0x22;
private static char HTAB = 0x09;
private static char SP = 0x20;
/**
* VCHAR = %x21-7E
*
* @param c character
* @return true if c matches VCHAR ABNF rule; else false
* @see <a href=
* "https://datatracker.ietf.org/doc/html/rfc5234#appendix-B">RFC-5234 Core
* ABNF</a>
*/
static boolean vchar(char c) {
return c >= 0x21 //
&& c <= 0x7e;
}
/**
* ALPHA = %x41-5A / %x61-7A
*
* @param c character
* @return true if c matches ALHPA ABNF rule; else false
* @see <a href=
* "https://datatracker.ietf.org/doc/html/rfc5234#appendix-B">RFC-5234 Core
* ABNF</a>
*/
static boolean alpha(char c) {
return (c >= 0x41 //
&& c <= 0x5a) //
|| (c >= 0x61 //
&& c <= 0x7a);
}
/**
* DIGIT = %x30-39
*
* @param c character
* @return true if c matches DIGIT ABNF rule; else false
* @see <a href=
* "https://datatracker.ietf.org/doc/html/rfc5234#appendix-B">RFC-5234 Core
* ABNF</a>
*/
static boolean digit(char c) {
return c >= 0x30 //
&& c <= 0x39;
}
/**
* ctext = HTAB / SP / %x21-27 / %x2A-5B / %x5D-7E / obs-text
*
* @param c character
* @return true if c matches ctext ABNF rule; else false
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static boolean ctext(char c) {
return c == HTAB //
|| c == SP //
|| (c >= 0x21 // '!'-'''
&& c <= 0x27) //
|| (c >= 0x2A // '*'-'['
&& c <= 0x5b) //
|| (c >= 0x5d // ']'-'~'
&& c <= 0x7e) //
|| obsText(c);
}
/**
* obs-text = %x80-FF
*
* @param c character
* @return true if c matches obs-text ABNF rule; else false
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static boolean obsText(char c) {
return c >= 0x80 //
&& c <= 0xff;
}
/**
* comment = "(" *( ctext / quoted-pair / comment ) ")"
*
* @param s input string
* @param pos position at start of comment
* @return end position after matching comment ABNF rule; returns pos if token68
* was not matched
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int comment(String s, final int pos) {
if (s.charAt(pos) != '(')
return pos;
var p = pos + 1;
int n;
char c;
do {
c = s.charAt(p);
if (ctext(c))
p++;
else if ((n = quotedPair(s, p)) > p //
|| (n = comment(s, p)) > p)
p = n;
else if (c == ')')
return p + 1;
else
return pos;
} while (p < s.length());
return pos;
}
/**
* token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
*
* @param s input string
* @param pos position at start of token68 character
* @return end position after matching token68 ABNF rule; returns pos if token68
* was not matched
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int token68(String s, int pos) {
if (pos >= s.length())
return pos;
char c;
do {
if (pos == s.length())
return pos;
c = s.charAt(pos++);
} while (alpha(c) //
|| digit(c) //
|| "-._~+/".indexOf(c) != -1);
pos--;
do {
if (pos == s.length())
return pos;
c = s.charAt(pos++);
} while (c == '=');
return pos - 1;
}
/**
* tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" /
* "_" / "`" / "|" / "~" / DIGIT / ALPHA
*
* @param c character
* @return true if c matches obs-text ABNF rule; else false
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static boolean tchar(char c) {
return alpha(c) //
|| digit(c) //
|| "!#$%&'*+-.^_`!~".indexOf(c) != -1;
}
/**
* token = 1*tchar
*
* @param s input string
* @param pos position at start of token character
* @return end position of a matching token ABNF rule; returns pos if token was
* not matched
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int token(String s, int pos) {
if (pos >= s.length())
return pos;
char c;
do {
c = s.charAt(pos++);
if (pos == s.length() //
&& tchar(c))
return pos;
} while (tchar(c));
return pos - 1;
}
/**
* BWS = OWS
*
* <p>
* OWS = *( SP / HTAB )
* </p>
*
* <p>
* RWS = 1*( SP / HTAB )
* </p>
*
* @param s input string
* @param pos position at start of token character
* @return end position of a matching BWS ABNF rule
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int bws(String s, int pos) {
if (pos >= s.length())
return pos;
char c;
do {
c = s.charAt(pos++);
if (pos == s.length() //
&& (c == SP //
|| c == HTAB))
return pos;
} while (c == SP //
|| c == HTAB);
return pos - 1;
}
/**
* 1*SP
*
* @param s input string
* @param pos position at start of token character
* @return end position of a matching sp ABNF rule
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int sp(String s, int pos) {
if (pos >= s.length())
return pos;
char c;
do {
c = s.charAt(pos++);
if (pos == s.length() //
&& (c == SP))
return pos;
} while (c == SP);
return pos - 1;
}
/**
* product = token [ "/" product-version ]
* <p>
* product-version = token
* </p>
*
* @param s input string
* @param pos position at start of product expression
* @return end position of a matching product ABNF rule; pos if not matched
*/
static int product(String s, int pos) {
final var token = token(s, pos);
if (token == pos //
|| s.length() == token //
|| s.charAt(token) != '/')
return token;
final var version = token(s, token + 1);
if (version == token + 1)
return pos;
else
return version;
}
/**
* qdtext = HTAB / SP / "!" / %x23-5B / %x5D-7E / obs-text
*
* @param c character
* @return true if c matches qdtext ABNF rule; else false
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static boolean qdtext(char c) {
return c == HTAB //
|| c == SP //
|| c == '!' //
|| (c >= 0x23 //
&& c <= 0x5b) //
|| (c >= 0x5d //
&& c <= 0x7e) //
|| obsText(c);
}
/**
* quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
*
* @param s input string
* @param pos position at start of token character
* @return end position of a matching quoted-pair ABNF rule; pos if the rule was
* not matched
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int quotedPair(String s, int pos) {
char c;
if (pos < s.length() - 1 //
&& s.charAt(pos) == '\\' //
&& ((c = s.charAt(pos + 1)) == HTAB //
|| c == SP //
|| vchar(c) //
|| obsText(c)))
pos += 2;
return pos;
}
/**
* quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
*
* @param s input string
* @param start position at start of token character
* @return end position of a matching BWS ABNF rule
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* HTTP Semantics Collected ABNF</a>
*/
static int quotedString(String s, int start) {
if (start >= s.length() - 1 //
|| s.charAt(start) != DQUOTE)
return start;
var pos = start + 1;
do {
final var qp = quotedPair(s, pos);
if (qp != pos)
pos = qp;
if (pos >= s.length())
return start;
} while (qdtext(s.charAt(pos++)));
if (s.charAt(pos - 1) != DQUOTE)
return start;
else
return pos;
}
/**
* Determines whether or not a string is composed entirely of non-whitespace
* visible ASCII characters, and at least minLength characters, but no longer
* than 1024.
*
* @param s string to check
* @param minLength minimum length
* @return true if all characters are visible ASCII
*/
public static boolean isVisibleAscii(String s, int minLength) {
final var len = s.length();
if (len < minLength //
|| len > 1024)
return false;
for (var i = 0; i < len; i++)
if (!vchar(s.charAt(i)))
return false;
return true;
}
/**
* Determines if a root {@link URI} encompasses a resource {@link URI}.
*
* @param rootUri root {@link URI}
* @param resourceUri resource {@link URI}
* @return {@link URI}
*/
public static boolean isRootOf(URI rootUri, URI resourceUri) {
if (rootUri.equals(resourceUri))
return true;
if (!resourceUri.isAbsolute() //
|| resourceUri.isOpaque() //
|| !IuObject.equals(rootUri.getScheme(), resourceUri.getScheme()) //
|| !IuObject.equals(rootUri.getAuthority(), resourceUri.getAuthority()))
return false;
final var root = rootUri.getPath();
if (root.isEmpty())
return true;
final var resource = resourceUri.getPath();
final var l = root.length();
return resource.startsWith(root) //
&& (root.charAt(l - 1) == '/' //
|| resource.charAt(l) == '/');
}
/**
* Creates an authentication challenge sending to a client via the
* <strong>WWW-Authenticate</strong> header.
*
* @param scheme authentication scheme to request
* @param params challenge attributes for informing the client of how to
* authenticate
* @return authentication challenge
* @see <a href="https://datatracker.ietf.org/doc/html/rfc9110">RFC-9110 Section
* 11.1</a>
*/
public static String createChallenge(String scheme, Map<String, String> params) {
if (!params.containsKey("realm"))
throw new IllegalArgumentException("missing realm");
final var sb = new StringBuilder();
// auth-scheme = token
if (token(scheme, 0) != scheme.length())
throw new IllegalArgumentException("invalid auth scheme");
sb.append(scheme);
sb.append(' ');
var first = true;
for (final var paramEntry : params.entrySet()) {
// auth-param = token BWS "=" BWS ( token / quoted-string )
final var key = paramEntry.getKey();
final var value = paramEntry.getValue();
if (token(key, 0) == key.length()) {
for (int i = 0; i < value.length(); i++) {
final var c = value.charAt(i);
if (c < SP //
&& c != HTAB)
throw new IllegalArgumentException("invalid parameter value");
}
} else if (token68(key, 0) == key.length()) {
if (!value.isEmpty())
throw new IllegalArgumentException("invalid encoded parameter");
} else
throw new IllegalArgumentException("invalid auth param");
if (first)
first = false;
else
sb.append(" ");
sb.append(key);
if (!value.isEmpty())
sb.append("=\"") //
.append(paramEntry.getValue() //
.replace("\\", "\\\\") //
.replace("\"", "\\\"")) //
.append("\"");
}
return sb.toString();
}
/**
* Parses challenge parameters from a <strong>WWW-Authenticate</strong> header.
*
* @param wwwAuthenticate WWW-Authenticate header challenge value
* @return Parsed authentication challenge parameters
*/
public static Iterator<IuWebAuthenticationChallenge> parseAuthenticateHeader(String wwwAuthenticate) {
// WWW-Authenticate = [ challenge *( OWS "," OWS challenge ) ]
return new Iterator<IuWebAuthenticationChallenge>() {
private int pos = 0;
@Override
public boolean hasNext() {
return pos < wwwAuthenticate.length();
}
@Override
public IuWebAuthenticationChallenge next() {
// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
// auth-scheme = token
final var endOfAuthScheme = token(wwwAuthenticate, pos);
if (endOfAuthScheme == pos)
throw new IllegalArgumentException("invalid auth-scheme at " + pos);
final var authScheme = wwwAuthenticate.substring(pos, endOfAuthScheme);
pos = sp(wwwAuthenticate, endOfAuthScheme);
final Map<String, String> params = new LinkedHashMap<>();
while (hasNext() && wwwAuthenticate.charAt(pos) != ',') {
// auth-param = token BWS "=" BWS ( token / quoted-string )
var endOfToken = token(wwwAuthenticate, pos);
var eqOrStartOfNextToken = bws(wwwAuthenticate, endOfToken);
if (endOfToken > pos //
&& endOfToken < wwwAuthenticate.length() //
&& eqOrStartOfNextToken < wwwAuthenticate.length() //
&& wwwAuthenticate.charAt(eqOrStartOfNextToken) == '=') {
final var name = wwwAuthenticate.substring(pos, endOfToken);
pos = bws(wwwAuthenticate, eqOrStartOfNextToken + 1);
var endOfValue = quotedString(wwwAuthenticate, pos);
if (endOfValue == pos) {
endOfValue = token(wwwAuthenticate, pos);
if (endOfValue == pos)
throw new IllegalArgumentException("expected quoted-string at " + pos);
else
params.put(name, wwwAuthenticate.substring(pos, endOfValue));
} else
params.put(name, wwwAuthenticate.substring(pos + 1, endOfValue - 1) //
.replace("\\\\", "\\") //
.replace("\\\"", "\""));
pos = sp(wwwAuthenticate, endOfValue);
} else {
endOfToken = token68(wwwAuthenticate, pos);
if (endOfToken == pos)
throw new IllegalArgumentException("invalid auth-param at " + pos);
else if (endOfToken < wwwAuthenticate.length())
throw new IllegalArgumentException("expected SP at " + pos);
params.put(wwwAuthenticate.substring(pos, endOfToken), "");
pos = sp(wwwAuthenticate, endOfToken);
}
final var bws = bws(wwwAuthenticate, pos);
if (bws < wwwAuthenticate.length() //
&& wwwAuthenticate.charAt(bws) == ',')
pos = bws;
}
final var realm = params.remove("realm");
if (hasNext())
pos = bws(wwwAuthenticate, pos + 1);
return new IuWebAuthenticationChallenge() {
@Override
public String getAuthScheme() {
return authScheme;
}
@Override
public String getRealm() {
return realm;
}
@Override
public Map<String, String> getParameters() {
return params;
}
};
}
};
}
/**
* Validates a user-agent header value.
*
* <p>
* User-Agent = product *( RWS ( product / comment ) )
* </p>
*
* @param userAgent user-agent header value
* @see <a href=
* "https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf">RFC-9110
* Appendix A</a>
*/
public static void validateUserAgent(String userAgent) {
var pos = product(userAgent, 0);
if (pos <= 0)
throw new IllegalArgumentException();
while (pos < userAgent.length()) {
var n = bws(userAgent, pos);
if (n <= pos)
throw new IllegalArgumentException();
else
pos++;
if ((n = product(userAgent, pos)) <= pos //
&& (n = comment(userAgent, pos)) <= pos)
throw new IllegalArgumentException();
else
pos = n;
}
}
/**
* Parses a query string.
*
* @param queryString query string
* @return {@link Map}
*/
public static Map<String, ? extends Iterable<String>> parseQueryString(String queryString) {
final Map<String, Queue<String>> parsedParameterValues = new LinkedHashMap<>();
var startOfName = queryString.startsWith("?") ? 1 : 0;
if (startOfName == queryString.length())
return parsedParameterValues;
var endOfName = queryString.indexOf('=', startOfName);
var endOfValue = queryString.indexOf('&', startOfName);
int startOfValue;
if (endOfValue == -1)
endOfValue = queryString.length();
if (endOfName == -1 //
|| endOfName > endOfValue)
endOfName = startOfValue = endOfValue;
else
startOfValue = endOfName + 1;
while (true) {
final var name = queryString.substring(startOfName, endOfName);
var values = parsedParameterValues.get(name);
if (values == null)
parsedParameterValues.put(name, values = new ArrayDeque<>());
if (endOfValue == queryString.length()) {
final var value = queryString.substring(startOfValue);
values.offer(IuException.unchecked(() -> URLDecoder.decode(value, "UTF-8")));
endOfName = -1;
break;
}
final var value = queryString.substring(startOfValue, endOfValue);
values.offer(IuException.unchecked(() -> URLDecoder.decode(value, "UTF-8")));
startOfName = endOfValue + 1;
endOfName = queryString.indexOf('=', startOfName);
endOfValue = queryString.indexOf('&', startOfName);
if (endOfValue == -1)
endOfValue = queryString.length();
if (endOfName == -1 //
|| endOfName > endOfValue)
endOfName = startOfValue = endOfValue;
else
startOfValue = endOfName + 1;
}
return parsedParameterValues;
}
/**
* Creates a query string from a map.
*
* @param params {@link Map} of parameter values
* @return query string
*/
public static String createQueryString(Map<String, ? extends Iterable<String>> params) {
final var queryString = new StringBuilder();
for (final var paramEntry : params.entrySet())
for (final var paramValue : paramEntry.getValue()) {
if (queryString.length() > 0)
queryString.append('&');
queryString.append(IuException.unchecked(() -> URLEncoder.encode(paramEntry.getKey(), "UTF-8")));
queryString.append("=").append(IuException.unchecked(() -> URLEncoder.encode(paramValue, "UTF-8")));
}
return queryString.toString();
}
/**
* Parses a header value composed of key/value pairs separated by semicolon ';'.
*
* @param headerValue header value
* @return {@link Map} of header elements
*/
public static Map<String, String> parseHeader(String headerValue) {
int semicolon = headerValue.indexOf(';');
if (semicolon == -1)
return Collections.singletonMap("", headerValue);
final Map<String, String> parsedHeader = new LinkedHashMap<>();
parsedHeader.put("", headerValue.substring(0, semicolon));
while (semicolon < headerValue.length()) {
final var start = semicolon + 1;
final var eq = headerValue.indexOf('=', start + 1);
semicolon = headerValue.indexOf(';', start + 1);
if (semicolon == -1)
semicolon = headerValue.length();
if (eq == -1 || eq > semicolon)
parsedHeader.put(headerValue.substring(start, semicolon).trim(), "");
else {
final var elementName = headerValue.substring(start, eq).trim();
final String elementValue = headerValue.substring(eq + 1, semicolon).trim();
parsedHeader.put(elementName, elementValue);
}
}
return parsedHeader;
}
/**
* cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E (US-ASCII
* characters excluding CTLs, whitespace, DQUOTE, comma, semicolon, and
* backslash)
*
* @param c character
* @return true if the character is in the cookie-octet character set.
* @see <a href=
* "https://datatracker.ietf.org/doc/html/rfc6265#section-4.1">RFC-6265
* HTTP State Management, Section 4.1</a>
*/
static boolean cookieOctet(char c) {
return c == 0x21 // '!'
|| (c >= 0x23 // '#'-'+'
&& c <= 0x2b) //
|| (c >= 0x2d // '-'-':'
&& c <= 0x3a) //
|| (c >= 0x3c // '<'-'['
&& c <= 0x5b) //
|| (c >= 0x5d // ']'-'~'
&& c <= 0x7e);
}
/**
* cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
* <p>
* cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E (US-ASCII
* characters excluding CTLs, whitespace, DQUOTE, comma, semicolon, and
* backslash)
* </p>
*
* @param cookieHeaderValue Cookie header value
* @param pos position at start of the cookie-value token
*
* @return end position of the cookie value
* @see <a href=
* "https://datatracker.ietf.org/doc/html/rfc6265#section-4.1">RFC-6265
* HTTP State Management, Section 4.1</a>
*/
static int cookieValue(String cookieHeaderValue, int pos) {
final var len = cookieHeaderValue.length();
if (pos >= len)
return pos;
var c = cookieHeaderValue.charAt(pos);
final boolean quoted = c == DQUOTE;
if (quoted) {
pos++;
if (pos >= len)
throw new IllegalArgumentException("unterminated '\"' at " + pos);
c = cookieHeaderValue.charAt(pos);
}
while (pos < len //
&& cookieOctet(c)) {
pos++;
if (pos < len)
c = cookieHeaderValue.charAt(pos);
}
if (quoted)
if (pos >= len //
|| cookieHeaderValue.charAt(pos) != DQUOTE)
throw new IllegalArgumentException("unterminated '\"' at " + pos);
else
return pos + 1;
else
return pos;
}
/**
* Parses the cookie request header, returning an {@link HttpCookie} for each
* cookie sent with the request.
*
* @param cookieHeaderValue {@code Cookie:} header value. This value MUST match
* the syntax defined for {@code cookie-string}
* <a href=
* "https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1">RFC-6265
* HTTP State Management, Section 4.2.1</a>
* @return Iterable of parsed cookies
*/
public static Iterable<HttpCookie> parseCookieHeader(String cookieHeaderValue) {
final var trimmedCookieHeaderValue = cookieHeaderValue.trim();
return IuIterable.of(() -> new Iterator<HttpCookie>() {
private int pos = 0;
@Override
public boolean hasNext() {
return pos < trimmedCookieHeaderValue.length();
}
@Override
public HttpCookie next() {
// cookie-header = "Cookie:" OWS cookie-string OWS
// cookie-string = cookie-pair *( ";" SP cookie-pair )
// cookie-pair = cookie-name "=" cookie-value
// cookie-name = token
// cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
// cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
final var endOfCookieName = token(trimmedCookieHeaderValue, pos);
if (endOfCookieName == pos)
throw new IllegalArgumentException("invalid cookie-name at " + pos);
if (endOfCookieName >= trimmedCookieHeaderValue.length() //
|| trimmedCookieHeaderValue.charAt(endOfCookieName) != '=')
throw new IllegalArgumentException("expected '=' at " + endOfCookieName);
final var cookieName = trimmedCookieHeaderValue.substring(pos, endOfCookieName);
pos = endOfCookieName + 1;
final String cookieValue;
final var startOfCookieValue = pos;
pos = cookieValue(trimmedCookieHeaderValue, startOfCookieValue);
if (pos == startOfCookieValue)
cookieValue = "";
else if (trimmedCookieHeaderValue.charAt(pos - 1) == DQUOTE)
cookieValue = trimmedCookieHeaderValue.substring(startOfCookieValue + 1, pos - 1);
else
cookieValue = trimmedCookieHeaderValue.substring(startOfCookieValue, pos);
if (pos + 1 < trimmedCookieHeaderValue.length()) {
if (trimmedCookieHeaderValue.charAt(pos++) != ';')
throw new IllegalArgumentException("expected ';' at " + (pos - 1));
if (trimmedCookieHeaderValue.charAt(pos++) != SP)
throw new IllegalArgumentException("expected ' ' at " + (pos - 1));
}
return new HttpCookie(cookieName, cookieValue);
}
});
}
/**
* Parses the cookie request header, returning an {@link HttpCookie} for each
* cookie sent with the request.
*
* @param forwardedHeaderValue {@code Forwarded:} header value. This value MUST
* match the syntax defined for {@code Forwarded} at
* <a href=
* "https://datatracker.ietf.org/doc/html/rfc7239#section-4">RFC-7238
* Forwarded HTTP Extension, Section 4</a>
* @return forwarded header value
*/
public static IuForwardedHeader parseForwardedHeader(String forwardedHeaderValue) {
// Forwarded = 1#forwarded-element
//
// forwarded-element =
// [ forwarded-pair ] *( ";" [ forwarded-pair ] )
//
// forwarded-pair = token "=" value
// value = token / quoted-string
//
// token = <Defined in [RFC7230], Section 3.2.6>
// quoted-string = <Defined in [RFC7230], Section 3.2.6>
final var parsedHeader = new IuForwardedHeader() {
String by;
String xfor;
String host;
String proto;
@Override
public String getBy() {
return by;
}
@Override
public String getFor() {
return xfor;
}
@Override
public String getHost() {
return host;
}
@Override
public String getProto() {
return proto;
}
};
for (var pos = 0; pos < forwardedHeaderValue.length();) {
final var endOfName = token(forwardedHeaderValue, pos);
if (pos == endOfName)
throw new IllegalArgumentException("expected token at " + pos);
if (endOfName >= forwardedHeaderValue.length() //
|| forwardedHeaderValue.charAt(endOfName) != '=')
throw new IllegalArgumentException("expected '=' at " + endOfName);
final var name = forwardedHeaderValue.substring(pos, endOfName);
pos = endOfName + 1;
final String value;
if (pos >= forwardedHeaderValue.length())
value = "";
else {
int endOfValue;
if (forwardedHeaderValue.charAt(pos) == DQUOTE) {
endOfValue = quotedString(forwardedHeaderValue, pos);
if (endOfValue == pos)
throw new IllegalArgumentException("unterminated '\"' at " + pos);
else
value = forwardedHeaderValue.substring(pos + 1, endOfValue - 1);
} else {
endOfValue = token(forwardedHeaderValue, pos);
value = forwardedHeaderValue.substring(pos, endOfValue);
}
pos = endOfValue;
}
if (name.equalsIgnoreCase("by"))
parsedHeader.by = IuObject.once(parsedHeader.by, value);
else if (name.equalsIgnoreCase("for"))
parsedHeader.xfor = IuObject.once(parsedHeader.xfor, value);
else if (name.equalsIgnoreCase("host"))
parsedHeader.host = IuObject.once(parsedHeader.host, value);
else if (name.equalsIgnoreCase("proto"))
parsedHeader.proto = IuObject.once(parsedHeader.proto, value);
else
throw new IllegalArgumentException("unexpected name " + name);
if (pos < forwardedHeaderValue.length()) {
if (forwardedHeaderValue.charAt(pos) == ';')
pos++;
else
throw new IllegalArgumentException("expected ';' at " + pos);
}
}
return parsedHeader;
}
/**
* Parses a decimal octet, as defined for an IPv4address.
*
* @param nodeId {@link #parseNodeIdentifier(String) node identifier}
* @param originalPos start position
* @return end position if matching; else returns start position
*/
static int decOctet(String nodeId, final int originalPos) {
final var len = nodeId.length();
if (originalPos >= len)
return originalPos;
var c1 = nodeId.charAt(originalPos);
if (!digit(c1))
return originalPos;
var pos = originalPos + 1;
char c2;
if (pos >= len // dec-octet = DIGIT ; 0-9
|| !digit(c2 = nodeId.charAt(pos)) //
|| c1 == 0x30)
return pos;
char c3;
if (++pos >= len // / %x31-39 DIGIT ; 10-99
|| c1 > 0x32 //
|| !digit(c3 = nodeId.charAt(pos)) // / "1" 2DIGIT ; 100-199
|| (c1 == 0x32 // / "2" %x30-34 DIGIT ; 200-249
&& c2 > 0x35 //
|| (c2 == 0x35 // / "25" %x30-35 ; 250-255
&& c3 > 0x35) //
))
return pos;
else
return pos + 1;
}
/**
* Parses an IPV4Address for {@link #parseNodeIdentifier(String)}.
*
* @param nodeId {@link #parseNodeIdentifier(String) node identifier}
* @param originalPos start position
* @return end position if matching; else returns start position
*/
static int parseIPv4Address(String nodeId, final int originalPos) {
final var len = nodeId.length();
var pos = originalPos;
for (var i = 0; i < 3; i++) {
final var dot = decOctet(nodeId, pos);
if (dot == pos //
|| dot >= len //
|| nodeId.charAt(dot) != '.')
return originalPos;
pos = dot + 1;
}
final var dot = decOctet(nodeId, pos);
if (dot == pos)
return originalPos;
else
return dot;
}
/**
* Determines if a character is a HEXDIG character.
* <p>
* HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"
* </p>
*
* @param c character
* @return true if the character is matches the HEXDIG ABNF rules from <a href=
* "https://datatracker.ietf.org/doc/html/rfc5234#page-13">RFC-5234</a>;
* else false
*/
static boolean hexdig(char c) {
return digit(c) //
|| (c >= 0x41 //
&& c <= 0x46) //
|| (c >= 0x61 //
&& c <= 0x66) //
;
}
/**
* Parses an h16 (16-bit hexadecimal number).
*
* @param nodeId {@link #parseNodeIdentifier(String) node identifier}
* @param originalPos start position
* @return end position if matching; else returns start position
*/
static int h16(String nodeId, final int originalPos) {
final var len = nodeId.length();
for (var i = 0; i < 4; i++) {
final var pos = originalPos + i;
if (pos >= len //
|| !hexdig(nodeId.charAt(pos)))
return pos;
}
return originalPos + 4;
}
/**
* Parses an ls32 (least significant 32-bits), either as an
* {@link #h16(String, int) h16 pair} or {@link #parseIPv4Address(String, int)}.
*
* @param nodeId {@link #parseNodeIdentifier(String) node identifier}
* @param originalPos start position
* @return end position if matching; else returns start position
*/
static int ls32(String nodeId, final int originalPos) {
final var len = nodeId.length();
final var h16a = h16(nodeId, originalPos);
if (h16a == originalPos //
|| h16a >= len)
return originalPos;
final var c = nodeId.charAt(h16a);
if (c == ':') {
final var pos = h16a + 1;
final var h16b = h16(nodeId, pos);
if (h16b == pos)
return originalPos;
else
return h16b;
} else if (c == '.')
return parseIPv4Address(nodeId, originalPos);
else
return originalPos;
}
/**
* Parses an IPV6Address for {@link #parseNodeIdentifier(String)}.
*
* @param nodeId {@link #parseNodeIdentifier(String) node identifier}
* @param originalPos start position
* @return end position if matching; else returns start position
*/
static int parseIPv6Address(String nodeId, final int originalPos) {
final var len = nodeId.length();
var pos = h16(nodeId, originalPos);
if (pos >= len)
return originalPos;
var rel = pos == originalPos;
if (rel) // starts with relative marker '::'
if (len <= originalPos + 1 //
|| nodeId.charAt(originalPos) != ':' //
|| nodeId.charAt(originalPos + 1) != ':')
return originalPos;
else
pos = originalPos + 1;
if (nodeId.charAt(pos) != ':')
return originalPos;
else
pos++;
// read up to five more ( h16 ":" ), non-terminal
// or at least one non-leading relative marker
for (var i = 0; i < 5; i++) {
if (pos >= len)
return originalPos;
// / [ *5( h16 ":" ) h16 ] "::" h16
final var next = h16(nodeId, pos);
final char c;
if (next >= len //
|| (c = nodeId.charAt(next)) == ']')
if (rel)
return next;
else
return originalPos;
if (next == pos //
&& c == ':')
if (rel) // may observe exactly one relative marker '::'
return originalPos;
else
rel = true;
else if (c != ':')
return originalPos;
pos = next + 1;
if (i < 4 // must have all 8 segments or relative marker
&& !rel) // IPv6address = 6( h16 ":" ) ls32
continue;
// / "::" 5( h16 ":" ) ls32
// / [ h16 ] "::" 4( h16 ":" ) ls32
// / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32
// / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32
// / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32
// / [ *4( h16 ":" ) h16 ] "::" ls32
final var ls32 = ls32(nodeId, pos);
if (ls32 >= len //
|| nodeId.charAt(ls32) == ']')
return ls32;
}
return originalPos;
}
/**
* Parses an obfnode or obfport for {@link #parseNodeIdentifier(String)}.
*
* @param nodeId {@link #parseNodeIdentifier(String) node identifier}
* @param originalPos start position
* @return end position if matching; else returns start position
*/
static int obf(String nodeId, final int originalPos) {
final var len = nodeId.length();
char c;
int pos;
for (pos = originalPos; pos < len //
&& (alpha(c = nodeId.charAt(pos)) //
|| digit(c) //
|| c == '.' //
|| c == '_' //
|| c == '-'); pos++)
;
if (pos - originalPos <= 1)
return originalPos;
else
return pos;
}
/**
* Determines whether or not a nodename string, i.e., from an HTTP header,
* contains a valid IPv4 address.
*
* @param nodename nodename string
* @return true if nodename is a string that contains a valid IPv4 address; else
* false
* @see #parseNodeIdentifier(String)
*/
public static boolean isIPv4Address(String nodename) {
return nodename != null //
&& !nodename.isEmpty() //
&& parseIPv4Address(nodename, 0) == nodename.length();
}
/**
* Determines whether or not a nodename string, i.e., from an HTTP header,
* contains a valid IPv6 address.
*
* @param nodename nodename string
* @return true if nodename is a string that contains a valid IPv6 address; else
* false
* @see #parseNodeIdentifier(String)
*/
public static boolean isIPv6Address(String nodename) {
return nodename != null //
&& !nodename.isEmpty() //
&& parseIPv6Address(nodename, 0) == nodename.length();
}
/**
* Determines whether or not a nodename string, i.e., from an HTTP header,
* contains a valid IP address.
*
* @param nodename nodename string
* @return true if nodename is a string that contains a valid IP address; else
* false
* @see #parseNodeIdentifier(String)
*/
public static boolean isIPAddress(String nodename) {
return isIPv4Address(nodename) || isIPv6Address(nodename);
}
/**
* Parses a node identifier.
* <p>
* The node identifier is defined by the ABNF syntax as:
* </p>
*
* <pre>
* node = nodename [ ":" node-port ]
* nodename = IPv4address / "[" IPv6address "]" / "unknown" / obfnode
*
* IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
* dec-octet = DIGIT ; 0-9
* / %x31-39 DIGIT ; 10-99
* / "1" 2DIGIT ; 100-199
* / "2" %x30-34 DIGIT ; 200-249
* / "25" %x30-35 ; 250-255
*
* IPv6address = 6( h16 ":" ) ls32
* / "::" 5( h16 ":" ) ls32
* / [ h16 ] "::" 4( h16 ":" ) ls32
* / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32
* / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32
* / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32
* / [ *4( h16 ":" ) h16 ] "::" ls32
* / [ *5( h16 ":" ) h16 ] "::" h16
* / [ *6( h16 ":" ) h16 ] "::"
* ls32 = ( h16 ":" h16 ) / IPv4address
* ; least-significant 32 bits of address
* h16 = 1*4HEXDIG
* ; 16 bits of address represented in hexadecimal
*
* obfnode = "_" 1*(ALPHA / DIGIT / "." / "_" / "-")
*
* node-port = port / obfport
* port = 1*5DIGIT
* obfport = "_" 1*(ALPHA / DIGIT / "." / "_" / "-")
* </pre>
*
* @param nodeId <a href=
* "https://datatracker.ietf.org/doc/html/rfc7239#section-6">Node
* identifier</a>
* @return {@link InetSocketAddress}
* @see #digit(char)
* @see #alpha(char)
*/
public static InetSocketAddress parseNodeIdentifier(String nodeId) {
return parseNodeIdentifier(nodeId, a -> {
throw new IllegalArgumentException("unknown obfnode");
}, a -> {
throw new IllegalArgumentException("unknown obfport");
});
}
/**
* Implements {@link #parseNodeIdentifier(String nodeId)} with obfuscated node
* and port lookups.
*
* @param nodeId <a href=
* "https://datatracker.ietf.org/doc/html/rfc7239#section-6">Node
* identifier</a>
* @param obfuscatedNodeLookup Resovles an obfuscated node name (obfnode) to
* {@link InetAddress}
* @param obfuscatedPortLookup Resovles an obfuscated node port (obfport) number
* @return {@link InetSocketAddress}
* @see #digit(char)
* @see #alpha(char)
*/
public static InetSocketAddress parseNodeIdentifier(final String nodeId,
Function<String, InetAddress> obfuscatedNodeLookup, Function<String, Integer> obfuscatedPortLookup) {
if (nodeId == null || nodeId.isEmpty())
return null;
final var len = nodeId.length();
final int endOfName;
InetAddress obfnode = null;
{
var pos = parseIPv4Address(nodeId, 0);
if (pos == 0) {
var c = nodeId.charAt(0);
if (c == '[') {
pos = parseIPv6Address(nodeId, 1);
if (pos == 1 //
|| pos >= len)
throw new IllegalArgumentException("invalid IPv6 address");
else
pos++;
} else if (c == '_') {
pos = obf(nodeId, 0);
if (pos == 0)
throw new IllegalArgumentException("invalid obfnode");
else
obfnode = obfuscatedNodeLookup.apply(nodeId.substring(0, pos));
} else if (nodeId.startsWith("unknown"))
return null;
else
throw new IllegalArgumentException("invalid nodename");
}
endOfName = pos;
}
final int port;
if (endOfName >= len)
port = 0;
else if (nodeId.charAt(endOfName) == ':') {
final var tail = nodeId.substring(endOfName + 1);
if (tail.isEmpty())
throw new IllegalArgumentException("empty node-port");
else if (tail.charAt(0) == '_') {
final var endOfObfport = obf(tail, 0);
if (endOfObfport == 0)
throw new IllegalArgumentException("invalid obfport");
else
port = obfuscatedPortLookup.apply(tail.substring(0, endOfObfport));
} else {
final var portlen = tail.length();
if (portlen > 5)
throw new IllegalArgumentException("invalid node-port");
for (var i = 0; i < portlen; i++)
if (!digit(tail.charAt(i)))
throw new IllegalArgumentException("invalid port");
port = Integer.parseInt(tail);
}
} else
throw new IllegalArgumentException("expected ':' or end of node");
return new InetSocketAddress(Objects.requireNonNullElseGet(obfnode, () -> {
String name;
if (nodeId.charAt(0) == '[') // trim brackets from IPv6
name = nodeId.substring(1, endOfName - 1);
else
name = nodeId.substring(0, endOfName);
return getInetAddress(name);
}), port);
}
/**
* Validates and normalizes case for an HTTP header name.
*
* <p>
* Follows each hyphen '-' character with an upper case character; converts
* other characters {@link Character#toLowerCase(char) to lower case}
* </p>
*
* @param headerName HTTP header name
* @return {@link String}
* @throws IllegalArgumentException If the name contains non-alphabetic
* characters other than hyphen '-', or if the
* name begins or ends with a hyphen.
*/
public static String normalizeHeaderName(String headerName) throws IllegalArgumentException {
if (!headerName.matches("\\p{Alpha}+(\\-\\p{Alpha}+?)*"))
throw new IllegalArgumentException("Invalid header name " + headerName);
final var sb = new StringBuilder(headerName.toLowerCase());
sb.setCharAt(0, Character.toUpperCase(sb.charAt(0)));
for (int i = headerName.indexOf('-') + 1; //
i != 0; //
i = headerName.indexOf('-', i + 1) + 1)
sb.setCharAt(i, Character.toUpperCase(sb.charAt(i)));
return sb.toString();
}
/**
* Describes an HTTP status code.
*
* @param statusCode HTTP status code
* @return {@link String}
*/
public static String describeStatus(int statusCode) {
switch (statusCode) {
case 100:
return statusCode + " CONTINUE";
case 101:
return statusCode + " SWITCHING PROTOCOLS";
case 200:
return statusCode + " OK";
case 201:
return statusCode + " CREATED";
case 202:
return statusCode + " ACCEPTED";
case 203:
return statusCode + " NON AUTHORITATIVE INFORMATION";
case 204:
return statusCode + " NO CONTENT";
case 205:
return statusCode + " RESET CONTENT";
case 206:
return statusCode + " PARTIAL CONTENT";
case 300:
return statusCode + " MULTIPLE CHOICES";
case 301:
return statusCode + " MOVED PERMANENTLY";
case 302:
return statusCode + " FOUND";
case 303:
return statusCode + " SEE OTHER";
case 304:
return statusCode + " NOT MODIFIED";
case 305:
return statusCode + " USE PROXY";
case 307:
return statusCode + " TEMPORARY REDIRECT";
case 400:
return statusCode + " BAD REQUEST";
case 401:
return statusCode + " UNAUTHORIZED";
case 402:
return statusCode + " PAYMENT REQUIRED";
case 403:
return statusCode + " FORBIDDEN";
case 404:
return statusCode + " NOT FOUND";
case 405:
return statusCode + " METHOD NOT ALLOWED";
case 406:
return statusCode + " NOT ACCEPTABLE";
case 407:
return statusCode + " PROXY AUTHENTICATION REQUIRED";
case 408:
return statusCode + " REQUEST TIMEOUT";
case 409:
return statusCode + " CONFLICT";
case 410:
return statusCode + " GONE";
case 411:
return statusCode + " LENGTH REQUIRED";
case 412:
return statusCode + " PRECONDITION FAILED";
case 413:
return statusCode + " REQUEST ENTITY TOO LARGE";
case 414:
return statusCode + " REQUEST URI TOO LONG";
case 415:
return statusCode + " UNSUPPORTED MEDIA TYPE";
case 416:
return statusCode + " REQUESTED RANGE NOT SATISFIABLE";
case 417:
return statusCode + " EXPECTATION FAILED";
case 500:
return statusCode + " INTERNAL SERVER ERROR";
case 501:
return statusCode + " NOT IMPLEMENTED";
case 502:
return statusCode + " BAD GATEWAY";
case 503:
return statusCode + " SERVICE UNAVAILABLE";
case 504:
return statusCode + " GATEWAY TIMEOUT";
case 505:
return statusCode + " HTTP VERSION NOT SUPPORTED";
default:
return statusCode + " UNKNOWN";
}
}
/**
* Resolves and caches the {@link InetAddress IP address} for a host name.
*
* @param hostname host name
* @return resolved {@link InetAddress}
*/
public static InetAddress getInetAddress(String hostname) {
var addr = IP_CACHE.get(hostname);
if (addr != null)
return addr;
addr = IuException.unchecked(() -> InetAddress.getByName(hostname));
IP_CACHE.put(hostname, addr);
return addr;
}
/**
* Determines whether or not an IP address is included in a CIDR range.
*
* @param address address
* @param range CIDR range
* @return true if the range includes the address; else false
*/
public static boolean isInetAddressInRange(InetAddress address, String range) {
byte[] hostaddr = address.getAddress();
int lastSlash = range.lastIndexOf('/');
byte[] rangeaddr;
int maskbits;
if (lastSlash == -1) {
rangeaddr = getInetAddress(range).getAddress();
maskbits = rangeaddr.length * 8;
} else {
rangeaddr = getInetAddress(range.substring(0, lastSlash)).getAddress();
maskbits = Integer.parseInt(range.substring(lastSlash + 1));
}
for (int i = 0; i < rangeaddr.length; i++) {
if (maskbits >= 8) {
maskbits -= 8;
if (hostaddr[i] != rangeaddr[i])
return false;
} else if (maskbits > 0) {
int mask = ~((1 << (8 - maskbits)) - 1);
return (hostaddr[i] & mask) == (rangeaddr[i] & mask);
} else
break;
}
return true;
}
private IuWebUtils() {
}
}