HttpCookie.java revision e9ed1b1450852172a48f9811ebb09d25f2f7e140
1/* Licensed to the Apache Software Foundation (ASF) under one or more
2 * contributor license agreements.  See the NOTICE file distributed with
3 * this work for additional information regarding copyright ownership.
4 * The ASF licenses this file to You under the Apache License, Version 2.0
5 * (the "License"); you may not use this file except in compliance with
6 * the License.  You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package java.net;
18
19import java.util.ArrayList;
20import java.util.Arrays;
21import java.util.Date;
22import java.util.HashSet;
23import java.util.List;
24import java.util.Locale;
25import java.util.Set;
26import libcore.net.http.HttpDate;
27import libcore.util.Objects;
28
29/**
30 * An opaque key-value value pair held by an HTTP client to permit a stateful
31 * session with an HTTP server. This class parses cookie headers for all three
32 * commonly used HTTP cookie specifications:
33 *
34 * <ul>
35 *     <li>The Netscape cookie spec is officially obsolete but widely used in
36 *         practice. Each cookie contains one key-value pair and the following
37 *         attributes: {@code Domain}, {@code Expires}, {@code Path}, and
38 *         {@code Secure}. The {@link #getVersion() version} of cookies in this
39 *         format is {@code 0}.
40 *         <p>There are no accessors for the {@code Expires} attribute. When
41 *         parsed, expires attributes are assigned to the {@link #getMaxAge()
42 *         Max-Age} attribute as an offset from {@link System#currentTimeMillis()
43 *         now}.
44 *     <li><a href="http://www.ietf.org/rfc/rfc2109.txt">RFC 2109</a> formalizes
45 *         the Netscape cookie spec. It replaces the {@code Expires} timestamp
46 *         with a {@code Max-Age} duration and adds {@code Comment} and {@code
47 *         Version} attributes. The {@link #getVersion() version} of cookies in
48 *         this format is {@code 1}.
49 *     <li><a href="http://www.ietf.org/rfc/rfc2965.txt">RFC 2965</a> refines
50 *         RFC 2109. It adds {@code Discard}, {@code Port}, and {@code
51 *         CommentURL} attributes and renames the header from {@code Set-Cookie}
52 *         to {@code Set-Cookie2}. The {@link #getVersion() version} of cookies
53 *         in this format is {@code 1}.
54 * </ul>
55 *
56 * <p>Support for the "HttpOnly" attribute specified in
57 * <a href="http://tools.ietf.org/html/rfc6265">RFC 6265</a> is also included. RFC 6265 is intended
58 * to obsolete RFC 2965. Support for features from RFC 2965 that have been deprecated by RFC 6265
59 * such as Cookie2, Set-Cookie2 headers and version information remain supported by this class.
60 *
61 * <p>This implementation silently discards unrecognized attributes.
62 *
63 * @since 1.6
64 */
65public final class HttpCookie implements Cloneable {
66
67    private static final Set<String> RESERVED_NAMES = new HashSet<String>();
68
69    static {
70        RESERVED_NAMES.add("comment");    //           RFC 2109  RFC 2965  RFC 6265
71        RESERVED_NAMES.add("commenturl"); //                     RFC 2965  RFC 6265
72        RESERVED_NAMES.add("discard");    //                     RFC 2965  RFC 6265
73        RESERVED_NAMES.add("domain");     // Netscape  RFC 2109  RFC 2965  RFC 6265
74        RESERVED_NAMES.add("expires");    // Netscape
75        RESERVED_NAMES.add("httponly");   //                               RFC 6265
76        RESERVED_NAMES.add("max-age");    //           RFC 2109  RFC 2965  RFC 6265
77        RESERVED_NAMES.add("path");       // Netscape  RFC 2109  RFC 2965  RFC 6265
78        RESERVED_NAMES.add("port");       //                     RFC 2965  RFC 6265
79        RESERVED_NAMES.add("secure");     // Netscape  RFC 2109  RFC 2965  RFC 6265
80        RESERVED_NAMES.add("version");    //           RFC 2109  RFC 2965  RFC 6265
81    }
82
83    /**
84     * Returns true if {@code host} matches the domain pattern {@code domain}.
85     *
86     * @param domainPattern a host name (like {@code android.com} or {@code
87     *     localhost}), or a pattern to match subdomains of a domain name (like
88     *     {@code .android.com}). A special case pattern is {@code .local},
89     *     which matches all hosts without a TLD (like {@code localhost}).
90     * @param host the host name or IP address from an HTTP request.
91     */
92    public static boolean domainMatches(String domainPattern, String host) {
93        if (domainPattern == null || host == null) {
94            return false;
95        }
96
97        String a = host.toLowerCase(Locale.US);
98        String b = domainPattern.toLowerCase(Locale.US);
99
100        /*
101         * From the spec: "both host names are IP addresses and their host name strings match
102         * exactly; or both host names are FQDN strings and their host name strings match exactly"
103         */
104        if (a.equals(b) && (isFullyQualifiedDomainName(a, 0) || InetAddress.isNumeric(a))) {
105            return true;
106        }
107        if (!isFullyQualifiedDomainName(a, 0)) {
108            return b.equals(".local");
109        }
110
111        /*
112         * Not in the spec! If prefixing a hostname with "." causes it to equal the domain pattern,
113         * then it should match. This is necessary so that the pattern ".google.com" will match the
114         * host "google.com".
115         */
116        if (b.length() == 1 + a.length()
117                && b.startsWith(".")
118                && b.endsWith(a)
119                && isFullyQualifiedDomainName(b, 1)) {
120            return true;
121        }
122
123        /*
124         * From the spec: "A is a HDN string and has the form NB, where N is a
125         * non-empty name string, B has the form .B', and B' is a HDN string.
126         * (So, x.y.com domain-matches .Y.com but not Y.com.)
127         */
128        return a.length() > b.length()
129                && a.endsWith(b)
130                && ((b.startsWith(".") && isFullyQualifiedDomainName(b, 1)) || b.equals(".local"));
131    }
132
133    /**
134     * Returns true if {@code cookie} should be sent to or accepted from {@code uri} with respect
135     * to the cookie's path. Cookies match by directory prefix: URI "/foo" matches cookies "/foo",
136     * "/foo/" and "/foo/bar", but not "/" or "/foobar".
137     */
138    static boolean pathMatches(HttpCookie cookie, URI uri) {
139        String uriPath = matchablePath(uri.getPath());
140        String cookiePath = matchablePath(cookie.getPath());
141        return uriPath.startsWith(cookiePath);
142    }
143
144    /**
145     * Returns true if {@code cookie} should be sent to {@code uri} with respect to the cookie's
146     * secure attribute. Secure cookies should not be sent in insecure (ie. non-HTTPS) requests.
147     */
148    static boolean secureMatches(HttpCookie cookie, URI uri) {
149        return !cookie.getSecure() || "https".equalsIgnoreCase(uri.getScheme());
150    }
151
152    /**
153     * Returns true if {@code cookie} should be sent to {@code uri} with respect to the cookie's
154     * port list.
155     */
156    static boolean portMatches(HttpCookie cookie, URI uri) {
157        if (cookie.getPortlist() == null) {
158            return true;
159        }
160        return Arrays.asList(cookie.getPortlist().split(","))
161                .contains(Integer.toString(uri.getEffectivePort()));
162    }
163
164    /**
165     * Returns a non-null path ending in "/".
166     */
167    private static String matchablePath(String path) {
168        if (path == null) {
169            return "/";
170        } else if (path.endsWith("/")) {
171            return path;
172        } else {
173            return path + "/";
174        }
175    }
176
177    /**
178     * Returns true if {@code s.substring(firstCharacter)} contains a dot
179     * between its first and last characters, exclusive. This considers both
180     * {@code android.com} and {@code co.uk} to be fully qualified domain names,
181     * but not {@code android.com.}, {@code .com}. or {@code android}.
182     *
183     * <p>Although this implements the cookie spec's definition of FQDN, it is
184     * not general purpose. For example, this returns true for IPv4 addresses.
185     */
186    private static boolean isFullyQualifiedDomainName(String s, int firstCharacter) {
187        int dotPosition = s.indexOf('.', firstCharacter + 1);
188        return dotPosition != -1 && dotPosition < s.length() - 1;
189    }
190
191    /**
192     * Constructs a cookie from a string. The string should comply with
193     * set-cookie or set-cookie2 header format as specified in
194     * <a href="http://www.ietf.org/rfc/rfc2965.txt">RFC 2965</a>. Since
195     * set-cookies2 syntax allows more than one cookie definitions in one
196     * header, the returned object is a list.
197     *
198     * @param header
199     *            a set-cookie or set-cookie2 header.
200     * @return a list of constructed cookies
201     * @throws IllegalArgumentException
202     *             if the string does not comply with cookie specification, or
203     *             the cookie name contains illegal characters, or reserved
204     *             tokens of cookie specification appears
205     * @throws NullPointerException
206     *             if header is null
207     */
208    public static List<HttpCookie> parse(String header) {
209        return new CookieParser(header).parse();
210    }
211
212    static class CookieParser {
213        private static final String ATTRIBUTE_NAME_TERMINATORS = ",;= \t";
214        private static final String WHITESPACE = " \t";
215        private final String input;
216        private final String inputLowerCase;
217        private int pos = 0;
218
219        /*
220         * The cookie's version is set based on an overly complex heuristic:
221         * If it has an expires attribute, the version is 0.
222         * Otherwise, if it has a max-age attribute, the version is 1.
223         * Otherwise, if the cookie started with "Set-Cookie2", the version is 1.
224         * Otherwise, if it has any explicit version attributes, use the first one.
225         * Otherwise, the version is 0.
226         */
227        boolean hasExpires = false;
228        boolean hasMaxAge = false;
229        boolean hasVersion = false;
230
231        CookieParser(String input) {
232            this.input = input;
233            this.inputLowerCase = input.toLowerCase(Locale.US);
234        }
235
236        public List<HttpCookie> parse() {
237            List<HttpCookie> cookies = new ArrayList<HttpCookie>(2);
238
239            // The RI permits input without either the "Set-Cookie:" or "Set-Cookie2" headers.
240            boolean pre2965 = true;
241            if (inputLowerCase.startsWith("set-cookie2:")) {
242                pos += "set-cookie2:".length();
243                pre2965 = false;
244                hasVersion = true;
245            } else if (inputLowerCase.startsWith("set-cookie:")) {
246                pos += "set-cookie:".length();
247            }
248
249            /*
250             * Read a comma-separated list of cookies. Note that the values may contain commas!
251             *   <NAME> "=" <VALUE> ( ";" <ATTR NAME> ( "=" <ATTR VALUE> )? )*
252             */
253            while (true) {
254                String name = readAttributeName(false);
255                if (name == null) {
256                    if (cookies.isEmpty()) {
257                        throw new IllegalArgumentException("No cookies in " + input);
258                    }
259                    return cookies;
260                }
261
262                if (!readEqualsSign()) {
263                    throw new IllegalArgumentException(
264                            "Expected '=' after " + name + " in " + input);
265                }
266
267                String value = readAttributeValue(pre2965 ? ";" : ",;");
268                HttpCookie cookie = new HttpCookie(name, value);
269                cookie.version = pre2965 ? 0 : 1;
270                cookies.add(cookie);
271
272                /*
273                 * Read the attributes of the current cookie. Each iteration of this loop should
274                 * enter with input either exhausted or prefixed with ';' or ',' as in ";path=/"
275                 * and ",COOKIE2=value2".
276                 */
277                while (true) {
278                    skipWhitespace();
279                    if (pos == input.length()) {
280                        break;
281                    }
282
283                    if (input.charAt(pos) == ',') {
284                        pos++;
285                        break; // a true comma delimiter; the current cookie is complete.
286                    } else if (input.charAt(pos) == ';') {
287                        pos++;
288                    }
289
290                    String attributeName = readAttributeName(true);
291                    if (attributeName == null) {
292                        continue; // for empty attribute as in "Set-Cookie: foo=Foo;;path=/"
293                    }
294
295                    /*
296                     * Since expires and port attributes commonly include comma delimiters, always
297                     * scan until a semicolon when parsing these attributes.
298                     */
299                    String terminators = pre2965
300                            || "expires".equals(attributeName) || "port".equals(attributeName)
301                            ? ";"
302                            : ";,";
303                    String attributeValue = null;
304                    if (readEqualsSign()) {
305                        attributeValue = readAttributeValue(terminators);
306                    }
307                    setAttribute(cookie, attributeName, attributeValue);
308                }
309
310                if (hasExpires) {
311                    cookie.version = 0;
312                } else if (hasMaxAge) {
313                    cookie.version = 1;
314                }
315            }
316        }
317
318        private void setAttribute(HttpCookie cookie, String name, String value) {
319            if (name.equals("comment") && cookie.comment == null) {
320                cookie.comment = value;
321            } else if (name.equals("commenturl") && cookie.commentURL == null) {
322                cookie.commentURL = value;
323            } else if (name.equals("discard")) {
324                cookie.discard = true;
325            } else if (name.equals("domain") && cookie.domain == null) {
326                cookie.domain = value;
327            } else if (name.equals("expires")) {
328                hasExpires = true;
329                if (cookie.maxAge == -1L) {
330                    Date date = HttpDate.parse(value);
331                    if (date != null) {
332                        cookie.setExpires(date);
333                    } else {
334                        cookie.maxAge = 0;
335                    }
336                }
337            } else if (name.equals("max-age") && cookie.maxAge == -1L) {
338                // RFCs 2109 and 2965 suggests a zero max-age as a way of deleting a cookie.
339                // RFC 6265 specifies the value must be > 0 but also describes what to do if the
340                // value is negative, zero or non-numeric in section 5.2.2. The RI does none of this
341                // and accepts negative, positive values and throws an IllegalArgumentException
342                // if the value is non-numeric.
343                try {
344                    long maxAge = Long.parseLong(value);
345                    hasMaxAge = true;
346                    cookie.maxAge = maxAge;
347                } catch (NumberFormatException e) {
348                    throw new IllegalArgumentException("Invalid max-age: " + value);
349                }
350            } else if (name.equals("path") && cookie.path == null) {
351                cookie.path = value;
352            } else if (name.equals("port") && cookie.portList == null) {
353                cookie.portList = value != null ? value : "";
354            } else if (name.equals("secure")) {
355                cookie.secure = true;
356            } else if (name.equals("httponly")) {
357                cookie.httpOnly = true;
358            } else if (name.equals("version") && !hasVersion) {
359                cookie.version = Integer.parseInt(value);
360            }
361        }
362
363        /**
364         * Returns the next attribute name, or null if the input has been
365         * exhausted. Returns wth the cursor on the delimiter that follows.
366         */
367        private String readAttributeName(boolean returnLowerCase) {
368            skipWhitespace();
369            int c = find(ATTRIBUTE_NAME_TERMINATORS);
370            String forSubstring = returnLowerCase ? inputLowerCase : input;
371            String result = pos < c ? forSubstring.substring(pos, c) : null;
372            pos = c;
373            return result;
374        }
375
376        /**
377         * Returns true if an equals sign was read and consumed.
378         */
379        private boolean readEqualsSign() {
380            skipWhitespace();
381            if (pos < input.length() && input.charAt(pos) == '=') {
382                pos++;
383                return true;
384            }
385            return false;
386        }
387
388        /**
389         * Reads an attribute value, by parsing either a quoted string or until
390         * the next character in {@code terminators}. The terminator character
391         * is not consumed.
392         */
393        private String readAttributeValue(String terminators) {
394            skipWhitespace();
395
396            /*
397             * Quoted string: read 'til the close quote. The spec mentions only "double quotes"
398             * but RI bug 6901170 claims that 'single quotes' are also used.
399             */
400            if (pos < input.length() && (input.charAt(pos) == '"' || input.charAt(pos) == '\'')) {
401                char quoteCharacter = input.charAt(pos++);
402                int closeQuote = input.indexOf(quoteCharacter, pos);
403                if (closeQuote == -1) {
404                    throw new IllegalArgumentException("Unterminated string literal in " + input);
405                }
406                String result = input.substring(pos, closeQuote);
407                pos = closeQuote + 1;
408                return result;
409            }
410
411            int c = find(terminators);
412            String result = input.substring(pos, c);
413            pos = c;
414            return result;
415        }
416
417        /**
418         * Returns the index of the next character in {@code chars}, or the end
419         * of the string.
420         */
421        private int find(String chars) {
422            for (int c = pos; c < input.length(); c++) {
423                if (chars.indexOf(input.charAt(c)) != -1) {
424                    return c;
425                }
426            }
427            return input.length();
428        }
429
430        private void skipWhitespace() {
431            for (; pos < input.length(); pos++) {
432                if (WHITESPACE.indexOf(input.charAt(pos)) == -1) {
433                    break;
434                }
435            }
436        }
437    }
438
439    private String comment;
440    private String commentURL;
441    private boolean discard;
442    private String domain;
443    private long maxAge = -1l;
444    private final String name;
445    private String path;
446    private String portList;
447    private boolean secure;
448    private boolean httpOnly;
449    private String value;
450    private int version = 1;
451
452    /**
453     * Creates a new cookie.
454     *
455     * @param name a non-empty string that contains only printable ASCII, no
456     *     commas or semicolons, and is not prefixed with  {@code $}. May not be
457     *     an HTTP attribute name.
458     * @param value an opaque value from the HTTP server.
459     * @throws IllegalArgumentException if {@code name} is invalid.
460     */
461    public HttpCookie(String name, String value) {
462        String ntrim = name.trim(); // erase leading and trailing whitespace
463        if (!isValidName(ntrim)) {
464            throw new IllegalArgumentException("Invalid name: " + name);
465        }
466
467        this.name = ntrim;
468        this.value = value;
469    }
470
471
472    private boolean isValidName(String n) {
473        // name cannot be empty or begin with '$' or equals the reserved
474        // attributes (case-insensitive)
475        boolean isValid = !(n.length() == 0 || n.startsWith("$")
476                || RESERVED_NAMES.contains(n.toLowerCase(Locale.US)));
477        if (isValid) {
478            for (int i = 0; i < n.length(); i++) {
479                char nameChar = n.charAt(i);
480                // name must be ASCII characters and cannot contain ';', ',' and
481                // whitespace
482                if (nameChar < 0
483                        || nameChar >= 127
484                        || nameChar == ';'
485                        || nameChar == ','
486                        || (Character.isWhitespace(nameChar) && nameChar != ' ')) {
487                    isValid = false;
488                    break;
489                }
490            }
491        }
492        return isValid;
493    }
494
495    /**
496     * Returns the {@code Comment} attribute.
497     */
498    public String getComment() {
499        return comment;
500    }
501
502    /**
503     * Returns the value of {@code CommentURL} attribute.
504     */
505    public String getCommentURL() {
506        return commentURL;
507    }
508
509    /**
510     * Returns the {@code Discard} attribute.
511     */
512    public boolean getDiscard() {
513        return discard;
514    }
515
516    /**
517     * Returns the {@code Domain} attribute.
518     */
519    public String getDomain() {
520        return domain;
521    }
522
523    /**
524     * Returns the {@code Max-Age} attribute, in delta-seconds.
525     */
526    public long getMaxAge() {
527        return maxAge;
528    }
529
530    /**
531     * Returns the name of this cookie.
532     */
533    public String getName() {
534        return name;
535    }
536
537    /**
538     * Returns the {@code Path} attribute. This cookie is visible to all
539     * subpaths.
540     */
541    public String getPath() {
542        return path;
543    }
544
545    /**
546     * Returns the {@code Port} attribute, usually containing comma-separated
547     * port numbers. A null port indicates that the cookie may be sent to any
548     * port. The empty string indicates that the cookie should only be sent to
549     * the port of the originating request.
550     */
551    public String getPortlist() {
552        return portList;
553    }
554
555    /**
556     * Returns the {@code Secure} attribute.
557     */
558    public boolean getSecure() {
559        return secure;
560    }
561
562    /**
563     * Returns the {@code HttpOnly} attribute. If {@code true} the cookie should not be accessible
564     * to scripts in a browser.
565     *
566     * @since 1.7
567     */
568    public boolean isHttpOnly() {
569        return httpOnly;
570    }
571
572    /**
573     * Returns the {@code HttpOnly} attribute. If {@code true} the cookie should not be accessible
574     * to scripts in a browser.
575     *
576     * @since 1.7
577     */
578    public void setHttpOnly(boolean httpOnly) {
579        this.httpOnly = httpOnly;
580    }
581
582    /**
583     * Returns the value of this cookie.
584     */
585    public String getValue() {
586        return value;
587    }
588
589    /**
590     * Returns the version of this cookie.
591     */
592    public int getVersion() {
593        return version;
594    }
595
596    /**
597     * Returns true if this cookie's Max-Age is 0.
598     */
599    public boolean hasExpired() {
600        // -1 indicates the cookie will persist until browser shutdown
601        // so the cookie is not expired.
602        if (maxAge == -1l) {
603            return false;
604        }
605
606        boolean expired = false;
607        if (maxAge <= 0l) {
608            expired = true;
609        }
610        return expired;
611    }
612
613    /**
614     * Set the {@code Comment} attribute of this cookie.
615     */
616    public void setComment(String comment) {
617        this.comment = comment;
618    }
619
620    /**
621     * Set the {@code CommentURL} attribute of this cookie.
622     */
623    public void setCommentURL(String commentURL) {
624        this.commentURL = commentURL;
625    }
626
627    /**
628     * Set the {@code Discard} attribute of this cookie.
629     */
630    public void setDiscard(boolean discard) {
631        this.discard = discard;
632    }
633
634    /**
635     * Set the {@code Domain} attribute of this cookie. HTTP clients send
636     * cookies only to matching domains.
637     */
638    public void setDomain(String pattern) {
639        domain = pattern == null ? null : pattern.toLowerCase(Locale.US);
640    }
641
642    /**
643     * Sets the {@code Max-Age} attribute of this cookie.
644     */
645    public void setMaxAge(long deltaSeconds) {
646        maxAge = deltaSeconds;
647    }
648
649    private void setExpires(Date expires) {
650        maxAge = (expires.getTime() - System.currentTimeMillis()) / 1000;
651    }
652
653    /**
654     * Set the {@code Path} attribute of this cookie. HTTP clients send cookies
655     * to this path and its subpaths.
656     */
657    public void setPath(String path) {
658        this.path = path;
659    }
660
661    /**
662     * Set the {@code Port} attribute of this cookie.
663     */
664    public void setPortlist(String portList) {
665        this.portList = portList;
666    }
667
668    /**
669     * Sets the {@code Secure} attribute of this cookie.
670     */
671    public void setSecure(boolean secure) {
672        this.secure = secure;
673    }
674
675    /**
676     * Sets the opaque value of this cookie.
677     */
678    public void setValue(String value) {
679        // FIXME: According to spec, version 0 cookie value does not allow many
680        // symbols. But RI does not implement it. Follow RI temporarily.
681        this.value = value;
682    }
683
684    /**
685     * Sets the {@code Version} attribute of the cookie.
686     *
687     * @throws IllegalArgumentException if v is neither 0 nor 1
688     */
689    public void setVersion(int newVersion) {
690        if (newVersion != 0 && newVersion != 1) {
691            throw new IllegalArgumentException("Bad version: " + newVersion);
692        }
693        version = newVersion;
694    }
695
696    @Override public Object clone() {
697        try {
698            return super.clone();
699        } catch (CloneNotSupportedException e) {
700            throw new AssertionError();
701        }
702    }
703
704    /**
705     * Returns true if {@code object} is a cookie with the same domain, name and
706     * path. Domain and name use case-insensitive comparison; path uses a
707     * case-sensitive comparison.
708     */
709    @Override public boolean equals(Object object) {
710        if (object == this) {
711            return true;
712        }
713        if (object instanceof HttpCookie) {
714            HttpCookie that = (HttpCookie) object;
715            return name.equalsIgnoreCase(that.getName())
716                    && (domain != null ? domain.equalsIgnoreCase(that.domain) : that.domain == null)
717                    && Objects.equal(path, that.path);
718        }
719        return false;
720    }
721
722    /**
723     * Returns the hash code of this HTTP cookie: <pre>   {@code
724     *   name.toLowerCase(Locale.US).hashCode()
725     *       + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
726     *       + (path == null ? 0 : path.hashCode())
727     * }</pre>
728     */
729    @Override public int hashCode() {
730        return name.toLowerCase(Locale.US).hashCode()
731                + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
732                + (path == null ? 0 : path.hashCode());
733    }
734
735    /**
736     * Returns a string representing this cookie in the format used by the
737     * {@code Cookie} header line in an HTTP request as specified by RFC 2965 section 3.3.4.
738     *
739     * <p>The resulting string does not include a "Cookie:" prefix or any version information.
740     * The returned {@code String} is not suitable for passing to {@link #parse(String)}: Several of
741     * the attributes that would be needed to preserve all of the cookie's information are omitted.
742     * The String is formatted for an HTTP request not an HTTP response.
743     *
744     * <p>The attributes included and the format depends on the cookie's {@code version}:
745     * <ul>
746     *     <li>Version 0: Includes only the name and value. Conforms to RFC 2965 (for
747     *     version 0 cookies). This should also be used to conform with RFC 6265.
748     *     </li>
749     *     <li>Version 1: Includes the name and value, and Path, Domain and Port attributes.
750     *     Conforms to RFC 2965 (for version 1 cookies).</li>
751     * </ul>
752     */
753    @Override public String toString() {
754        if (version == 0) {
755            return name + "=" + value;
756        }
757
758        StringBuilder result = new StringBuilder()
759                .append(name)
760                .append("=")
761                .append("\"")
762                .append(value)
763                .append("\"");
764        appendAttribute(result, "Path", path);
765        appendAttribute(result, "Domain", domain);
766        appendAttribute(result, "Port", portList);
767        return result.toString();
768    }
769
770    private void appendAttribute(StringBuilder builder, String name, String value) {
771        if (value != null && builder != null) {
772            builder.append(";$");
773            builder.append(name);
774            builder.append("=\"");
775            builder.append(value);
776            builder.append("\"");
777        }
778    }
779}
780