HttpCookie.java revision 3bb69fa0b8fe5119c3f19cd7f5d725118aa506af
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                hasMaxAge = true;
339                cookie.maxAge = Long.parseLong(value);
340            } else if (name.equals("path") && cookie.path == null) {
341                cookie.path = value;
342            } else if (name.equals("port") && cookie.portList == null) {
343                cookie.portList = value != null ? value : "";
344            } else if (name.equals("secure")) {
345                cookie.secure = true;
346            } else if (name.equals("httponly")) {
347                cookie.httpOnly = true;
348            } else if (name.equals("version") && !hasVersion) {
349                cookie.version = Integer.parseInt(value);
350            }
351        }
352
353        /**
354         * Returns the next attribute name, or null if the input has been
355         * exhausted. Returns wth the cursor on the delimiter that follows.
356         */
357        private String readAttributeName(boolean returnLowerCase) {
358            skipWhitespace();
359            int c = find(ATTRIBUTE_NAME_TERMINATORS);
360            String forSubstring = returnLowerCase ? inputLowerCase : input;
361            String result = pos < c ? forSubstring.substring(pos, c) : null;
362            pos = c;
363            return result;
364        }
365
366        /**
367         * Returns true if an equals sign was read and consumed.
368         */
369        private boolean readEqualsSign() {
370            skipWhitespace();
371            if (pos < input.length() && input.charAt(pos) == '=') {
372                pos++;
373                return true;
374            }
375            return false;
376        }
377
378        /**
379         * Reads an attribute value, by parsing either a quoted string or until
380         * the next character in {@code terminators}. The terminator character
381         * is not consumed.
382         */
383        private String readAttributeValue(String terminators) {
384            skipWhitespace();
385
386            /*
387             * Quoted string: read 'til the close quote. The spec mentions only "double quotes"
388             * but RI bug 6901170 claims that 'single quotes' are also used.
389             */
390            if (pos < input.length() && (input.charAt(pos) == '"' || input.charAt(pos) == '\'')) {
391                char quoteCharacter = input.charAt(pos++);
392                int closeQuote = input.indexOf(quoteCharacter, pos);
393                if (closeQuote == -1) {
394                    throw new IllegalArgumentException("Unterminated string literal in " + input);
395                }
396                String result = input.substring(pos, closeQuote);
397                pos = closeQuote + 1;
398                return result;
399            }
400
401            int c = find(terminators);
402            String result = input.substring(pos, c);
403            pos = c;
404            return result;
405        }
406
407        /**
408         * Returns the index of the next character in {@code chars}, or the end
409         * of the string.
410         */
411        private int find(String chars) {
412            for (int c = pos; c < input.length(); c++) {
413                if (chars.indexOf(input.charAt(c)) != -1) {
414                    return c;
415                }
416            }
417            return input.length();
418        }
419
420        private void skipWhitespace() {
421            for (; pos < input.length(); pos++) {
422                if (WHITESPACE.indexOf(input.charAt(pos)) == -1) {
423                    break;
424                }
425            }
426        }
427    }
428
429    private String comment;
430    private String commentURL;
431    private boolean discard;
432    private String domain;
433    private long maxAge = -1l;
434    private final String name;
435    private String path;
436    private String portList;
437    private boolean secure;
438    private boolean httpOnly;
439    private String value;
440    private int version = 1;
441
442    /**
443     * Creates a new cookie.
444     *
445     * @param name a non-empty string that contains only printable ASCII, no
446     *     commas or semicolons, and is not prefixed with  {@code $}. May not be
447     *     an HTTP attribute name.
448     * @param value an opaque value from the HTTP server.
449     * @throws IllegalArgumentException if {@code name} is invalid.
450     */
451    public HttpCookie(String name, String value) {
452        String ntrim = name.trim(); // erase leading and trailing whitespace
453        if (!isValidName(ntrim)) {
454            throw new IllegalArgumentException("Invalid name: " + name);
455        }
456
457        this.name = ntrim;
458        this.value = value;
459    }
460
461
462    private boolean isValidName(String n) {
463        // name cannot be empty or begin with '$' or equals the reserved
464        // attributes (case-insensitive)
465        boolean isValid = !(n.length() == 0 || n.startsWith("$")
466                || RESERVED_NAMES.contains(n.toLowerCase(Locale.US)));
467        if (isValid) {
468            for (int i = 0; i < n.length(); i++) {
469                char nameChar = n.charAt(i);
470                // name must be ASCII characters and cannot contain ';', ',' and
471                // whitespace
472                if (nameChar < 0
473                        || nameChar >= 127
474                        || nameChar == ';'
475                        || nameChar == ','
476                        || (Character.isWhitespace(nameChar) && nameChar != ' ')) {
477                    isValid = false;
478                    break;
479                }
480            }
481        }
482        return isValid;
483    }
484
485    /**
486     * Returns the {@code Comment} attribute.
487     */
488    public String getComment() {
489        return comment;
490    }
491
492    /**
493     * Returns the value of {@code CommentURL} attribute.
494     */
495    public String getCommentURL() {
496        return commentURL;
497    }
498
499    /**
500     * Returns the {@code Discard} attribute.
501     */
502    public boolean getDiscard() {
503        return discard;
504    }
505
506    /**
507     * Returns the {@code Domain} attribute.
508     */
509    public String getDomain() {
510        return domain;
511    }
512
513    /**
514     * Returns the {@code Max-Age} attribute, in delta-seconds.
515     */
516    public long getMaxAge() {
517        return maxAge;
518    }
519
520    /**
521     * Returns the name of this cookie.
522     */
523    public String getName() {
524        return name;
525    }
526
527    /**
528     * Returns the {@code Path} attribute. This cookie is visible to all
529     * subpaths.
530     */
531    public String getPath() {
532        return path;
533    }
534
535    /**
536     * Returns the {@code Port} attribute, usually containing comma-separated
537     * port numbers. A null port indicates that the cookie may be sent to any
538     * port. The empty string indicates that the cookie should only be sent to
539     * the port of the originating request.
540     */
541    public String getPortlist() {
542        return portList;
543    }
544
545    /**
546     * Returns the {@code Secure} attribute.
547     */
548    public boolean getSecure() {
549        return secure;
550    }
551
552    /**
553     * Returns the {@code HttpOnly} attribute. If {@code true} the cookie should not be accessible
554     * to scripts in a browser.
555     *
556     * @since 1.7
557     * @hide Until ready for an API update
558     */
559    public boolean isHttpOnly() {
560        return httpOnly;
561    }
562
563    /**
564     * Returns the {@code HttpOnly} attribute. If {@code true} the cookie should not be accessible
565     * to scripts in a browser.
566     *
567     * @since 1.7
568     * @hide Until ready for an API update
569     */
570    public void setHttpOnly(boolean httpOnly) {
571        this.httpOnly = httpOnly;
572    }
573
574    /**
575     * Returns the value of this cookie.
576     */
577    public String getValue() {
578        return value;
579    }
580
581    /**
582     * Returns the version of this cookie.
583     */
584    public int getVersion() {
585        return version;
586    }
587
588    /**
589     * Returns true if this cookie's Max-Age is 0.
590     */
591    public boolean hasExpired() {
592        // -1 indicates the cookie will persist until browser shutdown
593        // so the cookie is not expired.
594        if (maxAge == -1l) {
595            return false;
596        }
597
598        boolean expired = false;
599        if (maxAge <= 0l) {
600            expired = true;
601        }
602        return expired;
603    }
604
605    /**
606     * Set the {@code Comment} attribute of this cookie.
607     */
608    public void setComment(String comment) {
609        this.comment = comment;
610    }
611
612    /**
613     * Set the {@code CommentURL} attribute of this cookie.
614     */
615    public void setCommentURL(String commentURL) {
616        this.commentURL = commentURL;
617    }
618
619    /**
620     * Set the {@code Discard} attribute of this cookie.
621     */
622    public void setDiscard(boolean discard) {
623        this.discard = discard;
624    }
625
626    /**
627     * Set the {@code Domain} attribute of this cookie. HTTP clients send
628     * cookies only to matching domains.
629     */
630    public void setDomain(String pattern) {
631        domain = pattern == null ? null : pattern.toLowerCase(Locale.US);
632    }
633
634    /**
635     * Sets the {@code Max-Age} attribute of this cookie.
636     */
637    public void setMaxAge(long deltaSeconds) {
638        maxAge = deltaSeconds;
639    }
640
641    private void setExpires(Date expires) {
642        maxAge = (expires.getTime() - System.currentTimeMillis()) / 1000;
643    }
644
645    /**
646     * Set the {@code Path} attribute of this cookie. HTTP clients send cookies
647     * to this path and its subpaths.
648     */
649    public void setPath(String path) {
650        this.path = path;
651    }
652
653    /**
654     * Set the {@code Port} attribute of this cookie.
655     */
656    public void setPortlist(String portList) {
657        this.portList = portList;
658    }
659
660    /**
661     * Sets the {@code Secure} attribute of this cookie.
662     */
663    public void setSecure(boolean secure) {
664        this.secure = secure;
665    }
666
667    /**
668     * Sets the opaque value of this cookie.
669     */
670    public void setValue(String value) {
671        // FIXME: According to spec, version 0 cookie value does not allow many
672        // symbols. But RI does not implement it. Follow RI temporarily.
673        this.value = value;
674    }
675
676    /**
677     * Sets the {@code Version} attribute of the cookie.
678     *
679     * @throws IllegalArgumentException if v is neither 0 nor 1
680     */
681    public void setVersion(int newVersion) {
682        if (newVersion != 0 && newVersion != 1) {
683            throw new IllegalArgumentException("Bad version: " + newVersion);
684        }
685        version = newVersion;
686    }
687
688    @Override public Object clone() {
689        try {
690            return super.clone();
691        } catch (CloneNotSupportedException e) {
692            throw new AssertionError();
693        }
694    }
695
696    /**
697     * Returns true if {@code object} is a cookie with the same domain, name and
698     * path. Domain and name use case-insensitive comparison; path uses a
699     * case-sensitive comparison.
700     */
701    @Override public boolean equals(Object object) {
702        if (object == this) {
703            return true;
704        }
705        if (object instanceof HttpCookie) {
706            HttpCookie that = (HttpCookie) object;
707            return name.equalsIgnoreCase(that.getName())
708                    && (domain != null ? domain.equalsIgnoreCase(that.domain) : that.domain == null)
709                    && Objects.equal(path, that.path);
710        }
711        return false;
712    }
713
714    /**
715     * Returns the hash code of this HTTP cookie: <pre>   {@code
716     *   name.toLowerCase(Locale.US).hashCode()
717     *       + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
718     *       + (path == null ? 0 : path.hashCode())
719     * }</pre>
720     */
721    @Override public int hashCode() {
722        return name.toLowerCase(Locale.US).hashCode()
723                + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode())
724                + (path == null ? 0 : path.hashCode());
725    }
726
727    /**
728     * Returns a string representing this cookie in the format used by the
729     * {@code Cookie} header line in an HTTP request as specified by RFC 2965 section 3.3.4.
730     *
731     * <p>The resulting string does not include a "Cookie:" prefix or any version information.
732     * The returned {@code String} is not suitable for passing to {@link #parse(String)}: Several of
733     * the attributes that would be needed to preserve all of the cookie's information are omitted.
734     * The String is formatted for an HTTP request not an HTTP response.
735     *
736     * <p>The attributes included and the format depends on the cookie's {@code version}:
737     * <ul>
738     *     <li>Version 0: Includes only the name and value. Conforms to RFC 2965 (for
739     *     version 0 cookies). This should also be used to conform with RFC 6265.
740     *     </li>
741     *     <li>Version 1: Includes the name and value, and Path, Domain and Port attributes.
742     *     Conforms to RFC 2965 (for version 1 cookies).</li>
743     * </ul>
744     */
745    @Override public String toString() {
746        if (version == 0) {
747            return name + "=" + value;
748        }
749
750        StringBuilder result = new StringBuilder()
751                .append(name)
752                .append("=")
753                .append("\"")
754                .append(value)
755                .append("\"");
756        appendAttribute(result, "Path", path);
757        appendAttribute(result, "Domain", domain);
758        appendAttribute(result, "Port", portList);
759        return result.toString();
760    }
761
762    private void appendAttribute(StringBuilder builder, String name, String value) {
763        if (value != null && builder != null) {
764            builder.append(";$");
765            builder.append(name);
766            builder.append("=\"");
767            builder.append(value);
768            builder.append("\"");
769        }
770    }
771}
772