View Javadoc
1   /*
2    * Copyright 2016 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with 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, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package io.netty.handler.codec;
17  
18  import static io.netty.util.internal.ObjectUtil.checkNotNull;
19  
20  import io.netty.util.AsciiString;
21  import io.netty.util.concurrent.FastThreadLocal;
22  
23  import java.util.BitSet;
24  import java.util.Calendar;
25  import java.util.Date;
26  import java.util.GregorianCalendar;
27  import java.util.TimeZone;
28  
29  /**
30   * A formatter for HTTP header dates, such as "Expires" and "Date" headers, or "expires" field in "Set-Cookie".
31   *
32   * On the parsing side, it honors RFC6265 (so it supports RFC1123).
33   * Note that:
34   * <ul>
35   *     <li>Day of week is ignored and not validated</li>
36   *     <li>Timezone is ignored, as RFC6265 assumes UTC</li>
37   * </ul>
38   * If you're looking for a date format that validates day of week, or supports other timezones, consider using
39   * java.util.DateTimeFormatter.RFC_1123_DATE_TIME.
40   *
41   * On the formatting side, it uses RFC1123 format.
42   *
43   * @see <a href="https://tools.ietf.org/html/rfc6265#section-5.1.1">RFC6265</a> for the parsing side
44   * @see <a href="https://tools.ietf.org/html/rfc1123#page-55">RFC1123</a> for the encoding side.
45   */
46  public final class DateFormatter {
47  
48      private static final BitSet DELIMITERS = new BitSet();
49      static {
50          DELIMITERS.set(0x09);
51          for (char c = 0x20; c <= 0x2F; c++) {
52              DELIMITERS.set(c);
53          }
54          for (char c = 0x3B; c <= 0x40; c++) {
55              DELIMITERS.set(c);
56          }
57          for (char c = 0x5B; c <= 0x60; c++) {
58              DELIMITERS.set(c);
59          }
60          for (char c = 0x7B; c <= 0x7E; c++) {
61              DELIMITERS.set(c);
62          }
63      }
64  
65      private static final String[] DAY_OF_WEEK_TO_SHORT_NAME =
66              new String[]{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
67  
68      private static final String[] CALENDAR_MONTH_TO_SHORT_NAME =
69              new String[]{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
70  
71      private static final FastThreadLocal<DateFormatter> INSTANCES =
72              new FastThreadLocal<DateFormatter>() {
73                  @Override
74                  protected DateFormatter initialValue() {
75                      return new DateFormatter();
76                  }
77              };
78  
79      /**
80       * Parse some text into a {@link Date}, according to RFC6265
81       * @param txt text to parse
82       * @return a {@link Date}, or null if text couldn't be parsed
83       */
84      public static Date parseHttpDate(CharSequence txt) {
85          return parseHttpDate(txt, 0, txt.length());
86      }
87  
88      /**
89       * Parse some text into a {@link Date}, according to RFC6265
90       * @param txt text to parse
91       * @param start the start index inside {@code txt}
92       * @param end the end index inside {@code txt}
93       * @return a {@link Date}, or null if text couldn't be parsed
94       */
95      public static Date parseHttpDate(CharSequence txt, int start, int end) {
96          int length = end - start;
97          if (length == 0) {
98              return null;
99          } else if (length < 0) {
100             throw new IllegalArgumentException("Can't have end < start");
101         } else if (length > 64) {
102             throw new IllegalArgumentException("Can't parse more than 64 chars," +
103                     "looks like a user error or a malformed header");
104         }
105         return formatter().parse0(checkNotNull(txt, "txt"), start, end);
106     }
107 
108     /**
109      * Format a {@link Date} into RFC1123 format
110      * @param date the date to format
111      * @return a RFC1123 string
112      */
113     public static String format(Date date) {
114         return formatter().format0(checkNotNull(date, "date"));
115     }
116 
117     /**
118      * Append a {@link Date} to a {@link StringBuilder} into RFC1123 format
119      * @param date the date to format
120      * @param sb the StringBuilder
121      * @return the same StringBuilder
122      */
123     public static StringBuilder append(Date date, StringBuilder sb) {
124         return formatter().append0(checkNotNull(date, "date"), checkNotNull(sb, "sb"));
125     }
126 
127     private static DateFormatter formatter() {
128         DateFormatter formatter = INSTANCES.get();
129         formatter.reset();
130         return formatter;
131     }
132 
133     // delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E
134     private static boolean isDelim(char c) {
135         return DELIMITERS.get(c);
136     }
137 
138     private static boolean isDigit(char c) {
139         return c >= 48 && c <= 57;
140     }
141 
142     private static int getNumericalValue(char c) {
143         return c - 48;
144     }
145 
146     private final GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
147     private final StringBuilder sb = new StringBuilder(29); // Sun, 27 Nov 2016 19:37:15 GMT
148     private boolean timeFound;
149     private int hours;
150     private int minutes;
151     private int seconds;
152     private boolean dayOfMonthFound;
153     private int dayOfMonth;
154     private boolean monthFound;
155     private int month;
156     private boolean yearFound;
157     private int year;
158 
159     private DateFormatter() {
160         reset();
161     }
162 
163     public void reset() {
164         timeFound = false;
165         hours = -1;
166         minutes = -1;
167         seconds = -1;
168         dayOfMonthFound = false;
169         dayOfMonth = -1;
170         monthFound = false;
171         month = -1;
172         yearFound = false;
173         year = -1;
174         cal.clear();
175         sb.setLength(0);
176     }
177 
178     private boolean tryParseTime(CharSequence txt, int tokenStart, int tokenEnd) {
179         int len = tokenEnd - tokenStart;
180 
181         // h:m:s to hh:mm:ss
182         if (len < 5 || len > 8) {
183             return false;
184         }
185 
186         int localHours = -1;
187         int localMinutes = -1;
188         int localSeconds = -1;
189         int currentPartNumber = 0;
190         int currentPartValue = 0;
191         int numDigits = 0;
192 
193         for (int i = tokenStart; i < tokenEnd; i++) {
194             char c = txt.charAt(i);
195             if (isDigit(c)) {
196                 currentPartValue = currentPartValue * 10 + getNumericalValue(c);
197                 if (++numDigits > 2) {
198                   return false; // too many digits in this part
199                 }
200             } else if (c == ':') {
201                 if (numDigits == 0) {
202                     // no digits between separators
203                     return false;
204                 }
205                 switch (currentPartNumber) {
206                     case 0:
207                         // flushing hours
208                         localHours = currentPartValue;
209                         break;
210                     case 1:
211                         // flushing minutes
212                         localMinutes = currentPartValue;
213                         break;
214                     default:
215                         // invalid, too many :
216                         return false;
217                 }
218                 currentPartValue = 0;
219                 currentPartNumber++;
220                 numDigits = 0;
221             } else {
222                 // invalid char
223                 return false;
224             }
225         }
226 
227         if (numDigits > 0) {
228             // pending seconds
229             localSeconds = currentPartValue;
230         }
231 
232         if (localHours >= 0 && localMinutes >= 0 && localSeconds >= 0) {
233             hours = localHours;
234             minutes = localMinutes;
235             seconds = localSeconds;
236             return true;
237         }
238 
239         return false;
240     }
241 
242     private boolean tryParseDayOfMonth(CharSequence txt, int tokenStart, int tokenEnd) {
243         int len = tokenEnd - tokenStart;
244 
245         if (len == 1) {
246             char c0 = txt.charAt(tokenStart);
247             if (isDigit(c0)) {
248                 dayOfMonth = getNumericalValue(c0);
249                 return true;
250             }
251 
252         } else if (len == 2) {
253             char c0 = txt.charAt(tokenStart);
254             char c1 = txt.charAt(tokenStart + 1);
255             if (isDigit(c0) && isDigit(c1)) {
256                 dayOfMonth = getNumericalValue(c0) * 10 + getNumericalValue(c1);
257                 return true;
258             }
259         }
260 
261         return false;
262     }
263 
264     private static boolean matchMonth(String month, CharSequence txt, int tokenStart) {
265         return AsciiString.regionMatchesAscii(month, true, 0, txt, tokenStart, 3);
266     }
267 
268     private boolean tryParseMonth(CharSequence txt, int tokenStart, int tokenEnd) {
269         int len = tokenEnd - tokenStart;
270 
271         if (len != 3) {
272             return false;
273         }
274 
275         if (matchMonth("Jan", txt, tokenStart)) {
276             month = Calendar.JANUARY;
277         } else if (matchMonth("Feb", txt, tokenStart)) {
278             month = Calendar.FEBRUARY;
279         } else if (matchMonth("Mar", txt, tokenStart)) {
280             month = Calendar.MARCH;
281         } else if (matchMonth("Apr", txt, tokenStart)) {
282             month = Calendar.APRIL;
283         } else if (matchMonth("May", txt, tokenStart)) {
284             month = Calendar.MAY;
285         } else if (matchMonth("Jun", txt, tokenStart)) {
286             month = Calendar.JUNE;
287         } else if (matchMonth("Jul", txt, tokenStart)) {
288             month = Calendar.JULY;
289         } else if (matchMonth("Aug", txt, tokenStart)) {
290             month = Calendar.AUGUST;
291         } else if (matchMonth("Sep", txt, tokenStart)) {
292             month = Calendar.SEPTEMBER;
293         } else if (matchMonth("Oct", txt, tokenStart)) {
294             month = Calendar.OCTOBER;
295         } else if (matchMonth("Nov", txt, tokenStart)) {
296             month = Calendar.NOVEMBER;
297         } else if (matchMonth("Dec", txt, tokenStart)) {
298             month = Calendar.DECEMBER;
299         } else {
300             return false;
301         }
302 
303         return true;
304     }
305 
306     private boolean tryParseYear(CharSequence txt, int tokenStart, int tokenEnd) {
307         int len = tokenEnd - tokenStart;
308 
309         if (len == 2) {
310             char c0 = txt.charAt(tokenStart);
311             char c1 = txt.charAt(tokenStart + 1);
312             if (isDigit(c0) && isDigit(c1)) {
313                 year = getNumericalValue(c0) * 10 + getNumericalValue(c1);
314                 return true;
315             }
316 
317         } else if (len == 4) {
318             char c0 = txt.charAt(tokenStart);
319             char c1 = txt.charAt(tokenStart + 1);
320             char c2 = txt.charAt(tokenStart + 2);
321             char c3 = txt.charAt(tokenStart + 3);
322             if (isDigit(c0) && isDigit(c1) && isDigit(c2) && isDigit(c3)) {
323                 year = getNumericalValue(c0) * 1000 +
324                         getNumericalValue(c1) * 100 +
325                         getNumericalValue(c2) * 10 +
326                         getNumericalValue(c3);
327                 return true;
328             }
329         }
330 
331         return false;
332     }
333 
334     private boolean parseToken(CharSequence txt, int tokenStart, int tokenEnd) {
335         // return true if all parts are found
336         if (!timeFound) {
337             timeFound = tryParseTime(txt, tokenStart, tokenEnd);
338             if (timeFound) {
339                 return dayOfMonthFound && monthFound && yearFound;
340             }
341         }
342 
343         if (!dayOfMonthFound) {
344             dayOfMonthFound = tryParseDayOfMonth(txt, tokenStart, tokenEnd);
345             if (dayOfMonthFound) {
346                 return timeFound && monthFound && yearFound;
347             }
348         }
349 
350         if (!monthFound) {
351             monthFound = tryParseMonth(txt, tokenStart, tokenEnd);
352             if (monthFound) {
353                 return timeFound && dayOfMonthFound && yearFound;
354             }
355         }
356 
357         if (!yearFound) {
358             yearFound = tryParseYear(txt, tokenStart, tokenEnd);
359         }
360         return timeFound && dayOfMonthFound && monthFound && yearFound;
361     }
362 
363     private Date parse0(CharSequence txt, int start, int end) {
364         boolean allPartsFound = parse1(txt, start, end);
365         return allPartsFound && normalizeAndValidate() ? computeDate() : null;
366     }
367 
368     private boolean parse1(CharSequence txt, int start, int end) {
369         // return true if all parts are found
370         int tokenStart = -1;
371 
372         for (int i = start; i < end; i++) {
373             char c = txt.charAt(i);
374 
375             if (isDelim(c)) {
376                 if (tokenStart != -1) {
377                     // terminate token
378                     if (parseToken(txt, tokenStart, i)) {
379                         return true;
380                     }
381                     tokenStart = -1;
382                 }
383             } else if (tokenStart == -1) {
384                 // start new token
385                 tokenStart = i;
386             }
387         }
388 
389         // terminate trailing token
390         return tokenStart != -1 && parseToken(txt, tokenStart, txt.length());
391     }
392 
393     private boolean normalizeAndValidate() {
394         if (dayOfMonth < 1
395                 || dayOfMonth > 31
396                 || hours > 23
397                 || minutes > 59
398                 || seconds > 59) {
399             return false;
400         }
401 
402         if (year >= 70 && year <= 99) {
403             year += 1900;
404         } else if (year >= 0 && year < 70) {
405             year += 2000;
406         } else if (year < 1601) {
407             // invalid value
408             return false;
409         }
410         return true;
411     }
412 
413     private Date computeDate() {
414         cal.set(Calendar.DAY_OF_MONTH, dayOfMonth);
415         cal.set(Calendar.MONTH, month);
416         cal.set(Calendar.YEAR, year);
417         cal.set(Calendar.HOUR_OF_DAY, hours);
418         cal.set(Calendar.MINUTE, minutes);
419         cal.set(Calendar.SECOND, seconds);
420         return cal.getTime();
421     }
422 
423     private String format0(Date date) {
424         append0(date, sb);
425         return sb.toString();
426     }
427 
428     private StringBuilder append0(Date date, StringBuilder sb) {
429         cal.setTime(date);
430 
431         sb.append(DAY_OF_WEEK_TO_SHORT_NAME[cal.get(Calendar.DAY_OF_WEEK) - 1]).append(", ");
432         sb.append(cal.get(Calendar.DAY_OF_MONTH)).append(' ');
433         sb.append(CALENDAR_MONTH_TO_SHORT_NAME[cal.get(Calendar.MONTH)]).append(' ');
434         sb.append(cal.get(Calendar.YEAR)).append(' ');
435         appendZeroLeftPadded(cal.get(Calendar.HOUR_OF_DAY), sb).append(':');
436         appendZeroLeftPadded(cal.get(Calendar.MINUTE), sb).append(':');
437         return appendZeroLeftPadded(cal.get(Calendar.SECOND), sb).append(" GMT");
438     }
439 
440     private static StringBuilder appendZeroLeftPadded(int value, StringBuilder sb) {
441         if (value < 10) {
442             sb.append('0');
443         }
444         return sb.append(value);
445     }
446 }