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