/* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package java.net;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import libcore.net.http.HttpDate;
import libcore.util.Objects;
/**
* An opaque key-value value pair held by an HTTP client to permit a stateful
* session with an HTTP server. This class parses cookie headers for all three
* commonly used HTTP cookie specifications:
*
* <ul>
* <li>The Netscape cookie spec is officially obsolete but widely used in
* practice. Each cookie contains one key-value pair and the following
* attributes: {@code Domain}, {@code Expires}, {@code Path}, and
* {@code Secure}. The {@link #getVersion() version} of cookies in this
* format is {@code 0}.
* <p>There are no accessors for the {@code Expires} attribute. When
* parsed, expires attributes are assigned to the {@link #getMaxAge()
* Max-Age} attribute as an offset from {@link System#currentTimeMillis()
* now}.
* <li><a href="http://www.ietf.org/rfc/rfc2109.txt">RFC 2109</a> formalizes
* the Netscape cookie spec. It replaces the {@code Expires} timestamp
* with a {@code Max-Age} duration and adds {@code Comment} and {@code
* Version} attributes. The {@link #getVersion() version} of cookies in
* this format is {@code 1}.
* <li><a href="http://www.ietf.org/rfc/rfc2965.txt">RFC 2965</a> refines
* RFC 2109. It adds {@code Discard}, {@code Port}, and {@code
* CommentURL} attributes and renames the header from {@code Set-Cookie}
* to {@code Set-Cookie2}. The {@link #getVersion() version} of cookies
* in this format is {@code 1}.
* </ul>
*
* <p>Support for the "HttpOnly" attribute specified in
* <a href="http://tools.ietf.org/html/rfc6265">RFC 6265</a> is also included. RFC 6265 is intended
* to obsolete RFC 2965. Support for features from RFC 2965 that have been deprecated by RFC 6265
* such as Cookie2, Set-Cookie2 headers and version information remain supported by this class.
*
* <p>This implementation silently discards unrecognized attributes.
*
* @since 1.6
*/
public final class HttpCookie implements Cloneable {
private static final Set<String> RESERVED_NAMES = new HashSet<String>();
static {
RESERVED_NAMES.add("comment"); // RFC 2109 RFC 2965 RFC 6265
RESERVED_NAMES.add("commenturl"); // RFC 2965 RFC 6265
RESERVED_NAMES.add("discard"); // RFC 2965 RFC 6265
RESERVED_NAMES.add("domain"); // Netscape RFC 2109 RFC 2965 RFC 6265
RESERVED_NAMES.add("expires"); // Netscape
RESERVED_NAMES.add("httponly"); // RFC 6265
RESERVED_NAMES.add("max-age"); // RFC 2109 RFC 2965 RFC 6265
RESERVED_NAMES.add("path"); // Netscape RFC 2109 RFC 2965 RFC 6265
RESERVED_NAMES.add("port"); // RFC 2965 RFC 6265
RESERVED_NAMES.add("secure"); // Netscape RFC 2109 RFC 2965 RFC 6265
RESERVED_NAMES.add("version"); // RFC 2109 RFC 2965 RFC 6265
}
/**
* Returns true if {@code host} matches the domain pattern {@code domain}.
*
* @param domainPattern a host name (like {@code android.com} or {@code
* localhost}), or a pattern to match subdomains of a domain name (like
* {@code .android.com}). A special case pattern is {@code .local},
* which matches all hosts without a TLD (like {@code localhost}).
* @param host the host name or IP address from an HTTP request.
*/
public static boolean domainMatches(String domainPattern, String host) {
if (domainPattern == null || host == null) {
return false;
}
String a = host.toLowerCase(Locale.US);
String b = domainPattern.toLowerCase(Locale.US);
/*
* From the spec: "both host names are IP addresses and their host name strings match
* exactly; or both host names are FQDN strings and their host name strings match exactly"
*/
if (a.equals(b) && (isFullyQualifiedDomainName(a, 0) || InetAddress.isNumeric(a))) {
return true;
}
if (!isFullyQualifiedDomainName(a, 0)) {
return b.equals(".local");
}
/*
* Not in the spec! If prefixing a hostname with "." causes it to equal the domain pattern,
* then it should match. This is necessary so that the pattern ".google.com" will match the
* host "google.com".
*/
if (b.length() == 1 + a.length()
&& b.startsWith(".")
&& b.endsWith(a)
&& isFullyQualifiedDomainName(b, 1)) {
return true;
}
/*
* From the spec: "A is a HDN string and has the form NB, where N is a
* non-empty name string, B has the form .B', and B' is a HDN string.
* (So, x.y.com domain-matches .Y.com but not Y.com.)
*/
return a.length() > b.length()
&& a.endsWith(b)
&& ((b.startsWith(".") && isFullyQualifiedDomainName(b, 1)) || b.equals(".local"));
}
/**
* Returns true if {@code cookie} should be sent to or accepted from {@code uri} with respect
* to the cookie's path. Cookies match by directory prefix: URI "/foo" matches cookies "/foo",
* "/foo/" and "/foo/bar", but not "/" or "/foobar".
*/
static boolean pathMatches(HttpCookie cookie, URI uri) {
String uriPath = matchablePath(uri.getPath());
String cookiePath = matchablePath(cookie.getPath());
return uriPath.startsWith(cookiePath);
}
/**
* Returns true if {@code cookie} should be sent to {@code uri} with respect to the cookie's
* secure attribute. Secure cookies should not be sent in insecure (ie. non-HTTPS) requests.
*/
static boolean secureMatches(HttpCookie cookie, URI uri) {
return !cookie.getSecure() || "https".equalsIgnoreCase(uri.getScheme());
}
/**
* Returns true if {@code cookie} should be sent to {@code uri} with respect to the cookie's
* port list.
*/
static boolean portMatches(HttpCookie cookie, URI uri) {
if (cookie.getPortlist() == null) {
return true;
}
return Arrays.asList(cookie.getPortlist().split(","))
.contains(Integer.toString(uri.getEffectivePort()));
}
/**
* Returns a non-null path ending in "/".
*/
private static String matchablePath(String path) {
if (path == null) {
return "/";
} else if (path.endsWith("/")) {
return path;
} else {
return path + "/";
}
}
/**
* Returns true if {@code s.substring(firstCharacter)} contains a dot
* between its first and last characters, exclusive. This considers both
* {@code android.com} and {@code co.uk} to be fully qualified domain names,
* but not {@code android.com.}, {@code .com}. or {@code android}.
*
* <p>Although this implements the cookie spec's definition of FQDN, it is
* not general purpose. For example, this returns true for IPv4 addresses.
*/
private static boolean isFullyQualifiedDomainName(String s, int firstCharacter) {
int dotPosition = s.indexOf('.', firstCharacter + 1);
return dotPosition != -1 && dotPosition < s.length() - 1;
}
/**
* Constructs a cookie from a string. The string should comply with
* set-cookie or set-cookie2 header format as specified in
* <a href="http://www.ietf.org/rfc/rfc2965.txt">RFC 2965</a>. Since
* set-cookies2 syntax allows more than one cookie definitions in one
* header, the returned object is a list.
*
* @param header
* a set-cookie or set-cookie2 header.
* @return a list of constructed cookies
* @throws IllegalArgumentException
* if the string does not comply with cookie specification, or
* the cookie name contains illegal characters, or reserved
* tokens of cookie specification appears
* @throws NullPointerException
* if header is null
*/
public static List<HttpCookie> parse(String header) {
return new CookieParser(header).parse();
}
static class CookieParser {
private static final String ATTRIBUTE_NAME_TERMINATORS = ",;= \t";
private static final String WHITESPACE = " \t";
private final String input;
private final String inputLowerCase;
private int pos = 0;
/*
* The cookie's version is set based on an overly complex heuristic:
* If it has an expires attribute, the version is 0.
* Otherwise, if it has a max-age attribute, the version is 1.
* Otherwise, if the cookie started with "Set-Cookie2", the version is 1.
* Otherwise, if it has any explicit version attributes, use the first one.
* Otherwise, the version is 0.
*/
boolean hasExpires = false;
boolean hasMaxAge = false;
boolean hasVersion = false;
CookieParser(String input) {
this.input = input;
this.inputLowerCase = input.toLowerCase(Locale.US);
}
public List<HttpCookie> parse() {
List<HttpCookie> cookies = new ArrayList<HttpCookie>(2);
// The RI permits input without either the "Set-Cookie:" or "Set-Cookie2" headers.
boolean pre2965 = true;
if (inputLowerCase.startsWith("set-cookie2:")) {
pos += "set-cookie2:".length();
pre2965 = false;
hasVersion = true;
} else if (inputLowerCase.startsWith("set-cookie:")) {
pos += "set-cookie:".length();
}
/*
* Read a comma-separated list of cookies. Note that the values may contain commas!
* <NAME> "=" <VALUE> ( ";" <ATTR NAME> ( "=" <ATTR VALUE> )? )*
*/
while (true) {
String name = readAttributeName(false);
if (name == null) {
if (cookies.isEmpty()) {
throw new IllegalArgumentException("No cookies in " + input);
}
return cookies;
}
if (!readEqualsSign()) {
throw new IllegalArgumentException(
"Expected '=' after " + name + " in " + input);
}
String value = readAttributeValue(pre2965 ? ";" : ",;");
HttpCookie cookie = new HttpCookie(name, value);
cookie.version = pre2965 ? 0 : 1;
cookies.add(cookie);
/*
* Read the attributes of the current cookie. Each iteration of this loop should
* enter with input either exhausted or prefixed with ';' or ',' as in ";path=/"
* and ",COOKIE2=value2".
*/
while (true) {
skipWhitespace();
if (pos == input.length()) {
break;
}
if (input.charAt(pos) == ',') {
pos++;
break; // a true comma delimiter; the current cookie is complete.
} else if (input.charAt(pos) == ';') {
pos++;
}
String attributeName = readAttributeName(true);
if (attributeName == null) {
continue; // for empty attribute as in "Set-Cookie: foo=Foo;;path=/"
}
/*
* Since expires and port attributes commonly include comma delimiters, always
* scan until a semicolon when parsing these attributes.
*/
String terminators = pre2965
|| "expires".equals(attributeName) || "port".equals(attributeName)
? ";"
: ";,";
String attributeValue = null;
if (readEqualsSign()) {
attributeValue = readAttributeValue(terminators);
}
setAttribute(cookie, attributeName, attributeValue);
}
if (hasExpires) {
cookie.version = 0;
} else if (hasMaxAge) {
cookie.version = 1;
}
}
}
private void setAttribute(HttpCookie cookie, String name, String value) {
if (name.equals("comment") && cookie.comment == null) {
cookie.comment = value;
} else if (name.equals("commenturl") && cookie.commentURL == null) {
cookie.commentURL = value;
} else if (name.equals("discard")) {
cookie.discard = true;
} else if (name.equals("domain") && cookie.domain == null) {
cookie.domain = value;
} else if (name.equals("expires")) {
hasExpires = true;
if (cookie.maxAge == -1L) {
Date date = HttpDate.parse(value);
if (date != null) {
cookie.setExpires(date);
} else {
cookie.maxAge = 0;
}
}
} else if (name.equals("max-age") && cookie.maxAge == -1L) {
// RFCs 2109 and 2965 suggests a zero max-age as a way of deleting a cookie.
// RFC 6265 specifies the value must be > 0 but also describes what to do if the
// value is negative, zero or non-numeric in section 5.2.2. The RI does none of this
// and accepts negative, positive values and throws an IllegalArgumentException
// if the value is non-numeric.
try {
long maxAge = Long.parseLong(value);
hasMaxAge = true;
cookie.maxAge = maxAge;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid max-age: " + value);
}
} else if (name.equals("path") && cookie.path == null) {
cookie.path = value;
} else if (name.equals("port") && cookie.portList == null) {
cookie.portList = value != null ? value : "";
} else if (name.equals("secure")) {
cookie.secure = true;
} else if (name.equals("httponly")) {
cookie.httpOnly = true;
} else if (name.equals("version") && !hasVersion) {
cookie.version = Integer.parseInt(value);
}
}
/**
* Returns the next attribute name, or null if the input has been
* exhausted. Returns wth the cursor on the delimiter that follows.
*/
private String readAttributeName(boolean returnLowerCase) {
skipWhitespace();
int c = find(ATTRIBUTE_NAME_TERMINATORS);
String forSubstring = returnLowerCase ? inputLowerCase : input;
String result = pos < c ? forSubstring.substring(pos, c) : null;
pos = c;
return result;
}
/**
* Returns true if an equals sign was read and consumed.
*/
private boolean readEqualsSign() {
skipWhitespace();
if (pos < input.length() && input.charAt(pos) == '=') {
pos++;
return true;
}
return false;
}
/**
* Reads an attribute value, by parsing either a quoted string or until
* the next character in {@code terminators}. The terminator character
* is not consumed.
*/
private String readAttributeValue(String terminators) {
skipWhitespace();
/*
* Quoted string: read 'til the close quote. The spec mentions only "double quotes"
* but RI bug 6901170 claims that 'single quotes' are also used.
*/
if (pos < input.length() && (input.charAt(pos) == '"' || input.charAt(pos) == '\'')) {
char quoteCharacter = input.charAt(pos++);
int closeQuote = input.indexOf(quoteCharacter, pos);
if (closeQuote == -1) {
throw new IllegalArgumentException("Unterminated string literal in " + input);
}
String result = input.substring(pos, closeQuote);
pos = closeQuote + 1;
return result;
}
int c = find(terminators);
String result = input.substring(pos, c);
pos = c;
return result;
}
/**
* Returns the index of the next character in {@code chars}, or the end
* of the string.
*/
private int find(String chars) {
for (int c = pos; c < input.length(); c++) {
if (chars.indexOf(input.charAt(c)) != -1) {
return c;
}
}
return input.length();
}
private void skipWhitespace() {
for (; pos < input.length(); pos++) {
if (WHITESPACE.indexOf(input.charAt(pos)) == -1) {
break;
}
}
}
}
private String comment;
private String commentURL;
private boolean discard;
private String domain;
private long maxAge = -1l;
private final String name;
private String path;
private String portList;
private boolean secure;
private boolean httpOnly;
private String value;
private int version = 1;
/**
* Creates a new cookie.
*
* @param name a non-empty string that contains only printable ASCII, no
* commas or semicolons, and is not prefixed with {@code $}. May not be
* an HTTP attribute name.
* @param value an opaque value from the HTTP server.
* @throws IllegalArgumentException if {@code name} is invalid.
*/
public HttpCookie(String name, String value) {
String ntrim = name.trim(); // erase leading and trailing whitespace
if (!isValidName(ntrim)) {
throw new IllegalArgumentException("Invalid name: " + name);
}
this.name = ntrim;
this.value = value;
}
private boolean isValidName(String n) {
// name cannot be empty or begin with '$' or equals the reserved
// attributes (case-insensitive)
boolean isValid = !(n.length() == 0 || n.startsWith("$")
|| RESERVED_NAMES.contains(n.toLowerCase(Locale.US)));
if (isValid) {
for (int i = 0; i < n.length(); i++) {
char nameChar = n.charAt(i);
// name must be ASCII characters and cannot contain ';', ',' and
// whitespace
if (nameChar < 0
|| nameChar >= 127
|| nameChar == ';'
|| nameChar == ','
|| (Character.isWhitespace(nameChar) && nameChar != ' ')) {
isValid = false;
break;
}
}
}
return isValid;
}
/**
* Returns the {@code Comment} attribute.
*/
public String getComment() {
return comment;
}
/**
* Returns the value of {@code CommentURL} attribute.
*/
public String getCommentURL() {
return commentURL;
}
/**
* Returns the {@code Discard} attribute.
*/
public boolean getDiscard() {
return discard;
}
/**
* Returns the {@code Domain} attribute.
*/
public String getDomain() {
return domain;
}
/**
* Returns the {@code Max-Age} attribute, in delta-seconds.
*/
public long getMaxAge() {
return maxAge;
}
/**
* Returns the name of this cookie.
*/
public String getName() {
return name;
}
/**
* Returns the {@code Path} attribute. This cookie is visible to all
* subpaths.
*/
public String getPath() {
return path;
}
/**
* Returns the {@code Port} attribute, usually containing comma-separated
* port numbers. A null port indicates that the cookie may be sent to any
* port. The empty string indicates that the cookie should only be sent to
* the port of the originating request.
*/
public String getPortlist() {
return portList;
}
/**
* Returns the {@code Secure} attribute.
*/
public boolean getSecure() {
return secure;
}
/**
* Returns the value of this cookie.
*/
public String getValue() {
return value;
}
/**
* Returns the version of this cookie.
*/
public int getVersion() {
return version;
}
/**
* Returns true if this cookie's Max-Age is 0.
*/
public boolean hasExpired() {
// -1 indicates the cookie will persist until browser shutdown
// so the cookie is not expired.
if (maxAge == -1l) {
return false;
}
boolean expired = false;
if (maxAge <= 0l) {
expired = true;
}
return expired;
}
/**
* Set the {@code Comment} attribute of this cookie.
*/
public void setComment(String comment) {
this.comment = comment;
}
/**
* Set the {@code CommentURL} attribute of this cookie.
*/
public void setCommentURL(String commentURL) {
this.commentURL = commentURL;
}
/**
* Set the {@code Discard} attribute of this cookie.
*/
public void setDiscard(boolean discard) {
this.discard = discard;
}
/**
* Set the {@code Domain} attribute of this cookie. HTTP clients send
* cookies only to matching domains.
*/
public void setDomain(String pattern) {
domain = pattern == null ? null : pattern.toLowerCase(Locale.US);
}
/**
* Sets the {@code Max-Age} attribute of this cookie.
*/
public void setMaxAge(long deltaSeconds) {
maxAge = deltaSeconds;
}
private void setExpires(Date expires) {
maxAge = (expires.getTime() - System.currentTimeMillis()) / 1000;
}
/**
* Set the {@code Path} attribute of this cookie. HTTP clients send cookies
* to this path and its subpaths.
*/
public void setPath(String path) {
this.path = path;
}
/**
* Set the {@code Port} attribute of this cookie.
*/
public void setPortlist(String portList) {
this.portList = portList;
}
/**
* Sets the {@code Secure} attribute of this cookie.
*/
public void setSecure(boolean secure) {
this.secure = secure;
}
/**
* Sets the opaque value of this cookie.
*/
public void setValue(String value) {
// FIXME: According to spec, version 0 cookie value does not allow many
// symbols. But RI does not implement it. Follow RI temporarily.
this.value = value;
}
/**
* Sets the {@code Version} attribute of the cookie.
*
* @throws IllegalArgumentException if v is neither 0 nor 1
*/
public void setVersion(int newVersion) {
if (newVersion != 0 && newVersion != 1) {
throw new IllegalArgumentException("Bad version: " + newVersion);
}
version = newVersion;
}
@Override public Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
/**
* Returns true if {@code object} is a cookie with the same domain, name and
* path. Domain and name use case-insensitive comparison; path uses a
* case-sensitive comparison.
*/
@Override public boolean equals(Object object) {
if (object == this) {
return true;
}
if (object instanceof HttpCookie) {
HttpCookie that = (HttpCookie) object;
return name.equalsIgnoreCase(that.getName())
&& (domain != null ? domain.equalsIgnoreCase(that.domain) : that.domain == null)
&& Objects.equal(path, that.path);
}
return false;
}
/**
* Returns the hash code of this HTTP cookie: <pre> {@code
* name.toLowerCase(Locale.US).hashCode()
* + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
* + (path == null ? 0 : path.hashCode())
* }</pre>
*/
@Override public int hashCode() {
return name.toLowerCase(Locale.US).hashCode()
+ (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
+ (path == null ? 0 : path.hashCode());
}
/**
* Returns a string representing this cookie in the format used by the
* {@code Cookie} header line in an HTTP request as specified by RFC 2965 section 3.3.4.
*
* <p>The resulting string does not include a "Cookie:" prefix or any version information.
* The returned {@code String} is not suitable for passing to {@link #parse(String)}: Several of
* the attributes that would be needed to preserve all of the cookie's information are omitted.
* The String is formatted for an HTTP request not an HTTP response.
*
* <p>The attributes included and the format depends on the cookie's {@code version}:
* <ul>
* <li>Version 0: Includes only the name and value. Conforms to RFC 2965 (for
* version 0 cookies). This should also be used to conform with RFC 6265.
* </li>
* <li>Version 1: Includes the name and value, and Path, Domain and Port attributes.
* Conforms to RFC 2965 (for version 1 cookies).</li>
* </ul>
*/
@Override public String toString() {
if (version == 0) {
return name + "=" + value;
}
StringBuilder result = new StringBuilder()
.append(name)
.append("=")
.append("\"")
.append(value)
.append("\"");
appendAttribute(result, "Path", path);
appendAttribute(result, "Domain", domain);
appendAttribute(result, "Port", portList);
return result.toString();
}
private void appendAttribute(StringBuilder builder, String name, String value) {
if (value != null && builder != null) {
builder.append(";$");
builder.append(name);
builder.append("=\"");
builder.append(value);
builder.append("\"");
}
}
}