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}