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}