    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.ParsePosition;
    021import java.text.SimpleDateFormat;
    022import java.time.Instant;
    023import java.util.Calendar;
    024import java.util.Date;
    025import java.util.GregorianCalendar;
    026import java.util.HashMap;
    027import java.util.Locale;
    028import java.util.TimeZone;
    029
    030import org.apache.commons.net.ftp.FTPFile;
    031import org.apache.commons.net.ftp.FTPFileEntryParserImpl;
    032
    033/**
    034 * Parses {@code MSLT} and {@code MLSD} replies. See <a href="https://datatracker.ietf.org/doc/html/rfc3659">RFC 3659</a>.
    035 * <p>
    036 * The format is as follows:
    037 * </p>
    038 *
    039 * <pre>
    040 * entry            = [ facts ] SP path
    041 * facts            = 1*( fact ";" )
    042 * fact             = factname "=" value
    043 * factname         = "Size" / "Modify" / "Create" /
    044 *                    "Type" / "Unique" / "Perm" /
    045 *                    "Lang" / "Media-Type" / "CharSet" /
    046 * os-depend-fact / local-fact
    047 * os-depend-fact   = {IANA assigned OS name} "." token
    048 * local-fact       = "X." token
    049 * value            = *SCHAR
    050 *
    051 * Sample os-depend-fact:
    052 * UNIX.group=0;UNIX.mode=0755;UNIX.owner=0;
    053 * </pre>
    054 * <p>
    055 * A single control response entry (MLST) is returned with a leading space; multiple (data) entries are returned without any leading spaces. The parser requires
    056 * that the leading space from the MLST entry is removed. MLSD entries can begin with a single space if there are no facts.
    057 * </p>
    058 *
    059 * @since 3.0
    060 */
    061public class MLSxEntryParser extends FTPFileEntryParserImpl {
    062    // This class is immutable, so a single instance can be shared.
    063    private static final MLSxEntryParser INSTANCE = new MLSxEntryParser();
    064
    065    private static final HashMap<String, Integer> TYPE_TO_INT = new HashMap<>();
    066    static {
    067        TYPE_TO_INT.put("file", Integer.valueOf(FTPFile.FILE_TYPE));
    068        TYPE_TO_INT.put("cdir", Integer.valueOf(FTPFile.DIRECTORY_TYPE)); // listed directory
    069        TYPE_TO_INT.put("pdir", Integer.valueOf(FTPFile.DIRECTORY_TYPE)); // a parent dir
    070        TYPE_TO_INT.put("dir", Integer.valueOf(FTPFile.DIRECTORY_TYPE)); // dir or sub-dir
    071    }
    072
    073    private static final int[] UNIX_GROUPS = { // Groups in order of mode digits
    074            FTPFile.USER_ACCESS, FTPFile.GROUP_ACCESS, FTPFile.WORLD_ACCESS, };
    075
    076    private static final int[][] UNIX_PERMS = { // perm bits, broken down by octal int value
    077            /* 0 */ {}, /* 1 */ { FTPFile.EXECUTE_PERMISSION }, /* 2 */ { FTPFile.WRITE_PERMISSION },
    078            /* 3 */ { FTPFile.EXECUTE_PERMISSION, FTPFile.WRITE_PERMISSION }, /* 4 */ { FTPFile.READ_PERMISSION },
    079            /* 5 */ { FTPFile.READ_PERMISSION, FTPFile.EXECUTE_PERMISSION }, /* 6 */ { FTPFile.READ_PERMISSION, FTPFile.WRITE_PERMISSION },
    080            /* 7 */ { FTPFile.READ_PERMISSION, FTPFile.WRITE_PERMISSION, FTPFile.EXECUTE_PERMISSION }, };
    081
    082    /**
    083     * Gets the singleton instance.
    084     *
    085     * @return the singleton instance.
    086     */
    087    public static MLSxEntryParser getInstance() {
    088        return INSTANCE;
    089    }
    090
    091    /**
    092     * Parses a line of an FTP server file listing and converts it into a usable format in the form of an {@code FTPFile} instance. If the file listing
    093     * line doesn't describe a file, {@code null} should be returned, otherwise a {@code FTPFile} instance representing the files in the directory
    094     * is returned.
    095     *
    096     * @param entry A line of text from the file listing
    097     * @return An FTPFile instance corresponding to the supplied entry
    098     */
    099    public static FTPFile parseEntry(final String entry) {
    100        return INSTANCE.parseFTPEntry(entry);
    101    }
    102
    103    /**
    104     * Parse a GMT time stamp of the form yyyyMMDDHHMMSS[.sss]
    105     *
    106     * @param timestamp the date-time to parse
    107     * @return a Calendar entry, may be {@code null}
    108     * @since 3.4
    109     */
    110    public static Calendar parseGMTdateTime(final String timestamp) {
    111        final SimpleDateFormat dateFormat;
    112        final boolean hasMillis;
    113        if (timestamp.contains(".")) {
    114            dateFormat = new SimpleDateFormat("yyyyMMddHHmmss.SSS");
    115            hasMillis = true;
    116        } else {
    117            dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
    118            hasMillis = false;
    119        }
    120        final TimeZone gmtTimeZone = TimeZone.getTimeZone("GMT");
    121        // both time zones need to be set for the parse to work OK
    122        dateFormat.setTimeZone(gmtTimeZone);
    123        final GregorianCalendar gCalendar = new GregorianCalendar(gmtTimeZone);
    124        final ParsePosition pos = new ParsePosition(0);
    125        dateFormat.setLenient(false); // We want to parse the whole string
    126        final Date parsed = dateFormat.parse(timestamp, pos);
    127        if (pos.getIndex() != timestamp.length()) {
    128            return null; // did not fully parse the input
    129        }
    130        gCalendar.setTime(parsed);
    131        if (!hasMillis) {
    132            gCalendar.clear(Calendar.MILLISECOND); // flag up missing ms units
    133        }
    134        return gCalendar;
    135    }
    136
    137    /**
    138     * Parse a GMT time stamp of the form yyyyMMDDHHMMSS[.sss]
    139     *
    140     * @param timestamp the date-time to parse
    141     * @return a Calendar entry, may be {@code null}
    142     * @since 3.9.0
    143     */
    144    public static Instant parseGmtInstant(final String timestamp) {
    145        return parseGMTdateTime(timestamp).toInstant();
    146    }
    147
    148    /**
    149     * Creates the parser for MSLT and MSLD listing entries This class is immutable, so one can use {@link #getInstance()} instead.
    150     *
    151     * @deprecated Use {@link #getInstance()}.
    152     */
    153    @Deprecated
    154    public MLSxEntryParser() {
    155        // empty
    156    }
    157
    158    // perm-fact = "Perm" "=" *pvals
    159    // pvals = "a" / "c" / "d" / "e" / "f" /
    160    // "l" / "m" / "p" / "r" / "w"
    161    private void doUnixPerms(final FTPFile file, final String valueLowerCase) {
    162        for (final char c : valueLowerCase.toCharArray()) {
    163            // TODO these are mostly just guesses at present
    164            switch (c) {
    165            case 'a': // (file) may APPEnd
    166                file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    167                break;
    168            case 'c': // (dir) files may be created in the dir
    169                file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    170                break;
    171            case 'd': // deletable
    172                file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    173                break;
    174            case 'e': // (dir) can change to this dir
    175                file.setPermission(FTPFile.USER_ACCESS, FTPFile.READ_PERMISSION, true);
    176                break;
    177            case 'f': // (file) renamable
    178                // ?? file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    179                break;
    180            case 'l': // (dir) can be listed
    181                file.setPermission(FTPFile.USER_ACCESS, FTPFile.EXECUTE_PERMISSION, true);
    182                break;
    183            case 'm': // (dir) can create directory here
    184                file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    185                break;
    186            case 'p': // (dir) entries may be deleted
    187                file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    188                break;
    189            case 'r': // (files) file may be RETRieved
    190                file.setPermission(FTPFile.USER_ACCESS, FTPFile.READ_PERMISSION, true);
    191                break;
    192            case 'w': // (files) file may be STORed
    193                file.setPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION, true);
    194                break;
    195            default:
    196                break;
    197            // ignore unexpected flag for now.
    198            } // switch
    199        } // each char
    200    }
    201
    202    @Override
    203    public FTPFile parseFTPEntry(final String entry) {
    204        if (entry.startsWith(" ")) { // leading space means no facts are present
    205            if (entry.length() > 1) { // is there a path name?
    206                final FTPFile file = new FTPFile();
    207                file.setRawListing(entry);
    208                file.setName(entry.substring(1));
    209                return file;
    210            }
    211            return null; // Invalid - no path
    212
    213        }
    214        final String[] parts = entry.split(" ", 2); // Path may contain space
    215        if (parts.length != 2 || parts[1].isEmpty()) {
    216            return null; // no space found or no file name
    217        }
    218        final String factList = parts[0];
    219        if (!factList.endsWith(";")) {
    220            return null;
    221        }
    222        final FTPFile file = new FTPFile();
    223        file.setRawListing(entry);
    224        file.setName(parts[1]);
    225        final String[] facts = factList.split(";");
    226        final boolean hasUnixMode = parts[0].toLowerCase(Locale.ENGLISH).contains("unix.mode=");
    227        for (final String fact : facts) {
    228            final String[] factparts = fact.split("=", -1); // Don't drop empty values
    229// Sample missing permission
    230// drwx------   2 mirror   mirror       4096 Mar 13  2010 subversion
    231// modify=20100313224553;perm=;type=dir;unique=811U282598;UNIX.group=500;UNIX.mode=0700;UNIX.owner=500; subversion
    232            if (factparts.length != 2) {
    233                return null; // invalid - there was no "=" sign
    234            }
    235            final String factname = factparts[0].toLowerCase(Locale.ENGLISH);
    236            final String factvalue = factparts[1];
    237            if (factvalue.isEmpty()) {
    238                continue; // nothing to see here
    239            }
    240            final String valueLowerCase = factvalue.toLowerCase(Locale.ENGLISH);
    241            switch (factname) {
    242            case "size":
    243            case "sizd":
    244                file.setSize(Long.parseLong(factvalue));
    245                break;
    246            case "modify": {
    247                final Calendar parsed = parseGMTdateTime(factvalue);
    248                if (parsed == null) {
    249                    return null;
    250                }
    251                file.setTimestamp(parsed);
    252                break;
    253            }
    254            case "type": {
    255                final Integer intType = TYPE_TO_INT.get(valueLowerCase);
    256                if (intType == null) {
    257                    file.setType(FTPFile.UNKNOWN_TYPE);
    258                } else {
    259                    file.setType(intType.intValue());
    260                }
    261                break;
    262            }
    263            default:
    264                if (factname.startsWith("unix.")) {
    265                    final String unixfact = factname.substring("unix.".length()).toLowerCase(Locale.ENGLISH);
    266                    switch (unixfact) {
    267                    case "group":
    268                        file.setGroup(factvalue);
    269                        break;
    270                    case "owner":
    271                        file.setUser(factvalue);
    272                        break;
    273                    case "mode": {
    274                        final int off = factvalue.length() - 3; // only parse last 3 digits
    275                        for (int i = 0; i < 3; i++) {
    276                            final int ch = factvalue.charAt(off + i) - '0';
    277                            if (ch >= 0 && ch <= 7) { // Check it's valid octal
    278                                for (final int p : UNIX_PERMS[ch]) {
    279                                    file.setPermission(UNIX_GROUPS[i], p, true);
    280                                }
    281                            } else {
    282                                // TODO should this cause failure, or can it be reported somehow?
    283                            }
    284                        } // digits
    285                        break;
    286                    }
    287                    default:
    288                        break;
    289                    } // mode
    290                // unix.
    291                } else if (!hasUnixMode && "perm".equals(factname)) { // skip if we have the UNIX.mode
    292                    doUnixPerms(file, valueLowerCase);
    293                }
    294                break;
    295            } // process "perm"
    296        } // each fact
    297        return file;
    298    }
    299}