    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}