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 * https://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 analyzed 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 * Implements the {@link Configurable Configurable} interface. Configures this {@code FTPTimestampParser} according to the following logic:
135 * <p>
136 * Sets 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}, 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}, 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 final 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 lenientFutureDates = config.isLenientFutureDates();
177 }
178
179 /**
180 * Gets the defaultDateFormat.
181 *
182 * @return the defaultDateFormat.
183 */
184 public SimpleDateFormat getDefaultDateFormat() {
185 return defaultDateFormat;
186 }
187
188 /**
189 * Gets the defaultDateFormat pattern string.
190 *
191 * @return the defaultDateFormat pattern string.
192 */
193 public String getDefaultDateFormatString() {
194 return defaultDateFormat.toPattern();
195 }
196
197 /**
198 * Gets the recentDateFormat.
199 *
200 * @return the recentDateFormat.
201 */
202 public SimpleDateFormat getRecentDateFormat() {
203 return recentDateFormat;
204 }
205
206 /**
207 * Gets the recentDateFormat.
208 *
209 * @return the recentDateFormat.
210 */
211 public String getRecentDateFormatString() {
212 return recentDateFormat.toPattern();
213 }
214
215 /**
216 * Gets the serverTimeZone used by this parser.
217 *
218 * @return the serverTimeZone used by this parser.
219 */
220 public TimeZone getServerTimeZone() {
221 return defaultDateFormat.getTimeZone();
222 }
223
224 /**
225 * Gets an array of 12 strings representing the short month names used by this parse.
226 *
227 * @return an array of 12 strings representing the short month names used by this parse.
228 */
229 public String[] getShortMonths() {
230 return defaultDateFormat.getDateFormatSymbols().getShortMonths();
231 }
232
233 /**
234 * @return the lenientFutureDates.
235 */
236 boolean isLenientFutureDates() {
237 return lenientFutureDates;
238 }
239
240 /**
241 * Implements the one {@link FTPTimestampParser#parseTimestamp(String) method} in the {@link FTPTimestampParser FTPTimestampParser} interface according to
242 * this algorithm:
243 *
244 * 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
245 * not been defined, attempt to parse with the defaultDateFormat member. If that fails, throw a ParseException.
246 *
247 * This method assumes that the server time is the same as the local time.
248 *
249 * @see FTPTimestampParserImpl#parseTimestamp(String, Calendar)
250 * @param timestampStr The timestamp to be parsed
251 * @return a Calendar with the parsed timestamp
252 */
253 @Override
254 public Calendar parseTimestamp(final String timestampStr) throws ParseException {
255 return parseTimestamp(timestampStr, Calendar.getInstance());
256 }
257
258 /**
259 * 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
260 * not been defined, attempt to parse with the defaultDateFormat member. If that fails, throw a ParseException.
261 *
262 * This method allows a {@link Calendar} instance to be passed in which represents the current (system) time.
263 *
264 * @see FTPTimestampParser#parseTimestamp(String)
265 * @param timestampStr The timestamp to be parsed
266 * @param serverTime The current time for the server
267 * @return the calendar
268 * @throws ParseException if timestamp cannot be parsed
269 * @since 1.5
270 */
271 public Calendar parseTimestamp(final String timestampStr, final Calendar serverTime) throws ParseException {
272 final Calendar working = (Calendar) serverTime.clone();
273 working.setTimeZone(getServerTimeZone()); // is this needed?
274
275 Date parsed;
276
277 if (recentDateFormat != null) {
278 final Calendar now = (Calendar) serverTime.clone(); // Copy this, because we may change it
279 now.setTimeZone(getServerTimeZone());
280 if (lenientFutureDates) {
281 // add a day to "now" so that "slop" doesn't cause a date
282 // slightly in the future to roll back a full year. (Bug 35181 => NET-83)
283 now.add(Calendar.DAY_OF_MONTH, 1);
284 }
285 // The Java SimpleDateFormat class uses the epoch year 1970 if not present in the input
286 // As 1970 was not a leap year, it cannot parse "Feb 29" correctly.
287 // Java 1.5+ returns Mar 1 1970
288 // Temporarily add the current year to the short date time
289 // to cope with short-date leap year strings.
290 // Since Feb 29 is more that 6 months from the end of the year, this should be OK for
291 // all instances of short dates which are +- 6 months from current date.
292 // TODO this won't always work for systems that use short dates +0/-12months
293 // e.g. if today is Jan 1 2001 and the short date is Feb 29
294 final String year = Integer.toString(now.get(Calendar.YEAR));
295 final String timeStampStrPlusYear = timestampStr + " " + year;
296 final SimpleDateFormat hackFormatter = new SimpleDateFormat(recentDateFormat.toPattern() + " yyyy", recentDateFormat.getDateFormatSymbols());
297 hackFormatter.setLenient(false);
298 hackFormatter.setTimeZone(recentDateFormat.getTimeZone());
299 final ParsePosition pp = new ParsePosition(0);
300 parsed = hackFormatter.parse(timeStampStrPlusYear, pp);
301 // Check if we parsed the full string, if so it must have been a short date originally
302 if (parsed != null && pp.getIndex() == timeStampStrPlusYear.length()) {
303 working.setTime(parsed);
304 if (working.after(now)) { // must have been last year instead
305 working.add(Calendar.YEAR, -1);
306 }
307 setPrecision(recentDateSmallestUnitIndex, working);
308 return working;
309 }
310 }
311
312 final ParsePosition pp = new ParsePosition(0);
313 parsed = defaultDateFormat.parse(timestampStr, pp);
314 // note, length checks are mandatory for us since
315 // SimpleDateFormat methods will succeed if less than
316 // full string is matched. They will also accept,
317 // despite "leniency" setting, a two-digit number as
318 // a valid year (e.g. 22:04 will parse as 22 A.D.)
319 // so could mistakenly confuse an hour with a year,
320 // if we don't insist on full length parsing.
321 if (parsed == null || pp.getIndex() != timestampStr.length()) {
322 throw new ParseException("Timestamp '" + timestampStr + "' could not be parsed using a server time of " + serverTime.getTime().toString(),
323 pp.getErrorIndex());
324 }
325 working.setTime(parsed);
326 setPrecision(defaultDateSmallestUnitIndex, working);
327 return working;
328 }
329
330 /**
331 * @param format The defaultDateFormat to be set.
332 * @param dfs the symbols to use (may be null)
333 */
334 private void setDefaultDateFormat(final String format, final DateFormatSymbols dfs) {
335 if (format != null) {
336 if (dfs != null) {
337 defaultDateFormat = new SimpleDateFormat(format, dfs);
338 } else {
339 defaultDateFormat = new SimpleDateFormat(format);
340 }
341 defaultDateFormat.setLenient(false);
342 } else {
343 defaultDateFormat = null;
344 }
345 defaultDateSmallestUnitIndex = getEntry(defaultDateFormat);
346 }
347
348 /**
349 * @param lenientFutureDates The lenientFutureDates to set.
350 */
351 void setLenientFutureDates(final boolean lenientFutureDates) {
352 this.lenientFutureDates = lenientFutureDates;
353 }
354
355 /**
356 * @param format The recentDateFormat to set.
357 * @param dfs the symbols to use (may be null)
358 */
359 private void setRecentDateFormat(final String format, final DateFormatSymbols dfs) {
360 if (format != null) {
361 if (dfs != null) {
362 recentDateFormat = new SimpleDateFormat(format, dfs);
363 } else {
364 recentDateFormat = new SimpleDateFormat(format);
365 }
366 recentDateFormat.setLenient(false);
367 } else {
368 recentDateFormat = null;
369 }
370 recentDateSmallestUnitIndex = getEntry(recentDateFormat);
371 }
372
373 /**
374 * sets a TimeZone represented by the supplied ID string into all the parsers used by this server.
375 *
376 * @param serverTimeZoneId Time Id java.util.TimeZone id used by the ftp server. If null the client's local time zone is assumed.
377 */
378 private void setServerTimeZone(final String serverTimeZoneId) {
379 TimeZone serverTimeZone = TimeZone.getDefault();
380 if (serverTimeZoneId != null) {
381 serverTimeZone = TimeZone.getTimeZone(serverTimeZoneId);
382 }
383 defaultDateFormat.setTimeZone(serverTimeZone);
384 if (recentDateFormat != null) {
385 recentDateFormat.setTimeZone(serverTimeZone);
386 }
387 }
388 }