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