| /* 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>This implementation silently discards unrecognized attributes. In |
| * particular, the {@code HttpOnly} attribute is widely served but isn't in any |
| * of the above specs. It was introduced by Internet Explorer to prevent server |
| * cookies from being exposed in the DOM to JavaScript, etc. |
| * |
| * @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 |
| RESERVED_NAMES.add("commenturl"); // RFC 2965 |
| RESERVED_NAMES.add("discard"); // RFC 2965 |
| RESERVED_NAMES.add("domain"); // Netscape RFC 2109 RFC 2965 |
| RESERVED_NAMES.add("expires"); // Netscape |
| RESERVED_NAMES.add("max-age"); // RFC 2109 RFC 2965 |
| RESERVED_NAMES.add("path"); // Netscape RFC 2109 RFC 2965 |
| RESERVED_NAMES.add("port"); // RFC 2965 |
| RESERVED_NAMES.add("secure"); // Netscape RFC 2109 RFC 2965 |
| RESERVED_NAMES.add("version"); // RFC 2109 RFC 2965 |
| } |
| |
| /** |
| * 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) { |
| hasMaxAge = true; |
| cookie.maxAge = Long.parseLong(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("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 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. |
| */ |
| @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("\""); |
| } |
| } |
| } |