View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.net.ftp.parser;
19  
20  import java.text.ParsePosition;
21  import java.text.SimpleDateFormat;
22  import java.time.Instant;
23  import java.util.Calendar;
24  import java.util.Date;
25  import java.util.GregorianCalendar;
26  import java.util.HashMap;
27  import java.util.Locale;
28  import java.util.TimeZone;
29  
30  import org.apache.commons.net.ftp.FTPFile;
31  import org.apache.commons.net.ftp.FTPFileEntryParserImpl;
32  
33  /**
34   * Parses {@code MSLT} and {@code MLSD} replies. See <a href="https://datatracker.ietf.org/doc/html/rfc3659">RFC 3659</a>.
35   * <p>
36   * The format is as follows:
37   * </p>
38   *
39   * <pre>
40   * entry            = [ facts ] SP path
41   * facts            = 1*( fact ";" )
42   * fact             = factname "=" value
43   * factname         = "Size" / "Modify" / "Create" /
44   *                    "Type" / "Unique" / "Perm" /
45   *                    "Lang" / "Media-Type" / "CharSet" /
46   * os-depend-fact / local-fact
47   * os-depend-fact   = {IANA assigned OS name} "." token
48   * local-fact       = "X." token
49   * value            = *SCHAR
50   *
51   * Sample os-depend-fact:
52   * UNIX.group=0;UNIX.mode=0755;UNIX.owner=0;
53   * </pre>
54   * <p>
55   * A single control response entry (MLST) is returned with a leading space; multiple (data) entries are returned without any leading spaces. The parser requires
56   * that the leading space from the MLST entry is removed. MLSD entries can begin with a single space if there are no facts.
57   * </p>
58   *
59   * @since 3.0
60   */
61  public class MLSxEntryParser extends FTPFileEntryParserImpl {
62      // This class is immutable, so a single instance can be shared.
63      private static final MLSxEntryParser INSTANCE = new MLSxEntryParser();
64  
65      private static final HashMap<String, Integer> TYPE_TO_INT = new HashMap<>();
66      static {
67          TYPE_TO_INT.put("file", Integer.valueOf(FTPFile.FILE_TYPE));
68          TYPE_TO_INT.put("cdir", Integer.valueOf(FTPFile.DIRECTORY_TYPE)); // listed directory
69          TYPE_TO_INT.put("pdir", Integer.valueOf(FTPFile.DIRECTORY_TYPE)); // a parent dir
70          TYPE_TO_INT.put("dir", Integer.valueOf(FTPFile.DIRECTORY_TYPE)); // dir or sub-dir
71      }
72  
73      private static final int[] UNIX_GROUPS = { // Groups in order of mode digits
74              FTPFile.USER_ACCESS, FTPFile.GROUP_ACCESS, FTPFile.WORLD_ACCESS, };
75  
76      private static final int[][] UNIX_PERMS = { // perm bits, broken down by octal int value
77              /* 0 */ {}, /* 1 */ { FTPFile.EXECUTE_PERMISSION }, /* 2 */ { FTPFile.WRITE_PERMISSION },
78              /* 3 */ { FTPFile.EXECUTE_PERMISSION, FTPFile.WRITE_PERMISSION }, /* 4 */ { FTPFile.READ_PERMISSION },
79              /* 5 */ { FTPFile.READ_PERMISSION, FTPFile.EXECUTE_PERMISSION }, /* 6 */ { FTPFile.READ_PERMISSION, FTPFile.WRITE_PERMISSION },
80              /* 7 */ { FTPFile.READ_PERMISSION, FTPFile.WRITE_PERMISSION, FTPFile.EXECUTE_PERMISSION }, };
81  
82      /**
83       * Gets the singleton instance.
84       *
85       * @return the singleton instance.
86       */
87      public static MLSxEntryParser getInstance() {
88          return INSTANCE;
89      }
90  
91      /**
92       * 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
93       * line doesn't describe a file, {@code null} should be returned, otherwise a {@code FTPFile} instance representing the files in the directory
94       * is returned.
95       *
96       * @param entry A line of text from the file listing
97       * @return An FTPFile instance corresponding to the supplied entry
98       */
99      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 }