HttpCookie.java revision f33eae7e84eb6d3b0f4e86b59605bb3de73009f3
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.Date;
21import java.util.HashMap;
22import java.util.List;
23import java.util.Locale;
24import java.util.regex.Matcher;
25import java.util.regex.Pattern;
26
27import org.apache.harmony.luni.util.Msg;
28
29/**
30 * This class represents a http cookie, which indicates the status information
31 * between the client agent side and the server side. According to RFC, there
32 * are 3 http cookie specifications. This class is compatible with all the three
33 * forms. HttpCookie class can accept all these 3 forms of syntax.
34 *
35 * @since 1.6
36 */
37public final class HttpCookie implements Cloneable {
38
39    private abstract static class Setter {
40        boolean set;
41
42        Setter() {
43            set = false;
44        }
45
46        boolean isSet() {
47            return set;
48        }
49
50        void set(boolean isSet) {
51            set = isSet;
52        }
53
54        abstract void setValue(String value, HttpCookie cookie);
55
56        void validate(String value, HttpCookie cookie) {
57            if (cookie.getVersion() == 1 && value != null
58                    && value.contains(COMMA_STR)) {
59                throw new IllegalArgumentException();
60            }
61        }
62    }
63
64    private static final String DOT_STR = ".";
65
66    private static final String LOCAL_STR = ".local";
67
68    private static final String QUOTE_STR = "\"";
69
70    private static final String COMMA_STR = ",";
71
72    private static Pattern HEAD_PATTERN = Pattern.compile("Set-Cookie2?:",
73            Pattern.CASE_INSENSITIVE);
74
75    private static Pattern NAME_PATTERN = Pattern
76            .compile(
77                    "([^$=,\u0085\u2028\u2029][^,\n\t\r\r\n\u0085\u2028\u2029]*?)=([^;]*)(;)?",
78                    Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
79
80    private static Pattern ATTR_PATTERN0 = Pattern
81            .compile("([^;=]*)(?:=([^;]*))?");
82
83    private static Pattern ATTR_PATTERN1 = Pattern
84            .compile("(,?[^;=]*)(?:=([^;,]*))?((?=.))?");
85
86    private HashMap<String, Setter> attributeSet = new HashMap<String, Setter>();
87
88    /**
89     * A utility method used to check whether the host name is in a domain or
90     * not.
91     *
92     * @param domain
93     *            the domain to be checked against
94     * @param host
95     *            the host to be checked
96     * @return true if the host is in the domain, false otherwise
97     */
98    public static boolean domainMatches(String domain, String host) {
99        if (domain == null || host == null) {
100            return false;
101        }
102        String newDomain = domain.toLowerCase();
103        String newHost = host.toLowerCase();
104        return isValidDomain(newDomain) && effDomainMatches(newDomain, newHost)
105                && isValidHost(newDomain, newHost);
106    }
107
108    private static boolean effDomainMatches(String domain, String host) {
109        // calculate effective host name
110        String effHost = host.indexOf(DOT_STR) != -1 ? host
111                : (host + LOCAL_STR);
112
113        // Rule 2: domain and host are string-compare equal, or A = NB, B = .B'
114        // and N is a non-empty name string
115        boolean inDomain = domain.equals(effHost);
116        inDomain = inDomain
117                || (effHost.endsWith(domain)
118                        && effHost.length() > domain.length() && domain
119                        .startsWith(DOT_STR));
120        return inDomain;
121    }
122
123    private static boolean isCommaDelim(HttpCookie cookie) {
124        String value = cookie.getValue();
125        if (value.startsWith(QUOTE_STR) && value.endsWith(QUOTE_STR)) {
126            cookie.setValue(value.substring(1, value.length() - 1));
127            return false;
128        }
129        if (cookie.getVersion() == 1 && value.contains(COMMA_STR)) {
130            cookie.setValue(value.substring(0, value.indexOf(COMMA_STR)));
131            return true;
132        }
133        return false;
134    }
135
136    private static boolean isValidDomain(String domain) {
137        // Rule 1: The value for Domain contains embedded dots, or is .local
138        if (domain.length() <= 2) {
139            return false;
140        }
141        return domain.substring(1, domain.length() - 1).indexOf(DOT_STR) != -1
142                || domain.equals(LOCAL_STR);
143    }
144
145    private static boolean isValidHost(String domain, String host) {
146        // Rule 3: host does not end with domain, or the remainder does not
147        // contain "."
148        boolean matches = !host.endsWith(domain);
149        if (!matches) {
150            String hostSub = host.substring(0, host.length() - domain.length());
151            matches = hostSub.indexOf(DOT_STR) == -1;
152        }
153        return matches;
154    }
155
156    /**
157     * Constructs a cookie from a string. The string should comply with
158     * set-cookie or set-cookie2 header format as specified in RFC 2965. Since
159     * set-cookies2 syntax allows more than one cookie definitions in one
160     * header, the returned object is a list.
161     *
162     * @param header
163     *            a set-cookie or set-cookie2 header.
164     * @return a list of constructed cookies
165     * @throws IllegalArgumentException
166     *             if the string does not comply with cookie specification, or
167     *             the cookie name contains illegal characters, or reserved
168     *             tokens of cookie specification appears
169     * @throws NullPointerException
170     *             if header is null
171     */
172    public static List<HttpCookie> parse(String header) {
173        Matcher matcher = HEAD_PATTERN.matcher(header);
174        // Parse cookie name & value
175        List<HttpCookie> list = null;
176        HttpCookie cookie = null;
177        String headerString = header;
178        int version = 0;
179        // process set-cookie | set-cookie2 head
180        if (matcher.find()) {
181            String cookieHead = matcher.group();
182            if ("set-cookie2:".equalsIgnoreCase(cookieHead)) {
183                version = 1;
184            }
185            headerString = header.substring(cookieHead.length());
186        }
187
188        // parse cookie name/value pair
189        matcher = NAME_PATTERN.matcher(headerString);
190        if (matcher.lookingAt()) {
191            list = new ArrayList<HttpCookie>();
192            cookie = new HttpCookie(matcher.group(1), matcher.group(2));
193            cookie.setVersion(version);
194
195            /*
196             * Comma is a delimiter in cookie spec 1.1. If find comma in version
197             * 1 cookie header, part of matched string need to be spitted out.
198             */
199            String nameGroup = matcher.group();
200            if (isCommaDelim(cookie)) {
201                headerString = headerString.substring(nameGroup
202                        .indexOf(COMMA_STR));
203            } else {
204                headerString = headerString.substring(nameGroup.length());
205            }
206            list.add(cookie);
207        } else {
208            throw new IllegalArgumentException();
209        }
210
211        // parse cookie headerString
212        while (!(headerString.length() == 0)) {
213            matcher = cookie.getVersion() == 1 ? ATTR_PATTERN1
214                    .matcher(headerString) : ATTR_PATTERN0
215                    .matcher(headerString);
216
217            if (matcher.lookingAt()) {
218                String attrName = matcher.group(1).trim();
219
220                // handle special situation like: <..>;;<..>
221                if (attrName.length() == 0) {
222                    headerString = headerString.substring(1);
223                    continue;
224                }
225
226                // If port is the attribute, then comma will not be used as a
227                // delimiter
228                if (attrName.equalsIgnoreCase("port")
229                        || attrName.equalsIgnoreCase("expires")) {
230                    int start = matcher.regionStart();
231                    matcher = ATTR_PATTERN0.matcher(headerString);
232                    matcher.region(start, headerString.length());
233                    matcher.lookingAt();
234                } else if (cookie.getVersion() == 1
235                        && attrName.startsWith(COMMA_STR)) {
236                    // If the last encountered token is comma, and the parsed
237                    // attribute is not port, then this attribute/value pair
238                    // ends.
239                    headerString = headerString.substring(1);
240                    matcher = NAME_PATTERN.matcher(headerString);
241                    if (matcher.lookingAt()) {
242                        cookie = new HttpCookie(matcher.group(1), matcher
243                                .group(2));
244                        list.add(cookie);
245                        headerString = headerString.substring(matcher.group()
246                                .length());
247                        continue;
248                    }
249                }
250
251                Setter setter = cookie.attributeSet.get(attrName.toLowerCase());
252                if (null == setter) {
253                    throw new IllegalArgumentException();
254                }
255                if (!setter.isSet()) {
256                    String attrValue = matcher.group(2);
257                    setter.validate(attrValue, cookie);
258                    setter.setValue(matcher.group(2), cookie);
259                }
260                headerString = headerString.substring(matcher.end());
261            }
262        }
263
264        return list;
265    }
266
267    private String comment;
268
269    private String commentURL;
270
271    private boolean discard;
272
273    private String domain;
274
275    private long maxAge = -1l;
276
277    private String name;
278
279    private String path;
280
281    private String portList;
282
283    private boolean secure;
284
285    private String value;
286
287    private int version = 1;
288
289    {
290        attributeSet.put("comment", new Setter() {
291                    @Override
292                    void setValue(String value, HttpCookie cookie) {
293                        cookie.setComment(value);
294                        if (cookie.getComment() != null) {
295                            set(true);
296                        }
297                    }
298                });
299        attributeSet.put("commenturl", new Setter() {
300                    @Override
301                    void setValue(String value, HttpCookie cookie) {
302                        cookie.setCommentURL(value);
303                        if (cookie.getCommentURL() != null) {
304                            set(true);
305                        }
306                    }
307                });
308        attributeSet.put("discard", new Setter() {
309                    @Override
310                    void setValue(String value, HttpCookie cookie) {
311                        cookie.setDiscard(true);
312                        set(true);
313                    }
314                });
315        attributeSet.put("domain", new Setter() {
316                    @Override
317                    void setValue(String value, HttpCookie cookie) {
318                        cookie.setDomain(value);
319                        if (cookie.getDomain() != null) {
320                            set(true);
321                        }
322                    }
323                });
324        attributeSet.put("max-age", new Setter() {
325                    @Override
326                    void setValue(String value, HttpCookie cookie) {
327                        try {
328                            cookie.setMaxAge(Long.parseLong(value));
329                        } catch (NumberFormatException e) {
330                            throw new IllegalArgumentException(Msg.getString(
331                                    "KB001", "max-age"));
332                        }
333                        set(true);
334
335                        if (!attributeSet.get("version").isSet()) {
336                            cookie.setVersion(1);
337                        }
338                    }
339                });
340
341        attributeSet.put("path", new Setter() {
342                    @Override
343                    void setValue(String value, HttpCookie cookie) {
344                        cookie.setPath(value);
345                        if (cookie.getPath() != null) {
346                            set(true);
347                        }
348                    }
349                });
350        attributeSet.put("port", new Setter() {
351                    @Override
352                    void setValue(String value, HttpCookie cookie) {
353                        cookie.setPortlist(value);
354                        if (cookie.getPortlist() != null) {
355                            set(true);
356                        }
357                    }
358
359                    @Override
360                    void validate(String v, HttpCookie cookie) {
361                        return;
362                    }
363                });
364        attributeSet.put("secure", new Setter() {
365                    @Override
366                    void setValue(String value, HttpCookie cookie) {
367                        cookie.setSecure(true);
368                        set(true);
369                    }
370                });
371        attributeSet.put("version", new Setter() {
372                    @Override
373                    void setValue(String value, HttpCookie cookie) {
374                        try {
375                            int v = Integer.parseInt(value);
376                            if (v > cookie.getVersion()) {
377                                cookie.setVersion(v);
378                            }
379                        } catch (NumberFormatException e) {
380                            throw new IllegalArgumentException(Msg.getString(
381                                    "KB001", "version"));
382                        }
383                        if (cookie.getVersion() != 0) {
384                            set(true);
385                        }
386                    }
387                });
388
389        attributeSet.put("expires", new Setter() {
390                    @Override
391                    void setValue(String value, HttpCookie cookie) {
392                        cookie.setVersion(0);
393                        attributeSet.get("version").set(true);
394                        if (!attributeSet.get("max-age").isSet()) {
395                            attributeSet.get("max-age").set(true);
396                            if (!"en".equalsIgnoreCase(Locale.getDefault()
397                                    .getLanguage())) {
398                                cookie.setMaxAge(0);
399                                return;
400                            }
401                            try {
402                                cookie.setMaxAge((Date.parse(value) - System
403                                        .currentTimeMillis()) / 1000);
404                            } catch (IllegalArgumentException e) {
405                                cookie.setMaxAge(0);
406                            }
407                        }
408                    }
409
410                    @Override
411                    void validate(String v, HttpCookie cookie) {
412                        return;
413                    }
414                });
415    }
416
417    /**
418     * Initializes a cookie with the specified name and value.
419     *
420     * The name attribute can just contain ASCII characters, which is immutable
421     * after creation. Commas, white space and semicolons are not allowed. The $
422     * character is also not allowed to be the beginning of the name.
423     *
424     * The value attribute depends on what the server side is interested. The
425     * setValue method can be used to change it.
426     *
427     * RFC 2965 is the default cookie specification of this class. If one wants
428     * to change the version of the cookie, the setVersion method is available.
429     *
430     * @param name -
431     *            the specific name of the cookie
432     * @param value -
433     *            the specific value of the cookie
434     *
435     * @throws IllegalArgumentException -
436     *             if the name contains not-allowed or reserved characters
437     *
438     * @throws NullPointerException
439     *             if the value of name is null
440     */
441    public HttpCookie(String name, String value) {
442        String ntrim = name.trim(); // erase leading and trailing whitespaces
443        if (!isValidName(ntrim)) {
444            throw new IllegalArgumentException(Msg.getString("KB002"));
445        }
446
447        this.name = ntrim;
448        this.value = value;
449    }
450
451    private void attrToString(StringBuilder builder, String attrName,
452            String attrValue) {
453        if (attrValue != null && builder != null) {
454            builder.append(";");
455            builder.append("$");
456            builder.append(attrName);
457            builder.append("=\"");
458            builder.append(attrValue);
459            builder.append(QUOTE_STR);
460        }
461    }
462
463    @Override
464    public Object clone() {
465        try {
466            HttpCookie obj = (HttpCookie) super.clone();
467            return obj;
468        } catch (CloneNotSupportedException e) {
469            return null;
470        }
471    }
472
473    /**
474     * Returns true if {@code obj} equals this cookie. Two cookies are equal if they have
475     * the same domain and name in a case-insensitive mode and path in a
476     * case-sensitive mode.
477     */
478    @Override
479    public boolean equals(Object obj) {
480        if (obj == this) {
481            return true;
482        }
483        if (obj instanceof HttpCookie) {
484            HttpCookie anotherCookie = (HttpCookie) obj;
485            if (name.equalsIgnoreCase(anotherCookie.getName())) {
486                String anotherDomain = anotherCookie.getDomain();
487                boolean equals = domain == null ? anotherDomain == null
488                        : domain.equalsIgnoreCase(anotherDomain);
489                if (equals) {
490                    String anotherPath = anotherCookie.getPath();
491                    return path == null ? anotherPath == null : path
492                            .equals(anotherPath);
493                }
494            }
495        }
496        return false;
497    }
498
499    /**
500     * Returns the value of the "comment" attribute (specified in RFC 2965) of this cookie.
501     */
502    public String getComment() {
503        return comment;
504    }
505
506    /**
507     * Returns the value of the "commentURL" attribute (specified in RFC 2965) of this cookie.
508     */
509    public String getCommentURL() {
510        return commentURL;
511    }
512
513    /**
514     * Returns the value of the "discard" attribute (specified in RFC 2965) of this cookie.
515     */
516    public boolean getDiscard() {
517        return discard;
518    }
519
520    /**
521     * Returns the domain name for this cookie in the format specified in RFC 2965.
522     */
523    public String getDomain() {
524        return domain;
525    }
526
527    /**
528     * Returns the Max-Age value for this cookie as specified in RFC 2965.
529     */
530    public long getMaxAge() {
531        return maxAge;
532    }
533
534    /**
535     * Returns the name of this cookie.
536     */
537    public String getName() {
538        return name;
539    }
540
541    /**
542     * Returns the path part of a request URL to which this cookie is returned.
543     * This cookie is visible to all subpaths.
544     */
545    public String getPath() {
546        return path;
547    }
548
549    /**
550     * Returns the value of the "portlist" attribute (specified in RFC 2965) of this cookie.
551     */
552    public String getPortlist() {
553        return portList;
554    }
555
556    /**
557     * Returns true if the browser only sends cookies over a secure protocol.
558     * False if it can send cookies through any protocols.
559     */
560    public boolean getSecure() {
561        return secure;
562    }
563
564    /**
565     * Returns the value of this cookie.
566     */
567    public String getValue() {
568        return value;
569    }
570
571    /**
572     * Get the version of this cookie
573     *
574     * @return 0 indicates the original Netscape cookie specification, while 1
575     *         indicates RFC 2965/2109 specification.
576     */
577    public int getVersion() {
578        return version;
579    }
580
581    /**
582     * Returns true if this cookie has expired.
583     */
584    public boolean hasExpired() {
585        // -1 indicates the cookie will persist until browser shutdown
586        // so the cookie is not expired.
587        if (maxAge == -1l) {
588            return false;
589        }
590
591        boolean expired = false;
592        if (maxAge <= 0l) {
593            expired = true;
594        }
595        return expired;
596    }
597
598    /**
599     * Returns the hash code of this HTTP cookie. The hash code is
600     * {@code getName().toLowerCase().hashCode() + getDomain().toLowerCase().hashCode() + getPath().hashCode()}.
601     */
602    @Override
603    public int hashCode() {
604        int hashCode = name.toLowerCase().hashCode();
605        hashCode += domain == null ? 0 : domain.toLowerCase().hashCode();
606        hashCode += path == null ? 0 : path.hashCode();
607        return hashCode;
608    }
609
610    private boolean isValidName(String n) {
611        // name cannot be empty or begin with '$' or equals the reserved
612        // attributes (case-insensitive)
613        boolean isValid = !(n.length() == 0 || n.startsWith("$") || attributeSet.containsKey(n.toLowerCase()));
614        if (isValid) {
615            for (int i = 0; i < n.length(); i++) {
616                char nameChar = n.charAt(i);
617                // name must be ASCII characters and cannot contain ';', ',' and
618                // whitespace
619                if (nameChar < 0
620                        || nameChar >= 127
621                        || nameChar == ';'
622                        || nameChar == ','
623                        || (Character.isWhitespace(nameChar) && nameChar != ' ')) {
624                    isValid = false;
625                    break;
626                }
627            }
628        }
629        return isValid;
630    }
631
632    /**
633     * Set the value of comment attribute(specified in RFC 2965) of this cookie.
634     *
635     * @param purpose
636     *            the comment value to be set
637     */
638    public void setComment(String purpose) {
639        comment = purpose;
640    }
641
642    /**
643     * Set the value of commentURL attribute(specified in RFC 2965) of this
644     * cookie.
645     *
646     * @param purpose
647     *            the value of commentURL attribute to be set
648     */
649    public void setCommentURL(String purpose) {
650        commentURL = purpose;
651    }
652
653    /**
654     * Set the value of discard attribute(specified in RFC 2965) of this cookie.
655     *
656     * @param discard
657     *            the value for discard attribute
658     */
659    public void setDiscard(boolean discard) {
660        this.discard = discard;
661    }
662
663    /**
664     * Set the domain value for this cookie. Browsers send the cookie to the
665     * domain specified by this value. The form of the domain is specified in
666     * RFC 2965.
667     *
668     * @param pattern
669     *            the domain pattern
670     */
671    public void setDomain(String pattern) {
672        domain = pattern == null ? null : pattern.toLowerCase();
673    }
674
675    /**
676     * Sets the Max-Age value as specified in RFC 2965 of this cookie to expire.
677     *
678     * @param expiry
679     *            the value used to set the Max-Age value of this cookie
680     */
681    public void setMaxAge(long expiry) {
682        maxAge = expiry;
683    }
684
685    /**
686     * Set the path to which this cookie is returned. This cookie is visible to
687     * all the pages under the path and all subpaths.
688     *
689     * @param path
690     *            the path to which this cookie is returned
691     */
692    public void setPath(String path) {
693        this.path = path;
694    }
695
696    /**
697     * Set the value of port attribute(specified in RFC 2965) of this cookie.
698     *
699     * @param ports
700     *            the value for port attribute
701     */
702    public void setPortlist(String ports) {
703        portList = ports;
704    }
705
706    /*
707     * Handle 2 special cases: 1. value is wrapped by a quotation 2. value
708     * contains comma
709     */
710
711    /**
712     * Tells the browser whether the cookies should be sent to server through
713     * secure protocols.
714     *
715     * @param flag
716     *            tells browser to send cookie to server only through secure
717     *            protocol if flag is true
718     */
719    public void setSecure(boolean flag) {
720        secure = flag;
721    }
722
723    /**
724     * Sets the value for this cookie after it has been instantiated. String
725     * newValue can be in BASE64 form. If the version of the cookie is 0,
726     * special value as: white space, brackets, parentheses, equals signs,
727     * commas, double quotes, slashes, question marks, at signs, colons, and
728     * semicolons are not recommended. Empty values may lead to different
729     * behavior on different browsers.
730     *
731     * @param newValue
732     *            the value for this cookie
733     */
734    public void setValue(String newValue) {
735        // FIXME: According to spec, version 0 cookie value does not allow many
736        // symbols. But RI does not implement it. Follow RI temporarily.
737        value = newValue;
738    }
739
740    /**
741     * Sets the version of the cookie. 0 indicates the original Netscape cookie
742     * specification, while 1 indicates RFC 2965/2109 specification.
743     *
744     * @param v
745     *            0 or 1 as stated above
746     * @throws IllegalArgumentException
747     *             if v is neither 0 nor 1
748     */
749    public void setVersion(int v) {
750        if (v != 0 && v != 1) {
751            throw new IllegalArgumentException(Msg.getString("KB003"));
752        }
753        version = v;
754    }
755
756    /**
757     * Returns a string to represent the cookie. The format of string follows
758     * the cookie specification. The leading token "Cookie" is not included
759     *
760     * @return the string format of the cookie object
761     */
762    @Override
763    public String toString() {
764        StringBuilder cookieStr = new StringBuilder();
765        cookieStr.append(name);
766        cookieStr.append("=");
767        if (version == 0) {
768            cookieStr.append(value);
769        } else if (version == 1) {
770            cookieStr.append(QUOTE_STR);
771            cookieStr.append(value);
772            cookieStr.append(QUOTE_STR);
773
774            attrToString(cookieStr, "Path", path);
775            attrToString(cookieStr, "Domain", domain);
776            attrToString(cookieStr, "Port", portList);
777        }
778        return cookieStr.toString();
779    }
780}
781