diff --git a/src/freenet/client/filter/HTMLFilter.java b/src/freenet/client/filter/HTMLFilter.java index 1372b571cd2..88f83570b16 100644 --- a/src/freenet/client/filter/HTMLFilter.java +++ b/src/freenet/client/filter/HTMLFilter.java @@ -17,7 +17,7 @@ import java.io.Writer; import java.nio.charset.Charset; import java.nio.charset.MalformedInputException; -import java.text.ParseException; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -31,14 +31,9 @@ import java.util.Stack; import java.util.StringTokenizer; -import freenet.clients.http.ToadletContextImpl; import freenet.l10n.NodeL10n; -import freenet.support.HTMLDecoder; -import freenet.support.HTMLEncoder; -import freenet.support.Logger; +import freenet.support.*; import freenet.support.Logger.LogLevel; -import freenet.support.URLDecoder; -import freenet.support.URLEncodedFormatException; import freenet.support.io.NullWriter; public class HTMLFilter implements ContentDataFilter, CharsetExtractor { @@ -3008,13 +3003,13 @@ Map sanitizeHash(Map h, } else if ((http_equiv != null) && (name == null)) { if (http_equiv.equalsIgnoreCase("Expires")) { try { - ToadletContextImpl.parseHTTPDate(content); - hn.put("http-equiv", http_equiv); - hn.put("content", content); - } catch (ParseException e) { + TimeUtil.parseHttpDateTime(content); + } catch (DateTimeParseException e) { // Delete it. return null; } + hn.put("http-equiv", http_equiv); + hn.put("content", content); } else if ( http_equiv.equalsIgnoreCase("Content-Script-Type")) { // We don't support script at this time. diff --git a/src/freenet/clients/http/Cookie.java b/src/freenet/clients/http/Cookie.java index 02c25d22c85..484c689fc56 100644 --- a/src/freenet/clients/http/Cookie.java +++ b/src/freenet/clients/http/Cookie.java @@ -5,327 +5,315 @@ import java.net.URI; import java.net.URISyntaxException; -import java.nio.charset.Charset; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.*; -import freenet.support.CurrentTimeUTC; -import freenet.support.TimeUtil; /** * @author xor (xor@freenetproject.org) */ public class Cookie { - /** - * FIXME: Where was this taken from? - */ - private static final HashSet invalidValueCharacters = - new HashSet(Arrays.asList(new Character[] { '(', ')', '[', ']', '{', '}', '=', ',', '\"', '/', '\\', '?', '@', ':', ';' })); - - /** - * Taken from this discussion: - CTL = (octets 0 - 31) and DEL (127)> - CHAR = - token = 1* - separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT - so, anything from 32-126 that isn't in that list of seperators is valid. - TheSeeker: where did you copy that from? - http://www.ietf.org/rfc/rfc2616.txt - */ - public static final HashSet httpSeparatorCharacters = - new HashSet(Arrays.asList(new Character[] { '(', ')', '<', '>', '@', ',', ';', ':', '\\', '\"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' })); - - private static final Charset usasciiCharset = Charset.forName("US-ASCII"); - - - protected int version; - - protected URI domain; - - protected URI path; - - protected String name; - - protected String value; - - protected Date expirationDate; - - protected boolean discard; - - - /** - * Constructor for creating cookies which are to be used in {@link ToadletContext.setCookie}. - * - * @param myPath The path of the cookie. Must be absolute. - * @param myName The name of the cookie. Must be latin letters and numbers only and is converted to lowercase. - * @param myValue The value of the cookie. Must not contain any linebreaks. May be null, null means empty value. - * @throws URISyntaxException - */ - public Cookie(URI myPath, String myName, String myValue, Date myExpirationDate) { - version = 1; - - domain = null; - path = validatePath(myPath); - name = validateName(myName); - value = myValue != null ? validateValue(myValue) : ""; - expirationDate = validateExpirationDate(myExpirationDate); - discard = true; // Freenet cookies shall always be discarded when the browser is closed; - } - - protected Cookie() { - - } - - /** - * Returns true if two Cookies have equal domain, path and name. Does not check the value! - */ - @Override - public boolean equals(Object obj) { - if(obj == this) - return true; - - if(!(obj instanceof Cookie)) - return false; - - Cookie other = (Cookie)obj; - - // RFC2965: Two cookies are equal if name and domain are equal with case-insensitive comparison and path is equal with case-sensitive comparison. - // We don't have to do anything about the case here though because getName() / getDomain() returns lowercase and getPath() returns the original path. - - URI myDomain = getDomain(); URI otherDomain = other.getDomain(); - - if(myDomain != null) { - if(otherDomain == null || !otherDomain.toString().equals(myDomain.toString())) - return false; - } else if(otherDomain != null) - return false; - - - if(!getPath().toString().equals(other.getPath().toString())) - return false; - - if(!getName().equals(other.getName())) - return false; - - return true; - } - - @Override - public int hashCode() { - return domain.hashCode() + path.hashCode() + name.hashCode(); - } - - public static URI validateDomain(String domainString) throws URISyntaxException { - return validateDomain(new URI(domainString.toLowerCase())); - } - - public static URI validateDomain(URI domain) { - String scheme = domain.getScheme().toLowerCase(); - - if(!"http".equals(scheme) && !"https".equals(scheme)) - throw new IllegalArgumentException("Illegal cookie domain, must be http or https: " + domain); - - String path = domain.getPath(); - - if(!"".equals(path) && !"/".equals(path)) - throw new IllegalArgumentException("Illegal cookie domain, contains a path: " + domain); - - return domain; - } - - public static URI validatePath(String stringPath) throws URISyntaxException { - return validatePath(new URI(stringPath)); - } - - public static URI validatePath(URI path) { - // FIXME: Be more restrictive. - - if(path.isAbsolute()) - throw new IllegalArgumentException("Illegal cookie path, must be relative: " + path); - - if(path.toString().startsWith("/") == false) - throw new IllegalArgumentException("Illegal cookie path, must start with /: " + path); - - // RFC2965: Path is case sensitive! - - return path; - } - - - /** - * Validates the name of a cookie against a mixture of RFC2965, RFC2616 (from IETF.org) and personal choice :| - * The personal choice is mostly that it uses isISOControl for determining control characters instead of the - * list in the RFC - therefore, more characters are considered as control characters. - * So effectively this function is more restrictive than the RFCs. - * TODO: Read the RFCs in depth and make this function fully compatible. - */ - public static String validateName(String name) { - if("".equals(name)) - throw new IllegalArgumentException("Name is empty."); - - if(!isUSASCII(name)) - throw new IllegalArgumentException("Invalid name, contains non-US-ASCII characters: " + name); - - name = name.trim().toLowerCase(); // RFC2965: Name is case insensitive - - /* - CTL = (octets 0 - 31) and DEL (127)> - CHAR = - token = 1* - separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT - so, anything from 32-126 that isn't in that list of seperators is valid. - TheSeeker: where did you copy that from? - http://www.ietf.org/rfc/rfc2616.txt - The following grammar uses the notation, and tokens DIGIT (decimal digits), token (informally, a sequence of non-special, non-white space characters), and http_URL from the HTTP/1.1 specification [RFC2616] to describe their syntax. - quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) - qdtext = > - TEXT = - LWS = [CRLF] 1*( SP | HT ) - OCTET = - so, if it's quoted, it can be anything 32-126 and 128-255 (except a quote char, obviously) - */ - - for(Character c : name.toCharArray()) { - if(Character.isWhitespace(c)) - throw new IllegalArgumentException("Invalid name, contains whitespace: " + name); - - // From isISOControl javadoc: A character is considered to be an ISO control character if its in the range [0,31] or [127,159] - if(Character.isISOControl(c)) - throw new IllegalArgumentException("Invalid name, contains control characters."); - - if(httpSeparatorCharacters.contains(c)) - throw new IllegalArgumentException("Invalid name, contains one of the explicitely disallowed characters: " + name); - } - - if( name.startsWith("$") - || "comment".equals(name) - || "discard".equals(name) - || "domain".equals(name) - || "expires".equals(name) - || "max-age".equals(name) - || "path".equals(name) - || "secure".equals(name) - || "version".equals(name) - ) - throw new IllegalArgumentException("Name is reserved: " + name); - - return name; - } - - private static boolean isUSASCII(String name) { - for(int i=0;i 126) - return false; - } - return true; - } - - /** - * Validates the value of a cookie against a mixture of RFC2965, RFC2616 (from IETF.org) and personal choice :| - * The personal choice is mostly that it uses isISOControl for determining control characters instead of the - * list in the RFC - therefore, more characters are considered as control characters. - * So effectively this function is more restrictive than the RFCs. - * TODO: Read the RFCs in depth and make this function fully compatible. - */ - public static String validateValue(String value) { - if(!isUSASCII(value)) - throw new IllegalArgumentException("Invalid value, contains non-US-ASCII characters: " + value); - - value = value.trim(); - - /* - CTL = (octets 0 - 31) and DEL (127)> - CHAR = - token = 1* - separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT - so, anything from 32-126 that isn't in that list of seperators is valid. - TheSeeker: where did you copy that from? - http://www.ietf.org/rfc/rfc2616.txt - The following grammar uses the notation, and tokens DIGIT (decimal digits), token (informally, a sequence of non-special, non-white space characters), and http_URL from the HTTP/1.1 specification [RFC2616] to describe their syntax. - quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) - qdtext = > - TEXT = - LWS = [CRLF] 1*( SP | HT ) - OCTET = - so, if it's quoted, it can be anything 32-126 and 128-255 (except a quote char, obviously) - */ - - for(Character c : value.toCharArray()) { - // We allow whitespace in the value because quotation is allowed and supported by the parser in ReceivedCookie - - if(Character.isISOControl(c)) - throw new IllegalArgumentException("Invalid value, contains control characters."); - - // TODO: The source of the invalid value characters list is not mentioned in its javadoc - it has to be re-validated - if(invalidValueCharacters.contains(c)) - throw new IllegalArgumentException("Invalid value, contains one of the explicitely disallowed characters: " + value); - } - - return value; - } - - public static Date validateExpirationDate(Date expirationDate) { - if(CurrentTimeUTC.get().after(expirationDate)) - throw new IllegalArgumentException("Illegal expiration date, is in past: " + expirationDate); - - return expirationDate; - } - - public URI getDomain() { - return domain; - } - - public URI getPath() { - return path; - } - - public String getName() { - return name; - } - - public String getValue() { - return value; - } - -// TODO: This is broken in ReceivedCookie so it is also commented out here. See ReceivedCookie for the explanation. -// public Date getExpirationDate() { -// return expirationDate; -// } - - - /** - * Encodes the content of this cookie to the HTTP header value representation as of RFC2965. - * Does not quote any of the values as Firefox 4 does not support quotation and the latest IETF draft for cookies does not contain - * quotation: http://tools.ietf.org/html/draft-ietf-httpstate-cookie-23 - * The key "set-cookie2:" is not prefixed to the returned String! - */ - protected String encodeToHeaderValue() { - StringBuilder sb = new StringBuilder(512); // TODO: As soon as something else besides Freetalk uses cookies, adjust this value. - - // RFC2965: Name MUST be first. - sb.append(name); sb.append("="); sb.append(value); sb.append(';'); - - sb.append("version="); sb.append(version); sb.append(';'); - - if(domain != null) { - sb.append("domain="); sb.append(domain); sb.append(';'); - } - - sb.append("path="); sb.append(path); sb.append(';'); - - sb.append("expires="); sb.append(TimeUtil.makeHTTPDate(expirationDate.getTime())); sb.append(';'); - - if(discard) { - sb.append("discard="); sb.append(discard); sb.append(';'); - } - - return sb.toString(); - } + /** + * Cookie token separators from https://www.ietf.org/rfc/rfc2616.html#section-2.2 + */ + static final Set COOKIE_TOKEN_SEPARATOR_CHARACTERS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + '(', ')', '<', '>', '@', ',', ';', ':', '\\', '\"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' + ) + ) + ); + + /** + * Cookie value forbidden chars excluding ASCII control characters and space. + * From https://www.ietf.org/rfc/rfc6265.html#section-4.1.1 + */ + static final Set COOKIE_VALUE_FORBIDDEN_CHARS = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList('\"', ',', ';', '\\')) + ); + + /** + * Reserved cookie attribute names as for https://www.ietf.org/rfc/rfc2965.html#section-3.2 + * (in lower case) + */ + private static final Set RESERVED_ATTRIBUTE_NAMES = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + "comment", "commenturl", "discard", "domain", "expires", "max-age", "path", "port", "secure", "version" + ) + ) + ); + + + protected int version; + + protected URI domain; + + protected URI path; + + protected String name; + + protected String value; + + protected Instant expirationTime; + + protected boolean discard; + + + /** + * Constructor for creating cookies which are to be used in {@link ToadletContext.setCookie}. + * + * @param myPath The path of the cookie. Must be absolute. + * @param myName The name of the cookie. Must be latin letters and numbers only and is converted to lowercase. + * @param myValue The value of the cookie. Must not contain any linebreaks. May be null, null means empty value. + */ + public Cookie(URI myPath, String myName, String myValue, Instant myExpirationTime) { + version = 1; + domain = null; + path = validatePath(myPath); + name = validateName(myName); + value = myValue != null ? validateValue(myValue) : ""; + expirationTime = validateExpirationDate(myExpirationTime); + discard = true; // Freenet cookies shall always be discarded when the browser is closed; + } + + /** + * Returns true if two Cookies have equal domain, path and name. Does not check the value! + */ + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + + if (!(obj instanceof Cookie)) { + return false; + } + + Cookie other = (Cookie) obj; + + // RFC2965: Two cookies are equal if name and domain are equal with case-insensitive comparison and path is equal with case-sensitive comparison. + // We don't have to do anything about the case here though because getName() / getDomain() returns lowercase and getPath() returns the original path. + + URI myDomain = getDomain(); + URI otherDomain = other.getDomain(); + + if (myDomain != null) { + if (otherDomain == null || !otherDomain.toString().equals(myDomain.toString())) { + return false; + } + } else if (otherDomain != null) { + return false; + } + + if (!getPath().toString().equals(other.getPath().toString())) { + return false; + } + + return getName().equals(other.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(this.domain, this.path, this.name); + } + + protected static URI validateDomain(String domainString) throws URISyntaxException { + return validateDomain(new URI(domainString.toLowerCase())); + } + + protected static URI validateDomain(URI domain) { + String scheme = domain.getScheme().toLowerCase(); + + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException("Illegal cookie domain, must be http or https: " + domain); + } + + String path = domain.getPath(); + + if (!"".equals(path) && !"/".equals(path)) { + throw new IllegalArgumentException("Illegal cookie domain, contains a path: " + domain); + } + + return domain; + } + + protected static URI validatePath(URI path) { + // FIXME: Be more restrictive. + + if (path == null) { + throw new IllegalArgumentException("Illegal cookie path, must be present but actual value is null"); + } + + if (path.isAbsolute()) { + throw new IllegalArgumentException("Illegal cookie path, must be relative: " + path); + } + + if (!path.toString().startsWith("/")) { + throw new IllegalArgumentException("Illegal cookie path, must start with /: " + path); + } + + // RFC2965: Path is case sensitive! + + return path; + } + + + /** + * Validates the name of a cookie against a mixture of RFC2965, RFC2616 and RFC6265 (from IETF.org) + * and personal choice :| + * The personal choice is mostly that it uses isISOControl for determining control characters instead of the + * list in the RFC - therefore, more characters are considered as control characters. + * So effectively this function is more restrictive than the RFCs. + * TODO: Read the RFCs in depth and make this function fully compatible. + */ + protected static String validateName(String name) { + if (name == null) { + throw new IllegalArgumentException("Name is null."); + } + + if (name.isEmpty()) { + throw new IllegalArgumentException("Name is empty."); + } + + if (!isUSASCII(name)) { + throw new IllegalArgumentException("Invalid name, contains non-US-ASCII characters: " + name); + } + + name = name.trim().toLowerCase(); // RFC2965: Name is case-insensitive + + for (Character c : name.toCharArray()) { + if (Character.isWhitespace(c)) { + throw new IllegalArgumentException("Invalid name, contains whitespace: " + name); + } + + // From isISOControl javadoc: A character is considered to be an ISO control character if its in the range [0,31] or [127,159] + if (Character.isISOControl(c)) { + throw new IllegalArgumentException("Invalid name, contains control characters."); + } + + if (COOKIE_TOKEN_SEPARATOR_CHARACTERS.contains(c)) { + throw new IllegalArgumentException("Invalid name, contains one of the explicitely disallowed characters: " + name); + } + } + + if (name.startsWith("$") || RESERVED_ATTRIBUTE_NAMES.contains(name)) { + throw new IllegalArgumentException("Name is reserved: " + name); + } + + return name; + } + + private static boolean isUSASCII(String name) { + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + // Java chars are unicode. Unicode is a superset of US-ASCII. + if (c < 32 || c > 126) { + return false; + } + } + return true; + } + + /** + * Validates the value of a cookie against a mixture of RFC2965, RFC2616 and RFC6265 (from IETF.org) + * and personal choice :| + * The personal choice is mostly that it uses isISOControl for determining control characters instead of the + * list in the RFC - therefore, more characters are considered as control characters. + * So effectively this function is more restrictive than the RFCs. + * TODO: Read the RFCs in depth and make this function fully compatible. + */ + protected static String validateValue(String value) { + if (!isUSASCII(value)) { + throw new IllegalArgumentException("Invalid value, contains non-US-ASCII characters: " + value); + } + + value = value.trim(); + + for (Character c : value.toCharArray()) { + // We allow whitespace in the value because quotation is allowed and supported by the parser in ReceivedCookie + + if (Character.isISOControl(c)) { + throw new IllegalArgumentException("Invalid value, contains control characters."); + } + + if (COOKIE_VALUE_FORBIDDEN_CHARS.contains(c)) { + throw new IllegalArgumentException("Invalid value, contains one of the explicitly disallowed characters: " + value); + } + } + + return value; + } + + protected static Instant validateExpirationDate(Instant expirationTime) { + if (expirationTime == null) { + throw new IllegalArgumentException("Illegal expiration time, value is null"); + } + return expirationTime; + } + + public URI getDomain() { + return domain; + } + + public URI getPath() { + return path; + } + + public String getName() { + return name; + } + + public String getValue() { + return value; + } + + public Instant getExpirationTime() { + return expirationTime; + } + + /** + * Encodes the content of this cookie to the HTTP header value representation as of RFC2965. + * Does not quote any of the values as Firefox 4 does not support quotation and the latest IETF draft for cookies does not contain + * quotation: http://tools.ietf.org/html/draft-ietf-httpstate-cookie-23 + * The key "set-cookie2:" is not prefixed to the returned String! + */ + protected String encodeToHeaderValue() { + StringBuilder sb = new StringBuilder(512); // TODO: As soon as something else besides Freetalk uses cookies, adjust this value. + + // RFC2965: Name MUST be first. + sb.append(name); + sb.append("="); + sb.append(value); + sb.append(';'); + + sb.append("version="); + sb.append(version); + sb.append(';'); + + if (domain != null) { + sb.append("domain="); + sb.append(domain); + sb.append(';'); + } + + sb.append("path="); + sb.append(path); + sb.append(';'); + + sb.append("expires="); + sb.append(DateTimeFormatter.RFC_1123_DATE_TIME.format(expirationTime.atOffset(ZoneOffset.UTC))); + sb.append(';'); + + if (discard) { + sb.append("discard="); + sb.append(discard); + sb.append(';'); + } + + return sb.toString(); + } } diff --git a/src/freenet/clients/http/ImageCreatorToadlet.java b/src/freenet/clients/http/ImageCreatorToadlet.java index 92ccc7b3bdc..1897d32de39 100644 --- a/src/freenet/clients/http/ImageCreatorToadlet.java +++ b/src/freenet/clients/http/ImageCreatorToadlet.java @@ -10,12 +10,14 @@ import java.io.OutputStream; import java.net.URI; import java.sql.Date; -import java.text.ParseException; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; import javax.imageio.ImageIO; import freenet.client.HighLevelSimpleClient; import freenet.support.MultiValueTable; +import freenet.support.TimeUtil; import freenet.support.api.Bucket; import freenet.support.api.HTTPRequest; @@ -49,12 +51,13 @@ public void handleMethodGET(URI uri, HTTPRequest req, ToadletContext ctx) throws if (ctx.getHeaders().containsKey("if-modified-since")) { try { // If the received date is equal to the last modification of this class, then it doesn't need regeneration - if (ToadletContextImpl.parseHTTPDate(ctx.getHeaders().get("if-modified-since")).compareTo(LAST_MODIFIED) == 0) { + ZonedDateTime zonedDateTime = TimeUtil.parseHttpDateTime(ctx.getHeaders().get("if-modified-since")); + if (zonedDateTime.toInstant().equals(LAST_MODIFIED.toInstant())) { // So we just send the NOT_MODIFIED response, and skip the generation ctx.sendReplyHeadersStatic(304, "Not Modified", null, "image/png", 0, LAST_MODIFIED); needsGeneration = false; } - } catch (ParseException pe) { + } catch (DateTimeParseException pe) { // If something goes wrong, we regenerate } } diff --git a/src/freenet/clients/http/ReceivedCookie.java b/src/freenet/clients/http/ReceivedCookie.java index 1749c621e44..1b914cdaa9c 100644 --- a/src/freenet/clients/http/ReceivedCookie.java +++ b/src/freenet/clients/http/ReceivedCookie.java @@ -3,286 +3,199 @@ * http://www.gnu.org/ for further details of the GPL. */ package freenet.clients.http; -import java.net.URI; -import java.net.URISyntaxException; import java.text.ParseException; import java.util.ArrayList; -import java.util.Hashtable; - -import freenet.support.Logger; +import java.util.Collections; +import java.util.List; +import java.util.Objects; /** * A cookie which the server has received from the client. - * - * This class is not thread-safe! - * + * * @author xor (xor@freenetproject.org) */ -public final class ReceivedCookie extends Cookie { - - private static volatile boolean logMINOR; - private static volatile boolean logDEBUG; - - static { - Logger.registerClass(ReceivedCookie.class); - } - - private String notValidatedName; - - private Hashtable content; - - - /** - * Constructor for creating cookies from parsed key-value pairs. - * - * Does not validate the names or values of the keys, each attribute is validated at the first call to it's getter method. - * Therefore, no CPU time is wasted if the client sends cookies which we do not use. - */ - private ReceivedCookie(String myName, Hashtable myContent) { - // We do not validate the input here, we only parse it if someone actually tries to access this cookie. - notValidatedName = myName; - content = myContent; - - // We do NOT parse the version even though RFC2965 requires it because Firefox (3.0.14) does not give us a version. - - version = 1; - //version = Integer.parseInt(content.get("$version")); - - //if(version != 1) - // throw new IllegalArgumentException("Invalid version: " + version); - } - - /** - * Parses the value of a "Cookie:" HTTP header and returns a list of received cookies which it contained. - * - A single "Cookie:" header is allowed to contain multiple cookies. Further, a HTTP request can contain multiple "Cookie" keys!. - * - * @param httpHeader The value of a "Cookie:" header (i.e. the prefix "Cookie:" must not be contained in this parameter!) - * @return A list of {@link ReceivedCookie} objects. The validity of their name/value pairs is not deeply checked, their getName() / getValue() might throw! - * @throws ParseException If the general formatting of the cookie is wrong. - */ - protected static ArrayList parseHeader(String httpHeader) throws ParseException { - - if(logMINOR) - Logger.minor(ReceivedCookie.class, "Received HTTP cookie header:" + httpHeader); - - char[] header = httpHeader.toCharArray(); - - String currentCookieName = null; - Hashtable currentCookieContent = new Hashtable(16); - - ArrayList cookies = new ArrayList(4); // TODO: Adjust to the usual amount of cookies which fred uses + 1 - - // We do manual parsing instead of using regular expressions for two reasons: - // 1. The value of cookies can be quoted, therefore it is a context-free language and not a regular language - we cannot express it with a regexp! - // 2. Its very fast :) - - // Set to true if a broken browser (Konqueror) specifies a cookie where the name is NOT the first attribute. - try { - for(int i = 0; i < header.length;) { - // Skip leading whitespace of key, we must do a header.length check because there might be no more key, so we continue; - if(Character.isWhitespace(header[i])) { - ++i; - continue; - } - - String key; - String value = null; - - // Parse key - { - int keyBeginIndex = i; - - while(i < header.length && header[i] != '=' && header[i] != ';') - ++i; - - int keyEndIndex = i; - - if(keyEndIndex >= header.length || header[keyEndIndex] == ';') - value = ""; - - while(Character.isWhitespace(header[keyEndIndex-1])) // Remove trailing whitespace - --keyEndIndex; - - key = new String(header, keyBeginIndex, keyEndIndex - keyBeginIndex).toLowerCase(); - - if(key.length() == 0) - throw new ParseException("Invalid cookie: Contains an empty key: " + httpHeader, i); - - // We're done parsing the key, continue to the next character. - ++i; - } - - // Parse value (empty values are allowed). - if(value == null && i < header.length) { - while(Character.isWhitespace(header[i])) // Skip leading whitespace - ++i; - - int valueBeginIndex; - char valueEndChar; - - if(header[i] == '\"') { // Value is quoted - valueEndChar = '\"'; - valueBeginIndex = ++i; - - while(header[i] != valueEndChar) +public final class ReceivedCookie { + + private static final int ONE_MB_OF_ASCII_TEXT = 1048576; + + private final String name; + private final String value; + + + /** + * Constructor for creating cookies from parsed key-value pairs. + *

+ * Does not validate the names or values of the keys, each attribute is validated at the first call to it's getter method. + * Therefore, no CPU time is wasted if the client sends cookies which we do not use. + */ + private ReceivedCookie(String myName, String myValue) { + if (myName == null) { + throw new IllegalArgumentException("Cookie name is null"); + } + if (myName.isEmpty()) { + throw new IllegalArgumentException("Cookie name is empty"); + } + name = myName; + value = myValue; + } + + /** + * Parses the value of a "Cookie:" HTTP header and returns a list of received cookies which it contained. + * - A single "Cookie:" header is allowed to contain multiple cookies. Further, a HTTP request can contain multiple "Cookie" keys!. + * + * @param httpHeader The value of a "Cookie:" header (i.e. the prefix "Cookie:" must not be contained in this parameter!) + * @return A list of {@link ReceivedCookie} objects. The validity of their name/value pairs is not deeply checked, their getName() / getValue() might throw! + * @throws ParseException If the general formatting of the cookie is wrong. + */ + static List parseHeader(String httpHeader) throws ParseException { + if (httpHeader == null || httpHeader.isEmpty()) { + return Collections.emptyList(); + } + if (httpHeader.length() > ONE_MB_OF_ASCII_TEXT) { + throw new IllegalArgumentException("Cookie value is too long. Length: " + httpHeader.length()); + } + char[] header = httpHeader.toCharArray(); + List cookies = new ArrayList<>(4); // TODO: Adjust to the usual amount of cookies which fred uses + 1 + + // We do manual parsing instead of using regular expressions for two reasons: + // 1. The value of cookies can be quoted, therefore it is a context-free language and not a regular language - we cannot express it with a regexp! + // 2. Its very fast :) + + String key = null; + String value = null; + try { + for (int i = 0; i < header.length; ) { + // Skip leading whitespace of key, we must do a header.length check because there might be no more key, so we continue; + if (Character.isWhitespace(header[i])) { + ++i; + continue; + } + + // Parse key + { + int keyBeginIndex = i; + + while (i < header.length && header[i] != '=' && header[i] != ';') { ++i; - - } else { - valueEndChar = ';'; - valueBeginIndex = i; - - while(i < header.length && header[i] != valueEndChar) + } + + int keyEndIndex = i; + + // Remove trailing whitespace + while (Character.isWhitespace(header[keyEndIndex - 1])) { + --keyEndIndex; + } + + key = new String(header, keyBeginIndex, keyEndIndex - keyBeginIndex); + + if (key.length() == 0) { + throw new ParseException("Invalid cookie: Contains an empty key: " + httpHeader, i); + } + + // We're done parsing the key, continue to the next character. + ++i; + } + + // Parse value (empty values are allowed). + if (i < header.length) { + // Skip leading whitespace + while (Character.isWhitespace(header[i])) { ++i; - } - - - int valueEndIndex = i; - - while(valueEndIndex > valueBeginIndex && Character.isWhitespace(header[valueEndIndex-1])) // Remove trailing whitespace - --valueEndIndex; - - value = new String(header, valueBeginIndex, valueEndIndex - valueBeginIndex); - - // We're done parsing the value, continue to the next character - ++i; - - // Skip whitespace between end of quotation and the semicolon following the quotation. - if(valueEndChar == '\"') { - while(i < header.length && header[i] != ';') { - if(!Character.isWhitespace(header[i])) - throw new ParseException("Invalid cookie: Missing terminating semicolon after value quotation: " + httpHeader, i); - + } + + int valueBeginIndex; + char valueEndChar; + + if (header[i] == '\"') { // Value is quoted + valueEndChar = '\"'; + valueBeginIndex = ++i; + + while (header[i] != valueEndChar) { + ++i; + } + + } else { + valueEndChar = ';'; + valueBeginIndex = i; + + while (i < header.length && header[i] != valueEndChar) { + ++i; + } + } + + + int valueEndIndex = i; + + // Remove trailing whitespace + while (valueEndIndex > valueBeginIndex && Character.isWhitespace(header[valueEndIndex - 1])) { + --valueEndIndex; + } + + value = new String(header, valueBeginIndex, valueEndIndex - valueBeginIndex); + + // We're done parsing the value, continue to the next character + ++i; + + // Skip whitespace between end of quotation and the semicolon following the quotation. + if (valueEndChar == '\"') { + while (i < header.length && header[i] != ';') { + if (!Character.isWhitespace(header[i])) { + throw new ParseException("Invalid cookie: Missing terminating semicolon after value quotation: " + httpHeader, i); + } + ++i; + } + + // We found the semicolon, skip it ++i; } - - // We found the semicolon, skip it - ++i; - } - - } - else - value = ""; - - // RFC2965: Name MUST be first. Anything key besides the name of the cookie begins with $. The next cookie begins if a key occurs which is not - // prefixed with $. - - if(currentCookieName == null) { // We have not found the name yet, the first key/value pair must be the name and the value of the cookie. - if(key.charAt(0) == '$') { - // We cannot throw because Konqueror (4.2.2) is broken and specifies $version as the first attribute. - //throw new IllegalArgumentException("Invalid cookie: Name is not the first attribute: " + httpHeader); - - currentCookieContent.put(key, value); - } else { - currentCookieName = key; - currentCookieContent.put(currentCookieName, value); - } - } else { - if(key.charAt(0) == '$') - currentCookieContent.put(key, value); - else {// We finished parsing of the current cookie, a new one starts here. - //if(singleCookie) - // throw new ParseException("Invalid cookie header: Multiple cookies specified but " - // + " the name of the first cookie was not the first attribute: " + httpHeader, i); - - cookies.add(new ReceivedCookie(currentCookieName, currentCookieContent)); // Store the previous cookie. - - currentCookieName = key; - currentCookieContent = new Hashtable(16); - currentCookieContent.put(currentCookieName, value); - } - } - } - } - catch(ArrayIndexOutOfBoundsException e) { - ParseException p = new ParseException("Index out of bounds (" + e.getMessage() + ") for cookie " + httpHeader, 0); - p.setStackTrace(e.getStackTrace()); - throw p; - } - - // Store the last cookie (the loop only stores the current cookie when a new one starts). - if(currentCookieName != null) - cookies.add(new ReceivedCookie(currentCookieName, currentCookieContent)); - - return cookies; - } - - - /** - * @throws IllegalArgumentException If the validation of the name fails. - */ - @Override - public String getName() { - if(name == null) { - name = validateName(notValidatedName); - notValidatedName = null; - } - - return name; - } - - /** - * @throws IllegalArgumentException If the validation of the domain fails. - */ - @Override - public URI getDomain() { - if(domain == null) { - try { - String domainString = content.get("$domain"); - if(domainString == null) - return null; - - domain = validateDomain(domainString); - } catch (URISyntaxException e) { - throw new IllegalArgumentException(e); - } + } + + cookies.add(new ReceivedCookie(key, value)); + key = null; + value = null; + } + } catch (ArrayIndexOutOfBoundsException e) { + ParseException p = new ParseException("Index out of bounds (" + e.getMessage() + ") for cookie " + httpHeader, 0); + p.setStackTrace(e.getStackTrace()); + throw p; + } + + // Store the last cookie (the loop only stores the current cookie when a new one starts). + if (key != null) { + cookies.add(new ReceivedCookie(key, value)); + } + + return cookies; + } + + /** + * @return cookie name + */ + public String getName() { + return name; + } + + /** + * @return cookie value, may be {@code null} or empty + */ + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; } - - return domain; - } - - /** - * @throws IllegalArgumentException If the validation of the path fails. - */ - @Override - public URI getPath() { - if(path == null) { - try { - path = validatePath(content.get("$path")); - } catch (URISyntaxException e) { - throw new IllegalArgumentException(e); - } + if (o == null || getClass() != o.getClass()) { + return false; } - - return path; - } - - /** - * @throws IllegalArgumentException If the validation of the name fails. - */ - @Override - public String getValue() { - if(value == null) - value = validateValue(content.get(getName())); - - return value; - } - -// TODO: This is broken because TimeUtil.parseHTTPDate() does not work. -// public Date getExpirationDate() { -// if(expirationDate == null) { -// try { -// expirationDate = validateExpirationDate(TimeUtil.parseHTTPDate(content.get("$expires"))); -// } catch (ParseException e) { -// throw new IllegalArgumentException(e); -// } -// } -// -// return expirationDate; -// } - - @Override - protected String encodeToHeaderValue() { - throw new UnsupportedOperationException("ReceivedCookie objects cannot be encoded to a HTTP header value, use Cookie objects!"); - } - + ReceivedCookie that = (ReceivedCookie) o; + return name.equals(that.name) && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } } diff --git a/src/freenet/clients/http/SessionManager.java b/src/freenet/clients/http/SessionManager.java index 78029af6c15..e492a42a243 100644 --- a/src/freenet/clients/http/SessionManager.java +++ b/src/freenet/clients/http/SessionManager.java @@ -8,6 +8,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.text.ParseException; +import java.time.Instant; import java.util.Date; import java.util.Enumeration; import java.util.HashMap; @@ -23,407 +24,418 @@ /** * A basic session manager for cookie-based HTTP session. - * It allows its parent web interface to associate a "UserID" (a string) with a session ID. - * + * It allows its parent web interface to associate a "UserID" (a string) with a session ID. + *

* Formal definition of a SessionManager: * A 1:1 mapping of SessionID to UserID. Queries by SessionID and UserID run in O(1). * The Session ID primary key consists of: Cookie path + Cookie name + random "actual" session ID * The user ID is received from the client application. - * + *

* Therefore, when multiple client applications want to store sessions, each one is supposed * to create its own SessionManager because user IDs might overlap. - * + *

* The sessions of each application then get their {@link Session} by using a different * cookie path OR a different cookie namespace, depending on which constructor you use. - * + *

* Paths are used when client applications do NOT share the same path on the server. * For example "/Chat" would cause the browser to only send back the cookie if the user is - * browsing "/Chat/", not for "/". - * BUT usually we want the menu contents of client applications to be in the logged-in state even if - * the user is NOT browsing the client application web interface right now, therefore the "/" path + * browsing "/Chat/", not for "/". + * BUT usually we want the menu contents of client applications to be in the logged-in state even if + * the user is NOT browsing the client application web interface right now, therefore the "/" path * must be used in most cases. * If client application cookies shall be received from all paths on the server, the client * application should use the constructor which requires a cookie namespace. - * - * The usage of a namespace gurantees that Sessions of different client applications do not overlap. - * + *

+ * The usage of a namespace guarantees that Sessions of different client applications do not overlap. + * * @author xor (xor@freenetproject.org) */ public final class SessionManager { - /** - * The amount of milliseconds after which a session is deleted due to expiration. - */ - public static final long MAX_SESSION_IDLE_TIME = HOURS.toMillis(1); - - public static final String SESSION_COOKIE_NAME = "SessionID"; - - private final URI mCookiePath; - private final String mCookieNamespace; - private final String mCookieName; - - /** - * Constructs a new session manager for use with the given cookie path. - * Cookies are only sent back if the user is browsing the domain within that path. - * - * @param myCookiePath The path in which the cookies should be valid. - */ - public SessionManager(URI myCookiePath) { - if(myCookiePath.isAbsolute()) - throw new IllegalArgumentException("Illegal cookie path, must be relative: " + myCookiePath); - - if(myCookiePath.toString().startsWith("/") == false) - throw new IllegalArgumentException("Illegal cookie path, must start with /: " + myCookiePath); - - // FIXME: The new constructor was written at 2010-11-15. Uncomment the following safety check after we gave plugins some time to migrate - // if(myCookiePath.getPath().equals("/")) - // throw new IllegalArgumentException("Illegal cookie path '/'. You should use the constructor which allows the specification" + - // "of a namespace for using the global path."); - - - // TODO: Add further checks. - - //mCookieDomain = myCookieDomain; - mCookiePath = myCookiePath; - mCookieNamespace = ""; - mCookieName = SESSION_COOKIE_NAME; - } - - /** - * Constructs a new session manager for use with the "/" cookie path - * - * @param myCookieNamespace The name of the client application which uses this cookie. Must not be empty. Must be latin letters and numbers only. - */ - public SessionManager(String myCookieNamespace) { - if(myCookieNamespace.length() == 0) - throw new IllegalArgumentException("You must specify a cookie namespace or use the constructor " + - "which allows specification of a cookie path."); - - if(!StringValidityChecker.isLatinLettersAndNumbersOnly(myCookieNamespace)) - throw new IllegalArgumentException("The cookie namespace must be latin letters and numbers only."); - - //mCookieDomain = myCookieDomain; - try { - mCookiePath = new URI("/"); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - mCookieNamespace = myCookieNamespace; - mCookieName = myCookieNamespace + SESSION_COOKIE_NAME; - } - - - public static final class Session { - - private final UUID mID; - private final String mUserID; - private final Map mAttributes = new HashMap(); - - private long mExpiresAtTime; - - private Session(String myUserID, long currentTime) { - mID = UUID.randomUUID(); - mUserID = myUserID; - mExpiresAtTime = currentTime + SessionManager.MAX_SESSION_IDLE_TIME; - } - - @Override - public boolean equals(Object obj) { - if(obj == null) return false; - if(!(obj instanceof Session)) return false; - Session other = ((Session)obj); - return other.getID().equals(mID); - } - - @Override - public int hashCode() { - return mID.hashCode(); - } - - public UUID getID() { - return mID; - } - - public String getUserID() { - return mUserID; - } - - private long getExpirationTime() { - return mExpiresAtTime; - } - - private boolean isExpired(long time) { - return time >= mExpiresAtTime; - } - - private void updateExpiresAtTime(long currentTime) { - mExpiresAtTime = currentTime + SessionManager.MAX_SESSION_IDLE_TIME; - } - - /** - * Returns whether this session contains an attribute with the given - * name. - * - * @param name - * The name of the attribute to check for - * @return {@code true} if this session contains an attribute with the - * given name, {@code false} otherwise - */ - public boolean hasAttribute(String name) { - return mAttributes.containsKey(name); - } - - /** - * Returns the value of the attribute with the given name. If there is - * no attribute with the given name, {@code null} is returned. - * - * @param name - * The name of the attribute whose value to get - * @return The value of the attribute, or {@code null} - */ - public Object getAttribute(String name) { - return mAttributes.get(name); - } - - /** - * Sets the value of the attribute with the given name. - * - * @param name - * The name of the attribute whose value to set - * @param value - * The new value of the attribute - */ - public void setAttribute(String name, Object value) { - mAttributes.put(name, value); - } - - /** - * Removes the attribute with the given name. Nothing will happen if - * there is no attribute with the given name. - * - * @param name - * The name of the attribute to remove - */ - public void removeAttribute(String name) { - mAttributes.remove(name); - } - - /** - * Returns the names of all currently existing attributes. - * - * @return The names of all attributes - */ - public Set getAttributeNames() { - return mAttributes.keySet(); - } - - } - - private final LRUMap mSessionsByID = new LRUMap(); - private final Hashtable mSessionsByUserID = new Hashtable(); - - - /** - * Returns the cookie path as specified in the constructor. - * Returns "/" if the constructor which only requires a namespace was used. - */ - public URI getCookiePath() { - return mCookiePath; - } - - - /** - * Returns the namespace as specified in the constructor. - * Returns an empty string if the constructor which requires a cookie path only was used. - */ - public String getCookieNamespace() { - return mCookieNamespace; - } - - - /** - * Creates a new session for the given user ID. - * - * If a session for the given user ID already exists, it is deleted. It is not re-used to ensure that parallel logins with the same user account from - * different computers do not work. - * - * @param context The ToadletContext in which the session cookie shall be stored. - */ - public synchronized Session createSession(String userID, ToadletContext context) { - // We must synchronize around the fetching of the time and mSessionsByID.push() because mSessionsByID is no sorting data structure: It's a plain - // LRUMap so to ensure that it stays sorted the operation "getTime(); push();" must be atomic. - long time = CurrentTimeUTC.getInMillis(); - - removeExpiredSessions(time); - - deleteSessionByUserID(userID); - - Session session = new Session(userID, time); - mSessionsByID.push(session.getID(), session); - mSessionsByUserID.put(session.getUserID(), session); - - setSessionCookie(session, context); - - return session; - } - - /** - * Returns true if the given {@link ToadletContext} contains a session cookie for a valid (existing and not expired) session. - * - * In opposite to {@link getSessionUserID}, this function does NOT extend the validity of the session. - * Therefore, this function can be considered as a way of peeking for a session, to decide which Toadlet links should be visible. - */ - public synchronized boolean sessionExists(ToadletContext context) { - UUID sessionID = getSessionID(context); - - if(sessionID == null) - return false; - - removeExpiredSessions(CurrentTimeUTC.getInMillis()); - - return mSessionsByID.containsKey(sessionID); - } - - /** - * Retrieves the session ID from the session cookie in the given {@link ToadletContext}, checks if it contains a valid (existing and not expired) session - * and if yes, returns the {@link Session}. - * - * If the session was valid, then its validity is extended by {@link MAX_SESSION_IDLE_TIME}. - * - * If the session did not exist or is not valid anymore, null is returned. - */ - public synchronized Session useSession(ToadletContext context) { - UUID sessionID = getSessionID(context); - if(sessionID == null) - return null; - - // We must synchronize around the fetching of the time and mSessionsByID.push() because mSessionsByID is no sorting data structure: It's a plain - // LRUMap so to ensure that it stays sorted the operation "getTime(); push();" must be atomic. - long time = CurrentTimeUTC.getInMillis(); - - removeExpiredSessions(time); - - Session session = mSessionsByID.get(sessionID); - - if(session == null) - return null; - - - session.updateExpiresAtTime(time); - mSessionsByID.push(session.getID(), session); - - setSessionCookie(session, context); - - return session; - } - - /** - * Retrieves the session ID from the session cookie in the given {@link ToadletContext}, checks if it contains a valid (existing and not expired) session - * and if yes, deletes the session. - * - * @return True if the session was deleted, false if there was no session cookie or no session. - */ - public boolean deleteSession(ToadletContext context) { - UUID sessionID = getSessionID(context); - if(sessionID == null) - return false; - - return deleteSession(sessionID); - } - - /** - * @return Returns the session ID stored in the cookies of the HTTP headers of the given {@link ToadletContext}. Returns null if there is no session ID stored. - */ - private UUID getSessionID(ToadletContext context) { - if(context == null) - return null; - - try { - ReceivedCookie sessionCookie = context.getCookie(null, mCookiePath, mCookieName); - - return sessionCookie == null ? null : UUID.fromString(sessionCookie.getValue()); - } catch(ParseException e) { - Logger.error(this, "Getting session cookie failed", e); - return null; - } catch(IllegalArgumentException e) { - Logger.error(this, "Getting the value of the session cookie failed", e); - return null; - } - } - - /** - * Stores a session cookie for the given session in the given {@link ToadletContext}'s HTTP headers. - * @param session - * @param context - */ - private void setSessionCookie(Session session, ToadletContext context) { - context.setCookie(new Cookie(mCookiePath, mCookieName, session.getID().toString(), new Date(session.getExpirationTime()))); - } - - /** - * Deletes the session with the given ID. - * - * @return True if a session with the given ID existed. - */ - private synchronized boolean deleteSession(UUID sessionID) { - Session session = mSessionsByID.get(sessionID); - - if(session == null) - return false; - - mSessionsByID.removeKey(sessionID); - mSessionsByUserID.remove(session.getUserID()); - return true; - } - - /** - * Deletes the session associated with the given user ID. - * - * @return True if a session with the given ID existed. - */ - private synchronized boolean deleteSessionByUserID(String userID) { - Session session = mSessionsByUserID.remove(userID); - if(session == null) - return false; - - mSessionsByID.removeKey(session.getID()); - return true; - } - - /** - * Garbage-collects any expired sessions. Must be called before client-inteface functions do anything which relies on the existence a session, - * that is: creating sessions, using sessions or checking whether sessions exist. - * - * FIXME: Before putting the session manager into fred, write a thread which periodically garbage collects old sessions - currently, sessions - * will only be garbage collected if any client continues using the SessiomManager - * - * @param time The current time. - */ - private synchronized void removeExpiredSessions(long time) { - for(Session session = mSessionsByID.peekValue(); session != null && session.isExpired(time); session = mSessionsByID.peekValue()) { - mSessionsByID.popValue(); - mSessionsByUserID.remove(session.getUserID()); - } - - // FIXME: Execute every few hours only. - verifySessionsByUserIDTable(); - } - - /** - * Debug function which checks whether the sessions by user ID table does not contain any sessions which do not exist anymore; - */ - private synchronized void verifySessionsByUserIDTable() { - - Enumeration sessions = mSessionsByUserID.elements(); - while(sessions.hasMoreElements()) { - Session session = sessions.nextElement(); - - if(mSessionsByID.containsKey(session.getID()) == false) { - Logger.error(this, "Sessions by user ID hashtable contains deleted session, removing it: " + session); - - mSessionsByUserID.remove(session.getUserID()); - } - } - } + /** + * The amount of milliseconds after which a session is deleted due to expiration. + */ + public static final long MAX_SESSION_IDLE_TIME = HOURS.toMillis(1); + + public static final String SESSION_COOKIE_NAME = "SessionID"; + + private final LRUMap mSessionsByID = new LRUMap<>(); + private final Map mSessionsByUserID = new HashMap<>(); + + private final URI mCookiePath; + private final String mCookieNamespace; + private final String mCookieName; + + /** + * Constructs a new session manager for use with the given cookie path. + * Cookies are only sent back if the user is browsing the domain within that path. + * + * @param myCookiePath The path in which the cookies should be valid. + */ + public SessionManager(URI myCookiePath) { + if (myCookiePath.isAbsolute()) { + throw new IllegalArgumentException("Illegal cookie path, must be relative: " + myCookiePath); + } + + if (!myCookiePath.toString().startsWith("/")) { + throw new IllegalArgumentException("Illegal cookie path, must start with /: " + myCookiePath); + } + + // FIXME: The new constructor was written at 2010-11-15. Uncomment the following safety check after we gave plugins some time to migrate + // if(myCookiePath.getPath().equals("/")) + // throw new IllegalArgumentException("Illegal cookie path '/'. You should use the constructor which allows the specification" + + // "of a namespace for using the global path."); + + + // TODO: Add further checks. + + //mCookieDomain = myCookieDomain; + mCookiePath = myCookiePath; + mCookieNamespace = ""; + mCookieName = SESSION_COOKIE_NAME; + } + + /** + * Constructs a new session manager for use with the "/" cookie path + * + * @param myCookieNamespace The name of the client application which uses this cookie. Must not be empty. Must be latin letters and numbers only. + */ + public SessionManager(String myCookieNamespace) { + if (myCookieNamespace.length() == 0) { + throw new IllegalArgumentException("You must specify a cookie namespace or use the constructor " + + "which allows specification of a cookie path."); + } + + if (!StringValidityChecker.isLatinLettersAndNumbersOnly(myCookieNamespace)) { + throw new IllegalArgumentException("The cookie namespace must be latin letters and numbers only."); + } + + try { + mCookiePath = new URI("/"); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + mCookieNamespace = myCookieNamespace; + mCookieName = myCookieNamespace + SESSION_COOKIE_NAME; + } + + + public static final class Session { + + private final UUID mID; + private final String mUserID; + private final Map mAttributes = new HashMap<>(); + + private long mExpiresAtTime; + + private Session(String myUserID, long currentTime) { + mID = UUID.randomUUID(); + mUserID = myUserID; + mExpiresAtTime = currentTime + SessionManager.MAX_SESSION_IDLE_TIME; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (!(obj instanceof Session)) { + return false; + } + Session other = ((Session) obj); + return other.getID().equals(mID); + } + + @Override + public int hashCode() { + return mID.hashCode(); + } + + public UUID getID() { + return mID; + } + + public String getUserID() { + return mUserID; + } + + private long getExpirationTime() { + return mExpiresAtTime; + } + + private boolean isExpired(long time) { + return time >= mExpiresAtTime; + } + + private void updateExpiresAtTime(long currentTime) { + mExpiresAtTime = currentTime + SessionManager.MAX_SESSION_IDLE_TIME; + } + + /** + * Returns whether this session contains an attribute with the given + * name. + * + * @param name The name of the attribute to check for + * @return {@code true} if this session contains an attribute with the + * given name, {@code false} otherwise + */ + public boolean hasAttribute(String name) { + return mAttributes.containsKey(name); + } + + /** + * Returns the value of the attribute with the given name. If there is + * no attribute with the given name, {@code null} is returned. + * + * @param name The name of the attribute whose value to get + * @return The value of the attribute, or {@code null} + */ + public Object getAttribute(String name) { + return mAttributes.get(name); + } + + /** + * Sets the value of the attribute with the given name. + * + * @param name The name of the attribute whose value to set + * @param value The new value of the attribute + */ + public void setAttribute(String name, Object value) { + mAttributes.put(name, value); + } + + /** + * Removes the attribute with the given name. Nothing will happen if + * there is no attribute with the given name. + * + * @param name The name of the attribute to remove + */ + public void removeAttribute(String name) { + mAttributes.remove(name); + } + + /** + * Returns the names of all currently existing attributes. + * + * @return The names of all attributes + */ + public Set getAttributeNames() { + return mAttributes.keySet(); + } + + } + + + /** + * Returns the cookie path as specified in the constructor. + * Returns "/" if the constructor which only requires a namespace was used. + */ + public URI getCookiePath() { + return mCookiePath; + } + + + /** + * Returns the namespace as specified in the constructor. + * Returns an empty string if the constructor which requires a cookie path only was used. + */ + public String getCookieNamespace() { + return mCookieNamespace; + } + + + /** + * Creates a new session for the given user ID. + *

+ * If a session for the given user ID already exists, it is deleted. It is not re-used to ensure that parallel logins with the same user account from + * different computers do not work. + * + * @param context The ToadletContext in which the session cookie shall be stored. + */ + public synchronized Session createSession(String userID, ToadletContext context) { + // We must synchronize around the fetching of the time and mSessionsByID.push() because mSessionsByID is no sorting data structure: It's a plain + // LRUMap so to ensure that it stays sorted the operation "getTime(); push();" must be atomic. + long time = CurrentTimeUTC.getInMillis(); + + removeExpiredSessions(time); + + deleteSessionByUserID(userID); + + Session session = new Session(userID, time); + mSessionsByID.push(session.getID(), session); + mSessionsByUserID.put(session.getUserID(), session); + + setSessionCookie(session, context); + + return session; + } + + /** + * Returns true if the given {@link ToadletContext} contains a session cookie for a valid (existing and not expired) session. + *

+ * In opposite to {@link #useSession}, this function does NOT extend the validity of the session. + * Therefore, this function can be considered as a way of peeking for a session, to decide which Toadlet links should be visible. + */ + public synchronized boolean sessionExists(ToadletContext context) { + UUID sessionID = getSessionID(context); + + if (sessionID == null) { + return false; + } + + removeExpiredSessions(CurrentTimeUTC.getInMillis()); + + return mSessionsByID.containsKey(sessionID); + } + + /** + * Retrieves the session ID from the session cookie in the given {@link ToadletContext}, checks if it contains a valid (existing and not expired) session + * and if yes, returns the {@link Session}. + *

+ * If the session was valid, then its validity is extended by {@link #MAX_SESSION_IDLE_TIME}. + *

+ * If the session did not exist or is not valid anymore, null is returned. + */ + public synchronized Session useSession(ToadletContext context) { + UUID sessionID = getSessionID(context); + if (sessionID == null) { + return null; + } + + // We must synchronize around the fetching of the time and mSessionsByID.push() because mSessionsByID is no sorting data structure: It's a plain + // LRUMap so to ensure that it stays sorted the operation "getTime(); push();" must be atomic. + long time = CurrentTimeUTC.getInMillis(); + + removeExpiredSessions(time); + + Session session = mSessionsByID.get(sessionID); + if (session == null) { + return null; + } + + session.updateExpiresAtTime(time); + mSessionsByID.push(session.getID(), session); + + setSessionCookie(session, context); + + return session; + } + + /** + * Retrieves the session ID from the session cookie in the given {@link ToadletContext}, checks if it contains a valid (existing and not expired) session + * and if yes, deletes the session. + * + * @return True if the session was deleted, false if there was no session cookie or no session. + */ + public boolean deleteSession(ToadletContext context) { + UUID sessionID = getSessionID(context); + if (sessionID == null) { + return false; + } + + return deleteSession(sessionID); + } + + /** + * @return Returns the session ID stored in the cookies of the HTTP headers of the given {@link ToadletContext}. Returns null if there is no session ID stored. + */ + private UUID getSessionID(ToadletContext context) { + if (context == null) { + return null; + } + try { + ReceivedCookie sessionCookie = context.getCookie(null, mCookiePath, mCookieName); + if (sessionCookie == null) { + return null; + } + return UUID.fromString(sessionCookie.getValue()); + } catch (ParseException e) { + Logger.error(this, "Getting session cookie failed", e); + return null; + } catch (IllegalArgumentException e) { + Logger.error(this, "Getting the value of the session cookie failed", e); + return null; + } + } + + /** + * Stores a session cookie for the given session in the given {@link ToadletContext}'s HTTP headers. + * + * @param session + * @param context + */ + private void setSessionCookie(Session session, ToadletContext context) { + context.setCookie( + new Cookie( + mCookiePath, + mCookieName, + session.getID().toString(), + Instant.ofEpochMilli(session.getExpirationTime()) + ) + ); + } + + /** + * Deletes the session with the given ID. + * + * @return True if a session with the given ID existed. + */ + private synchronized boolean deleteSession(UUID sessionID) { + Session session = mSessionsByID.get(sessionID); + if (session == null) { + return false; + } + + mSessionsByID.removeKey(sessionID); + mSessionsByUserID.remove(session.getUserID()); + return true; + } + + /** + * Deletes the session associated with the given user ID. + * + * @return True if a session with the given ID existed. + */ + private synchronized boolean deleteSessionByUserID(String userID) { + Session session = mSessionsByUserID.remove(userID); + if (session == null) { + return false; + } + + mSessionsByID.removeKey(session.getID()); + return true; + } + + /** + * Garbage-collects any expired sessions. Must be called before client-interface functions do anything which relies on the existence a session, + * that is: creating sessions, using sessions or checking whether sessions exist. + *

+ * FIXME: Before putting the session manager into fred, write a thread which periodically garbage collects old sessions - currently, sessions + * will only be garbage collected if any client continues using the SessionManager + * + * @param time The current time. + */ + private synchronized void removeExpiredSessions(long time) { + for (Session session = mSessionsByID.peekValue(); session != null && session.isExpired(time); session = mSessionsByID.peekValue()) { + mSessionsByID.popValue(); + mSessionsByUserID.remove(session.getUserID()); + } + + // FIXME: Execute every few hours only. + verifySessionsByUserIDTable(); + } + + /** + * Debug function which checks whether the sessions by user ID table does not contain any sessions which do not exist anymore; + */ + private synchronized void verifySessionsByUserIDTable() { + for (Session session : mSessionsByUserID.values()) { + if (!mSessionsByID.containsKey(session.getID())) { + Logger.error(this, "Sessions by user ID hashtable contains deleted session, removing it: " + session); + + mSessionsByUserID.remove(session.getUserID()); + } + } + } } diff --git a/src/freenet/clients/http/ToadletContextImpl.java b/src/freenet/clients/http/ToadletContextImpl.java index f3518a72fe0..4739d97de21 100644 --- a/src/freenet/clients/http/ToadletContextImpl.java +++ b/src/freenet/clients/http/ToadletContextImpl.java @@ -12,16 +12,10 @@ import java.net.Socket; import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.Enumeration; -import java.util.List; -import java.util.Locale; -import java.util.StringJoiner; -import java.util.TimeZone; +import java.util.*; import freenet.clients.http.FProxyFetchInProgress.REFILTER_POLICY; import freenet.clients.http.annotation.AllowData; @@ -54,7 +48,7 @@ */ public class ToadletContextImpl implements ToadletContext { - private static final Class HANDLE_PARAMETERS[] = new Class[] { + private static final Class[] HANDLE_PARAMETERS = new Class[] { URI.class, HTTPRequest.class, ToadletContext.class }; @@ -81,7 +75,7 @@ public class ToadletContextImpl implements ToadletContext { /** The unique id of the request*/ private final String uniqueId; - private URI uri; + private final URI uri; private static volatile boolean logMINOR; private static volatile boolean logDEBUG; @@ -101,7 +95,7 @@ public void shouldUpdate(){ private boolean closed; private boolean shouldDisconnect; - public ToadletContextImpl(Socket sock, MultiValueTable headers, BucketFactory bf, PageMaker pageMaker, ToadletContainer container, UserAlertManager userAlertManager, BookmarkManager bookmarkManager, URI uri, long uniqueID) throws IOException { + public ToadletContextImpl(Socket sock, MultiValueTable headers, BucketFactory bf, PageMaker pageMaker, ToadletContainer container, UserAlertManager userAlertManager, BookmarkManager bookmarkManager, URI uri) throws IOException { this.headers = headers; this.cookies = null; this.replyCookies = null; @@ -117,16 +111,18 @@ public ToadletContextImpl(Socket sock, MultiValueTable headers, B this.userAlertManager = userAlertManager; this.bookmarkManager = bookmarkManager; //Generate an unique id - uniqueId=String.valueOf(Math.random()); + this.uniqueId = String.valueOf(Math.random()); } private void close() { closed = true; } - private void sendMethodNotAllowed(String method, boolean shouldDisconnect) throws ToadletContextClosedException, IOException { - if(closed) throw new ToadletContextClosedException(); - MultiValueTable mvt = new MultiValueTable(); + private void sendMethodNotAllowed(boolean shouldDisconnect) throws ToadletContextClosedException, IOException { + if(closed) { + throw new ToadletContextClosedException(); + } + MultiValueTable mvt = new MultiValueTable<>(); mvt.put("Allow", "GET, PUT"); sendError(sockOutputStream, 405, "Method Not Allowed", l10n("methodNotAllowed"), shouldDisconnect, mvt); } @@ -159,8 +155,10 @@ private static void sendError(OutputStream os, int code, String httpReason, Stri * @throws IOException If we could not send the error message. */ private static void sendHTMLError(OutputStream os, int code, String httpReason, String htmlMessage, boolean disconnect, MultiValueTable mvt) throws IOException { - if(mvt == null) mvt = new MultiValueTable(); - byte[] messageBytes = htmlMessage.getBytes("UTF-8"); + if(mvt == null) { + mvt = new MultiValueTable<>(); + } + byte[] messageBytes = htmlMessage.getBytes(StandardCharsets.UTF_8); sendReplyHeaders(os, code, httpReason, mvt, "text/html; charset=UTF-8", messageBytes.length, null, disconnect, false, false); os.write(messageBytes); } @@ -175,7 +173,7 @@ private static void sendURIParseError(OutputStream os, boolean shouldDisconnect, PrintWriter pw = new PrintWriter(sw); e.printStackTrace(pw); pw.close(); - String message = ""+l10n("uriParseErrorTitle")+"

"+HTMLEncoder.encode(e.getMessage())+"

\n"+sw.toString();
+		String message = ""+l10n("uriParseErrorTitle")+"

"+HTMLEncoder.encode(e.getMessage())+"

\n" + sw;
 		sendHTMLError(os, 400, "Bad Request", message, shouldDisconnect, null);
 	}
 
@@ -203,14 +201,14 @@ public void sendReplyHeadersStatic(int replyCode, String replyDescription, Multi
 	
 	@Override
 	public void sendReplyHeadersFProxy(int replyCode, String replyDescription, MultiValueTable mvt, String mimeType, long contentLength) throws ToadletContextClosedException, IOException {
-	    boolean enableJavascript = false;
-	    if(container.isFProxyWebPushingEnabled() && container.isFProxyJavascriptEnabled())
-	        enableJavascript = true;
-	    sendReplyHeaders(replyCode, replyDescription, mvt, mimeType, contentLength, null, false, true, enableJavascript);
+	    boolean enableJavascript = container.isFProxyWebPushingEnabled() && container.isFProxyJavascriptEnabled();
+		sendReplyHeaders(replyCode, replyDescription, mvt, mimeType, contentLength, null, false, true, enableJavascript);
 	}
 	
 	private void sendReplyHeaders(int replyCode, String replyDescription, MultiValueTable mvt, String mimeType, long contentLength, Date mTime, boolean isOutlinkConfirmationPage, boolean allowFrames, boolean enableJavascript) throws ToadletContextClosedException, IOException {
-		if(closed) throw new ToadletContextClosedException();
+		if(closed) {
+			throw new ToadletContextClosedException();
+		}
 		if(firstReplySendingException != null) {
 			throw new IllegalStateException("Already sent headers!", firstReplySendingException);
 		}
@@ -218,7 +216,7 @@ private void sendReplyHeaders(int replyCode, String replyDescription, MultiValue
 		
 		if(replyCookies != null) {
 			if (mvt == null) {
-				mvt = new MultiValueTable();
+				mvt = new MultiValueTable<>();
 			}
 			
 			// We do NOT use "set-cookie2" even though we should according though RFC2965 - Firefox 3.0.14 ignores it for me!
@@ -226,8 +224,9 @@ private void sendReplyHeaders(int replyCode, String replyDescription, MultiValue
 			for(Cookie cookie : replyCookies) {
 				final String cookieHeader = cookie.encodeToHeaderValue();
 				mvt.put("set-cookie", cookieHeader);
-				if(logMINOR)
+				if(logMINOR) {
 					Logger.minor(this, "set-cookie: " + cookieHeader);
+				}
 			}
 		}
 		sendReplyHeaders(sockOutputStream, replyCode, replyDescription, mvt, mimeType, contentLength, mTime, shouldDisconnect, enableJavascript, allowFrames);
@@ -253,7 +252,7 @@ public boolean checkFormPassword(HTTPRequest request)
 	public boolean checkFormPassword(HTTPRequest request, String redirectTo)
 			throws ToadletContextClosedException, IOException {
 		if (!hasFormPassword(request)) {
-			MultiValueTable headers = new MultiValueTable();
+			MultiValueTable headers = new MultiValueTable<>();
 			headers.put("Location", redirectTo);
 			sendReplyHeaders(302, "Found", headers, null, 0);
 			return false;
@@ -276,15 +275,18 @@ public boolean checkFullAccess(Toadlet toadlet) throws ToadletContextClosedExcep
     }
 	
 	@Override
-	public boolean hasFormPassword(HTTPRequest request) throws IOException {
+	public boolean hasFormPassword(HTTPRequest request) {
 		String pass = request.getPartAsStringFailsafe("formPassword", 32);
-		byte[] inputBytes = pass.getBytes("UTF-8");
-		byte[] compareBytes = getFormPassword().getBytes("UTF-8");
+		byte[] inputBytes = pass.getBytes(StandardCharsets.UTF_8);
+		byte[] compareBytes = getFormPassword().getBytes(StandardCharsets.UTF_8);
 		if(!MessageDigest.isEqual(inputBytes, compareBytes)) {
-			if (logMINOR)
+			if (logMINOR) {
 				Logger.minor(this, "Bad formPassword: " + pass);
+			}
 			return false;
-		} else return true;
+		} else {
+			return true;
+		}
 	}
 	
 	@Override
@@ -303,18 +305,23 @@ public MultiValueTable getHeaders() {
 	}
 	
 	private void parseCookies() throws ParseException {
-		if(cookies != null)
+		if(cookies != null) {
 			return;
+		}
 		
 		int cookieAmount = headers.countAll("cookie");
 		
-		if(cookieAmount == 0)
+		if(cookieAmount == 0) {
 			return;
+		}
 		
-		cookies = new ArrayList(cookieAmount + 1);
+		cookies = new ArrayList<>(cookieAmount + 1);
 		
 		for(String cookieHeader : headers.iterateAll("cookie")) {
-			ArrayList parsedCookies = ReceivedCookie.parseHeader(cookieHeader);
+			if(logMINOR) {
+				Logger.minor(this, "Received HTTP cookie header:" + cookieHeader);
+			}
+			List parsedCookies = ReceivedCookie.parseHeader(cookieHeader);
 			cookies.addAll(parsedCookies);
 		}
 	}
@@ -328,31 +335,15 @@ public ReceivedCookie getCookie(URI domain, URI path, String name) throws ParseE
 		
 		name = name.toLowerCase();
 		
-		//String stringDomain = domain==null ? null : domain.toString().toLowerCase();
-		//String stringPath = path.toString();
-		
+
 		// RFC2965: Two cookies are equal if name and domain are equal with case-insensitive comparison and path is equal with case-sensitive comparison.
 		//getName() / getDomain() returns lowercase and getPath() returns the original path.
 		
 		// UNFORTUNATELY firefox will ONLY give us the name and the value of the cookie, so we ignore everything else.
 		
 		for(ReceivedCookie cookie : cookies) {
-			try {
-			//if(stringDomain != null) {
-			//	URI cookieDomain = cookie.getDomain();
-			//	
-			//	if(cookieDomain==null || !stringDomain.equals(cookieDomain.toString()))
-			//		continue;
-			//}
-			//
-			//if(cookie.getPath().toString().equals(stringPath) && cookie.getName().equals(name))
-			//	return cookie;
-				
-				if(cookie.getName().equals(name))
-					return cookie;
-			}
-			catch(RuntimeException e) {
-				Logger.error(this, "Error in cookie", e);
+			if(cookie.getName().equalsIgnoreCase(name)) {
+				return cookie;
 			}
 		}
 		
@@ -361,8 +352,9 @@ public ReceivedCookie getCookie(URI domain, URI path, String name) throws ParseE
 	
 	@Override
 	public void setCookie(Cookie newCookie) {
-		if(replyCookies == null)
-			replyCookies = new ArrayList(4);
+		if(replyCookies == null) {
+			replyCookies = new ArrayList<>(4);
+		}
 		
 		replyCookies.add(newCookie);
 	}
@@ -370,31 +362,31 @@ public void setCookie(Cookie newCookie) {
 	static void sendReplyHeaders(OutputStream sockOutputStream, int replyCode, String replyDescription, MultiValueTable mvt, String mimeType, long contentLength, Date mTime, boolean disconnect, boolean allowScripts, boolean allowFrames) throws IOException {
 		
 		// Construct headers
-		if(mvt == null)
-			mvt = new MultiValueTable();
-		if(mimeType != null)
-			if(mimeType.equalsIgnoreCase("text/html")){
-				mvt.put("content-type", mimeType+"; charset=UTF-8");
-			}else{
+		if(mvt == null) {
+			mvt = new MultiValueTable<>();
+		}
+		if(mimeType != null) {
+			if (mimeType.equalsIgnoreCase("text/html")) {
+				mvt.put("content-type", mimeType + "; charset=UTF-8");
+			} else {
 				mvt.put("content-type", mimeType);
 			}
-		if(contentLength >= 0)
+		}
+		if(contentLength >= 0) {
 			mvt.put("content-length", Long.toString(contentLength));
-
-		boolean allowCaching; // For privacy reasons, only static
-							  // content may be cached
-		if (mTime == null) {
-			allowCaching = false;
-		} else {
-			allowCaching = true;
 		}
+
+		// For privacy reasons, only static
+		// content may be cached
+		boolean allowCaching = mTime != null;
+
 		String expiresTime;
 		String cacheControl;
 		if (allowCaching) {
 			// use an expiry time of 30 day from now, about the frequency of Freenet releases
 			// Expires is needed for older browsers
 			expiresTime = TimeUtil.makeHTTPDate(System.currentTimeMillis() + DAYS.toMillis(30));
-			cacheControl = "public, max-age=" + String.valueOf(3600 * 24 * 30);
+			cacheControl = "public, max-age=" + 3600 * 24 * 30;
 		} else {
 			expiresTime = "Thu, 01 Jan 1970 00:00:00 GMT";
 			// no-cache for Internet Explorer, no-store for Firefox
@@ -414,10 +406,11 @@ static void sendReplyHeaders(OutputStream sockOutputStream, int replyCode, Strin
 		
 		mvt.put("last-modified", lastModString);
 		mvt.put("date", nowString);
-		if(disconnect)
+		if(disconnect) {
 			mvt.put("connection", "close");
-		else
+		} else {
 			mvt.put("connection", "keep-alive");
+		}
 		String contentSecurityPolicy = generateCSP(allowScripts, allowFrames);
 		mvt.put("content-security-policy", contentSecurityPolicy);
 		mvt.put("x-content-security-policy", contentSecurityPolicy);
@@ -429,12 +422,12 @@ static void sendReplyHeaders(OutputStream sockOutputStream, int replyCode, Strin
 		buf.append(' ');
 		buf.append(replyDescription);
 		buf.append("\r\n");
-		for(Enumeration e = mvt.keys();e.hasMoreElements();) {
+		for (Enumeration e = mvt.keys(); e.hasMoreElements(); ) {
 			String key = e.nextElement();
 			Object[] list = mvt.getArray(key);
 			key = fixKey(key);
-			for(int i=0;i headers = new MultiValueTable();
+				MultiValueTable headers = new MultiValueTable<>();
 				
 				while(true) {
 					String line = lis.readLine(32768, 128, false); // ISO-8859 or US-ASCII, not UTF-8
@@ -569,7 +546,7 @@ public static void handle(Socket sock, ToadletContainer container, PageMaker pag
 				boolean allowPost = container.allowPosts();
 				BucketFactory bf = container.getBucketFactory();
 				
-				ToadletContextImpl ctx = new ToadletContextImpl(sock, headers, bf, pageMaker, container, userAlertManager, bookmarkManager, uri, container.generateUniqueID());
+				ToadletContextImpl ctx = new ToadletContextImpl(sock, headers, bf, pageMaker, container, userAlertManager, bookmarkManager, uri);
 				ctx.shouldDisconnect = disconnect;
 				
 				/*
@@ -614,7 +591,7 @@ public static void handle(Socket sock, ToadletContainer container, PageMaker pag
 					} else {
 						FileUtil.skipFully(is, len);
 						if (method.equals("POST")) {
-							ctx.sendMethodNotAllowed("POST", true);
+							ctx.sendMethodNotAllowed(true);
 						} else {
 							sendError(sock.getOutputStream(), 403, "Forbidden", "Content not allowed in this configuration", true, null);
 						}
@@ -657,7 +634,7 @@ public static void handle(Socket sock, ToadletContainer container, PageMaker pag
 						// if the Toadlet does not support the method, we don't need to parse the data
 						// also due this pre check a 'NoSuchMethodException' should never appear
 						if (!(t.findSupportedMethods().contains(method))) {
-							ctx.sendMethodNotAllowed(method, ctx.shouldDisconnect);
+							ctx.sendMethodNotAllowed(ctx.shouldDisconnect);
 							break;
 						}
 
@@ -717,8 +694,8 @@ public static void handle(Socket sock, ToadletContainer container, PageMaker pag
 				PrintWriter pw = new PrintWriter(sw);
 				t.printStackTrace(pw);
 				pw.flush();
-				msg = msg + sw.toString() + "
"; - byte[] messageBytes = msg.getBytes("UTF-8"); + msg = msg + sw + "
"; + byte[] messageBytes = msg.getBytes(StandardCharsets.UTF_8); sendReplyHeaders(sock.getOutputStream(), 500, "Internal failure", null, "text/html; charset=UTF-8", messageBytes.length, null, true, false, false); sock.getOutputStream().write(messageBytes); } catch (IOException e1) { @@ -761,7 +738,7 @@ private static void callToadletMethod(Toadlet t, String method, URI uri, HTTPReq } } ctx.setActiveToadlet(t); - Object arglist[] = new Object[] {uri, req, ctx}; + Object[] arglist = new Object[] {uri, req, ctx}; m.invoke(t, arglist); } catch (InvocationTargetException ite) { throw ite.getCause(); @@ -792,11 +769,7 @@ private static boolean shouldDisconnectAfterHandled(boolean isHTTP10, MultiValue if(connection.equalsIgnoreCase("keep-alive")) return false; } - if(isHTTP10 == true) - return true; - else - // HTTP 1.1 - return false; + return isHTTP10; // or else HTTP 1.1 } @Override diff --git a/src/freenet/support/TimeUtil.java b/src/freenet/support/TimeUtil.java index 92695950369..1c0a64fb9e2 100644 --- a/src/freenet/support/TimeUtil.java +++ b/src/freenet/support/TimeUtil.java @@ -26,7 +26,14 @@ the License, or (at your option) any later version. import java.text.DecimalFormat; import java.text.SimpleDateFormat; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Time formatting utility. @@ -36,7 +43,75 @@ public class TimeUtil { public static final TimeZone TZ_UTC = TimeZone.getTimeZone("UTC"); - /** + // https://www.ietf.org/rfc/rfc6265.html#section-5.1.1 + private static final Pattern DATE_DELIMETERS = Pattern.compile("[\\x09\\x20-\\x2F\\x3B-\\x40\\x5B-\\x60\\x7B-\\x7E]+"); + private static final Pattern TIME_PATTERN = Pattern.compile("(\\d{1,2}):(\\d{1,2}):(\\d{1,2})"); + private static final Pattern DAY_OF_MONTH_PATTERN = Pattern.compile("\\d{1,2}"); + private static final Pattern YEAR_PATTERN = Pattern.compile("\\d{2,4}"); + + private static final List DATE_TIME_FORMATTERS = Collections.unmodifiableList( + Arrays.asList( + DateTimeFormatter.RFC_1123_DATE_TIME, + // Same as RFC1123 but with timezone name + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz"), + + // Monday, 07 Nov 1994 08:49:37 GMT + DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy HH:mm:ss zzz"), + + // Sunday, 06-Nov-94 08:49:37 GMT ; RFC 850, obsoleted by RFC 1036 + // Current century two digit years + DateTimeFormatter.ofPattern("EEEE, dd'-'MMM'-'uu HH:mm:ss zzz"), + + // two digit years between 1900 - 2000 + new DateTimeFormatterBuilder() + .appendPattern("EEEE, dd'-'MMM'-'") + .appendValueReduced(ChronoField.YEAR, 2, 2, 1900) + .appendPattern(" HH:mm:ss zzz") + .toFormatter(), + + DateTimeFormatter.ISO_DATE_TIME, + DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ISO_ZONED_DATE_TIME, + DateTimeFormatter.ISO_OFFSET_DATE, + DateTimeFormatter.ISO_INSTANT, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:sszzz") + + ) + ); + + private static final List MONTHS = Collections.unmodifiableList( + Arrays.asList("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec") + ); + + private static final List MONTH_FORMATTERS = Collections.unmodifiableList( + Arrays.asList( + DateTimeFormatter.ofPattern("MMMM", Locale.US), + DateTimeFormatter.ofPattern("MMMM", Locale.getDefault()) + ) + ); + + private static final List DAY_OF_WEEK_FORMATTER = Collections.unmodifiableList( + Arrays.asList( + DateTimeFormatter.ofPattern("EEE", Locale.US), + DateTimeFormatter.ofPattern("EEE", Locale.getDefault()), + DateTimeFormatter.ofPattern("EEEE", Locale.US), + DateTimeFormatter.ofPattern("EEEE", Locale.getDefault()) + ) + ); + + private static final List TZ_FORMATTERS = Collections.unmodifiableList( + Arrays.asList( + new DateTimeFormatterBuilder().appendOffset("+HH:mm", "Z").toFormatter(), + new DateTimeFormatterBuilder().appendOffset("+HHmm", "Z").toFormatter(), + new DateTimeFormatterBuilder().appendOffset("+HH:MM:ss", "Z").toFormatter(), + new DateTimeFormatterBuilder().appendOffset("+HHMMss", "Z").toFormatter(), + new DateTimeFormatterBuilder().appendZoneOrOffsetId().toFormatter() + ) + ); + + + /** * It converts a given time interval into a * week/day/hour/second.milliseconds string. * @param timeInterval interval to convert, millis @@ -187,17 +262,178 @@ public static String makeHTTPDate(long time) { sdf.setTimeZone(TZ_UTC); return sdf.format(new Date(time)); } - -// FIXME: For me it returns a parsed time with 2 hours difference, so it seems to parse localtime. WHY? - -// public static Date parseHTTPDate(String date) throws ParseException { -// SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'",Locale.US); -// sdf.setTimeZone(TZ_UTC); -// return sdf.parse(date); -// } - - - /** + + /** + * Parses text datetime defined in multiple standards (RFC822, RFC1036, RFC1123, ISO8601) + * + * @param dateTimeText datetime text, must have textual month or be an ISO8601 timestamp. + * @return date time with time zone + * @throws DateTimeParseException if cannot parse datetime + */ + public static ZonedDateTime parseHttpDateTime(String dateTimeText) { + for (DateTimeFormatter df : DATE_TIME_FORMATTERS) { + try { + return ZonedDateTime.parse(dateTimeText, df); + } catch (DateTimeParseException ignored) { + // continue with next pattern + } + try { + return LocalDateTime.parse(dateTimeText, df).atZone(ZoneOffset.UTC); + } catch (DateTimeParseException ignored) { + // continue with next pattern + } + } + + boolean foundTime = false; + boolean foundDayOfWeek = false; + boolean foundDayOfMonth = false; + boolean foundMonth = false; + boolean foundYear = false; + + int hour = -1; + int min = -1; + int second = -1; + + ZoneId zoneId = ZoneOffset.UTC; + + int dayOfWeek = -1; + int dayOfMonth = -1; + int month = -1; + int year = -1; + + // This algorithm is defined in https://www.ietf.org/rfc/rfc6265.html#section-5.1.1 + // It is extended with timezone parsing + + String[] tokens = DATE_DELIMETERS.split(dateTimeText); + for (String token : tokens) { + Matcher timeMatcher = TIME_PATTERN.matcher(token); + if (!foundTime && timeMatcher.matches()) { + hour = Integer.parseInt(timeMatcher.group(1)); + min = Integer.parseInt(timeMatcher.group(2)); + second = Integer.parseInt(timeMatcher.group(3)); + foundTime = true; + continue; + } + if (!foundDayOfMonth && DAY_OF_MONTH_PATTERN.matcher(token).matches()) { + dayOfMonth = Integer.parseInt(token); + foundDayOfMonth = true; + continue; + } + if (!foundMonth) { + month = parseMonth(token); + if (month > 0) { + foundMonth = true; + continue; + } + } + if (!foundYear && YEAR_PATTERN.matcher(token).matches()) { + year = Integer.parseInt(token); + foundYear = true; + } + + if (!foundDayOfWeek) { + dayOfWeek = parseDayOfWeek(token); + if (dayOfWeek > 0) { + foundDayOfWeek = true; + } + } + + ZoneId newZoneId = parseZoneId(dateTimeText, foundTime, foundYear, token); + if (newZoneId != null) { + zoneId = newZoneId; + } + } + + if (foundTime && foundDayOfMonth && foundMonth && foundYear) { + if (year >= 70 && year <= 99) { + year += 1900; + } + if (year >= 0 && year <= 69) { + year += 2000; + } + ZonedDateTime dateTime; + try { + dateTime = ZonedDateTime.of( + year, + month, + dayOfMonth, + hour, + min, + second, + 0, + zoneId + ); + } catch (DateTimeException dte) { + throw new DateTimeParseException("Cannot parse datetime", dateTimeText, 0, dte); + } + if (foundDayOfWeek && dayOfWeek != dateTime.getDayOfWeek().getValue()) { + throw new DateTimeParseException("Invalid day of week", dateTimeText, -0); + } + return dateTime; + } + + throw new DateTimeParseException("Cannot parse datetime", dateTimeText, 0); + } + + private static int parseMonth(String token) { + int month = MONTHS.indexOf(token.toLowerCase(Locale.ROOT)) + 1; + if (month > 0) { + return month; + } + for (DateTimeFormatter monthFormatter : MONTH_FORMATTERS) { + try { + return monthFormatter.parse(token).get(ChronoField.MONTH_OF_YEAR); + } catch (DateTimeParseException ignored) { + } + } + return -1; + } + + private static int parseDayOfWeek(String token) { + for (DateTimeFormatter dateTimeFormatter : DAY_OF_WEEK_FORMATTER) { + try { + return dateTimeFormatter.parse(token).get(ChronoField.DAY_OF_WEEK); + } catch (DateTimeParseException ignored) { + } + } + return -1; + } + + private static ZoneId parseZoneId(String dateTimeText, boolean foundTime, boolean foundYear, String token) { + for (DateTimeFormatter tzFormatter : TZ_FORMATTERS) { + try { + return ZoneId.from(tzFormatter.parse(token)); + } catch (DateTimeException ignored) { + } + } + try { + return ZoneId.of(token); + } catch (DateTimeException ignored2) { + } + try { + return ZoneId.of(token, ZoneId.SHORT_IDS); + } catch (DateTimeException ignored3) { + } + if (foundYear && foundTime && token.matches("(\\d{6})|(\\d{4})|(\\d{2}:\\d{2}(:\\d{2})?)")) { + int idx = dateTimeText.indexOf(token); + if (idx > 0) { + char c = dateTimeText.charAt(idx - 1); + if (c == '+' || c == '-') { + String offset = c + token; + for (DateTimeFormatter tzFormatter : TZ_FORMATTERS) { + try { + return ZoneId.from(tzFormatter.parse(offset)); + } catch (DateTimeException ignored) { + } + } + } + } + } + return null; + } + + + /** * @return Returns the passed date with the same year/month/day but with the time set to 00:00:00.000 */ public static Date setTimeToZero(final Date date) { diff --git a/test/freenet/clients/fcp/FCPPluginConnectionImplTest.java b/test/freenet/clients/fcp/FCPPluginConnectionImplTest.java index e8b42a979a8..a1d12781995 100644 --- a/test/freenet/clients/fcp/FCPPluginConnectionImplTest.java +++ b/test/freenet/clients/fcp/FCPPluginConnectionImplTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.*; import java.io.IOException; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -30,7 +31,7 @@ public final class FCPPluginConnectionImplTest { * checking whether it is empty after all send threads have terminated.
*/ @Test - public final void testSendSynchronousThreadSafety() throws InterruptedException { + public void testSendSynchronousThreadSafety() throws InterruptedException { // JUnit ignores failures in threads other than the threads which it runs tests from. // Thus we pass failures out with this boolean. // NOTICE: We also use JUnit assert*() / fail() even though they won't work in threads @@ -45,31 +46,37 @@ public final void testSendSynchronousThreadSafety() throws InterruptedException // This is by design: Plugins are supposed to be unloadable and the FCPPluginConnectionImpl // must not keep them pinned in memory after unload. final ServerSideFCPMessageHandler server = new ServerSideFCPMessageHandler() { - @Override public FCPPluginMessage handlePluginFCPMessage( - final FCPPluginConnection connection, final FCPPluginMessage message) { - - final FCPPluginMessage reply = FCPPluginMessage.constructSuccessReply(message); - reply.params.putSingle("replyToThread", message.params.get("thread")); - return reply; - } - }; - + @Override + public FCPPluginMessage handlePluginFCPMessage( + final FCPPluginConnection connection, + final FCPPluginMessage message + ) { + final FCPPluginMessage reply = FCPPluginMessage.constructSuccessReply(message); + reply.params.putSingle("replyToThread", message.params.get("thread")); + return reply; + } + }; + final ClientSideFCPMessageHandler client = new ClientSideFCPMessageHandler() { - @Override public FCPPluginMessage handlePluginFCPMessage( - final FCPPluginConnection connection, final FCPPluginMessage message) { - - failure.set(true); - fail("This test is about sendSynchronous() so the reply messages should not " - + "hit the client message handler"); - throw new UnsupportedOperationException(); - } - }; + @Override + public FCPPluginMessage handlePluginFCPMessage( + final FCPPluginConnection connection, + final FCPPluginMessage message + ) { + failure.set(true); + fail( + "This test is about sendSynchronous() so the reply messages should not hit the client message handler" + ); + throw new UnsupportedOperationException(); + } + }; - final FCPPluginConnectionImpl connection = FCPPluginConnectionImpl.constructForUnitTest( - server, client); + final FCPPluginConnectionImpl connection = FCPPluginConnectionImpl.constructForUnitTest(server, client); final int threadCount = 100; final Thread[] threads = new Thread[threadCount]; + final CountDownLatch allThreadsStarted = new CountDownLatch(threadCount); + final CountDownLatch concurrentStart = new CountDownLatch(1); for(int i=0; i < threadCount; ++i) { final String threadIndex = Integer.toString(i); @@ -83,6 +90,7 @@ public final void testSendSynchronousThreadSafety() throws InterruptedException @Override public void run() { try { + awaitConcurrentStart(); final FCPPluginMessage reply = connection.sendSynchronous( SendDirection.ToServer, message, TimeUnit.SECONDS.toNanos(10)); @@ -98,6 +106,15 @@ public final void testSendSynchronousThreadSafety() throws InterruptedException fail("InterruptedException " + e); } } + + private void awaitConcurrentStart() throws InterruptedException { + allThreadsStarted.countDown(); + boolean waitSuccess = concurrentStart.await(1, TimeUnit.MINUTES); + if (!waitSuccess) { + failure.set(true); + fail("Start timeout in thread " + threadIndex); + } + } }); threads[i] = thread; @@ -106,14 +123,21 @@ public final void testSendSynchronousThreadSafety() throws InterruptedException // Start them in a separate loop, not in the loop where we construct them, to ensure that // they are all started at the same time, execute in parallel, and thus have maximal // probability of race conditions. - for(int i=0; i < threadCount; ++i) + for (int i = 0; i < threadCount; ++i) { threads[i].start(); - - for(int i=0; i < threadCount; ++i) + } + + boolean waitSuccess = allThreadsStarted.await(1, TimeUnit.MINUTES); + if (!waitSuccess) { + fail("Start timeout in the main test method"); + } + concurrentStart.countDown(); + + for (int i = 0; i < threadCount; ++i) { threads[i].join(); - - assertEquals("JUnit failures cannot be passed out of threads, please check stdout/stderr.", - false, failure.get()); + } + + assertFalse("JUnit failures cannot be passed out of threads, please check stdout/stderr.", failure.get()); assertEquals("FCPPluginConnectionImpl sendSynchronous() map should not leak", 0, connection.getSendSynchronousCount()); diff --git a/test/freenet/clients/fcp/FCPPluginMessageEncodeDecodeTest.java b/test/freenet/clients/fcp/FCPPluginMessageEncodeDecodeTest.java index 6714dce2cb5..6618e812405 100644 --- a/test/freenet/clients/fcp/FCPPluginMessageEncodeDecodeTest.java +++ b/test/freenet/clients/fcp/FCPPluginMessageEncodeDecodeTest.java @@ -5,12 +5,11 @@ import static org.junit.Assert.*; -import java.io.IOException; import java.util.ArrayList; +import java.util.List; import org.junit.Test; -import freenet.node.FSParseException; import freenet.support.SimpleFieldSet; /** @@ -37,8 +36,8 @@ public final class FCPPluginMessageEncodeDecodeTest { * decoding is then tested using {@link #testEncodeDecode(FCPPluginMessage)}. */ @Test - public final void testEncodeDecode() throws MessageInvalidException, IOException, FSParseException { - ArrayList messages = new ArrayList(); + public void testEncodeDecode() throws MessageInvalidException { + List messages = new ArrayList<>(); // Non-reply messages. Can either have a SimpleFieldSet, or a Bucket, or both. We don't use // Buckets because the parsing code for those is higher level code, so we only have to use @@ -87,11 +86,9 @@ public final void testEncodeDecode() throws MessageInvalidException, IOException /** * @see FCPPluginMessageEncodeDecodeTest Explained at class-level JavaDoc of this class. */ - private final void testEncodeDecode(FCPPluginMessage message) - throws MessageInvalidException, IOException, FSParseException { + private void testEncodeDecode(FCPPluginMessage message) throws MessageInvalidException { - SimpleFieldSet encodedMessage - = new FCPPluginServerMessage("testPlugin", message).getFieldSet(); + SimpleFieldSet encodedMessage = new FCPPluginServerMessage("testPlugin", message).getFieldSet(); // The params have a different prefix in FCPPluginServerMessage and FCPPluginClientMessage. // So we have to rename them by removing the sub-SimpleFieldSet with the old prefix and @@ -102,24 +99,23 @@ private final void testEncodeDecode(FCPPluginMessage message) encodedMessage.put(FCPPluginClientMessage.PARAM_PREFIX, params); } - FCPPluginMessage decodedMessage - = new FCPPluginClientMessage(encodedMessage).constructFCPPluginMessage(); + FCPPluginMessage decodedMessage = new FCPPluginClientMessage(encodedMessage).constructFCPPluginMessage(); // Permissions are set by the FCPPluginConnectionImpl when the message is actually delivered - assertEquals(null, decodedMessage.permissions); + assertNull(decodedMessage.permissions); assertEquals(message.identifier, decodedMessage.identifier); // SimpleFieldSet offers no equals(). But its designed to be human readable, so we can // just encode them into Strings and compare those. if(message.params == null) { - assertEquals(null, decodedMessage.params); + assertNull(decodedMessage.params); } else { assertEquals(message.params.toOrderedString(), decodedMessage.params.toOrderedString()); } if(message.data == null) { - assertEquals(null, decodedMessage.data); + assertNull(decodedMessage.data); } else { // Not implemented yet because the parsing of the data is higher level FCP code; i.e. // it is not implemented in FCPPluginClientMessage, but one of its parent classes. diff --git a/test/freenet/clients/http/CookieTest.java b/test/freenet/clients/http/CookieTest.java index 636cbd2e680..95cf7250464 100644 --- a/test/freenet/clients/http/CookieTest.java +++ b/test/freenet/clients/http/CookieTest.java @@ -3,105 +3,310 @@ * http://www.gnu.org/ for further details of the GPL. */ package freenet.clients.http; +import static freenet.clients.http.Cookie.COOKIE_TOKEN_SEPARATOR_CHARACTERS; +import static freenet.clients.http.Cookie.COOKIE_VALUE_FORBIDDEN_CHARS; import static org.junit.Assert.*; import java.net.URI; import java.net.URISyntaxException; -import java.util.Date; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.Before; import org.junit.Test; -import freenet.support.CurrentTimeUTC; +import org.junit.function.ThrowingRunnable; public class CookieTest { - - static final String VALID_PATH = "/Freetalk"; - static final String VALID_NAME = "SessionID"; - static final String VALID_VALUE = "abCd12345"; - + + private static final String VALID_PATH = "/Freetalk"; + private static final String VALID_NAME = "SessionID"; + private static final String VALID_VALUE = "abCd12345"; + + + /** + * https://www.ietf.org/rfc/rfc2965.html#section-3.2 + */ + private static final Set RESERVED_COOKIE_MEMBERS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + "Comment", + "CommentURL", + "Discard", + "Domain", + "Max-Age", + "Path", + "Port", + "Secure", + "Version" + ) + ) + ); + + /** + * Sample control characters + */ + private static final Set SOME_CONTROL_CHARACTERS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + (char) 127, // DEL + (char) 27, // ESC + (char) 8, // backspace + '\n', + '\r', + '\t', + (char) 0 + ) + ) + ); + + /** + * https://www.ietf.org/rfc/rfc2396.html#section-2.2 + */ + private static final Set URI_RESERVED_CHARACTERS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + ';', '/', '?', ':', ':', '@', '&', '=', '+', '$', ',' + ) + ) + ); + /** + * https://www.ietf.org/rfc/rfc2396.html#section-2.3 + */ + private static final Set URI_UNRESERVED_CHARACTERS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + 'a', 'A', '7', '-', '_', '.', '!', '~', '*', '\'', '(', ')' + ) + ) + ); + + private static final Set VALID_TOKENS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + "my-name", + "-name", + "name-", + "my_name", + "_name", + "name_", + "my+name", + "+name", + "name+", + "my.name", + ".name", + "name.", + " name " + ) + ) + ); + URI validPath; - Date validExpiresDate; + Instant validExpiresTime; Cookie cookie; @Before public void setUp() throws Exception { validPath = new URI(VALID_PATH); - validExpiresDate = new Date(CurrentTimeUTC.getInMillis()+60*60*1000); - cookie = new Cookie(validPath, VALID_NAME, VALID_VALUE, validExpiresDate); + validExpiresTime = Instant.now().plusMillis(+60*60*1000); + cookie = new Cookie(validPath, VALID_NAME, VALID_VALUE, validExpiresTime); } @Test - public void testCookieURIStringStringDate() throws URISyntaxException { - try { - new Cookie(null, VALID_NAME, VALID_VALUE, validExpiresDate); - fail("Constructor allows path to be null"); - } catch(RuntimeException e) {} - - try { - new Cookie(new URI(""), VALID_NAME, VALID_VALUE, validExpiresDate); - fail("Constructor allows path to be empty"); + public void testCookieURIParameter() throws Exception { + assertThrowsIllegalArgumentException( + "Constructor allows path to be null", + () -> new Cookie(null, VALID_NAME, VALID_VALUE, validExpiresTime) + ); + assertThrowsIllegalArgumentException( + "Constructor allows path to be empty", + () -> new Cookie(new URI(""), VALID_NAME, VALID_VALUE, validExpiresTime) + ); + + for (Character c : URI_RESERVED_CHARACTERS) { + new Cookie(new URI("/my" + c + "/value"), VALID_NAME, VALID_VALUE, validExpiresTime); } - catch(RuntimeException e) {} - - // TODO: Test for invalid characters in path. - - try { - new Cookie(validPath, null, VALID_VALUE, validExpiresDate); - fail("Constructor allows name to be null"); - } catch(RuntimeException e) {} - - try { - new Cookie(validPath, "", VALID_VALUE, validExpiresDate); - fail("Constructor allows name to be empty"); - } catch(RuntimeException e) {} - - try { - new Cookie(validPath, "test;", VALID_VALUE, validExpiresDate); - fail("Constructor allows invalid characters in name"); - } catch(RuntimeException e) {} - - // TODO: Test for more invalid characters in name + + for (Character c : URI_UNRESERVED_CHARACTERS) { + new Cookie(new URI("/my" + c + "/value"), VALID_NAME, VALID_VALUE, validExpiresTime); + } + + assertThrowsIllegalArgumentException( + "Constructor allows path not starting with /", + () -> new Cookie(new URI("my/path"), VALID_NAME, VALID_VALUE, validExpiresTime) + ); + + assertThrowsIllegalArgumentException( + "Constructor allows path containing full URI", + () -> new Cookie(new URI("http://example.com/my/path"), VALID_NAME, VALID_VALUE, validExpiresTime) + ); + + for (String s : Arrays.asList("free%20net", "net%09free")) { + new Cookie(new URI("/" + s), VALID_NAME, VALID_VALUE, validExpiresTime); + } + } + + @Test + public void testCookieNameParameter() { + assertThrowsIllegalArgumentException( + "Constructor allows name to be null", + () -> cookieForName(null) + ); + + assertThrowsIllegalArgumentException( + "Constructor allows name to be empty", + () -> cookieForName("") + ); + + assertThrowsIllegalArgumentException( + "Constructor allows invalid characters in name", + () -> cookieForName("test;") + ); + + for (String reservedName: RESERVED_COOKIE_MEMBERS) { + assertInvalidCookieAttributeName(reservedName); + assertInvalidCookieAttributeName(reservedName.toLowerCase(Locale.ROOT)); + assertInvalidCookieAttributeName(reservedName.toUpperCase(Locale.ROOT)); + + for (int i = 0; i < reservedName.length(); i++) { + char[] chars = reservedName.toCharArray(); + char c = chars[i]; + chars[i] = Character.toString(c).toLowerCase(Locale.ROOT).charAt(0); + + String nameWithLowerChar = new String(chars); + assertInvalidCookieAttributeName(nameWithLowerChar); + + chars[i] = Character.toString(c).toUpperCase(Locale.ROOT).charAt(0); + String nameWithUpperChar = new String(chars); + assertInvalidCookieAttributeName(nameWithUpperChar); + } + } + + for (Character c : COOKIE_TOKEN_SEPARATOR_CHARACTERS) { + assertInvalidCookieAttributeName("separator" + c + "name"); + } + for (Character c : COOKIE_VALUE_FORBIDDEN_CHARS) { + assertInvalidCookieAttributeName("separator" + c + "name"); + } + for (Character c : SOME_CONTROL_CHARACTERS) { + assertInvalidCookieAttributeName("control" + c + "name"); + } + // US-ASCII only + assertInvalidCookieAttributeName("cöölName"); + + for (String validToken : VALID_TOKENS) { + cookieForName(validToken); + } + } + + private Cookie cookieForName(String name) { + return new Cookie(validPath, name, VALID_VALUE, validExpiresTime); + } + + private void assertInvalidCookieAttributeName(String name) { + assertThrowsIllegalArgumentException( + "Constructor allows invalid cookie attribute name: '" + name + "'", + () -> cookieForName(name) + ); + } + + @Test + public void testCookieValueParameter() { // Empty values are allowed; - new Cookie(validPath, VALID_NAME, null, validExpiresDate).getValue(); - new Cookie(validPath, VALID_NAME, "", validExpiresDate); - - try { - new Cookie(validPath, VALID_NAME, "\"", validExpiresDate); - fail("Constructor allows invalid characters in value"); - } catch(RuntimeException e) {} - - try { - new Cookie(validPath, VALID_NAME, VALID_VALUE + "ä", validExpiresDate); - fail("Constructor allows non-US-ASCII characters in value"); - } catch(RuntimeException e) {} - - // TODO: Test for more invalid characters in value; - - try { - new Cookie(validPath, VALID_NAME, VALID_VALUE, new Date(CurrentTimeUTC.getInMillis()-1)); - fail("Constructor allows construction of expired cookies."); - } catch(RuntimeException e) {} + assertEquals("", cookieForValue(null).getValue()); + assertEquals("", cookieForValue("").getValue()); + + assertThrowsIllegalArgumentException( + "Constructor allows invalid characters in value", + () -> cookieForValue("\"") + ); + + assertThrowsIllegalArgumentException( + "Constructor allows non-US-ASCII characters in value", + () -> cookieForValue(VALID_VALUE + "ä") + ); + + for (String cookieMember : RESERVED_COOKIE_MEMBERS) { + // allow reserved words as values + cookieForValue(cookieMember); + } + + for (Character c : COOKIE_VALUE_FORBIDDEN_CHARS) { + assertInvalidCookieAttributeValue("separator" + c + "value"); + } + for (Character c : SOME_CONTROL_CHARACTERS) { + assertInvalidCookieAttributeValue("control" + c + "value"); + } + // US-ASCII only + assertInvalidCookieAttributeValue("cöölValue"); + + for (String validToken : VALID_TOKENS) { + new Cookie(validPath, validToken, VALID_VALUE, validExpiresTime); + } + for (String validName : Arrays.asList( + "my name", + " my name " + )) { + cookieForValue(validName); + } + } + + private Cookie cookieForValue(String myValue) { + return new Cookie(validPath, VALID_NAME, myValue, validExpiresTime); + } + + private void assertInvalidCookieAttributeValue(String value) { + assertThrowsIllegalArgumentException( + "Constructor allows invalid cookie attribute value: '" + value + "'", + () -> cookieForValue(value) + ); + } + + @Test + public void testCookieDateParameter() { + assertThrowsIllegalArgumentException( + "Constructor allows construction with null date.", + () -> new Cookie(validPath, VALID_NAME, VALID_VALUE, null) + ); + + new Cookie(validPath, VALID_NAME, VALID_VALUE, Instant.now().minusMillis(-1)); } @Test public void testEqualsObject() throws URISyntaxException { assertEquals(cookie, cookie); - assertEquals(cookie, new Cookie(validPath, VALID_NAME, VALID_VALUE, new Date(CurrentTimeUTC.getInMillis()+60*1000))); + assertEquals(cookie, new Cookie(validPath, VALID_NAME, VALID_VALUE, Instant.now().plusMillis(60*1000))); // Value is not checked in equals(). - assertEquals(cookie, new Cookie(validPath, VALID_NAME, "", new Date(CurrentTimeUTC.getInMillis()+60*1000))); - - assertFalse(cookie.equals(new Cookie(new URI(VALID_PATH.toLowerCase()), VALID_NAME, VALID_VALUE, validExpiresDate))); - assertEquals(cookie, new Cookie(validPath, VALID_NAME.toLowerCase(), VALID_VALUE, validExpiresDate)); - + assertEquals(cookie, new Cookie(validPath, VALID_NAME, "", Instant.now().plusMillis(60*1000))); + + assertNotEquals(cookie, new Cookie(new URI(VALID_PATH.toLowerCase()), VALID_NAME, VALID_VALUE, validExpiresTime)); + assertEquals(cookie, new Cookie(validPath, VALID_NAME.toLowerCase(), VALID_VALUE, validExpiresTime)); + + assertNotEquals(cookie, new Object()); + assertNotEquals(cookieForName("first"), cookieForName("second")); + // TODO: Test domain. This is currently done in ReceivedCookieTest } + @Test + public void testHashCodeMethod() { + MatcherAssert.assertThat(cookie.hashCode(), Matchers.any(Integer.class)); + } + @Test public void testGetDomain() { + assertNull(cookie.getDomain()); // TODO: Implement. } @@ -119,17 +324,39 @@ public void testGetName() { public void testGetValue() { assertEquals(VALID_VALUE, cookie.getValue()); } - -// TODO: getExpirationDate() is commented out because it is broken, see ReceivedCookie.java -// public void testGetExpirationDate() { -// assertEquals(validExpiresDate, cookie.getExpirationDate()); -// } + + @Test + public void testGetExpirationDate() { + assertEquals(validExpiresTime, cookie.getExpirationTime()); + } @Test public void testEncodeToHeaderValue() { - System.out.println(cookie.encodeToHeaderValue()); - - // TODO: Implement. + String headerValue = cookie.encodeToHeaderValue(); + + assertNotNull(headerValue); + assertFalse(headerValue.isEmpty()); + + assertTrue(headerValue.contains(String.format("%s=%s;", VALID_NAME.toLowerCase(), VALID_VALUE))); + assertTrue(headerValue.contains("version=1;")); + assertTrue(headerValue.contains(String.format("path=%s;", validPath.getRawPath()))); + + String expireTimestampStr = DateTimeFormatter.RFC_1123_DATE_TIME.format(validExpiresTime.atZone(ZoneOffset.UTC)); + assertTrue(headerValue.contains(String.format("expires=%s;", expireTimestampStr))); + + assertTrue(headerValue.contains("discard=true;")); } + @Test + public void encodeToHeaderValueCanEncodeValueWithSpaces() { + String strWithSpace = "test space"; + Cookie cookie = cookieForValue(strWithSpace); + assertEquals(strWithSpace, cookie.getValue()); + String encoded = cookie.encodeToHeaderValue(); + assertTrue(encoded.contains(String.format("%s=%s;", VALID_NAME.toLowerCase(), strWithSpace))); + } + + protected static void assertThrowsIllegalArgumentException(String message, ThrowingRunnable runnable) { + assertThrows(message, IllegalArgumentException.class, runnable); + } } diff --git a/test/freenet/clients/http/FilterCSSIdentifierTest.java b/test/freenet/clients/http/FilterCSSIdentifierTest.java index 2370fb064b1..10f3a12bd49 100644 --- a/test/freenet/clients/http/FilterCSSIdentifierTest.java +++ b/test/freenet/clients/http/FilterCSSIdentifierTest.java @@ -14,7 +14,7 @@ public class FilterCSSIdentifierTest { @Test public void testKnownValid() { - String identifiers[] = { "sample_key-1", "-_", "-k_d", "_testing-key" }; + String[] identifiers = { "sample_key-1", "-_", "-k_d", "_testing-key" }; for (String identifier : identifiers) { assertEquals(identifier, PageMaker.filterCSSIdentifier(identifier)); diff --git a/test/freenet/clients/http/ReceivedCookieTest.java b/test/freenet/clients/http/ReceivedCookieTest.java index 4ba22156a05..c021adaecbf 100644 --- a/test/freenet/clients/http/ReceivedCookieTest.java +++ b/test/freenet/clients/http/ReceivedCookieTest.java @@ -6,34 +6,31 @@ import static org.junit.Assert.*; import java.text.ParseException; -import java.util.ArrayList; -import java.util.Date; +import java.util.Arrays; +import java.util.List; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.Before; import org.junit.Test; -public class ReceivedCookieTest extends CookieTest { - - static final String validEncodedCookie = " SessionID = \"abCd12345\" ;" - + " $Version = 1 ;" - + " $Path = \"/Freetalk\";" - + " $Discard; " - + " $Expires = \"Sun, 25 Oct 2030 15:09:37 GMT\"; " - + " $blah;"; +public class ReceivedCookieTest { + + private static final String validEncodedCookie = " SessionID = \"abCd12345\" ;" + + " $Version = 1 ;" + + " $Path = \"/Freetalk\";" + + " $Discard; " + + " $Expires = \"Fri, 25 Oct 2030 15:09:37 GMT\"; " + + " $blah;"; + + private static final String VALID_NAME = "SessionID"; + private static final String VALID_VALUE = "abCd12345"; + + private ReceivedCookie cookie; @Before - @SuppressWarnings("deprecation") public void setUp() throws Exception { - super.setUp(); - - validExpiresDate = new Date(2030 - 1900, 10 - 1, 25, 15, 9, 37); - - cookie = ReceivedCookie.parseHeader(validEncodedCookie).get(0); - } - - @Override - public void testGetDomain() { - // TODO: Implement. + cookie = parseHeaderAndGetFirst(validEncodedCookie); } @Test @@ -41,37 +38,89 @@ public void testParseHeader() throws ParseException { // The tests for getPath(), getName() etc will be executed using the parsed mCookie and therefore also test parseHeader() for valid values, // we only need to test special cases here. - ArrayList cookies; - Cookie cookie; + List cookies; + ReceivedCookie cookie; // Plain firefox cookie - - cookie = ReceivedCookie.parseHeader("SessionID=abCd12345").get(0); - assertEquals(VALID_NAME.toLowerCase(), cookie.getName()); assertEquals(VALID_VALUE, cookie.getValue()); + + cookie = parseHeaderAndGetFirst("SessionID=abCd12345"); + assertEquals(VALID_NAME, cookie.getName()); + assertEquals(VALID_VALUE, cookie.getValue()); // Two plain firefox cookies cookies = ReceivedCookie.parseHeader("SessionID=abCd12345;key2=valUe2"); - cookie = cookies.get(0); assertEquals(VALID_NAME.toLowerCase(), cookie.getName()); assertEquals(VALID_VALUE, cookie.getValue()); - cookie = cookies.get(1); assertEquals("key2", cookie.getName()); assertEquals("valUe2", cookie.getValue()); - - // Key without value at end: - - cookie = ReceivedCookie.parseHeader(" SessionID = \"abCd12345\" ;" - + " $blah;").get(0); - assertEquals(VALID_NAME.toLowerCase(), cookie.getName()); assertEquals(VALID_VALUE, cookie.getValue()); - - // Key without value and without semicolon at end - cookie = ReceivedCookie.parseHeader(" SessionID = \"abCd12345\" ;" - + " $blah").get(0); - assertEquals(VALID_NAME.toLowerCase(), cookie.getName()); assertEquals(VALID_VALUE, cookie.getValue()); + cookie = cookies.get(0); + assertEquals(VALID_NAME, cookie.getName()); + assertEquals(VALID_VALUE, cookie.getValue()); + + cookie = cookies.get(1); + assertEquals("key2", cookie.getName()); + assertEquals("valUe2", cookie.getValue()); + } + + @Test + public void canParseKeyOnly() throws ParseException { + ReceivedCookie cookie = parseHeaderAndGetFirst("$blah"); + assertEquals("$blah", cookie.getName()); + assertNull(cookie.getValue()); + } + + @Test + public void canParseKeyWithoutValue() throws ParseException { + List cookies = ReceivedCookie.parseHeader(" SessionID = \"abCd12345\" ; $blah;"); + assertEquals(2, cookies.size()); + + ReceivedCookie cookie = cookies.get(0); + assertEquals(VALID_NAME, cookie.getName()); + assertEquals(VALID_VALUE, cookie.getValue()); + + cookie = cookies.get(1); + assertEquals("$blah", cookie.getName()); + assertNull(cookie.getValue()); + } + + @Test + public void canParseKeyWithoutValueAndSemicolonAtTheEnd() throws ParseException { + List cookies = ReceivedCookie.parseHeader(" SessionID = \"abCd12345\" ; $blah"); + assertEquals(2, cookies.size()); + ReceivedCookie cookie = cookies.get(0); + assertEquals(VALID_NAME, cookie.getName()); + assertEquals(VALID_VALUE, cookie.getValue()); + cookie = cookies.get(1); + assertEquals("$blah", cookie.getName()); + assertNull(cookie.getValue()); + } + + @Test + public void canParseEmptyString() throws ParseException { + for (String empty: Arrays.asList( + null, + "", + " ", + "\n", + "\t", + " \t \n" + )) { + List cookies = ReceivedCookie.parseHeader(empty); + assertNotNull(cookies); + assertEquals(0, cookies.size()); + } + } + + @Test + public void testEqualsMethod() throws ParseException { + assertEquals(cookie, cookie); + assertEquals(parseHeaderAndGetFirst(validEncodedCookie), parseHeaderAndGetFirst(validEncodedCookie)); + assertEquals(cookie, parseHeaderAndGetFirst(validEncodedCookie)); + } + + @Test + public void testHashCodeMethod() { + MatcherAssert.assertThat(cookie.hashCode(), Matchers.any(Integer.class)); } - @Override - public void testEncodeToHeaderValue() { - try { - cookie.encodeToHeaderValue(); - fail("ReceivedCookie.encodeToHeaderValue() should throw UnsupportedOperationException!"); - } catch(UnsupportedOperationException e) {} + private static ReceivedCookie parseHeaderAndGetFirst(String cookieValue) throws ParseException { + return ReceivedCookie.parseHeader(cookieValue).get(0); } } diff --git a/test/freenet/clients/http/utils/UriFilterProxyHeaderParserTest.java b/test/freenet/clients/http/utils/UriFilterProxyHeaderParserTest.java index bac35e4ca44..0532ef9bd87 100644 --- a/test/freenet/clients/http/utils/UriFilterProxyHeaderParserTest.java +++ b/test/freenet/clients/http/utils/UriFilterProxyHeaderParserTest.java @@ -1,5 +1,6 @@ package freenet.clients.http.utils; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -281,15 +282,16 @@ private void testUriPrefixMatchesExpected( String uriScheme, String uriHost, MultiValueTable headers, - String resultUriPrefix) throws Exception { + String resultUriPrefix + ) throws Exception { String schemeHostAndPort = UriFilterProxyHeaderParser.parse( fakePortOption(fProxyPort), fakeBindToOption(fProxyBindTo), uriScheme, uriHost, - headers) - .toString(); - assertTrue( + headers + ).toString(); + assertEquals( String.format( "schemeHostAndPort %s does not match expected %s; portConfig=\"%s\", bindTo=\"%s\", uriScheme=\"%s\", uriHost=\"%s\", headers=%s, expected=\"%s\"", schemeHostAndPort, @@ -299,8 +301,11 @@ private void testUriPrefixMatchesExpected( uriScheme, uriHost, headers, - resultUriPrefix), - schemeHostAndPort.equals(resultUriPrefix)); + resultUriPrefix + ), + schemeHostAndPort, + resultUriPrefix + ); } @@ -336,7 +341,7 @@ private StringOption fakePortOption(String value) return option; } - private class DummyStringCallback extends StringCallback { + private static class DummyStringCallback extends StringCallback { @Override public String get() { diff --git a/test/freenet/support/TimeUtilTest.java b/test/freenet/support/TimeUtilTest.java index a5febf1fee1..f05a8a2d403 100644 --- a/test/freenet/support/TimeUtilTest.java +++ b/test/freenet/support/TimeUtilTest.java @@ -19,10 +19,13 @@ import static org.junit.Assert.*; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Locale; -import java.util.TimeZone; +import java.text.ParseException; +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.*; import org.junit.Before; import org.junit.Test; @@ -35,7 +38,7 @@ public class TimeUtilTest { //1w+1d+1h+1m+1s+1ms - private long oneForTermLong = 694861001; + private final long oneForTermLong = 694861001; @Before public void setUp() throws Exception { @@ -49,8 +52,10 @@ public void setUp() throws Exception { @Test public void testFormatTime_LongIntBoolean_MaxValue() { String expectedForMaxLongValue = "15250284452w3d7h12m55.807s"; - assertEquals(TimeUtil.formatTime(Long.MAX_VALUE,6,true), - expectedForMaxLongValue); + assertEquals( + expectedForMaxLongValue, + TimeUtil.formatTime(Long.MAX_VALUE,6,true) + ); } /** @@ -60,8 +65,10 @@ public void testFormatTime_LongIntBoolean_MaxValue() { @Test public void testFormatTime_LongInt() { String expectedForMaxLongValue = "15250284452w3d7h12m55s"; - assertEquals(TimeUtil.formatTime(Long.MAX_VALUE,6), - expectedForMaxLongValue); + assertEquals( + expectedForMaxLongValue, + TimeUtil.formatTime(Long.MAX_VALUE,6) + ); } /** @@ -72,8 +79,10 @@ public void testFormatTime_LongInt() { public void testFormatTime_Long() { //it uses two terms by default String expectedForMaxLongValue = "15250284452w3d"; - assertEquals(TimeUtil.formatTime(Long.MAX_VALUE), - expectedForMaxLongValue); + assertEquals( + expectedForMaxLongValue, + TimeUtil.formatTime(Long.MAX_VALUE) + ); } /** @@ -84,7 +93,6 @@ public void testFormatTime_Long() { */ @Test public void testFormatTime_KnownValues() { - Long methodLong; String[][] valAndExpected = { //one week {"604800000","1w"}, @@ -97,10 +105,12 @@ public void testFormatTime_KnownValues() { //one second {"1000","1s"} }; - for(int i = 0; i < valAndExpected.length; i++) { - methodLong = Long.valueOf(valAndExpected[i][0]); - assertEquals(TimeUtil.formatTime(methodLong.longValue()), - valAndExpected[i][1]); } + for (String[] pair : valAndExpected) { + assertEquals( + pair[1], + TimeUtil.formatTime(Long.parseLong(pair[0])) + ); + } } /** @@ -112,24 +122,27 @@ public void testFormatTime_KnownValues() { @Test public void testFormatTime_LongIntBoolean_maxTerms() { String[] valAndExpected = { - //0 terms - "", - //1 term - "1w", - //2 terms - "1w1d", - //3 terms - "1w1d1h", - //4 terms - "1w1d1h1m", - //5 terms - "1w1d1h1m1s", - //6 terms - "1w1d1h1m1.001s" + //0 terms + "", + //1 term + "1w", + //2 terms + "1w1d", + //3 terms + "1w1d1h", + //4 terms + "1w1d1h1m", + //5 terms + "1w1d1h1m1s", + //6 terms + "1w1d1h1m1.001s" }; - for(int i = 0; i < valAndExpected.length; i++) - assertEquals(TimeUtil.formatTime(oneForTermLong,i,true), - valAndExpected[i]); + for (int maxTerms = 0; maxTerms < valAndExpected.length; maxTerms++) { + assertEquals( + valAndExpected[maxTerms], + TimeUtil.formatTime(oneForTermLong, maxTerms, true) + ); + } } /** @@ -141,8 +154,8 @@ public void testFormatTime_LongIntBoolean_maxTerms() { @Test public void testFormatTime_LongIntBoolean_milliseconds() { long methodValue = 1; //1ms - assertEquals(TimeUtil.formatTime(methodValue,6,false),"0s"); - assertEquals(TimeUtil.formatTime(methodValue,6,true),"0.001s"); + assertEquals("0s", TimeUtil.formatTime(methodValue,6,false)); + assertEquals("0.001s", TimeUtil.formatTime(methodValue,6,true)); } /** @@ -153,11 +166,11 @@ public void testFormatTime_LongIntBoolean_milliseconds() { */ @Test public void testFormatTime_LongIntBoolean_tooManyTerms() { - try { - TimeUtil.formatTime(oneForTermLong,7); - fail("Expected IllegalArgumentException not thrown"); } - catch (IllegalArgumentException anException) { - assertNotNull(anException); } + assertThrows( + "Expected exception was not thrown for invalid maxTerms parameter", + IllegalArgumentException.class, + () -> TimeUtil.formatTime(oneForTermLong,7) + ); } /** Tests {@link TimeUtil#setTimeToZero(Date)} */ @@ -218,10 +231,84 @@ public void testToMillis_empty() { @Test public void testToMillis_unknownFormat() { - try { - TimeUtil.toMillis("15250284452w3q7h12m55.807s"); - } catch (NumberFormatException e) { - assertNotNull(e); + assertThrows( + "Expected exception was not thrown for invalid time interval parameter", + NumberFormatException.class, + () -> TimeUtil.toMillis("15250284452w3q7h12m55.807s") + ); + } + + @Test + public void parseHttpDateTime() { + Object[][] dateTimeSamples = { + {"Sun, 06 Nov 1994 08:49:37 GMT", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Sun, 06 Nov 1994 08:49:37 UTC", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Sun, 06 Nov 1994 08:49:37 ABC", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Some text: Sun, 06 Nov 1994 08:49:37 ", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Sun, 06 Nov 1994 08:49:37", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Sun, 1994 Nov 6 08:49:37 GMT", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Sunday, 06-Nov-94 08:49:37 GMT", ZonedDateTime.of(1994, 11, 6, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Monday, 30-Jan-23 08:49:37 GMT", ZonedDateTime.of(2023, 1, 30, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Monday, 07 Nov 1994 08:49:37 GMT", ZonedDateTime.of(1994, 11, 7, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Tue Nov 8 08:49:37 1994", ZonedDateTime.of(1994, 11, 8, 8, 49, 37, 0, ZoneOffset.UTC)}, + {"Wed, 21.Oct.2015 07:28:00 GMT", ZonedDateTime.of(2015, 10, 21, 7, 28, 0, 0, ZoneOffset.UTC)}, + {"Thu, 01 Jan 1970 00:00:00 UTC", ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)}, + {"Fri, 15-Jan-2021 22:23:01 GMT", ZonedDateTime.of(2021, 1, 15, 22, 23, 1, 0, ZoneOffset.UTC)}, + {"Saturday, 15/January/2022 09:55:01 PST", ZonedDateTime.of(2022, 1, 15, 9, 55, 1, 0, ZoneId.of("PST", ZoneId.SHORT_IDS))}, + {"Sunday, 22 August 99 06:30:07", ZonedDateTime.of(1999, 8, 22, 6, 30, 7, 0, ZoneOffset.UTC)}, + {"2008-08-08T08:08:08", ZonedDateTime.of(2008, 8, 8, 8, 8, 8, 0, ZoneOffset.UTC)}, + {"2009-09-09T09:09:09+00:00", ZonedDateTime.of(2009, 9, 9, 9, 9, 9, 0, ZoneOffset.UTC)}, + {"2010-10-10T10:10:10.719922211-04:00", ZonedDateTime.of(2010, 10, 10, 10, 10, 10, 719922211, ZoneOffset.ofHours(-4))}, + {"2010-10-10T10:10:10.719922211", ZonedDateTime.of(2010, 10, 10, 10, 10, 10, 719922211, ZoneOffset.UTC)}, + {"2011-11-11T11:11:11.123+02:30", ZonedDateTime.of(2011, 11, 11, 11, 11, 11, 123000000, ZoneOffset.ofHoursMinutes(2, 30))}, + {"2011-12-03T10:15:30Z", ZonedDateTime.of(2011, 12, 3, 10, 15, 30, 0, ZoneOffset.UTC)}, + {"2011-12-03 10:15:30Z", ZonedDateTime.of(2011, 12, 3, 10, 15, 30, 0, ZoneOffset.UTC)}, + {"2011-12-03 10:15:30", ZonedDateTime.of(2011, 12, 3, 10, 15, 30, 0, ZoneOffset.UTC)}, + {"2011-12-03 10:15:30+01:00", ZonedDateTime.of(2011, 12, 3, 10, 15, 30, 0, ZoneOffset.ofHours(1))}, + {"2011-12-03 10:15:30-03:00", ZonedDateTime.of(2011, 12, 3, 10, 15, 30, 0, ZoneOffset.ofHours(-3))}, + {"Fri, 2013-DEC-13 13:13:13 GMT", ZonedDateTime.of(2013, 12, 13, 13, 13, 13, 0, ZoneOffset.UTC)}, + {"Tue Apr 12 09:45:14 2016", ZonedDateTime.of(2016, 4, 12, 9, 45, 14, 0, ZoneOffset.UTC)}, + {"Tue Aug 19 1975 23:15:30 GMT+0100 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(1))}, + {"Tue Aug 19 1975 23:15:30 +0100 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(1))}, + {"Tue Aug 19 +1975 23:15:30 +0100 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(1))}, + {"Tue Aug-19 -1975 23:15:30 -0100 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(-1))}, + {"Tue Aug-19-1975 23:15:30 -0100 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(-1))}, + {"Tue+Aug+19+1975+23:15:30+GMT+01:00 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(1))}, + {"Tue+Aug+19+1975+23:15:30+GMT-01:00 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(-1))}, + {"Tue Aug-19-1975 23:15:30 GMT+0100 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHours(1))}, + {"Tue Aug-19-1975 23:15:30 GMT+023015 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHoursMinutesSeconds(2, 30, 15))}, + {"Tue Aug-19-1975 23:15:30 GMT+02:30:15 (Western European Summer Time)", ZonedDateTime.of(1975, 8, 19, 23, 15, 30, 0, ZoneOffset.ofHoursMinutesSeconds(2, 30, 15))}, + {"Wednesday, Aug 20 1975 12:00:01 GMT+08:15", ZonedDateTime.of(1975, 8, 20, 12, 0, 1, 0, ZoneOffset.ofHoursMinutes(8, 15))} + }; + + for (Object[] sample: dateTimeSamples) { + String dateTimeText = (String) sample[0]; + ZonedDateTime validDateTime = (ZonedDateTime) sample[1]; + assertEquals( + "Result is not equal for input datetime text: '" + dateTimeText + "'", + validDateTime.toInstant(), + TimeUtil.parseHttpDateTime(dateTimeText).toInstant() + ); + } + + List invalidDateTimeSamples = Arrays.asList( + // invalid day + "Saturday, 22 August 99 06:30:07", + // cannot detect month or day + "Sun, 1994 11 06 Nov 08:49:37 GMT", + "08/22/2006 06:30", + "08/22/2006 06:30 AM", + "08/22/2006 6:30", + "08/22/2006 08:49:37", + "2006 08 22 08:49:37", + "30 Feb 1970 00:00:00 UTC" + ); + for (String dateTimeText: invalidDateTimeSamples) { + assertThrows( + "Parse method should throw exception for invalid input datetime text: '" + dateTimeText + "'", + DateTimeParseException.class, + () -> TimeUtil.parseHttpDateTime(dateTimeText) + ); } } }