View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.net.ftp.parser;
19  
20  import java.text.DateFormatSymbols;
21  import java.text.ParseException;
22  import java.text.ParsePosition;
23  import java.text.SimpleDateFormat;
24  import java.util.Calendar;
25  import java.util.Date;
26  import java.util.TimeZone;
27  
28  import org.apache.commons.net.ftp.Configurable;
29  import org.apache.commons.net.ftp.FTPClientConfig;
30  
31  /**
32   * Default implementation of the {@link FTPTimestampParser FTPTimestampParser} interface also implements the {@link org.apache.commons.net.ftp.Configurable
33   * Configurable} interface to allow the parsing to be configured from the outside.
34   *
35   * @see ConfigurableFTPFileEntryParserImpl
36   * @since 1.4
37   */
38  public class FTPTimestampParserImpl implements FTPTimestampParser, Configurable {
39  
40      /*
41       * List of units in order of increasing significance. This allows the code to clear all units in the Calendar until it reaches the least significant unit in
42       * the parse string. The date formats are analysed to find the least significant unit (e.g. Minutes or Milliseconds) and the appropriate index to the array
43       * is saved. This is done by searching the array for the unit specifier, and returning the index. When clearing the Calendar units, the code loops through
44       * the array until the previous entry. e.g. for MINUTE it would clear MILLISECOND and SECOND
45       */
46      private static final int[] CALENDAR_UNITS = { Calendar.MILLISECOND, Calendar.SECOND, Calendar.MINUTE, Calendar.HOUR_OF_DAY, Calendar.DAY_OF_MONTH,
47              Calendar.MONTH, Calendar.YEAR };
48  
49      /*
50       * Return the index to the array representing the least significant unit found in the date format. Default is 0 (to avoid dropping precision)
51       */
52      private static int getEntry(final SimpleDateFormat dateFormat) {
53          if (dateFormat == null) {
54              return 0;
55          }
56          final String FORMAT_CHARS = "SsmHdM";
57          final String pattern = dateFormat.toPattern();
58          for (final char ch : FORMAT_CHARS.toCharArray()) {
59              if (pattern.indexOf(ch) != -1) { // found the character
60                  switch (ch) {
61                  case 'S':
62                      return indexOf(Calendar.MILLISECOND);
63                  case 's':
64                      return indexOf(Calendar.SECOND);
65                  case 'm':
66                      return indexOf(Calendar.MINUTE);
67                  case 'H':
68                      return indexOf(Calendar.HOUR_OF_DAY);
69                  case 'd':
70                      return indexOf(Calendar.DAY_OF_MONTH);
71                  case 'M':
72                      return indexOf(Calendar.MONTH);
73                  }
74              }
75          }
76          return 0;
77      }
78  
79      /*
80       * Find the entry in the CALENDAR_UNITS array.
81       */
82      private static int indexOf(final int calendarUnit) {
83          int i;
84          for (i = 0; i < CALENDAR_UNITS.length; i++) {
85              if (calendarUnit == CALENDAR_UNITS[i]) {
86                  return i;
87              }
88          }
89          return 0;
90      }
91  
92      /*
93       * Sets the Calendar precision (used by FTPFile#toFormattedDate) by clearing the immediately preceding unit (if any). Unfortunately the clear(int) method
94       * results in setting all other units.
95       */
96      private static void setPrecision(final int index, final Calendar working) {
97          if (index <= 0) { // e.g. MILLISECONDS
98              return;
99          }
100         final int field = CALENDAR_UNITS[index - 1];
101         // Just in case the analysis is wrong, stop clearing if
102         // field value is not the default.
103         final int value = working.get(field);
104         if (value != 0) { // don't reset if it has a value
105 //            new Throwable("Unexpected value "+value).printStackTrace(); // DEBUG
106         } else {
107             working.clear(field); // reset just the required field
108         }
109     }
110 
111     /** The date format for all dates, except possibly recent dates. Assumed to include the year. */
112     private SimpleDateFormat defaultDateFormat;
113 
114     /* The index in CALENDAR_UNITS of the smallest time unit in defaultDateFormat */
115     private int defaultDateSmallestUnitIndex;
116 
117     /** The format used for recent dates (which don't have the year). May be null. */
118     private SimpleDateFormat recentDateFormat;
119 
120     /* The index in CALENDAR_UNITS of the smallest time unit in recentDateFormat */
121     private int recentDateSmallestUnitIndex;
122 
123     private boolean lenientFutureDates;
124 
125     /**
126      * The only constructor for this class.
127      */
128     public FTPTimestampParserImpl() {
129         setDefaultDateFormat(DEFAULT_SDF, null);
130         setRecentDateFormat(DEFAULT_RECENT_SDF, null);
131     }
132 
133     /**
134      * Implementation of the {@link Configurable Configurable} interface. Configures this <code>FTPTimestampParser</code> according to the following logic:
135      * <p>
136      * Set up the {@link FTPClientConfig#setDefaultDateFormatStr(java.lang.String) defaultDateFormat} and optionally the
137      * {@link FTPClientConfig#setRecentDateFormatStr(String) recentDateFormat} to values supplied in the config based on month names configured as follows:
138      * </p>
139      * <ul>
140      * <li>If a {@link FTPClientConfig#setShortMonthNames(String) shortMonthString} has been supplied in the <code>config</code>, use that to parse parse
141      * timestamps.</li>
142      * <li>Otherwise, if a {@link FTPClientConfig#setServerLanguageCode(String) serverLanguageCode} has been supplied in the <code>config</code>, use the month
143      * names represented by that {@link FTPClientConfig#lookupDateFormatSymbols(String) language} to parse timestamps.</li>
144      * <li>otherwise use default English month names</li>
145      * </ul>
146      * <p>
147      * Finally if a {@link org.apache.commons.net.ftp.FTPClientConfig#setServerTimeZoneId(String) serverTimeZoneId} has been supplied via the config, set that
148      * into all date formats that have been configured.
149      * </p>
150      */
151     @Override
152     public void configure(final FTPClientConfig config) {
153         DateFormatSymbols dfs;
154 
155         final String languageCode = config.getServerLanguageCode();
156         final String shortmonths = config.getShortMonthNames();
157         if (shortmonths != null) {
158             dfs = FTPClientConfig.getDateFormatSymbols(shortmonths);
159         } else if (languageCode != null) {
160             dfs = FTPClientConfig.lookupDateFormatSymbols(languageCode);
161         } else {
162             dfs = FTPClientConfig.lookupDateFormatSymbols("en");
163         }
164 
165         final String recentFormatString = config.getRecentDateFormatStr();
166         setRecentDateFormat(recentFormatString, dfs);
167 
168         final String defaultFormatString = config.getDefaultDateFormatStr();
169         if (defaultFormatString == null) {
170             throw new IllegalArgumentException("defaultFormatString cannot be null");
171         }
172         setDefaultDateFormat(defaultFormatString, dfs);
173 
174         setServerTimeZone(config.getServerTimeZoneId());
175 
176         this.lenientFutureDates = config.isLenientFutureDates();
177     }
178 
179     /**
180      * @return Returns the defaultDateFormat.
181      */
182     public SimpleDateFormat getDefaultDateFormat() {
183         return defaultDateFormat;
184     }
185 
186     /**
187      * @return Returns the defaultDateFormat pattern string.
188      */
189     public String getDefaultDateFormatString() {
190         return defaultDateFormat.toPattern();
191     }
192 
193     /**
194      * @return Returns the recentDateFormat.
195      */
196     public SimpleDateFormat getRecentDateFormat() {
197         return recentDateFormat;
198     }
199 
200     /**
201      * @return Returns the recentDateFormat.
202      */
203     public String getRecentDateFormatString() {
204         return recentDateFormat.toPattern();
205     }
206 
207     /**
208      * @return Returns the serverTimeZone used by this parser.
209      */
210     public TimeZone getServerTimeZone() {
211         return this.defaultDateFormat.getTimeZone();
212     }
213 
214     /**
215      * @return returns an array of 12 strings representing the short month names used by this parse.
216      */
217     public String[] getShortMonths() {
218         return defaultDateFormat.getDateFormatSymbols().getShortMonths();
219     }
220 
221     /**
222      * @return Returns the lenientFutureDates.
223      */
224     boolean isLenientFutureDates() {
225         return lenientFutureDates;
226     }
227 
228     /**
229      * Implements the one {@link FTPTimestampParser#parseTimestamp(String) method} in the {@link FTPTimestampParser FTPTimestampParser} interface according to
230      * this algorithm:
231      *
232      * If the recentDateFormat member has been defined, try to parse the supplied string with that. If that parse fails, or if the recentDateFormat member has
233      * not been defined, attempt to parse with the defaultDateFormat member. If that fails, throw a ParseException.
234      *
235      * This method assumes that the server time is the same as the local time.
236      *
237      * @see FTPTimestampParserImpl#parseTimestamp(String, Calendar)
238      *
239      * @param timestampStr The timestamp to be parsed
240      * @return a Calendar with the parsed timestamp
241      */
242     @Override
243     public Calendar parseTimestamp(final String timestampStr) throws ParseException {
244         final Calendar now = Calendar.getInstance();
245         return parseTimestamp(timestampStr, now);
246     }
247 
248     /**
249      * If the recentDateFormat member has been defined, try to parse the supplied string with that. If that parse fails, or if the recentDateFormat member has
250      * not been defined, attempt to parse with the defaultDateFormat member. If that fails, throw a ParseException.
251      *
252      * This method allows a {@link Calendar} instance to be passed in which represents the current (system) time.
253      *
254      * @see FTPTimestampParser#parseTimestamp(String)
255      * @param timestampStr The timestamp to be parsed
256      * @param serverTime   The current time for the server
257      * @return the calendar
258      * @throws ParseException if timestamp cannot be parsed
259      * @since 1.5
260      */
261     public Calendar parseTimestamp(final String timestampStr, final Calendar serverTime) throws ParseException {
262         final Calendar working = (Calendar) serverTime.clone();
263         working.setTimeZone(getServerTimeZone()); // is this needed?
264 
265         Date parsed;
266 
267         if (recentDateFormat != null) {
268             final Calendar now = (Calendar) serverTime.clone();// Copy this, because we may change it
269             now.setTimeZone(this.getServerTimeZone());
270             if (lenientFutureDates) {
271                 // add a day to "now" so that "slop" doesn't cause a date
272                 // slightly in the future to roll back a full year. (Bug 35181 => NET-83)
273                 now.add(Calendar.DAY_OF_MONTH, 1);
274             }
275             // The Java SimpleDateFormat class uses the epoch year 1970 if not present in the input
276             // As 1970 was not a leap year, it cannot parse "Feb 29" correctly.
277             // Java 1.5+ returns Mar 1 1970
278             // Temporarily add the current year to the short date time
279             // to cope with short-date leap year strings.
280             // Since Feb 29 is more that 6 months from the end of the year, this should be OK for
281             // all instances of short dates which are +- 6 months from current date.
282             // TODO this won't always work for systems that use short dates +0/-12months
283             // e.g. if today is Jan 1 2001 and the short date is Feb 29
284             final String year = Integer.toString(now.get(Calendar.YEAR));
285             final String timeStampStrPlusYear = timestampStr + " " + year;
286             final SimpleDateFormat hackFormatter = new SimpleDateFormat(recentDateFormat.toPattern() + " yyyy", recentDateFormat.getDateFormatSymbols());
287             hackFormatter.setLenient(false);
288             hackFormatter.setTimeZone(recentDateFormat.getTimeZone());
289             final ParsePosition pp = new ParsePosition(0);
290             parsed = hackFormatter.parse(timeStampStrPlusYear, pp);
291             // Check if we parsed the full string, if so it must have been a short date originally
292             if (parsed != null && pp.getIndex() == timeStampStrPlusYear.length()) {
293                 working.setTime(parsed);
294                 if (working.after(now)) { // must have been last year instead
295                     working.add(Calendar.YEAR, -1);
296                 }
297                 setPrecision(recentDateSmallestUnitIndex, working);
298                 return working;
299             }
300         }
301 
302         final ParsePosition pp = new ParsePosition(0);
303         parsed = defaultDateFormat.parse(timestampStr, pp);
304         // note, length checks are mandatory for us since
305         // SimpleDateFormat methods will succeed if less than
306         // full string is matched. They will also accept,
307         // despite "leniency" setting, a two-digit number as
308         // a valid year (e.g. 22:04 will parse as 22 A.D.)
309         // so could mistakenly confuse an hour with a year,
310         // if we don't insist on full length parsing.
311         if (parsed == null || pp.getIndex() != timestampStr.length()) {
312             throw new ParseException("Timestamp '" + timestampStr + "' could not be parsed using a server time of " + serverTime.getTime().toString(),
313                     pp.getErrorIndex());
314         }
315         working.setTime(parsed);
316         setPrecision(defaultDateSmallestUnitIndex, working);
317         return working;
318     }
319 
320     /**
321      * @param format The defaultDateFormat to be set.
322      * @param dfs    the symbols to use (may be null)
323      */
324     private void setDefaultDateFormat(final String format, final DateFormatSymbols dfs) {
325         if (format != null) {
326             if (dfs != null) {
327                 this.defaultDateFormat = new SimpleDateFormat(format, dfs);
328             } else {
329                 this.defaultDateFormat = new SimpleDateFormat(format);
330             }
331             this.defaultDateFormat.setLenient(false);
332         } else {
333             this.defaultDateFormat = null;
334         }
335         this.defaultDateSmallestUnitIndex = getEntry(this.defaultDateFormat);
336     }
337 
338     /**
339      * @param lenientFutureDates The lenientFutureDates to set.
340      */
341     void setLenientFutureDates(final boolean lenientFutureDates) {
342         this.lenientFutureDates = lenientFutureDates;
343     }
344 
345     /**
346      * @param format The recentDateFormat to set.
347      * @param dfs    the symbols to use (may be null)
348      */
349     private void setRecentDateFormat(final String format, final DateFormatSymbols dfs) {
350         if (format != null) {
351             if (dfs != null) {
352                 this.recentDateFormat = new SimpleDateFormat(format, dfs);
353             } else {
354                 this.recentDateFormat = new SimpleDateFormat(format);
355             }
356             this.recentDateFormat.setLenient(false);
357         } else {
358             this.recentDateFormat = null;
359         }
360         this.recentDateSmallestUnitIndex = getEntry(this.recentDateFormat);
361     }
362 
363     /**
364      * sets a TimeZone represented by the supplied ID string into all the parsers used by this server.
365      *
366      * @param serverTimeZoneId Time Id java.util.TimeZone id used by the ftp server. If null the client's local time zone is assumed.
367      */
368     private void setServerTimeZone(final String serverTimeZoneId) {
369         TimeZone serverTimeZone = TimeZone.getDefault();
370         if (serverTimeZoneId != null) {
371             serverTimeZone = TimeZone.getTimeZone(serverTimeZoneId);
372         }
373         this.defaultDateFormat.setTimeZone(serverTimeZone);
374         if (this.recentDateFormat != null) {
375             this.recentDateFormat.setTimeZone(serverTimeZone);
376         }
377     }
378 }