001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      https://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.apache.commons.net.ftp.parser;
019
020import java.text.DateFormatSymbols;
021import java.text.ParseException;
022import java.text.ParsePosition;
023import java.text.SimpleDateFormat;
024import java.util.Calendar;
025import java.util.Date;
026import java.util.TimeZone;
027
028import org.apache.commons.net.ftp.Configurable;
029import org.apache.commons.net.ftp.FTPClientConfig;
030
031/**
032 * Default implementation of the {@link FTPTimestampParser FTPTimestampParser} interface also implements the {@link org.apache.commons.net.ftp.Configurable
033 * Configurable} interface to allow the parsing to be configured from the outside.
034 *
035 * @see ConfigurableFTPFileEntryParserImpl
036 * @since 1.4
037 */
038public class FTPTimestampParserImpl implements FTPTimestampParser, Configurable {
039
040    /*
041     * 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
042     * 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
043     * 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
044     * the array until the previous entry. e.g. for MINUTE it would clear MILLISECOND and SECOND
045     */
046    private static final int[] CALENDAR_UNITS = { Calendar.MILLISECOND, Calendar.SECOND, Calendar.MINUTE, Calendar.HOUR_OF_DAY, Calendar.DAY_OF_MONTH,
047            Calendar.MONTH, Calendar.YEAR };
048
049    /*
050     * Return the index to the array representing the least significant unit found in the date format. Default is 0 (to avoid dropping precision)
051     */
052    private static int getEntry(final SimpleDateFormat dateFormat) {
053        if (dateFormat == null) {
054            return 0;
055        }
056        final String FORMAT_CHARS = "SsmHdM";
057        final String pattern = dateFormat.toPattern();
058        for (final char ch : FORMAT_CHARS.toCharArray()) {
059            if (pattern.indexOf(ch) != -1) { // found the character
060                switch (ch) {
061                case 'S':
062                    return indexOf(Calendar.MILLISECOND);
063                case 's':
064                    return indexOf(Calendar.SECOND);
065                case 'm':
066                    return indexOf(Calendar.MINUTE);
067                case 'H':
068                    return indexOf(Calendar.HOUR_OF_DAY);
069                case 'd':
070                    return indexOf(Calendar.DAY_OF_MONTH);
071                case 'M':
072                    return indexOf(Calendar.MONTH);
073                }
074            }
075        }
076        return 0;
077    }
078
079    /*
080     * Find the entry in the CALENDAR_UNITS array.
081     */
082    private static int indexOf(final int calendarUnit) {
083        int i;
084        for (i = 0; i < CALENDAR_UNITS.length; i++) {
085            if (calendarUnit == CALENDAR_UNITS[i]) {
086                return i;
087            }
088        }
089        return 0;
090    }
091
092    /*
093     * Sets the Calendar precision (used by FTPFile#toFormattedDate) by clearing the immediately preceding unit (if any). Unfortunately the clear(int) method
094     * results in setting all other units.
095     */
096    private static void setPrecision(final int index, final Calendar working) {
097        if (index <= 0) { // e.g. MILLISECONDS
098            return;
099        }
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}