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 value of this cookie.
564     */
565    public String getValue() {
566        return value;
567    }
568
569    /**
570     * Returns the version of this cookie.
571     */
572    public int getVersion() {
573        return version;
574    }
575
576    /**
577     * Returns true if this cookie's Max-Age is 0.
578     */
579    public boolean hasExpired() {
580        // -1 indicates the cookie will persist until browser shutdown
581        // so the cookie is not expired.
582        if (maxAge == -1l) {
583            return false;
584        }
585
586        boolean expired = false;
587        if (maxAge <= 0l) {
588            expired = true;
589        }
590        return expired;
591    }
592
593    /**
594     * Set the {@code Comment} attribute of this cookie.
595     */
596    public void setComment(String comment) {
597        this.comment = comment;
598    }
599
600    /**
601     * Set the {@code CommentURL} attribute of this cookie.
602     */
603    public void setCommentURL(String commentURL) {
604        this.commentURL = commentURL;
605    }
606
607    /**
608     * Set the {@code Discard} attribute of this cookie.
609     */
610    public void setDiscard(boolean discard) {
611        this.discard = discard;
612    }
613
614    /**
615     * Set the {@code Domain} attribute of this cookie. HTTP clients send
616     * cookies only to matching domains.
617     */
618    public void setDomain(String pattern) {
619        domain = pattern == null ? null : pattern.toLowerCase(Locale.US);
620    }
621
622    /**
623     * Sets the {@code Max-Age} attribute of this cookie.
624     */
625    public void setMaxAge(long deltaSeconds) {
626        maxAge = deltaSeconds;
627    }
628
629    private void setExpires(Date expires) {
630        maxAge = (expires.getTime() - System.currentTimeMillis()) / 1000;
631    }
632
633    /**
634     * Set the {@code Path} attribute of this cookie. HTTP clients send cookies
635     * to this path and its subpaths.
636     */
637    public void setPath(String path) {
638        this.path = path;
639    }
640
641    /**
642     * Set the {@code Port} attribute of this cookie.
643     */
644    public void setPortlist(String portList) {
645        this.portList = portList;
646    }
647
648    /**
649     * Sets the {@code Secure} attribute of this cookie.
650     */
651    public void setSecure(boolean secure) {
652        this.secure = secure;
653    }
654
655    /**
656     * Sets the opaque value of this cookie.
657     */
658    public void setValue(String value) {
659        // FIXME: According to spec, version 0 cookie value does not allow many
660        // symbols. But RI does not implement it. Follow RI temporarily.
661        this.value = value;
662    }
663
664    /**
665     * Sets the {@code Version} attribute of the cookie.
666     *
667     * @throws IllegalArgumentException if v is neither 0 nor 1
668     */
669    public void setVersion(int newVersion) {
670        if (newVersion != 0 && newVersion != 1) {
671            throw new IllegalArgumentException("Bad version: " + newVersion);
672        }
673        version = newVersion;
674    }
675
676    @Override public Object clone() {
677        try {
678            return super.clone();
679        } catch (CloneNotSupportedException e) {
680            throw new AssertionError();
681        }
682    }
683
684    /**
685     * Returns true if {@code object} is a cookie with the same domain, name and
686     * path. Domain and name use case-insensitive comparison; path uses a
687     * case-sensitive comparison.
688     */
689    @Override public boolean equals(Object object) {
690        if (object == this) {
691            return true;
692        }
693        if (object instanceof HttpCookie) {
694            HttpCookie that = (HttpCookie) object;
695            return name.equalsIgnoreCase(that.getName())
696                    && (domain != null ? domain.equalsIgnoreCase(that.domain) : that.domain == null)
697                    && Objects.equal(path, that.path);
698        }
699        return false;
700    }
701
702    /**
703     * Returns the hash code of this HTTP cookie: <pre>   {@code
704     *   name.toLowerCase(Locale.US).hashCode()
705     *       + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
706     *       + (path == null ? 0 : path.hashCode())
707     * }</pre>
708     */
709    @Override public int hashCode() {
710        return name.toLowerCase(Locale.US).hashCode()
711                + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
712                + (path == null ? 0 : path.hashCode());
713    }
714
715    /**
716     * Returns a string representing this cookie in the format used by the
717     * {@code Cookie} header line in an HTTP request as specified by RFC 2965 section 3.3.4.
718     *
719     * <p>The resulting string does not include a "Cookie:" prefix or any version information.
720     * The returned {@code String} is not suitable for passing to {@link #parse(String)}: Several of
721     * the attributes that would be needed to preserve all of the cookie's information are omitted.
722     * The String is formatted for an HTTP request not an HTTP response.
723     *
724     * <p>The attributes included and the format depends on the cookie's {@code version}:
725     * <ul>
726     *     <li>Version 0: Includes only the name and value. Conforms to RFC 2965 (for
727     *     version 0 cookies). This should also be used to conform with RFC 6265.
728     *     </li>
729     *     <li>Version 1: Includes the name and value, and Path, Domain and Port attributes.
730     *     Conforms to RFC 2965 (for version 1 cookies).</li>
731     * </ul>
732     */
733    @Override public String toString() {
734        if (version == 0) {
735            return name + "=" + value;
736        }
737
738        StringBuilder result = new StringBuilder()
739                .append(name)
740                .append("=")
741                .append("\"")
742                .append(value)
743                .append("\"");
744        appendAttribute(result, "Path", path);
745        appendAttribute(result, "Domain", domain);
746        appendAttribute(result, "Port", portList);
747        return result.toString();
748    }
749
750    private void appendAttribute(StringBuilder builder, String name, String value) {
751        if (value != null && builder != null) {
752            builder.append(";$");
753            builder.append(name);
754            builder.append("=\"");
755            builder.append(value);
756            builder.append("\"");
757        }
758    }
759}
760