/** * This file is part of muCommander, http://www.mucommander.com * Copyright (C) 2002-2016 Maxence Bernard * * muCommander is free software; you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * muCommander is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.mucommander.commons.file.protocol.ftp; import com.mucommander.commons.file.*; import com.mucommander.commons.file.connection.ConnectionHandler; import com.mucommander.commons.file.connection.ConnectionHandlerFactory; import com.mucommander.commons.file.connection.ConnectionPool; import com.mucommander.commons.file.protocol.FileProtocols; import com.mucommander.commons.file.protocol.ProtocolFile; import com.mucommander.commons.io.ByteUtils; import com.mucommander.commons.io.FilteredOutputStream; import com.mucommander.commons.io.RandomAccessInputStream; import com.mucommander.commons.io.RandomAccessOutputStream; import org.apache.commons.net.ftp.FTPClient; import org.apache.commons.net.ftp.FTPConnectionClosedException; import org.apache.commons.net.ftp.FTPReply; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.*; import java.net.SocketException; import java.net.SocketTimeoutException; import java.text.SimpleDateFormat; import java.util.Date; /** * FTPFile provides access to files located on an FTP server. * * <p>The associated {@link FileURL} scheme is {@link FileProtocols#FTP}. The host part of the URL designates the * FTP server. Credentials must be specified in the login and password parts as FTP servers require a login and * password. The path separator is '/'. * * <p>Here are a few examples of valid FTP URLs: * <code> * ftp://garfield/stuff/somefile<br> * ftp://john:p4sswd@garfield/stuff/somefile<br> * ftp://anonymous:john@somewhere.net@garfield/stuff/somefile<br> * </code> * * <p>Internally, FTPFile uses {@link ConnectionPool} to create FTP connections as needed and allows them to be reused * by FTPFile instances located on the same server, dealing with concurrency issues. Connections are thus managed * transparently and need not be manually managed. * * <p>Some FileURL properties control certain FTP connection settings: * <ul> * <li>{@link #PASSIVE_MODE_PROPERTY_NAME}: controls whether passive or active transfer mode, <code>"true"</code> for * passive mode, <code>"false"</code> for activemode. If the property is not specified when the connection is created, * passive mode is assumed. * <li>{@link #ENCODING_PROPERTY_NAME}: specifies the character encoding used by the server. If the property is not * specified when the connection is created, {@link #DEFAULT_ENCODING} is assumed. * </ul> * These properties are only used when the FTP connection is created. Setting them after the connection is created * will not have any immediate effect, their values will only be used if the connection needs to be re-established. * * <p>Access to FTP files is provided by the <code>Commons-net</code> library distributed under the Apache Software License. * The {@link #getUnderlyingFileObject()} method allows to retrieve a <code>org.apache.commons.net.ftp.FTPFile</code> * instance corresponding to this FTPFile. * * @see ConnectionPool * @author Maxence Bernard */ public class FTPFile extends ProtocolFile implements ConnectionHandlerFactory { private static final Logger LOGGER = LoggerFactory.getLogger(FTPFile.class); private org.apache.commons.net.ftp.FTPFile file; private String absPath; private AbstractFile parent; private boolean parentValSet; private FilePermissions permissions; private boolean fileExists; private AbstractFile canonicalFile; private final static String SEPARATOR = "/"; /** Name of the FTP passive mode property */ public final static String PASSIVE_MODE_PROPERTY_NAME = "passiveMode"; /** Name of the FTP encoding property */ public final static String ENCODING_PROPERTY_NAME = "encoding"; /** Default FTP encoding if {@link #ENCODING_PROPERTY_NAME} is not set */ public final static String DEFAULT_ENCODING = "UTF-8"; /** Name of the property that holds the number of retries after a recoverable connection failure (connection error * or temporary server error in the 4xx range) */ public final static String NB_CONNECTION_RETRIES_PROPERTY_NAME = "nbConnectionRetries"; /** Default value if {@link #NB_CONNECTION_RETRIES_PROPERTY_NAME} is not set */ public final static int DEFAULT_NB_CONNECTION_RETRIES = 0; /** Name of the property that holds the amount of time (in seconds) to wait before retrying to connect after a * temporary connection failure. */ public final static String CONNECTION_RETRY_DELAY_PROPERTY_NAME = "connectionRetryDelay"; /** Default value if {@link #CONNECTION_RETRY_DELAY_PROPERTY_NAME} is not set */ public final static int DEFAULT_CONNECTION_RETRY_DELAY = 15; /** Date format used by the SITE UTIME command */ private final static SimpleDateFormat SITE_UTIME_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmm"); protected FTPFile(FileURL fileURL) throws IOException { this(fileURL, null); } protected FTPFile(FileURL fileURL, org.apache.commons.net.ftp.FTPFile file) throws IOException { super(fileURL); this.absPath = fileURL.getPath(); if(file==null) { this.file = getFTPFile(fileURL); // If file doesn't exist (could not be resolved), create it if(this.file==null) { String name = fileURL.getFilename(); // Filename could potentially be null this.file = createFTPFile(name==null?"":name, false); this.fileExists = false; } else { this.fileExists = true; } } else { this.file = file; this.fileExists = true; } this.permissions = new FTPFilePermissions(this.file); } private org.apache.commons.net.ftp.FTPFile getFTPFile(FileURL fileURL) throws IOException { // Todo: this method is very ineffective as it lists the parent directory to retrieve the information about the // requested file to workaround the fact that FTPClient#listFiles follows directories. // => Use the MLST command if supported by the server (use FEAT command to find out if it is supported). // See http://tools.ietf.org/html/draft-ietf-ftpext-mlst-16 FileURL parentURL = fileURL.getParent(); LOGGER.trace("fileURL={} parent={}", fileURL, parentURL); // Parent is null, create '/' file if(parentURL==null) { return createFTPFile("/", true); } else { FTPConnectionHandler connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); org.apache.commons.net.ftp.FTPFile files[]; try { // Makes sure the connection is started, if not starts it connHandler.checkConnection(); // List files contained by this file's parent in order to retrieve the FTPFile instance corresponding // to this file files = listFiles(connHandler, parentURL.getPath()); } finally { // Release the lock on the ConnectionHandler connHandler.releaseLock(); } // File doesn't exist if(files==null || files.length==0) return null; // Find the file in the parent folder's contents int nbFiles = files.length; String wantedName = fileURL.getFilename(); for(int i=0; i<nbFiles; i++) { if(files[i].getName().equalsIgnoreCase(wantedName)) return files[i]; } // File doesn't exists return null; } } private org.apache.commons.net.ftp.FTPFile createFTPFile(String name, boolean isDirectory) { org.apache.commons.net.ftp.FTPFile file = new org.apache.commons.net.ftp.FTPFile(); file.setName(name); file.setSize(0); file.setTimestamp(java.util.Calendar.getInstance()); file.setType(isDirectory?org.apache.commons.net.ftp.FTPFile.DIRECTORY_TYPE:org.apache.commons.net.ftp.FTPFile.FILE_TYPE); return file; } /** * Lists and returns the contents of the given path on the server using the given connection handler. * The directory contents is listed by issuing a CWD followed by a LIST so after this method is called, the current * working directory is left to the specified path. * * @param connHandler the connection handler to use for communicating with the server * @param absPath absolute path to the directory to list * @return the directory's contents. The returned array may be empty but never null. The array may contain null * individual entries as FTPClient#listFiles's Javadoc mentions. * @throws IOException if an error occurred while communicating with the server * @throws AuthException if the user is not allowed to access this directory */ private static org.apache.commons.net.ftp.FTPFile[] listFiles(FTPConnectionHandler connHandler, String absPath) throws IOException, AuthException { org.apache.commons.net.ftp.FTPFile files[]; try { // Important: the folder is listed by changing the current working directory using the CWD command and then // issuing a LIST to list the current directory, instead of issuing a LIST with the path as an argument. // So we're sending: // // CWD path // LIST // // Instead of: // // LIST path // // The reason for that is that on some servers 'LIST path with spaces' fails whereas 'CWD path with spaces' // succeeds. Most FTP clients seem to be doing this (CWD/LIST instead of LIST), there must be a reason. // // See: // http://www.mucommander.com/forums/viewtopic.php?f=4&t=714 // http://issues.apache.org/jira/browse/NET-10 connHandler.ftpClient.changeWorkingDirectory(absPath); files = connHandler.ftpClient.listFiles(); // Throw an IOException if server replied with an error connHandler.checkServerReply(); if(files==null) // In some rare conditions (bug) this method can return null return new org.apache.commons.net.ftp.FTPFile[0]; return files; } // This exception is not an IOException and needs to be caught and thrown back as an IOException catch(org.apache.commons.net.ftp.parser.ParserInitializationException e) { LOGGER.info("ParserInitializationException caught", e); throw new IOException(); } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Throw back the IOException throw e; } } ///////////////////////////////////////////// // ConnectionHandlerFactory implementation // ///////////////////////////////////////////// public ConnectionHandler createConnectionHandler(FileURL location) { return new FTPConnectionHandler(location); } ///////////////////////////////////////// // AbstractFile methods implementation // ///////////////////////////////////////// @Override public boolean isSymlink() { return file.isSymbolicLink(); } @Override public boolean isSystem() { return false; } @Override public long getDate() { if(isSymlink()) return ((org.apache.commons.net.ftp.FTPFile)getCanonicalFile().getUnderlyingFileObject()).getTimestamp().getTimeInMillis(); return file.getTimestamp().getTimeInMillis(); } /** * Attempts to change this file's date using the <i>'SITE UTIME'</i> FTP command. * This command seems to be implemeted by modern FTP servers such as ProFTPd or PureFTP Server but since it is not * part of the basic FTP command set, it may as well not be supported by the remote server. */ @Override public void changeDate(long lastModified) throws IOException, UnsupportedFileOperationException { // Note: FTPFile.setTimeStamp only changes the instance's date, but doesn't change it on the server-side. FTPConnectionHandler connHandler = null; try { // Retrieve a ConnectionHandler and lock it connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); // Throw UnsupportedFileOperationException if we know the 'SITE UTIME' command is not supported by the server if(!connHandler.utimeCommandSupported) throw new UnsupportedFileOperationException(FileOperation.CHANGE_DATE); // Makes sure the connection is started, if not starts it connHandler.checkConnection(); String sdate; // SimpleDateFormat instance must be synchronized externally if it is accessed concurrently synchronized(SITE_UTIME_DATE_FORMAT) { sdate = SITE_UTIME_DATE_FORMAT.format(new Date(lastModified)); } LOGGER.info("sending SITE UTIME {} {}", sdate, absPath); boolean success = connHandler.ftpClient.sendSiteCommand("UTIME "+sdate+" "+absPath); LOGGER.info("server reply: {}", connHandler.ftpClient.getReplyString()); if(!success) { int replyCode = connHandler.ftpClient.getReplyCode(); // If server reported that the command is not supported, mark it in the ConnectionHandler so that // we don't try it anymore if(replyCode==FTPReply.UNRECOGNIZED_COMMAND || replyCode==FTPReply.COMMAND_NOT_IMPLEMENTED || replyCode==FTPReply.COMMAND_NOT_IMPLEMENTED_FOR_PARAMETER) { LOGGER.info("marking UTIME command as unsupported"); connHandler.utimeCommandSupported = false; } throw new IOException(); } } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection if(connHandler!=null) connHandler.checkSocketException(e); throw e; } finally { // Release the lock on the ConnectionHandler if(connHandler!=null) connHandler.releaseLock(); } } @Override public long getSize() { if(isSymlink()) return ((org.apache.commons.net.ftp.FTPFile)getCanonicalFile().getUnderlyingFileObject()).getSize(); return file.getSize(); } @Override public AbstractFile getParent() { if(!parentValSet) { FileURL parentFileURL = this.fileURL.getParent(); if(parentFileURL!=null) { try { parent = FileFactory.getFile(parentFileURL, null, createFTPFile(parentFileURL.getFilename(), true)); } catch(IOException e) { // No parent, that's all } } parentValSet = true; } return parent; } @Override public void setParent(AbstractFile parent) { this.parent = parent; this.parentValSet = true; } @Override public boolean exists() { return this.fileExists; } @Override public FilePermissions getPermissions() { if(isSymlink()) return getCanonicalFile().getAncestor(FTPFile.class).permissions; return permissions; } @Override public void changePermission(PermissionAccess access, PermissionType permission, boolean enabled) throws IOException, UnsupportedFileOperationException { changePermissions(ByteUtils.setBit(permissions.getIntValue(), (permission.toInt() << (access.toInt()*3)), enabled)); } /** * Returns {@link PermissionBits#FULL_PERMISSION_BITS} if the server supports the 'site chmod' command (not all * servers do), {@link PermissionBits#EMPTY_PERMISSION_BITS} otherwise. * * @return {@link PermissionBits#FULL_PERMISSION_BITS} if the server supports the 'site chmod' command (not all * servers do), {@link PermissionBits#EMPTY_PERMISSION_BITS} otherwise */ @Override public PermissionBits getChangeablePermissions() { try { // Do not lock the connection handler, not needed. return ((FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, false)).chmodCommandSupported ?PermissionBits.FULL_PERMISSION_BITS // Full permission support (777 octal) :PermissionBits.EMPTY_PERMISSION_BITS; // Permissions can't be changed } catch(InterruptedIOException e) { // Should not happen in practice return PermissionBits.EMPTY_PERMISSION_BITS; // Permissions can't be changed } } @Override public String getOwner() { return file.getUser(); } @Override public boolean canGetOwner() { return true; } @Override public String getGroup() { return file.getGroup(); } @Override public boolean canGetGroup() { return true; } @Override public boolean isDirectory() { // org.apache.commons.net.ftp.FTPFile#isDirectory() returns false if the file is a symlink pointing to a // directory, this is a limitation of the Commons-net library. // Todo: fix this by either: // a) find a combination of 'LIST' switches which allows the output to contain both the 'is symlink' and the // 'is the symlink target a directory' information. At a first glance, there doesn't seem to be one: either // symlinks are followed or there aren't. // b) Patch #ls() to issue an extra 'LIST -ldH *' to retrieve all symlinks' information when the directory has // at least one symlink. // c) if this file is a symlink, retrieve the symlink's target using #getFTPFile(FileURL) with '-ldH' switches // and return the value of isDirectory(). This clearly is the least effective solution at it requires issuing // one 'ls' command per symlink. if(isSymlink()) return ((org.apache.commons.net.ftp.FTPFile)getCanonicalFile().getUnderlyingFileObject()).isDirectory(); return file.isDirectory(); } @Override public InputStream getInputStream() throws IOException { return getInputStream(0); } @Override public OutputStream getOutputStream() throws IOException { return new FTPOutputStream(false); } @Override public OutputStream getAppendOutputStream() throws IOException { return new FTPOutputStream(true); } /** * Always throws an {@link UnsupportedFileOperationException}: random read access is not available. * * @throws UnsupportedFileOperationException always */ @Override @UnsupportedFileOperation public RandomAccessInputStream getRandomAccessInputStream() throws UnsupportedFileOperationException { throw new UnsupportedFileOperationException(FileOperation.RANDOM_READ_FILE); } // public RandomAccessInputStream getRandomAccessInputStream() throws IOException { // return new FTPRandomAccessInputStream(); // } /** * Always throws an {@link UnsupportedFileOperationException}: random write access is not available. * * @throws UnsupportedFileOperationException always */ @Override @UnsupportedFileOperation public RandomAccessOutputStream getRandomAccessOutputStream() throws UnsupportedFileOperationException { throw new UnsupportedFileOperationException(FileOperation.RANDOM_WRITE_FILE); } @Override public void delete() throws IOException { // Retrieve a ConnectionHandler and lock it FTPConnectionHandler connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); try { // Makes sure the connection is started, if not starts it connHandler.checkConnection(); if(isDirectory()) connHandler.ftpClient.removeDirectory(absPath); else connHandler.ftpClient.deleteFile(absPath); fileExists = false; // need to set to false since the file is cached // Throw an IOException if server replied with an error connHandler.checkServerReply(); } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Re-throw IOException throw e; } finally { // Release the lock on the ConnectionHandler connHandler.releaseLock(); } } @Override public AbstractFile[] ls() throws IOException { // Retrieve a ConnectionHandler and lock it FTPConnectionHandler connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); org.apache.commons.net.ftp.FTPFile files[]; try { // Makes sure the connection is started, if not starts it connHandler.checkConnection(); files = listFiles(connHandler, absPath); } finally { // Release the lock on the ConnectionHandler connHandler.releaseLock(); } if(files==null || files.length==0) return new AbstractFile[] {}; AbstractFile children[] = new AbstractFile[files.length]; AbstractFile child; FileURL childURL; String childName; int nbFiles = files.length; int fileCount = 0; String parentPath = fileURL.getPath(); if(!parentPath.endsWith(SEPARATOR)) parentPath += SEPARATOR; for(int i=0; i<nbFiles; i++) { if(files[i]==null) continue; childName = files[i].getName(); if(childName.equals(".") || childName.equals("..")) continue; // Note: properties and credentials are cloned for every children's url childURL = (FileURL)fileURL.clone(); childURL.setPath(parentPath+childName); // Discard '.' and '..' files if(childName.equals(".") || childName.equals("..")) continue; child = FileFactory.getFile(childURL, this, files[i]); children[fileCount++] = child; } // Create new array of the exact file count if(fileCount<nbFiles) { AbstractFile newChildren[] = new AbstractFile[fileCount]; System.arraycopy(children, 0, newChildren, 0, fileCount); return newChildren; } return children; } @Override public void mkdir() throws IOException { // Retrieve a ConnectionHandler and lock it FTPConnectionHandler connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); try { // Makes sure the connection is started, if not starts it connHandler.checkConnection(); connHandler.ftpClient.makeDirectory(absPath); // Throw an IOException if server replied with an error connHandler.checkServerReply(); file.setType(org.apache.commons.net.ftp.FTPFile.DIRECTORY_TYPE); fileExists = true; } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Re-throw IOException throw e; } finally { // Release the lock on the ConnectionHandler connHandler.releaseLock(); } } /** * Always throws {@link UnsupportedFileOperationException} when called. * * @throws UnsupportedFileOperationException, always */ @Override @UnsupportedFileOperation public long getFreeSpace() throws UnsupportedFileOperationException { throw new UnsupportedFileOperationException(FileOperation.GET_FREE_SPACE); } /** * Always throws {@link UnsupportedFileOperationException} when called. * * @throws UnsupportedFileOperationException, always */ @Override @UnsupportedFileOperation public long getTotalSpace() throws UnsupportedFileOperationException { throw new UnsupportedFileOperationException(FileOperation.GET_TOTAL_SPACE); } /** * Returns an <code>org.apache.commons.net.FTPFile</code> instance corresponding to this file. */ @Override public Object getUnderlyingFileObject() { return file; } //////////////////////// // Overridden methods // //////////////////////// /** * Changes permissions using the SITE CHMOD FTP command. * * This command is optional but seems to be supported by modern FTP servers such as ProFTPd or PureFTP Server. * But it may as well not be supported by the remote FTP server as it is not part of the basic FTP command set. * * Implementation note: FTPFile.setPermission only changes the instance's permissions, but doesn't change it on the * server-side. */ @Override public void changePermissions(int permissions) throws IOException, UnsupportedFileOperationException { FTPConnectionHandler connHandler = null; try { // Retrieve a ConnectionHandler and lock it connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); // Return if we know the CHMOD command is not supported by the server if(!connHandler.chmodCommandSupported) throw new UnsupportedFileOperationException(FileOperation.CHANGE_PERMISSION); // Makes sure the connection is started, if not starts it connHandler.checkConnection(); LOGGER.info("sending SITE CHMOD {} {}", Integer.toOctalString(permissions), absPath); boolean success = connHandler.ftpClient.sendSiteCommand("CHMOD "+Integer.toOctalString(permissions)+" "+absPath); LOGGER.info("server reply: {}", connHandler.ftpClient.getReplyString()); if(!success) { int replyCode = connHandler.ftpClient.getReplyCode(); // If server reported that the command is not supported, mark it in the ConnectionHandler so that // we don't try it anymore if(replyCode==FTPReply.UNRECOGNIZED_COMMAND || replyCode==FTPReply.COMMAND_NOT_IMPLEMENTED || replyCode==FTPReply.COMMAND_NOT_IMPLEMENTED_FOR_PARAMETER) { LOGGER.info("marking CHMOD command as unsupported"); connHandler.chmodCommandSupported = false; } throw new IOException(); } } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection if(connHandler!=null) connHandler.checkSocketException(e); throw e; } finally { // Release the lock on the ConnectionHandler if(connHandler!=null) connHandler.releaseLock(); } } /** * Implementation notes: always throws an {@link UnsupportedFileOperationException}. * * @throws UnsupportedFileOperationException always */ @Override @UnsupportedFileOperation public void copyRemotelyTo(AbstractFile destFile) throws UnsupportedFileOperationException { throw new UnsupportedFileOperationException(FileOperation.COPY_REMOTELY); } /** * Implementation notes: server-to-server renaming will work if the destination file also uses the 'FTP' scheme * and is located on the same host. */ @Override public void renameTo(AbstractFile destFile) throws IOException { // Throw an exception if the file cannot be renamed to the specified destination checkRenamePrerequisites(destFile, false, false); FTPConnectionHandler connHandler = null; try { // Retrieve a ConnectionHandler and lock it connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(this, fileURL, true); // Makes sure the connection is started, if not starts it connHandler.checkConnection(); if(!connHandler.ftpClient.rename(absPath, destFile.getURL().getPath())) throw new IOException(); } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection if(connHandler!=null) connHandler.checkSocketException(e); throw e; } finally { // Release the lock on the ConnectionHandler if(connHandler!=null) connHandler.releaseLock(); } } @Override public InputStream getInputStream(long offset) throws IOException { return new FTPInputStream(offset); } @Override public AbstractFile getCanonicalFile() { if(!isSymlink()) return this; // Create the canonical file instance and cache it if(canonicalFile==null) { // getLink() returns the raw symlink target which can either be an absolute or a relative path. If the path is // relative, preprend the absolute path of the symlink's parent folder. String symlinkTargetPath = file.getLink(); if(!symlinkTargetPath.startsWith("/")) { String parentPath = fileURL.getParent().getPath(); if(!parentPath.endsWith("/")) parentPath += "/"; symlinkTargetPath = parentPath + symlinkTargetPath; } FileURL canonicalURL = (FileURL)fileURL.clone(); canonicalURL.setPath(symlinkTargetPath); canonicalFile = FileFactory.getFile(canonicalURL); } return canonicalFile; } /////////////////// // Inner classes // /////////////////// // private class FTPProcess extends AbstractProcess { // // /** True if the command returned a positive FTP reply code */ // private boolean success; // // /** Allows to read the command's output */ // private ByteArrayInputStream bais; // // // public FTPProcess(String tokens[]) throws IOException { // // // Concatenates all tokens to create the command string // String command = ""; // int nbTokens = tokens.length; // for(int i=0; i<nbTokens; i++) { // command += tokens[i]; // if(i!=nbTokens-1) // command += " "; // } // // FTPConnectionHandler connHandler = null; // try { // // Retrieve a ConnectionHandler and lock it // connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(FTPFile.this, fileURL, true); // // Makes sure the connection is started, if not starts it // connHandler.checkConnection(); // // // Change the current directory on the remote server to : // // - this file's path if this file is a directory // // - to the parent folder's path otherwise // if(!connHandler.ftpClient.changeWorkingDirectory(isDirectory()?fileURL.getPath():fileURL.getParent().getPath())) // throw new IOException(); // // // Has the command been successfully completed by the server ? // success = FTPReply.isPositiveCompletion(connHandler.ftpClient.sendCommand(command)); // // // Retrieves the command's output and create an InputStream for getInputStream() // ByteArrayOutputStream baos = new ByteArrayOutputStream(); // PrintWriter pw = new PrintWriter(baos, true); // String replyStrings[] = connHandler.ftpClient.getReplyStrings(); // for(int i=0; i<replyStrings.length; i++) // pw.println(replyStrings[i]); // pw.close(); // // bais = new ByteArrayInputStream(baos.toByteArray()); // // No need to close the ByteArrayOutputStream // } // catch(IOException e) { // // Checks if the IOException corresponds to a socket error and in that case, closes the connection // connHandler.checkSocketException(e); // // // Re-throw IOException // throw e; // } // finally { // // Release the lock on the ConnectionHandler // if(connHandler!=null) // connHandler.releaseLock(); // } // } // // public boolean usesMergedStreams() { // // No specific stream for errors // return true; // } // // public int waitFor() throws InterruptedException, IOException { // return success?0:1; // } // // protected void destroyProcess() throws IOException { // // No-op, command has already been executed // } // // public int exitValue() { // return success?0:1; // } // // public OutputStream getOutputStream() throws IOException { // // FTP commands are not interactive, the returned OutputStream simply ignores data that's fed to it // return new SinkOutputStream(); // } // // public InputStream getInputStream() throws IOException { // if(bais==null) // throw new IOException(); // // return bais; // } // // public InputStream getErrorStream() throws IOException { // return getInputStream(); // } // } private class FTPInputStream extends FilterInputStream { private FTPConnectionHandler connHandler; private boolean isClosed; private FTPInputStream(long skipBytes) throws IOException { super(null); try { // Retrieve a ConnectionHandler and lock it connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(FTPFile.this, FTPFile.this.fileURL, true); // Makes sure the connection is started, if not starts it connHandler.checkConnection(); if(skipBytes>0) { // Resume transfer at the given offset connHandler.ftpClient.setRestartOffset(skipBytes); } in = connHandler.ftpClient.retrieveFileStream(absPath); if(in==null) { if(skipBytes>0) { // Reset offset connHandler.ftpClient.setRestartOffset(0); } throw new IOException(); } } catch(IOException e) { if(connHandler!=null) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Release the lock on the ConnectionHandler if the InputStream could not be created connHandler.releaseLock(); } // Re-throw IOException throw e; } } @Override public void close() throws IOException { // Make sure this method is only executed once, otherwise FTPClient#completePendingCommand() would lock if(isClosed) return; // we need to refresh the file after an update // otherwise the displayed size of archive files is incorrect file = getFTPFile(getURL()); isClosed = true; try { super.close(); LOGGER.info("complete pending commands"); connHandler.ftpClient.completePendingCommand(); LOGGER.info("commands completed"); // Todo: An IOException will be thrown by completePendingCommand if the transfer has not finished before calling close. // An 'abort' command should be issued to the server before closing if the transfer is not finished yet. // Currently in that case (transfer not finished) the whole connection has to be re-established (bad!). // FTPClient#abort() is difficult to use to say the least. This post gives some insight: http://mail-archives.apache.org/mod_mbox/commons-user/200604.mbox/%3c78A73ABD8DB470439179DB682EA990B3025B87DF@mtlex02.NEXXLINK.INT%3e } catch(IOException e) { LOGGER.info("exception in completePendingCommands()", e); // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Do not re-throw the exception because an IOException will be thrown if close is called before // the transfer is finished (see above) which is pseudo-normal behavior (though sub-optimal). // // Re-throw IOException // throw e; } finally { // Release the lock on the ConnectionHandler connHandler.releaseLock(); } } } // This class works but because of the bug in FTPInputStream#close() which fails to interrupt an ongoing transfer // gracefully, seek() will re-establish the FTP connection each time it is called, which is definitely not acceptable. // Therefore, this class cannot be used at the moment. private class FTPRandomAccessInputStream extends RandomAccessInputStream { private FTPInputStream in; private long offset; private FTPRandomAccessInputStream() throws IOException { this.in = new FTPInputStream(0); } @Override public int read() throws IOException { int read = in.read(); if(read!=-1) offset += 1; return read; } @Override public int read(byte b[], int off, int len) throws IOException { int nbRead = in.read(b, off, len); if(nbRead!=-1) offset += nbRead; return nbRead; } public long getOffset() throws IOException { return offset; } public long getLength() throws IOException { return FTPFile.this.getSize(); } public void seek(final long offset) throws IOException { try { in.close(); } catch(IOException e) {} in = new FTPInputStream(offset); this.offset = offset; } @Override public void close() throws IOException { in.close(); } } private class FTPOutputStream extends FilteredOutputStream { private FTPConnectionHandler connHandler; private boolean isClosed; private FTPOutputStream(boolean append) throws IOException { super(null); try { // Retrieve a ConnectionHandler and lock it connHandler = (FTPConnectionHandler)ConnectionPool.getConnectionHandler(FTPFile.this, fileURL, true); // Makes sure the connection is started, if not starts it connHandler.checkConnection(); if(append) out = connHandler.ftpClient.appendFileStream(absPath); else out = connHandler.ftpClient.storeFileStream(absPath); // Note: do NOT use storeUniqueFileStream which appends .1 if the file already exists and fails with proftpd if(out==null) throw new IOException(); } catch(IOException e) { if(connHandler!=null) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Release the lock on the ConnectionHandler if the OutputStream could not be created connHandler.releaseLock(); } // Re-throw IOException throw e; } } @Override public void close() throws IOException { // Make sure this method is only executed once, otherwise FTPClient#completePendingCommand() would lock if(isClosed) return; isClosed = true; try { super.close(); LOGGER.trace("complete pending commands"); connHandler.ftpClient.completePendingCommand(); LOGGER.trace("commands completed"); } catch(IOException e) { LOGGER.info("exception in completePendingCommands()", e); // Checks if the IOException corresponds to a socket error and in that case, closes the connection connHandler.checkSocketException(e); // Re-throw IOException throw e; } finally { // Release the lock on the ConnectionHandler connHandler.releaseLock(); } } } /** * Handles connection to an FTP server. */ private static class FTPConnectionHandler extends ConnectionHandler { private FTPClient ftpClient; // private CustomFTPClient ftpClient; /** Controls whether passive mode should be used for data transfers (default is true) */ private boolean passiveMode; /** Encoding used by the FTP control connection */ private String encoding; /** Number of connection retry attempts after a recoverable connection failure */ private int nbConnectionRetries; /** Amount of time (in seconds) to wait before retrying to connect after a recoverable connection failure */ private int connectionRetryDelay; /** False if SITE UTIME command is not supported by the remote server (once tried and failed) */ private boolean utimeCommandSupported = true; /** False if SITE CHMOD command is not supported by the remote server (once tried and failed) */ private boolean chmodCommandSupported = true; /** Controls how ofter should keepAlive() be called by ConnectionPool */ private final static long KEEP_ALIVE_PERIOD = 60; // /** Connection timeout to the FTP server in seconds */ // private final static int CONNECTION_TIMEOUT = 30; // private class CustomFTPClient extends FTPClient { // // private Socket getSocket() { // return _socket_; // } // } private FTPConnectionHandler(FileURL location) { super(location); // Use the passive mode property if it is set String passiveModeProperty = location.getProperty(PASSIVE_MODE_PROPERTY_NAME); // Passive mode is enabled by default if property isn't specified this.passiveMode = passiveModeProperty==null || !passiveModeProperty.equals("false"); // Use the encoding property if it is set this.encoding = location.getProperty(ENCODING_PROPERTY_NAME); if(encoding==null || encoding.equals("")) encoding = DEFAULT_ENCODING; // Use the property that controls the number of connection retries after a recoverable connection failure, // if the property is set String prop = location.getProperty(NB_CONNECTION_RETRIES_PROPERTY_NAME); if(prop==null) { nbConnectionRetries = DEFAULT_NB_CONNECTION_RETRIES; } else { try { nbConnectionRetries = Integer.parseInt(prop); } catch(NumberFormatException e) { nbConnectionRetries = DEFAULT_NB_CONNECTION_RETRIES; } } // Use the property that controls the connection retry delay after a recoverable connection failure, // if the property is set prop = location.getProperty(CONNECTION_RETRY_DELAY_PROPERTY_NAME); if(prop==null) { connectionRetryDelay = DEFAULT_CONNECTION_RETRY_DELAY; } else { try { connectionRetryDelay = Integer.parseInt(prop); } catch(NumberFormatException e) { connectionRetryDelay = DEFAULT_CONNECTION_RETRY_DELAY; } } setKeepAlivePeriod(KEEP_ALIVE_PERIOD); } /** * Checks the last server reply code and throws an IOException if the code doesn't correspond to a positive * FTP reply: * * <ul> * <li>If the reply is a credentials error (lack of permissions or not logged in), an {@link AuthException} * is thrown. For all other error codes, an IOException is thrown with the server reply message. * <li>If the reply code is FTPReply.SERVICE_NOT_AVAILABLE (connection dropped prematurely), the connection * will be closed before an IOException with the server reply message is thrown. * </ul> * * <p>If the reply is a positive one (not an error error), this method does nothing. */ private void checkServerReply() throws IOException, AuthException { // Check that connection went ok int replyCode = ftpClient.getReplyCode(); LOGGER.trace("server reply="+ftpClient.getReplyString()); // Close connection if the connection dropped prematurely so that isConnected() returns false if(replyCode==FTPReply.SERVICE_NOT_AVAILABLE) closeConnection(); // If not, throw an exception using the reply string if(!FTPReply.isPositiveCompletion(replyCode)) { if(replyCode==FTPReply.BAD_COMMAND_SEQUENCE || replyCode==FTPReply.NEED_PASSWORD || replyCode==FTPReply.NOT_LOGGED_IN) throwAuthException(ftpClient.getReplyString()); else throw new IOException(ftpClient.getReplyString()); } } /** * Checks if the given IOException corresponds to a low-level socket exception, and if that is the case, * closes the connection so that {@link #isConnected()} returns false. * All IOException raised by FTPClient should be checked by this method so that socket errors are properly detected. */ private void checkSocketException(IOException e) { if(((e instanceof FTPConnectionClosedException) || (e instanceof SocketException) || (e instanceof SocketTimeoutException)) && isConnected()) { LOGGER.info("socket exception detected, closing connection", e); closeConnection(); } } ////////////////////////////////////// // ConnectionHandler implementation // ////////////////////////////////////// @Override public void startConnection() throws IOException { LOGGER.info("connecting to {}", getRealm().getHost()); // this.ftpClient = new CustomFTPClient(); this.ftpClient = new FTPClient(); int retriesLeft = nbConnectionRetries; int retryDelay = connectionRetryDelay *1000; do{ try { FileURL realm = getRealm(); // Override default port (21) if a custom port was specified in the URL int port = realm.getPort(); LOGGER.info("custom port={}", port); if(port!=-1) ftpClient.setDefaultPort(port); // Sets the control encoding // - most modern FTP servers seem to default to UTF-8, but not all of them do. // - commons-ftp defaults to ISO-8859-1 which is not good // Note: this has to be done before the connection is established otherwise it won't be taken into account LOGGER.info("encoding={}", encoding); ftpClient.setControlEncoding(encoding); // Connect to the FTP server ftpClient.connect(realm.getHost()); // // Set a socket timeout: default value is 0 (no timeout) // ftpClient.setSoTimeout(CONNECTION_TIMEOUT*1000); // FileLogger.finer("soTimeout="+ftpClient.getSoTimeout()); // Throw an IOException if server replied with an error checkServerReply(); Credentials credentials = getCredentials(); // Throw an AuthException if there are no credentials LOGGER.info("fileURL={} credentials={}", realm.toString(true), credentials); if(credentials ==null) throwAuthException(null); // Login ftpClient.login(credentials.getLogin(), credentials.getPassword()); // Throw an IOException (potentially an AuthException) if the server replied with an error checkServerReply(); // Enables/disables passive mode LOGGER.info("passiveMode={}", passiveMode); if(passiveMode) this.ftpClient.enterLocalPassiveMode(); else this.ftpClient.enterLocalActiveMode(); // Set file type to 'binary' ftpClient.setFileType(FTPClient.BINARY_FILE_TYPE); // Issue 'LIST -al' command to list hidden files (instead of LIST -l), only if the corresponding // configuration option has been manually enabled in the preferences. // The reason for not doing so by default is that the commons-net library will fail to properly parse // directory listings on some servers when 'LIST -al' is used (bug). // Note that by default, if 'LIST -l' is used, the decision to list hidden files is left to the // FTP server: some servers will choose to show them, some will not. This behavior is usually a // configuration setting of the FTP server. ftpClient.setListHiddenFiles(FTPProtocolProvider.getForceHiddenFilesListing()); if(encoding.equalsIgnoreCase("UTF-8")) { // This command enables UTF8 on the remote server... but only a few FTP servers currently support this command ftpClient.sendCommand("OPTS UTF8 ON"); } break; } catch(IOException e) { // Attempt to retry if the connection failed, or if the server reply corresponds to a temporary error. // Unlike 5xx errors which are permanent, 4xx errors are temporary and may be retried, quote from // RFC 959: "The command was not accepted and the requested action did not take place, but the error // condition is temporary and the action may be requested again." int replyCode = ftpClient.getReplyCode(); if(!ftpClient.isConnected() || FTPReply.isNegativeTransient(replyCode)) { LOGGER.info((!ftpClient.isConnected()?"Connection error":"Temporary server error ("+replyCode+")")+", retries left="+retriesLeft, e); // Retry to connect, if we have at least an attempt left if(retriesLeft>0) { retriesLeft--; // Wait before retrying if(retryDelay>0) { LOGGER.info("waiting {} ms before retrying to connect", retryDelay); try { Thread.sleep(retryDelay); } catch(InterruptedException e2) {} } continue; } } // Disconnect if the connection could not be established if(ftpClient.isConnected()) try { ftpClient.disconnect(); } catch(IOException e2) {} // Re-throw the exception throw e; } } while(true); } @Override public boolean isConnected() { // FTPClient#isConnected() will always return true once it is connected and does not detect socket // disconnections. Furthermore, retrieving the underlying Socket instance does not help any more: // Socket#isConnected() and Socket#isClosed() do not reflect socket errors that happen after the socket is // connected. // Thus, the only way (AFAIK) to know if the socket is still connected is to intercept all IOException // thrown by FTPClient and check if they correspond to a socket exception. return ftpClient!=null && ftpClient.isConnected(); // if(ftpClient==null || !ftpClient.isConnected()) // return false; // // Socket socket = ftpClient.getSocket(); // FileLogger.finest("socket="+socket+" socket.isConnected()"+socket.isConnected()+" socket.isClosed()="+socket.isClosed()); // // return socket!=null && socket.isConnected() && !socket.isClosed(); } @Override public void closeConnection() { if(ftpClient!=null) { // Try to logout, this may fail if the connection is broken try { ftpClient.logout(); } catch(IOException e) {} // Close the socket connection try { ftpClient.disconnect(); } catch(IOException e) {} ftpClient = null; } } @Override public void keepAlive() { // Send a NOOP command to the server to keep the connection alive. // Note: not all FTP servers support the NOOP command. if(ftpClient!=null) { try { ftpClient.sendNoOp(); } catch(IOException e) { // Checks if the IOException corresponds to a socket error and in that case, closes the connection checkSocketException(e); } } } } /** * A Permissions implementation for FTPFile. */ private static class FTPFilePermissions extends IndividualPermissionBits implements FilePermissions { private org.apache.commons.net.ftp.FTPFile file; public FTPFilePermissions(org.apache.commons.net.ftp.FTPFile file) { this.file = file; } public boolean getBitValue(PermissionAccess access, PermissionType type) { int fAccess; int fPermission; switch(access) { case USER: fAccess = org.apache.commons.net.ftp.FTPFile.USER_ACCESS; break; case GROUP: fAccess = org.apache.commons.net.ftp.FTPFile.GROUP_ACCESS; break; case OTHER: fAccess = org.apache.commons.net.ftp.FTPFile.WORLD_ACCESS; break; default: return false; } switch(type) { case READ: fPermission = org.apache.commons.net.ftp.FTPFile.READ_PERMISSION; break; case WRITE: fPermission = org.apache.commons.net.ftp.FTPFile.WRITE_PERMISSION; break; case EXECUTE: fPermission = org.apache.commons.net.ftp.FTPFile.EXECUTE_PERMISSION; break; default: return false; } return file.hasPermission(fAccess, fPermission); } public PermissionBits getMask() { return FULL_PERMISSION_BITS; } } }