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