    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 *      http://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 analysed 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 preceeding 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     * 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 = null;
    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 = null;
    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 of 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}