Browse Source

把内容添进空仓库

zry 8 years ago
parent
commit
84cbf6680e

+ 5 - 5
LICENSE

@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2014
+Copyright (c) 2014 swzry.com
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 copies of the Software, and to permit persons to whom the Software is
 furnished to do so, subject to the following conditions:
 
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
 
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 35 - 4
README.md

@@ -1,8 +1,39 @@
-ZFTP_Server
-===========
-
 ZFTP Server V1.1
+===========
+
 http://swzry.com/
 
 本项目本是为swzry.com专门开发,通用性并不强,您使用时可能需要酌情修改部分内容。
-本项目按照MIT License 发行。
+本项目按照MIT License 发行。
+
+### 使用方法 ###
+1、将该项目解压到一个合适的目录,为方便讲解,这里以/home/zftpd为例
+2、编辑zftpd文件,将第九行的zftpPath="/home/ftpd/"设置为你存放ZFTP Server的路径,例如/home/zftpd/
+	(注意:如果该程序在传播过程中文件权限信息丢失,请赋予zftpd文件执行权限,例如chmod 777 zftpd)
+3、编辑配置文件。配置文件内有注释,请照着注释根据实际情况进行配置。
+	附:权限字符串的说明
+		读取相关权限:
+         - "e" = 更改目录 (CWD命令)
+         - "l" = 列出文件 (LIST, NLST, STAT, MLSD, MLST, SIZE, MDTM命令)
+         - "r" = 从服务器上读取文件 (RETR命令)
+
+        写入相关权限:
+         - "a" = 追加数据到已存在文件 (APPE命令)
+         - "d" = 删除文件或目录 (DELE, RMD命令)
+         - "f" = 重命名文件或目录 (RNFR, RNTO命令)
+         - "m" = 创建目录 (MKD命令)
+         - "w" = 上传文件到服务器 (STOR, STOU命令)
+         - "M" = 修改文件权限 (SITE CHMOD命令)
+4、测试:
+	cd /home/zftpd
+	./zftpd start
+	测试完毕后使用./zftpd stop停止服务
+5、安装为系统守护进程
+	将zftpd文件拷贝到/etc/init.d/目录下(可能因系统而异)
+6、设置守护进程自启动
+	使用chkconfig之类的工具进行配置,使zftpd自启动
+
+### 注意事项 ###
+1、目前版本对中文文件名的支持尚不是很可靠,如果遇到含中文文件名的目录无法列出,请重启ZFTP Server
+2、ZFTP Server运行过程中您可以更改配置文件中的用户信息等配置项目,生效时间间隔由配置文件中的ttl配置项决定
+

+ 124 - 0
daemonlib.py

@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+import sys, os, time, atexit
+from signal import SIGTERM
+class Daemon:
+    """
+    A generic daemon class.
+    
+    Usage: subclass the Daemon class and override the _run() method
+    """
+    def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
+        self.stdin = stdin
+        self.stdout = stdout
+        self.stderr = stderr
+        self.pidfile = pidfile
+    
+    def _daemonize(self):
+        """
+        do the UNIX double-fork magic, see Stevens' "Advanced 
+        Programming in the UNIX Environment" for details (ISBN 0201563177)
+        http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16
+        """
+        
+        #脱离父进程
+        try: 
+            pid = os.fork() 
+            if pid > 0:
+                sys.exit(0) 
+        except OSError, e: 
+            sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
+            sys.exit(1)
+    
+        #脱离终端
+        os.setsid() 
+        #修改当前工作目录  
+        os.chdir("/") 
+        #重设文件创建权限
+        os.umask(0) 
+    
+        #第二次fork,禁止进程重新打开控制终端
+        try: 
+            pid = os.fork() 
+            if pid > 0:
+                sys.exit(0) 
+        except OSError, e: 
+            sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
+            sys.exit(1) 
+    
+        sys.stdout.flush()
+        sys.stderr.flush()
+        si = file(self.stdin, 'r')
+        so = file(self.stdout, 'a+')
+        se = file(self.stderr, 'a+', 0)
+        #重定向标准输入/输出/错误
+        os.dup2(si.fileno(), sys.stdin.fileno())
+        os.dup2(so.fileno(), sys.stdout.fileno())
+        os.dup2(se.fileno(), sys.stderr.fileno())
+    
+        #注册程序退出时的函数,即删掉pid文件
+        atexit.register(self.delpid)
+        pid = str(os.getpid())
+        file(self.pidfile,'w+').write("%s\n" % pid)
+    
+    def delpid(self):
+        os.remove(self.pidfile)
+    def start(self):
+        """
+        Start the daemon
+        """
+        # Check for a pidfile to see if the daemon already runs
+        try:
+            pf = file(self.pidfile,'r')
+            pid = int(pf.read().strip())
+            pf.close()
+        except IOError:
+            pid = None
+    
+        if pid:
+            message = "pidfile %s already exist. Daemon already running?\n"
+            sys.stderr.write(message % self.pidfile)
+            sys.exit(1)
+        
+        # Start the daemon
+        self._daemonize()
+        self._run()
+    def stop(self):
+        """
+        Stop the daemon
+        """
+        # Get the pid from the pidfile
+        try:
+            pf = file(self.pidfile,'r')
+            pid = int(pf.read().strip())
+            pf.close()
+        except IOError:
+            pid = None
+    
+        if not pid:
+            message = "pidfile %s does not exist. Daemon not running?\n"
+            sys.stderr.write(message % self.pidfile)
+            return # not an error in a restart
+        # Try killing the daemon process    
+        try:
+            while 1:
+                os.kill(pid, SIGTERM)
+                time.sleep(0.1)
+        except OSError, err:
+            err = str(err)
+            if err.find("No such process") > 0:
+                if os.path.exists(self.pidfile):
+                    os.remove(self.pidfile)
+            else:
+                print str(err)
+                sys.exit(1)
+    def restart(self):
+        """
+        Restart the daemon
+        """
+        self.stop()
+        self.start()
+    def _run(self):
+        """
+        You should override this method when you subclass Daemon. It will be called after the process has been
+        daemonized by start() or restart().
+        """

+ 0 - 0
ftpd.lines.log


+ 0 - 0
ftpd.log


+ 1036 - 0
ftplib.py

@@ -0,0 +1,1036 @@
+"""An FTP client class and some helper functions.
+
+Based on RFC 959: File Transfer Protocol (FTP), by J. Postel and J. Reynolds
+
+Example:
+
+>>> from ftplib import FTP
+>>> ftp = FTP('ftp.python.org') # connect to host, default port
+>>> ftp.login() # default, i.e.: user anonymous, passwd anonymous@
+'230 Guest login ok, access restrictions apply.'
+>>> ftp.retrlines('LIST') # list directory contents
+total 9
+drwxr-xr-x   8 root     wheel        1024 Jan  3  1994 .
+drwxr-xr-x   8 root     wheel        1024 Jan  3  1994 ..
+drwxr-xr-x   2 root     wheel        1024 Jan  3  1994 bin
+drwxr-xr-x   2 root     wheel        1024 Jan  3  1994 etc
+d-wxrwxr-x   2 ftp      wheel        1024 Sep  5 13:43 incoming
+drwxr-xr-x   2 root     wheel        1024 Nov 17  1993 lib
+drwxr-xr-x   6 1094     wheel        1024 Sep 13 19:07 pub
+drwxr-xr-x   3 root     wheel        1024 Jan  3  1994 usr
+-rw-r--r--   1 root     root          312 Aug  1  1994 welcome.msg
+'226 Transfer complete.'
+>>> ftp.quit()
+'221 Goodbye.'
+>>>
+
+A nice test that reveals some of the network dialogue would be:
+python ftplib.py -d localhost -l -p -l
+"""
+
+#
+# Changes and improvements suggested by Steve Majewski.
+# Modified by Jack to work on the mac.
+# Modified by Siebren to support docstrings and PASV.
+# Modified by Phil Schwartz to add storbinary and storlines callbacks.
+# Modified by Giampaolo Rodola' to add TLS support.
+#
+
+import os
+import sys
+
+# Import SOCKS module if it exists, else standard socket module socket
+try:
+    import SOCKS; socket = SOCKS; del SOCKS # import SOCKS as socket
+    from socket import getfqdn; socket.getfqdn = getfqdn; del getfqdn
+except ImportError:
+    import socket
+from socket import _GLOBAL_DEFAULT_TIMEOUT
+
+__all__ = ["FTP","Netrc"]
+
+# Magic number from <socket.h>
+MSG_OOB = 0x1                           # Process data out of band
+
+
+# The standard FTP server control port
+FTP_PORT = 21
+
+
+# Exception raised when an error or invalid response is received
+class Error(Exception): pass
+class error_reply(Error): pass          # unexpected [123]xx reply
+class error_temp(Error): pass           # 4xx errors
+class error_perm(Error): pass           # 5xx errors
+class error_proto(Error): pass          # response does not begin with [1-5]
+
+
+# All exceptions (hopefully) that may be raised here and that aren't
+# (always) programming errors on our side
+all_errors = (Error, IOError, EOFError)
+
+
+# Line terminators (we always output CRLF, but accept any of CRLF, CR, LF)
+CRLF = '\r\n'
+
+# The class itself
+class FTP:
+
+    '''An FTP client class.
+
+    To create a connection, call the class using these arguments:
+            host, user, passwd, acct, timeout
+
+    The first four arguments are all strings, and have default value ''.
+    timeout must be numeric and defaults to None if not passed,
+    meaning that no timeout will be set on any ftp socket(s)
+    If a timeout is passed, then this is now the default timeout for all ftp
+    socket operations for this instance.
+
+    Then use self.connect() with optional host and port argument.
+
+    To download a file, use ftp.retrlines('RETR ' + filename),
+    or ftp.retrbinary() with slightly different arguments.
+    To upload a file, use ftp.storlines() or ftp.storbinary(),
+    which have an open file as argument (see their definitions
+    below for details).
+    The download/upload functions first issue appropriate TYPE
+    and PORT or PASV commands.
+'''
+
+    debugging = 0
+    host = ''
+    port = FTP_PORT
+    sock = None
+    file = None
+    welcome = None
+    passiveserver = 1
+
+    # Initialization method (called by class instantiation).
+    # Initialize host to localhost, port to standard ftp port
+    # Optional arguments are host (for connect()),
+    # and user, passwd, acct (for login())
+    def __init__(self, host='', user='', passwd='', acct='',
+                 timeout=_GLOBAL_DEFAULT_TIMEOUT):
+        self.timeout = timeout
+        if host:
+            self.connect(host)
+            if user:
+                self.login(user, passwd, acct)
+
+    def connect(self, host='', port=0, timeout=-999):
+        '''Connect to host.  Arguments are:
+         - host: hostname to connect to (string, default previous host)
+         - port: port to connect to (integer, default previous port)
+        '''
+        if host != '':
+            self.host = host
+        if port > 0:
+            self.port = port
+        if timeout != -999:
+            self.timeout = timeout
+        self.sock = socket.create_connection((self.host, self.port), self.timeout)
+        self.af = self.sock.family
+        self.file = self.sock.makefile('rb')
+        self.welcome = self.getresp()
+        return self.welcome
+
+    def getwelcome(self):
+        '''Get the welcome message from the server.
+        (this is read and squirreled away by connect())'''
+        if self.debugging:
+            print '*welcome*', self.sanitize(self.welcome)
+        return self.welcome
+
+    def set_debuglevel(self, level):
+        '''Set the debugging level.
+        The required argument level means:
+        0: no debugging output (default)
+        1: print commands and responses but not body text etc.
+        2: also print raw lines read and sent before stripping CR/LF'''
+        self.debugging = level
+    debug = set_debuglevel
+
+    def set_pasv(self, val):
+        '''Use passive or active mode for data transfers.
+        With a false argument, use the normal PORT mode,
+        With a true argument, use the PASV command.'''
+        self.passiveserver = val
+
+    # Internal: "sanitize" a string for printing
+    def sanitize(self, s):
+        if s[:5] == 'pass ' or s[:5] == 'PASS ':
+            i = len(s)
+            while i > 5 and s[i-1] in '\r\n':
+                i = i-1
+            s = s[:5] + '*'*(i-5) + s[i:]
+        return repr(s)
+
+    # Internal: send one line to the server, appending CRLF
+    def putline(self, line):
+        line = line + CRLF
+        if self.debugging > 1: print '*put*', self.sanitize(line)
+        self.sock.sendall(line)
+
+    # Internal: send one command to the server (through putline())
+    def putcmd(self, line):
+        if self.debugging: print '*cmd*', self.sanitize(line)
+        self.putline(line)
+
+    # Internal: return one line from the server, stripping CRLF.
+    # Raise EOFError if the connection is closed
+    def getline(self):
+        line = self.file.readline()
+        if self.debugging > 1:
+            print '*get*', self.sanitize(line)
+        if not line: raise EOFError
+        if line[-2:] == CRLF: line = line[:-2]
+        elif line[-1:] in CRLF: line = line[:-1]
+        return line
+
+    # Internal: get a response from the server, which may possibly
+    # consist of multiple lines.  Return a single string with no
+    # trailing CRLF.  If the response consists of multiple lines,
+    # these are separated by '\n' characters in the string
+    def getmultiline(self):
+        line = self.getline()
+        if line[3:4] == '-':
+            code = line[:3]
+            while 1:
+                nextline = self.getline()
+                line = line + ('\n' + nextline)
+                if nextline[:3] == code and \
+                        nextline[3:4] != '-':
+                    break
+        return line
+
+    # Internal: get a response from the server.
+    # Raise various errors if the response indicates an error
+    def getresp(self):
+        resp = self.getmultiline()
+        if self.debugging: print '*resp*', self.sanitize(resp)
+        self.lastresp = resp[:3]
+        c = resp[:1]
+        if c in ('1', '2', '3'):
+            return resp
+        if c == '4':
+            raise error_temp, resp
+        if c == '5':
+            raise error_perm, resp
+        raise error_proto, resp
+
+    def voidresp(self):
+        """Expect a response beginning with '2'."""
+        resp = self.getresp()
+        if resp[:1] != '2':
+            raise error_reply, resp
+        return resp
+
+    def abort(self):
+        '''Abort a file transfer.  Uses out-of-band data.
+        This does not follow the procedure from the RFC to send Telnet
+        IP and Synch; that doesn't seem to work with the servers I've
+        tried.  Instead, just send the ABOR command as OOB data.'''
+        line = 'ABOR' + CRLF
+        if self.debugging > 1: print '*put urgent*', self.sanitize(line)
+        self.sock.sendall(line, MSG_OOB)
+        resp = self.getmultiline()
+        if resp[:3] not in ('426', '225', '226'):
+            raise error_proto, resp
+
+    def sendcmd(self, cmd):
+        '''Send a command and return the response.'''
+        self.putcmd(cmd)
+        return self.getresp()
+
+    def voidcmd(self, cmd):
+        """Send a command and expect a response beginning with '2'."""
+        self.putcmd(cmd)
+        return self.voidresp()
+
+    def sendport(self, host, port):
+        '''Send a PORT command with the current host and the given
+        port number.
+        '''
+        hbytes = host.split('.')
+        pbytes = [repr(port//256), repr(port%256)]
+        bytes = hbytes + pbytes
+        cmd = 'PORT ' + ','.join(bytes)
+        return self.voidcmd(cmd)
+
+    def sendeprt(self, host, port):
+        '''Send a EPRT command with the current host and the given port number.'''
+        af = 0
+        if self.af == socket.AF_INET:
+            af = 1
+        if self.af == socket.AF_INET6:
+            af = 2
+        if af == 0:
+            raise error_proto, 'unsupported address family'
+        fields = ['', repr(af), host, repr(port), '']
+        cmd = 'EPRT ' + '|'.join(fields)
+        return self.voidcmd(cmd)
+
+    def makeport(self):
+        '''Create a new socket and send a PORT command for it.'''
+        msg = "getaddrinfo returns an empty list"
+        sock = None
+        for res in socket.getaddrinfo(None, 0, self.af, socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
+            af, socktype, proto, canonname, sa = res
+            try:
+                sock = socket.socket(af, socktype, proto)
+                sock.bind(sa)
+            except socket.error, msg:
+                if sock:
+                    sock.close()
+                sock = None
+                continue
+            break
+        if not sock:
+            raise socket.error, msg
+        sock.listen(1)
+        port = sock.getsockname()[1] # Get proper port
+        host = self.sock.getsockname()[0] # Get proper host
+        if self.af == socket.AF_INET:
+            resp = self.sendport(host, port)
+        else:
+            resp = self.sendeprt(host, port)
+        if self.timeout is not _GLOBAL_DEFAULT_TIMEOUT:
+            sock.settimeout(self.timeout)
+        return sock
+
+    def makepasv(self):
+        if self.af == socket.AF_INET:
+            host, port = parse227(self.sendcmd('PASV'))
+        else:
+            host, port = parse229(self.sendcmd('EPSV'), self.sock.getpeername())
+        return host, port
+
+    def ntransfercmd(self, cmd, rest=None):
+        """Initiate a transfer over the data connection.
+
+        If the transfer is active, send a port command and the
+        transfer command, and accept the connection.  If the server is
+        passive, send a pasv command, connect to it, and start the
+        transfer command.  Either way, return the socket for the
+        connection and the expected size of the transfer.  The
+        expected size may be None if it could not be determined.
+
+        Optional `rest' argument can be a string that is sent as the
+        argument to a REST command.  This is essentially a server
+        marker used to tell the server to skip over any data up to the
+        given marker.
+        """
+        size = None
+        if self.passiveserver:
+            host, port = self.makepasv()
+            conn = socket.create_connection((host, port), self.timeout)
+            if rest is not None:
+                self.sendcmd("REST %s" % rest)
+            resp = self.sendcmd(cmd)
+            # Some servers apparently send a 200 reply to
+            # a LIST or STOR command, before the 150 reply
+            # (and way before the 226 reply). This seems to
+            # be in violation of the protocol (which only allows
+            # 1xx or error messages for LIST), so we just discard
+            # this response.
+            if resp[0] == '2':
+                resp = self.getresp()
+            if resp[0] != '1':
+                raise error_reply, resp
+        else:
+            sock = self.makeport()
+            if rest is not None:
+                self.sendcmd("REST %s" % rest)
+            resp = self.sendcmd(cmd)
+            # See above.
+            if resp[0] == '2':
+                resp = self.getresp()
+            if resp[0] != '1':
+                raise error_reply, resp
+            conn, sockaddr = sock.accept()
+            if self.timeout is not _GLOBAL_DEFAULT_TIMEOUT:
+                conn.settimeout(self.timeout)
+        if resp[:3] == '150':
+            # this is conditional in case we received a 125
+            size = parse150(resp)
+        return conn, size
+
+    def transfercmd(self, cmd, rest=None):
+        """Like ntransfercmd() but returns only the socket."""
+        return self.ntransfercmd(cmd, rest)[0]
+
+    def login(self, user = '', passwd = '', acct = ''):
+        '''Login, default anonymous.'''
+        if not user: user = 'anonymous'
+        if not passwd: passwd = ''
+        if not acct: acct = ''
+        if user == 'anonymous' and passwd in ('', '-'):
+            # If there is no anonymous ftp password specified
+            # then we'll just use anonymous@
+            # We don't send any other thing because:
+            # - We want to remain anonymous
+            # - We want to stop SPAM
+            # - We don't want to let ftp sites to discriminate by the user,
+            #   host or country.
+            passwd = passwd + 'anonymous@'
+        resp = self.sendcmd('USER ' + user)
+        if resp[0] == '3': resp = self.sendcmd('PASS ' + passwd)
+        if resp[0] == '3': resp = self.sendcmd('ACCT ' + acct)
+        if resp[0] != '2':
+            raise error_reply, resp
+        return resp
+
+    def retrbinary(self, cmd, callback, blocksize=8192, rest=None):
+        """Retrieve data in binary mode.  A new port is created for you.
+
+        Args:
+          cmd: A RETR command.
+          callback: A single parameter callable to be called on each
+                    block of data read.
+          blocksize: The maximum number of bytes to read from the
+                     socket at one time.  [default: 8192]
+          rest: Passed to transfercmd().  [default: None]
+
+        Returns:
+          The response code.
+        """
+        self.voidcmd('TYPE I')
+        conn = self.transfercmd(cmd, rest)
+        while 1:
+            data = conn.recv(blocksize)
+            if not data:
+                break
+            callback(data)
+        conn.close()
+        return self.voidresp()
+
+    def retrlines(self, cmd, callback = None):
+        """Retrieve data in line mode.  A new port is created for you.
+
+        Args:
+          cmd: A RETR, LIST, NLST, or MLSD command.
+          callback: An optional single parameter callable that is called
+                    for each line with the trailing CRLF stripped.
+                    [default: print_line()]
+
+        Returns:
+          The response code.
+        """
+        if callback is None: callback = print_line
+        resp = self.sendcmd('TYPE A')
+        conn = self.transfercmd(cmd)
+        fp = conn.makefile('rb')
+        while 1:
+            line = fp.readline()
+            if self.debugging > 2: print '*retr*', repr(line)
+            if not line:
+                break
+            if line[-2:] == CRLF:
+                line = line[:-2]
+            elif line[-1:] == '\n':
+                line = line[:-1]
+            callback(line)
+        fp.close()
+        conn.close()
+        return self.voidresp()
+
+    def storbinary(self, cmd, fp, blocksize=8192, callback=None, rest=None):
+        """Store a file in binary mode.  A new port is created for you.
+
+        Args:
+          cmd: A STOR command.
+          fp: A file-like object with a read(num_bytes) method.
+          blocksize: The maximum data size to read from fp and send over
+                     the connection at once.  [default: 8192]
+          callback: An optional single parameter callable that is called on
+                    on each block of data after it is sent.  [default: None]
+          rest: Passed to transfercmd().  [default: None]
+
+        Returns:
+          The response code.
+        """
+        self.voidcmd('TYPE I')
+        conn = self.transfercmd(cmd, rest)
+        while 1:
+            buf = fp.read(blocksize)
+            if not buf: break
+            conn.sendall(buf)
+            if callback: callback(buf)
+        conn.close()
+        return self.voidresp()
+
+    def storlines(self, cmd, fp, callback=None):
+        """Store a file in line mode.  A new port is created for you.
+
+        Args:
+          cmd: A STOR command.
+          fp: A file-like object with a readline() method.
+          callback: An optional single parameter callable that is called on
+                    on each line after it is sent.  [default: None]
+
+        Returns:
+          The response code.
+        """
+        self.voidcmd('TYPE A')
+        conn = self.transfercmd(cmd)
+        while 1:
+            buf = fp.readline()
+            if not buf: break
+            if buf[-2:] != CRLF:
+                if buf[-1] in CRLF: buf = buf[:-1]
+                buf = buf + CRLF
+            conn.sendall(buf)
+            if callback: callback(buf)
+        conn.close()
+        return self.voidresp()
+
+    def acct(self, password):
+        '''Send new account name.'''
+        cmd = 'ACCT ' + password
+        return self.voidcmd(cmd)
+
+    def nlst(self, *args):
+        '''Return a list of files in a given directory (default the current).'''
+        cmd = 'NLST'
+        for arg in args:
+            cmd = cmd + (' ' + arg)
+        files = []
+        self.retrlines(cmd, files.append)
+        return files
+
+    def dir(self, *args):
+        '''List a directory in long form.
+        By default list current directory to stdout.
+        Optional last argument is callback function; all
+        non-empty arguments before it are concatenated to the
+        LIST command.  (This *should* only be used for a pathname.)'''
+        cmd = 'LIST'
+        func = None
+        if args[-1:] and type(args[-1]) != type(''):
+            args, func = args[:-1], args[-1]
+        for arg in args:
+            if arg:
+                cmd = cmd + (' ' + arg)
+        self.retrlines(cmd, func)
+
+    def rename(self, fromname, toname):
+        '''Rename a file.'''
+        resp = self.sendcmd('RNFR ' + fromname)
+        if resp[0] != '3':
+            raise error_reply, resp
+        return self.voidcmd('RNTO ' + toname)
+
+    def delete(self, filename):
+        '''Delete a file.'''
+        resp = self.sendcmd('DELE ' + filename)
+        if resp[:3] in ('250', '200'):
+            return resp
+        else:
+            raise error_reply, resp
+
+    def cwd(self, dirname):
+        '''Change to a directory.'''
+        if dirname == '..':
+            try:
+                return self.voidcmd('CDUP')
+            except error_perm, msg:
+                if msg.args[0][:3] != '500':
+                    raise
+        elif dirname == '':
+            dirname = '.'  # does nothing, but could return error
+        cmd = 'CWD ' + dirname
+        return self.voidcmd(cmd)
+
+    def size(self, filename):
+        '''Retrieve the size of a file.'''
+        # The SIZE command is defined in RFC-3659
+        resp = self.sendcmd('SIZE ' + filename)
+        if resp[:3] == '213':
+            s = resp[3:].strip()
+            try:
+                return int(s)
+            except (OverflowError, ValueError):
+                return long(s)
+
+    def mkd(self, dirname):
+        '''Make a directory, return its full pathname.'''
+        resp = self.sendcmd('MKD ' + dirname)
+        return parse257(resp)
+
+    def rmd(self, dirname):
+        '''Remove a directory.'''
+        return self.voidcmd('RMD ' + dirname)
+
+    def pwd(self):
+        '''Return current working directory.'''
+        resp = self.sendcmd('PWD')
+        return parse257(resp)
+
+    def quit(self):
+        '''Quit, and close the connection.'''
+        resp = self.voidcmd('QUIT')
+        self.close()
+        return resp
+
+    def close(self):
+        '''Close the connection without assuming anything about it.'''
+        if self.file:
+            self.file.close()
+            self.sock.close()
+            self.file = self.sock = None
+
+
+try:
+    import ssl
+except ImportError:
+    pass
+else:
+    class FTP_TLS(FTP):
+        '''A FTP subclass which adds TLS support to FTP as described
+        in RFC-4217.
+
+        Connect as usual to port 21 implicitly securing the FTP control
+        connection before authenticating.
+
+        Securing the data connection requires user to explicitly ask
+        for it by calling prot_p() method.
+
+        Usage example:
+        >>> from ftplib import FTP_TLS
+        >>> ftps = FTP_TLS('ftp.python.org')
+        >>> ftps.login()  # login anonimously previously securing control channel
+        '230 Guest login ok, access restrictions apply.'
+        >>> ftps.prot_p()  # switch to secure data connection
+        '200 Protection level set to P'
+        >>> ftps.retrlines('LIST')  # list directory content securely
+        total 9
+        drwxr-xr-x   8 root     wheel        1024 Jan  3  1994 .
+        drwxr-xr-x   8 root     wheel        1024 Jan  3  1994 ..
+        drwxr-xr-x   2 root     wheel        1024 Jan  3  1994 bin
+        drwxr-xr-x   2 root     wheel        1024 Jan  3  1994 etc
+        d-wxrwxr-x   2 ftp      wheel        1024 Sep  5 13:43 incoming
+        drwxr-xr-x   2 root     wheel        1024 Nov 17  1993 lib
+        drwxr-xr-x   6 1094     wheel        1024 Sep 13 19:07 pub
+        drwxr-xr-x   3 root     wheel        1024 Jan  3  1994 usr
+        -rw-r--r--   1 root     root          312 Aug  1  1994 welcome.msg
+        '226 Transfer complete.'
+        >>> ftps.quit()
+        '221 Goodbye.'
+        >>>
+        '''
+        ssl_version = ssl.PROTOCOL_TLSv1
+
+        def __init__(self, host='', user='', passwd='', acct='', keyfile=None,
+                     certfile=None, timeout=_GLOBAL_DEFAULT_TIMEOUT):
+            self.keyfile = keyfile
+            self.certfile = certfile
+            self._prot_p = False
+            FTP.__init__(self, host, user, passwd, acct, timeout)
+
+        def login(self, user='', passwd='', acct='', secure=True):
+            if secure and not isinstance(self.sock, ssl.SSLSocket):
+                self.auth()
+            return FTP.login(self, user, passwd, acct)
+
+        def auth(self):
+            '''Set up secure control connection by using TLS/SSL.'''
+            if isinstance(self.sock, ssl.SSLSocket):
+                raise ValueError("Already using TLS")
+            if self.ssl_version == ssl.PROTOCOL_TLSv1:
+                resp = self.voidcmd('AUTH TLS')
+            else:
+                resp = self.voidcmd('AUTH SSL')
+            self.sock = ssl.wrap_socket(self.sock, self.keyfile, self.certfile,
+                                        ssl_version=self.ssl_version)
+            self.file = self.sock.makefile(mode='rb')
+            return resp
+
+        def prot_p(self):
+            '''Set up secure data connection.'''
+            # PROT defines whether or not the data channel is to be protected.
+            # Though RFC-2228 defines four possible protection levels,
+            # RFC-4217 only recommends two, Clear and Private.
+            # Clear (PROT C) means that no security is to be used on the
+            # data-channel, Private (PROT P) means that the data-channel
+            # should be protected by TLS.
+            # PBSZ command MUST still be issued, but must have a parameter of
+            # '0' to indicate that no buffering is taking place and the data
+            # connection should not be encapsulated.
+            self.voidcmd('PBSZ 0')
+            resp = self.voidcmd('PROT P')
+            self._prot_p = True
+            return resp
+
+        def prot_c(self):
+            '''Set up clear text data connection.'''
+            resp = self.voidcmd('PROT C')
+            self._prot_p = False
+            return resp
+
+        # --- Overridden FTP methods
+
+        def ntransfercmd(self, cmd, rest=None):
+            conn, size = FTP.ntransfercmd(self, cmd, rest)
+            if self._prot_p:
+                conn = ssl.wrap_socket(conn, self.keyfile, self.certfile,
+                                       ssl_version=self.ssl_version)
+            return conn, size
+
+        def retrbinary(self, cmd, callback, blocksize=8192, rest=None):
+            self.voidcmd('TYPE I')
+            conn = self.transfercmd(cmd, rest)
+            try:
+                while 1:
+                    data = conn.recv(blocksize)
+                    if not data:
+                        break
+                    callback(data)
+                # shutdown ssl layer
+                if isinstance(conn, ssl.SSLSocket):
+                    conn.unwrap()
+            finally:
+                conn.close()
+            return self.voidresp()
+
+        def retrlines(self, cmd, callback = None):
+            if callback is None: callback = print_line
+            resp = self.sendcmd('TYPE A')
+            conn = self.transfercmd(cmd)
+            fp = conn.makefile('rb')
+            try:
+                while 1:
+                    line = fp.readline()
+                    if self.debugging > 2: print '*retr*', repr(line)
+                    if not line:
+                        break
+                    if line[-2:] == CRLF:
+                        line = line[:-2]
+                    elif line[-1:] == '\n':
+                        line = line[:-1]
+                    callback(line)
+                # shutdown ssl layer
+                if isinstance(conn, ssl.SSLSocket):
+                    conn.unwrap()
+            finally:
+                fp.close()
+                conn.close()
+            return self.voidresp()
+
+        def storbinary(self, cmd, fp, blocksize=8192, callback=None, rest=None):
+            self.voidcmd('TYPE I')
+            conn = self.transfercmd(cmd, rest)
+            try:
+                while 1:
+                    buf = fp.read(blocksize)
+                    if not buf: break
+                    conn.sendall(buf)
+                    if callback: callback(buf)
+                # shutdown ssl layer
+                if isinstance(conn, ssl.SSLSocket):
+                    conn.unwrap()
+            finally:
+                conn.close()
+            return self.voidresp()
+
+        def storlines(self, cmd, fp, callback=None):
+            self.voidcmd('TYPE A')
+            conn = self.transfercmd(cmd)
+            try:
+                while 1:
+                    buf = fp.readline()
+                    if not buf: break
+                    if buf[-2:] != CRLF:
+                        if buf[-1] in CRLF: buf = buf[:-1]
+                        buf = buf + CRLF
+                    conn.sendall(buf)
+                    if callback: callback(buf)
+                # shutdown ssl layer
+                if isinstance(conn, ssl.SSLSocket):
+                    conn.unwrap()
+            finally:
+                conn.close()
+            return self.voidresp()
+
+    __all__.append('FTP_TLS')
+    all_errors = (Error, IOError, EOFError, ssl.SSLError)
+
+
+_150_re = None
+
+def parse150(resp):
+    '''Parse the '150' response for a RETR request.
+    Returns the expected transfer size or None; size is not guaranteed to
+    be present in the 150 message.
+    '''
+    if resp[:3] != '150':
+        raise error_reply, resp
+    global _150_re
+    if _150_re is None:
+        import re
+        _150_re = re.compile("150 .* \((\d+) bytes\)", re.IGNORECASE)
+    m = _150_re.match(resp)
+    if not m:
+        return None
+    s = m.group(1)
+    try:
+        return int(s)
+    except (OverflowError, ValueError):
+        return long(s)
+
+
+_227_re = None
+
+def parse227(resp):
+    '''Parse the '227' response for a PASV request.
+    Raises error_proto if it does not contain '(h1,h2,h3,h4,p1,p2)'
+    Return ('host.addr.as.numbers', port#) tuple.'''
+
+    if resp[:3] != '227':
+        raise error_reply, resp
+    global _227_re
+    if _227_re is None:
+        import re
+        _227_re = re.compile(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)')
+    m = _227_re.search(resp)
+    if not m:
+        raise error_proto, resp
+    numbers = m.groups()
+    host = '.'.join(numbers[:4])
+    port = (int(numbers[4]) << 8) + int(numbers[5])
+    return host, port
+
+
+def parse229(resp, peer):
+    '''Parse the '229' response for a EPSV request.
+    Raises error_proto if it does not contain '(|||port|)'
+    Return ('host.addr.as.numbers', port#) tuple.'''
+
+    if resp[:3] != '229':
+        raise error_reply, resp
+    left = resp.find('(')
+    if left < 0: raise error_proto, resp
+    right = resp.find(')', left + 1)
+    if right < 0:
+        raise error_proto, resp # should contain '(|||port|)'
+    if resp[left + 1] != resp[right - 1]:
+        raise error_proto, resp
+    parts = resp[left + 1:right].split(resp[left+1])
+    if len(parts) != 5:
+        raise error_proto, resp
+    host = peer[0]
+    port = int(parts[3])
+    return host, port
+
+
+def parse257(resp):
+    '''Parse the '257' response for a MKD or PWD request.
+    This is a response to a MKD or PWD request: a directory name.
+    Returns the directoryname in the 257 reply.'''
+
+    if resp[:3] != '257':
+        raise error_reply, resp
+    if resp[3:5] != ' "':
+        return '' # Not compliant to RFC 959, but UNIX ftpd does this
+    dirname = ''
+    i = 5
+    n = len(resp)
+    while i < n:
+        c = resp[i]
+        i = i+1
+        if c == '"':
+            if i >= n or resp[i] != '"':
+                break
+            i = i+1
+        dirname = dirname + c
+    return dirname
+
+
+def print_line(line):
+    '''Default retrlines callback to print a line.'''
+    print line
+
+
+def ftpcp(source, sourcename, target, targetname = '', type = 'I'):
+    '''Copy file from one FTP-instance to another.'''
+    if not targetname: targetname = sourcename
+    type = 'TYPE ' + type
+    source.voidcmd(type)
+    target.voidcmd(type)
+    sourcehost, sourceport = parse227(source.sendcmd('PASV'))
+    target.sendport(sourcehost, sourceport)
+    # RFC 959: the user must "listen" [...] BEFORE sending the
+    # transfer request.
+    # So: STOR before RETR, because here the target is a "user".
+    treply = target.sendcmd('STOR ' + targetname)
+    if treply[:3] not in ('125', '150'): raise error_proto  # RFC 959
+    sreply = source.sendcmd('RETR ' + sourcename)
+    if sreply[:3] not in ('125', '150'): raise error_proto  # RFC 959
+    source.voidresp()
+    target.voidresp()
+
+
+class Netrc:
+    """Class to parse & provide access to 'netrc' format files.
+
+    See the netrc(4) man page for information on the file format.
+
+    WARNING: This class is obsolete -- use module netrc instead.
+
+    """
+    __defuser = None
+    __defpasswd = None
+    __defacct = None
+
+    def __init__(self, filename=None):
+        if filename is None:
+            if "HOME" in os.environ:
+                filename = os.path.join(os.environ["HOME"],
+                                        ".netrc")
+            else:
+                raise IOError, \
+                      "specify file to load or set $HOME"
+        self.__hosts = {}
+        self.__macros = {}
+        fp = open(filename, "r")
+        in_macro = 0
+        while 1:
+            line = fp.readline()
+            if not line: break
+            if in_macro and line.strip():
+                macro_lines.append(line)
+                continue
+            elif in_macro:
+                self.__macros[macro_name] = tuple(macro_lines)
+                in_macro = 0
+            words = line.split()
+            host = user = passwd = acct = None
+            default = 0
+            i = 0
+            while i < len(words):
+                w1 = words[i]
+                if i+1 < len(words):
+                    w2 = words[i + 1]
+                else:
+                    w2 = None
+                if w1 == 'default':
+                    default = 1
+                elif w1 == 'machine' and w2:
+                    host = w2.lower()
+                    i = i + 1
+                elif w1 == 'login' and w2:
+                    user = w2
+                    i = i + 1
+                elif w1 == 'password' and w2:
+                    passwd = w2
+                    i = i + 1
+                elif w1 == 'account' and w2:
+                    acct = w2
+                    i = i + 1
+                elif w1 == 'macdef' and w2:
+                    macro_name = w2
+                    macro_lines = []
+                    in_macro = 1
+                    break
+                i = i + 1
+            if default:
+                self.__defuser = user or self.__defuser
+                self.__defpasswd = passwd or self.__defpasswd
+                self.__defacct = acct or self.__defacct
+            if host:
+                if host in self.__hosts:
+                    ouser, opasswd, oacct = \
+                           self.__hosts[host]
+                    user = user or ouser
+                    passwd = passwd or opasswd
+                    acct = acct or oacct
+                self.__hosts[host] = user, passwd, acct
+        fp.close()
+
+    def get_hosts(self):
+        """Return a list of hosts mentioned in the .netrc file."""
+        return self.__hosts.keys()
+
+    def get_account(self, host):
+        """Returns login information for the named host.
+
+        The return value is a triple containing userid,
+        password, and the accounting field.
+
+        """
+        host = host.lower()
+        user = passwd = acct = None
+        if host in self.__hosts:
+            user, passwd, acct = self.__hosts[host]
+        user = user or self.__defuser
+        passwd = passwd or self.__defpasswd
+        acct = acct or self.__defacct
+        return user, passwd, acct
+
+    def get_macros(self):
+        """Return a list of all defined macro names."""
+        return self.__macros.keys()
+
+    def get_macro(self, macro):
+        """Return a sequence of lines which define a named macro."""
+        return self.__macros[macro]
+
+
+
+def test():
+    '''Test program.
+    Usage: ftp [-d] [-r[file]] host [-l[dir]] [-d[dir]] [-p] [file] ...
+
+    -d dir
+    -l list
+    -p password
+    '''
+
+    if len(sys.argv) < 2:
+        print test.__doc__
+        sys.exit(0)
+
+    debugging = 0
+    rcfile = None
+    while sys.argv[1] == '-d':
+        debugging = debugging+1
+        del sys.argv[1]
+    if sys.argv[1][:2] == '-r':
+        # get name of alternate ~/.netrc file:
+        rcfile = sys.argv[1][2:]
+        del sys.argv[1]
+    host = sys.argv[1]
+    ftp = FTP(host)
+    ftp.set_debuglevel(debugging)
+    userid = passwd = acct = ''
+    try:
+        netrc = Netrc(rcfile)
+    except IOError:
+        if rcfile is not None:
+            sys.stderr.write("Could not open account file"
+                             " -- using anonymous login.")
+    else:
+        try:
+            userid, passwd, acct = netrc.get_account(host)
+        except KeyError:
+            # no account for host
+            sys.stderr.write(
+                    "No account -- using anonymous login.")
+    ftp.login(userid, passwd, acct)
+    for file in sys.argv[2:]:
+        if file[:2] == '-l':
+            ftp.dir(file[2:])
+        elif file[:2] == '-d':
+            cmd = 'CWD'
+            if file[2:]: cmd = cmd + ' ' + file[2:]
+            resp = ftp.sendcmd(cmd)
+        elif file == '-p':
+            ftp.set_pasv(not ftp.passiveserver)
+        else:
+            ftp.retrbinary('RETR ' + file, \
+                           sys.stdout.write, 1024)
+    ftp.quit()
+
+
+if __name__ == '__main__':
+    test()

+ 116 - 0
pyftpdlib/__init__.py

@@ -0,0 +1,116 @@
+#!/usr/bin/env python
+# -*- coding:utf8 -*-
+# $Id: __init__.py 1220 2013-04-22 14:47:16Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+pyftpdlib: RFC-959 asynchronous FTP server.
+
+pyftpdlib implements a fully functioning asynchronous FTP server as
+defined in RFC-959.  A hierarchy of classes outlined below implement
+the backend functionality for the FTPd:
+
+    [pyftpdlib.ftpservers.FTPServer]
+      accepts connections and dispatches them to a handler
+
+    [pyftpdlib.handlers.FTPHandler]
+      a class representing the server-protocol-interpreter
+      (server-PI, see RFC-959). Each time a new connection occurs
+      FTPServer will create a new FTPHandler instance to handle the
+      current PI session.
+
+    [pyftpdlib.handlers.ActiveDTP]
+    [pyftpdlib.handlers.PassiveDTP]
+      base classes for active/passive-DTP backends.
+
+    [pyftpdlib.handlers.DTPHandler]
+      this class handles processing of data transfer operations (server-DTP,
+      see RFC-959).
+
+    [pyftpdlib.authorizers.DummyAuthorizer]
+      an "authorizer" is a class handling FTPd authentications and
+      permissions. It is used inside FTPHandler class to verify user
+      passwords, to get user's home directory and to get permissions
+      when a filesystem read/write occurs. "DummyAuthorizer" is the
+      base authorizer class providing a platform independent interface
+      for managing virtual users.
+
+    [pyftpdlib.filesystems.AbstractedFS]
+      class used to interact with the file system, providing a high level,
+      cross-platform interface compatible with both Windows and UNIX style
+      filesystems.
+
+Usage example:
+
+>>> from pyftpdlib.authorizers import DummyAuthorizer
+>>> from pyftpdlib.handlers import FTPHandler
+>>> from pyftpdlib.servers import FTPServer
+>>>
+>>> authorizer = DummyAuthorizer()
+>>> authorizer.add_user("user", "12345", "/home/giampaolo", perm="elradfmw")
+>>> authorizer.add_anonymous("/home/nobody")
+>>>
+>>> handler = FTPHandler
+>>> handler.authorizer = authorizer
+>>>
+>>> server = FTPServer(("127.0.0.1", 21), handler)
+>>> server.serve_forever()
+[I 13-02-19 10:55:42] >>> starting FTP server on 127.0.0.1:21 <<<
+[I 13-02-19 10:55:42] poller: <class 'pyftpdlib.ioloop.Epoll'>
+[I 13-02-19 10:55:42] masquerade (NAT) address: None
+[I 13-02-19 10:55:42] passive ports: None
+[I 13-02-19 10:55:42] use sendfile(2): True
+[I 13-02-19 10:55:45] 127.0.0.1:34178-[] FTP session opened (connect)
+[I 13-02-19 10:55:48] 127.0.0.1:34178-[user] USER 'user' logged in.
+[I 13-02-19 10:56:27] 127.0.0.1:34179-[user] RETR /home/giampaolo/.vimrc completed=1 bytes=1700 seconds=0.001
+[I 13-02-19 10:56:39] 127.0.0.1:34179-[user] FTP session closed (disconnect).
+"""
+
+import logging
+
+__ver__     = '1.2.0'
+__date__    = '2013-04-22'
+__author__  = "Giampaolo Rodola' <g.rodola@gmail.com>"
+__web__     = 'http://code.google.com/p/pyftpdlib/'
+
+def _depwarn(msg):
+    """
+    Force DeprecationWarning to be temporarily shown (it's been
+    disabled by default starting from python 2.7 / 3.2), then
+    re-set the default behavior.
+    """
+    import warnings
+    orig_filters = warnings.filters[:]
+    try:
+        #warnings.simplefilter('default')
+        warnings.resetwarnings()
+        warnings.warn(msg, category=DeprecationWarning, stacklevel=2)
+    finally:
+        warnings.filters = orig_filters

+ 115 - 0
pyftpdlib/__init__.py~

@@ -0,0 +1,115 @@
+#!/usr/bin/env python
+# $Id: __init__.py 1220 2013-04-22 14:47:16Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+pyftpdlib: RFC-959 asynchronous FTP server.
+
+pyftpdlib implements a fully functioning asynchronous FTP server as
+defined in RFC-959.  A hierarchy of classes outlined below implement
+the backend functionality for the FTPd:
+
+    [pyftpdlib.ftpservers.FTPServer]
+      accepts connections and dispatches them to a handler
+
+    [pyftpdlib.handlers.FTPHandler]
+      a class representing the server-protocol-interpreter
+      (server-PI, see RFC-959). Each time a new connection occurs
+      FTPServer will create a new FTPHandler instance to handle the
+      current PI session.
+
+    [pyftpdlib.handlers.ActiveDTP]
+    [pyftpdlib.handlers.PassiveDTP]
+      base classes for active/passive-DTP backends.
+
+    [pyftpdlib.handlers.DTPHandler]
+      this class handles processing of data transfer operations (server-DTP,
+      see RFC-959).
+
+    [pyftpdlib.authorizers.DummyAuthorizer]
+      an "authorizer" is a class handling FTPd authentications and
+      permissions. It is used inside FTPHandler class to verify user
+      passwords, to get user's home directory and to get permissions
+      when a filesystem read/write occurs. "DummyAuthorizer" is the
+      base authorizer class providing a platform independent interface
+      for managing virtual users.
+
+    [pyftpdlib.filesystems.AbstractedFS]
+      class used to interact with the file system, providing a high level,
+      cross-platform interface compatible with both Windows and UNIX style
+      filesystems.
+
+Usage example:
+
+>>> from pyftpdlib.authorizers import DummyAuthorizer
+>>> from pyftpdlib.handlers import FTPHandler
+>>> from pyftpdlib.servers import FTPServer
+>>>
+>>> authorizer = DummyAuthorizer()
+>>> authorizer.add_user("user", "12345", "/home/giampaolo", perm="elradfmw")
+>>> authorizer.add_anonymous("/home/nobody")
+>>>
+>>> handler = FTPHandler
+>>> handler.authorizer = authorizer
+>>>
+>>> server = FTPServer(("127.0.0.1", 21), handler)
+>>> server.serve_forever()
+[I 13-02-19 10:55:42] >>> starting FTP server on 127.0.0.1:21 <<<
+[I 13-02-19 10:55:42] poller: <class 'pyftpdlib.ioloop.Epoll'>
+[I 13-02-19 10:55:42] masquerade (NAT) address: None
+[I 13-02-19 10:55:42] passive ports: None
+[I 13-02-19 10:55:42] use sendfile(2): True
+[I 13-02-19 10:55:45] 127.0.0.1:34178-[] FTP session opened (connect)
+[I 13-02-19 10:55:48] 127.0.0.1:34178-[user] USER 'user' logged in.
+[I 13-02-19 10:56:27] 127.0.0.1:34179-[user] RETR /home/giampaolo/.vimrc completed=1 bytes=1700 seconds=0.001
+[I 13-02-19 10:56:39] 127.0.0.1:34179-[user] FTP session closed (disconnect).
+"""
+
+import logging
+
+__ver__     = '1.2.0'
+__date__    = '2013-04-22'
+__author__  = "Giampaolo Rodola' <g.rodola@gmail.com>"
+__web__     = 'http://code.google.com/p/pyftpdlib/'
+
+def _depwarn(msg):
+    """
+    Force DeprecationWarning to be temporarily shown (it's been
+    disabled by default starting from python 2.7 / 3.2), then
+    re-set the default behavior.
+    """
+    import warnings
+    orig_filters = warnings.filters[:]
+    try:
+        #warnings.simplefilter('default')
+        warnings.resetwarnings()
+        warnings.warn(msg, category=DeprecationWarning, stacklevel=2)
+    finally:
+        warnings.filters = orig_filters

+ 117 - 0
pyftpdlib/__main__.py

@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+# $Id: __main__.py 1206 2013-04-06 15:26:00Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+Start a stand alone anonymous FTP server from the command line as in:
+
+$ python -m pyftpdlib
+"""
+
+import optparse
+import sys
+import os
+
+from pyftpdlib import __ver__
+from pyftpdlib.authorizers import DummyAuthorizer
+from pyftpdlib.handlers import FTPHandler
+from pyftpdlib.servers import FTPServer
+from pyftpdlib._compat import getcwdu
+
+
+class CustomizedOptionFormatter(optparse.IndentedHelpFormatter):
+    """Formats options shown in help in a prettier way."""
+    def format_option(self, option):
+        result = []
+        opts = self.option_strings[option]
+        result.append('  %s\n' % opts)
+        if option.help:
+            help_text = '     %s\n\n' % self.expand_default(option)
+            result.append(help_text)
+        return ''.join(result)
+
+def main():
+    """Start a stand alone anonymous FTP server."""
+    usage = "python -m pyftpdlib.ftpserver [options]"
+    parser = optparse.OptionParser(usage=usage, description=main.__doc__,
+                                   formatter=CustomizedOptionFormatter())
+    parser.add_option('-i', '--interface', default=None, metavar="ADDRESS",
+                      help="specify the interface to run on (default all "
+                           "interfaces)")
+    parser.add_option('-p', '--port', type="int", default=2121, metavar="PORT",
+                      help="specify port number to run on (default 21)")
+    parser.add_option('-w', '--write', action="store_true", default=False,
+                      help="grants write access for the anonymous user "
+                           "(default read-only)")
+    parser.add_option('-d', '--directory', default=getcwdu(), metavar="FOLDER",
+                      help="specify the directory to share (default current "
+                           "directory)")
+    parser.add_option('-n', '--nat-address', default=None, metavar="ADDRESS",
+                      help="the NAT address to use for passive connections")
+    parser.add_option('-r', '--range', default=None, metavar="FROM-TO",
+                      help="the range of TCP ports to use for passive "
+                           "connections (e.g. -r 8000-9000)")
+    parser.add_option('-v', '--version', action='store_true',
+                      help="print pyftpdlib version and exit")
+
+    options, args = parser.parse_args()
+    if options.version:
+        sys.exit("pyftpdlib %s" % __ver__)
+    passive_ports = None
+    if options.range:
+        try:
+            start, stop = options.range.split('-')
+            start = int(start)
+            stop = int(stop)
+        except ValueError:
+            parser.error('invalid argument passed to -r option')
+        else:
+            passive_ports = list(range(start, stop + 1))
+    # On recent Windows versions, if address is not specified and IPv6
+    # is installed the socket will listen on IPv6 by default; in this
+    # case we force IPv4 instead.
+    if os.name in ('nt', 'ce') and not options.interface:
+        options.interface = '0.0.0.0'
+
+    authorizer = DummyAuthorizer()
+    perm = options.write and "elradfmwM" or "elr"
+    authorizer.add_anonymous(options.directory, perm=perm)
+    handler = FTPHandler
+    handler.authorizer = authorizer
+    handler.masquerade_address = options.nat_address
+    handler.passive_ports = passive_ports
+    ftpd = FTPServer((options.interface, options.port), FTPHandler)
+    try:
+        ftpd.serve_forever()
+    finally:
+        ftpd.close_all()
+
+if __name__ == '__main__':
+    main()

+ 92 - 0
pyftpdlib/_compat.py

@@ -0,0 +1,92 @@
+#!/usr/bin/env python
+# $Id: _compat.py 1117 2012-11-02 19:45:33Z g.rodola $
+
+"""
+Compatibility module similar to six which helps maintaining
+a single code base working with python from 2.4 to 3.x.
+"""
+
+import sys
+import os
+
+PY3 = sys.version_info[0] == 3
+
+if PY3:
+    import builtins
+
+    def u(s):
+        return s
+
+    def b(s):
+        return s.encode("latin-1")
+
+    print_ = getattr(builtins, "print")
+    getcwdu = os.getcwd
+    unicode = str
+    xrange = range
+else:
+    def u(s):
+        return unicode(s)
+
+    def b(s):
+        return s
+
+    def print_(s):
+        sys.stdout.write(s + '\n')
+        sys.stdout.flush()
+
+    getcwdu = os.getcwdu
+    unicode = unicode
+    xrange = xrange
+
+# introduced in 2.6
+if hasattr(sys, 'maxsize'):
+    MAXSIZE = sys.maxsize
+else:
+    class X(object):
+        def __len__(self):
+            return 1 << 31
+    try:
+        len(X())
+    except OverflowError:
+        MAXSIZE = int((1 << 31) - 1)  # 32-bit
+    else:
+        MAXSIZE = int((1 << 63) - 1)  # 64-bit
+    del X
+
+# removed in 3.0, reintroduced in 3.2
+try:
+    callable = callable
+except Exception:
+    def callable(obj):
+        for klass in type(obj).__mro__:
+            if "__call__" in klass.__dict__:
+                return True
+        return False
+
+# introduced in 2.6
+_default = object()
+try:
+    next = next
+except NameError:
+    def next(iterable, default=_default):
+        if default == _default:
+            return iterable.next()
+        else:
+            try:
+                return iterable.next()
+            except StopIteration:
+                return default
+
+# dirty hack to support property.setter on python < 2.6
+property = property
+if not hasattr(property, "setter"):
+    class property(property):
+        def setter(self, value):
+            cls_ns = sys._getframe(1).f_locals
+            for k, v in cls_ns.iteritems():
+                if v == self:
+                    name = k
+                    break
+            cls_ns[name] = property(self.fget, value, self.fdel, self.__doc__)
+            return cls_ns[name]

+ 897 - 0
pyftpdlib/authorizers.py

@@ -0,0 +1,897 @@
+#!/usr/bin/env python
+# $Id: authorizers.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""An "authorizer" is a class handling authentications and permissions
+of the FTP server. It is used by pyftpdlib.handlers.FTPHandler
+class for:
+
+- verifying user password
+- getting user home directory
+- checking user permissions when a filesystem read/write event occurs
+- changing user when accessing the filesystem
+
+DummyAuthorizer is the main class which handles virtual users.
+
+UnixAuthorizer and WindowsAuthorizer are platform specific and
+interact with UNIX and Windows password database.
+"""
+
+
+import os
+import warnings
+import errno
+import sys
+
+from pyftpdlib._compat import PY3, unicode, getcwdu
+
+
+__all__ = ['DummyAuthorizer',
+           #'BaseUnixAuthorizer', 'UnixAuthorizer',
+           #'BaseWindowsAuthorizer', 'WindowsAuthorizer',
+          ]
+
+
+# ===================================================================
+# --- exceptions
+# ===================================================================
+
+class AuthorizerError(Exception):
+    """Base class for authorizer exceptions."""
+
+class AuthenticationFailed(Exception):
+    """Exception raised when authentication fails for any reason."""
+
+
+# ===================================================================
+# --- base class
+# ===================================================================
+
+class DummyAuthorizer(object):
+    """Basic "dummy" authorizer class, suitable for subclassing to
+    create your own custom authorizers.
+
+    An "authorizer" is a class handling authentications and permissions
+    of the FTP server.  It is used inside FTPHandler class for verifying
+    user's password, getting users home directory, checking user
+    permissions when a file read/write event occurs and changing user
+    before accessing the filesystem.
+
+    DummyAuthorizer is the base authorizer, providing a platform
+    independent interface for managing "virtual" FTP users. System
+    dependent authorizers can by written by subclassing this base
+    class and overriding appropriate methods as necessary.
+    """
+
+    read_perms = "elr"
+    write_perms = "adfmwM"
+
+    def __init__(self):
+        self.user_table = {}
+
+    def add_user(self, username, password, homedir, perm='elr',
+                    msg_login="Login successful.", msg_quit="Goodbye."):
+        """Add a user to the virtual users table.
+
+        AuthorizerError exceptions raised on error conditions such as
+        invalid permissions, missing home directory or duplicate usernames.
+
+        Optional perm argument is a string referencing the user's
+        permissions explained below:
+
+        Read permissions:
+         - "e" = change directory (CWD command)
+         - "l" = list files (LIST, NLST, STAT, MLSD, MLST, SIZE, MDTM commands)
+         - "r" = retrieve file from the server (RETR command)
+
+        Write permissions:
+         - "a" = append data to an existing file (APPE command)
+         - "d" = delete file or directory (DELE, RMD commands)
+         - "f" = rename file or directory (RNFR, RNTO commands)
+         - "m" = create directory (MKD command)
+         - "w" = store a file to the server (STOR, STOU commands)
+         - "M" = change file mode (SITE CHMOD command)
+
+        Optional msg_login and msg_quit arguments can be specified to
+        provide customized response strings when user log-in and quit.
+        """
+        if self.has_user(username):
+            raise ValueError('user %r already exists' % username)
+        if not isinstance(homedir, unicode):
+            homedir = homedir.decode('utf8')
+        if not os.path.isdir(homedir):
+            raise ValueError('no such directory: %r' % homedir)
+        homedir = os.path.realpath(homedir)
+        self._check_permissions(username, perm)
+        dic = {'pwd': str(password),
+               'home': homedir,
+               'perm': perm,
+               'operms': {},
+               'msg_login': str(msg_login),
+               'msg_quit': str(msg_quit)
+               }
+        self.user_table[username] = dic
+
+    def add_anonymous(self, homedir, **kwargs):
+        """Add an anonymous user to the virtual users table.
+
+        AuthorizerError exception raised on error conditions such as
+        invalid permissions, missing home directory, or duplicate
+        anonymous users.
+
+        The keyword arguments in kwargs are the same expected by
+        add_user method: "perm", "msg_login" and "msg_quit".
+
+        The optional "perm" keyword argument is a string defaulting to
+        "elr" referencing "read-only" anonymous user's permissions.
+
+        Using write permission values ("adfmwM") results in a
+        RuntimeWarning.
+        """
+        DummyAuthorizer.add_user(self, 'anonymous', '', homedir, **kwargs)
+
+    def remove_user(self, username):
+        """Remove a user from the virtual users table."""
+        del self.user_table[username]
+
+    def override_perm(self, username, directory, perm, recursive=False):
+        """Override permissions for a given directory."""
+        self._check_permissions(username, perm)
+        if not os.path.isdir(directory):
+            raise ValueError('no such directory: %r' % directory)
+        directory = os.path.normcase(os.path.realpath(directory))
+        home = os.path.normcase(self.get_home_dir(username))
+        if directory == home:
+            raise ValueError("can't override home directory permissions")
+        if not self._issubpath(directory, home):
+            raise ValueError("path escapes user home directory")
+        self.user_table[username]['operms'][directory] = perm, recursive
+
+    def validate_authentication(self, username, password, handler):
+        """Raises AuthenticationFailed if supplied username and
+        password don't match the stored credentials, else return
+        None.
+        """
+        msg = "Authentication failed."
+        if not self.has_user(username):
+            if username == 'anonymous':
+                msg = "Anonymous access not allowed."
+            raise AuthenticationFailed(msg)
+        if username != 'anonymous':
+            if self.user_table[username]['pwd'] != password:
+                raise AuthenticationFailed(msg)
+
+    def get_home_dir(self, username):
+        """Return the user's home directory.
+        Since this is called during authentication (PASS),
+        AuthenticationFailed can be freely raised by subclasses in case
+        the provided username no longer exists.
+        """
+        return self.user_table[username]['home']
+
+    def impersonate_user(self, username, password):
+        """Impersonate another user (noop).
+
+        It is always called before accessing the filesystem.
+        By default it does nothing.  The subclass overriding this
+        method is expected to provide a mechanism to change the
+        current user.
+        """
+
+    def terminate_impersonation(self, username):
+        """Terminate impersonation (noop).
+
+        It is always called after having accessed the filesystem.
+        By default it does nothing.  The subclass overriding this
+        method is expected to provide a mechanism to switch back
+        to the original user.
+        """
+
+    def has_user(self, username):
+        """Whether the username exists in the virtual users table."""
+        return username in self.user_table
+
+    def has_perm(self, username, perm, path=None):
+        """Whether the user has permission over path (an absolute
+        pathname of a file or a directory).
+
+        Expected perm argument is one of the following letters:
+        "elradfmwM".
+        """
+        if path is None:
+            return perm in self.user_table[username]['perm']
+
+        path = os.path.normcase(path)
+        for dir in self.user_table[username]['operms'].keys():
+            operm, recursive = self.user_table[username]['operms'][dir]
+            if self._issubpath(path, dir):
+                if recursive:
+                    return perm in operm
+                if (path == dir) or (os.path.dirname(path) == dir \
+                and not os.path.isdir(path)):
+                    return perm in operm
+
+        return perm in self.user_table[username]['perm']
+
+    def get_perms(self, username):
+        """Return current user permissions."""
+        return self.user_table[username]['perm']
+
+    def get_msg_login(self, username):
+        """Return the user's login message."""
+        return self.user_table[username]['msg_login']
+
+    def get_msg_quit(self, username):
+        """Return the user's quitting message."""
+        return self.user_table[username]['msg_quit']
+
+    def _check_permissions(self, username, perm):
+        warned = 0
+        for p in perm:
+            if p not in self.read_perms + self.write_perms:
+                raise ValueError('no such permission %r' % p)
+            if (username == 'anonymous') and (p in self.write_perms) and not warned:
+                warnings.warn("write permissions assigned to anonymous user.",
+                              RuntimeWarning)
+                warned = 1
+
+    def _issubpath(self, a, b):
+        """Return True if a is a sub-path of b or if the paths are equal."""
+        p1 = a.rstrip(os.sep).split(os.sep)
+        p2 = b.rstrip(os.sep).split(os.sep)
+        return p1[:len(p2)] == p2
+
+
+def replace_anonymous(callable):
+    """A decorator to replace anonymous user string passed to authorizer
+    methods as first argument with the actual user used to handle
+    anonymous sessions.
+    """
+    def wrapper(self, username, *args, **kwargs):
+        if username == 'anonymous':
+            username = self.anonymous_user or username
+        return callable(self, username, *args, **kwargs)
+    return wrapper
+
+
+# ===================================================================
+# --- platform specific authorizers
+# ===================================================================
+
+class _Base(object):
+    """Methods common to both Unix and Windows authorizers.
+    Not supposed to be used directly.
+    """
+
+    msg_no_such_user = "Authentication failed."
+    msg_wrong_password = "Authentication failed."
+    msg_anon_not_allowed = "Anonymous access not allowed."
+    msg_invalid_shell = "User %s doesn't have a valid shell."
+    msg_rejected_user = "User %s is not allowed to login."
+
+    def __init__(self):
+        """Check for errors in the constructor."""
+        if self.rejected_users and self.allowed_users:
+            raise AuthorizerError("rejected_users and allowed_users options are "
+                                  "mutually exclusive")
+
+        users = self._get_system_users()
+        for user in (self.allowed_users or self.rejected_users):
+            if user == 'anonymous':
+                raise AuthorizerError('invalid username "anonymous"')
+            if user not in users:
+                raise AuthorizerError('unknown user %s' % user)
+
+        if self.anonymous_user is not None:
+            if not self.has_user(self.anonymous_user):
+                raise AuthorizerError('no such user %s' % self.anonymous_user)
+            home = self.get_home_dir(self.anonymous_user)
+            if not os.path.isdir(home):
+                raise AuthorizerError('no valid home set for user %s'
+                                      % self.anonymous_user)
+
+    def override_user(self, username, password=None, homedir=None, perm=None,
+                      msg_login=None, msg_quit=None):
+        """Overrides the options specified in the class constructor
+        for a specific user.
+        """
+        if not password and not homedir and not perm and not msg_login \
+        and not msg_quit:
+            raise AuthorizerError("at least one keyword argument must be specified")
+        if self.allowed_users and username not in self.allowed_users:
+            raise AuthorizerError('%s is not an allowed user' % username)
+        if self.rejected_users and username in self.rejected_users:
+            raise AuthorizerError('%s is not an allowed user' % username)
+        if username == "anonymous" and password:
+            raise AuthorizerError("can't assign password to anonymous user")
+        if not self.has_user(username):
+            raise AuthorizerError('no such user %s' % username)
+        if homedir is not None and not isinstance(homedir, unicode):
+            homedir = homedir.decode('utf8')
+
+        if username in self._dummy_authorizer.user_table:
+            # re-set parameters
+            del self._dummy_authorizer.user_table[username]
+        self._dummy_authorizer.add_user(username, password or "",
+                                                  homedir or getcwdu(),
+                                                  perm or "",
+                                                  msg_login or "",
+                                                  msg_quit or "")
+        if homedir is None:
+            self._dummy_authorizer.user_table[username]['home'] = ""
+
+    def get_msg_login(self, username):
+        return self._get_key(username, 'msg_login') or self.msg_login
+
+    def get_msg_quit(self, username):
+        return self._get_key(username, 'msg_quit') or self.msg_quit
+
+    def get_perms(self, username):
+        overridden_perms = self._get_key(username, 'perm')
+        if overridden_perms:
+            return overridden_perms
+        if username == 'anonymous':
+            return 'elr'
+        return self.global_perm
+
+    def has_perm(self, username, perm, path=None):
+        return perm in self.get_perms(username)
+
+    def _get_key(self, username, key):
+        if self._dummy_authorizer.has_user(username):
+            return self._dummy_authorizer.user_table[username][key]
+
+    def _is_rejected_user(self, username):
+        """Return True if the user has been black listed via
+        allowed_users or rejected_users options.
+        """
+        if self.allowed_users and username not in self.allowed_users:
+            return True
+        if self.rejected_users and username in self.rejected_users:
+            return True
+        return False
+
+
+# ===================================================================
+# --- UNIX
+# ===================================================================
+
+# Note: requires python >= 2.5
+try:
+    import pwd, spwd, crypt
+except ImportError:
+    pass
+else:
+    __all__.extend(['BaseUnixAuthorizer', 'UnixAuthorizer'])
+
+    # the uid/gid the server runs under
+    PROCESS_UID = os.getuid()
+    PROCESS_GID = os.getgid()
+
+    class BaseUnixAuthorizer(object):
+        """An authorizer compatible with Unix user account and password
+        database.
+        This class should not be used directly unless for subclassing.
+        Use higher-level UnixAuthorizer class instead.
+        """
+
+        def __init__(self, anonymous_user=None):
+            if os.geteuid() != 0 or not spwd.getspall():
+                raise AuthorizerError("super user privileges are required")
+            self.anonymous_user = anonymous_user
+
+            if self.anonymous_user is not None:
+                try:
+                    pwd.getpwnam(self.anonymous_user).pw_dir
+                except KeyError:
+                    raise AuthorizerError('no such user %s' % anonymous_user)
+
+        # --- overridden / private API
+
+        def validate_authentication(self, username, password, handler):
+            """Authenticates against shadow password db; raises
+            AuthenticationFailed in case of failed authentication.
+            """
+            if username == "anonymous":
+                if self.anonymous_user is None:
+                    raise AuthenticationFailed(self.msg_anon_not_allowed)
+            else:
+                try:
+                    pw1 = spwd.getspnam(username).sp_pwd
+                    pw2 = crypt.crypt(password, pw1)
+                except KeyError:  # no such username
+                    raise AuthenticationFailed(self.msg_no_such_user)
+                else:
+                    if pw1 != pw2:
+                        raise AuthenticationFailed(self.msg_wrong_password)
+
+        @replace_anonymous
+        def impersonate_user(self, username, password):
+            """Change process effective user/group ids to reflect
+            logged in user.
+            """
+            try:
+                pwdstruct = pwd.getpwnam(username)
+            except KeyError:
+                raise AuthorizerError(self.msg_no_such_user)
+            else:
+                os.setegid(pwdstruct.pw_gid)
+                os.seteuid(pwdstruct.pw_uid)
+
+        def terminate_impersonation(self, username):
+            """Revert process effective user/group IDs."""
+            os.setegid(PROCESS_GID)
+            os.seteuid(PROCESS_UID)
+
+        @replace_anonymous
+        def has_user(self, username):
+            """Return True if user exists on the Unix system.
+            If the user has been black listed via allowed_users or
+            rejected_users options always return False.
+            """
+            return username in self._get_system_users()
+
+        @replace_anonymous
+        def get_home_dir(self, username):
+            """Return user home directory."""
+            try:
+                home = pwd.getpwnam(username).pw_dir
+            except KeyError:
+                raise AuthorizerError(self.msg_no_such_user)
+            else:
+                if not PY3:
+                    home = home.decode('utf8')
+                return home
+
+        @staticmethod
+        def _get_system_users():
+            """Return all users defined on the UNIX system."""
+            # there should be no need to convert usernames to unicode
+            # as UNIX does not allow chars outside of ASCII set
+            return [entry.pw_name for entry in pwd.getpwall()]
+
+        def get_msg_login(self, username):
+            return "Login successful."
+
+        def get_msg_quit(self, username):
+            return "Goodbye."
+
+        def get_perms(self, username):
+            return "elradfmw"
+
+        def has_perm(self, username, perm, path=None):
+            return perm in self.get_perms(username)
+
+
+    class UnixAuthorizer(_Base, BaseUnixAuthorizer):
+        """A wrapper on top of BaseUnixAuthorizer providing options
+        to specify what users should be allowed to login, per-user
+        options, etc.
+
+        Example usages:
+
+         >>> from pyftpdlib.contrib.authorizers import UnixAuthorizer
+         >>> # accept all except root
+         >>> auth = UnixAuthorizer(rejected_users=["root"])
+         >>>
+         >>> # accept some users only
+         >>> auth = UnixAuthorizer(allowed_users=["matt", "jay"])
+         >>>
+         >>> # accept everybody and don't care if they have not a valid shell
+         >>> auth = UnixAuthorizer(require_valid_shell=False)
+         >>>
+         >>> # set specific options for a user
+         >>> auth.override_user("matt", password="foo", perm="elr")
+        """
+
+        # --- public API
+
+        def __init__(self, global_perm="elradfmw",
+                           allowed_users=None,
+                           rejected_users=None,
+                           require_valid_shell=True,
+                           anonymous_user=None,
+                           msg_login="Login successful.",
+                           msg_quit="Goodbye."):
+            """Parameters:
+
+             - (string) global_perm:
+                a series of letters referencing the users permissions;
+                defaults to "elradfmw" which means full read and write
+                access for everybody (except anonymous).
+
+             - (list) allowed_users:
+                a list of users which are accepted for authenticating
+                against the FTP server; defaults to [] (no restrictions).
+
+             - (list) rejected_users:
+                a list of users which are not accepted for authenticating
+                against the FTP server; defaults to [] (no restrictions).
+
+             - (bool) require_valid_shell:
+                Deny access for those users which do not have a valid shell
+                binary listed in /etc/shells.
+                If /etc/shells cannot be found this is a no-op.
+                Anonymous user is not subject to this option, and is free
+                to not have a valid shell defined.
+                Defaults to True (a valid shell is required for login).
+
+             - (string) anonymous_user:
+                specify it if you intend to provide anonymous access.
+                The value expected is a string representing the system user
+                to use for managing anonymous sessions;  defaults to None
+                (anonymous access disabled).
+
+             - (string) msg_login:
+                the string sent when client logs in.
+
+             - (string) msg_quit:
+                the string sent when client quits.
+            """
+            BaseUnixAuthorizer.__init__(self, anonymous_user)
+            if allowed_users is None:
+                allowed_users = []
+            if rejected_users is None:
+                rejected_users = []
+            self.global_perm = global_perm
+            self.allowed_users = allowed_users
+            self.rejected_users = rejected_users
+            self.anonymous_user = anonymous_user
+            self.require_valid_shell = require_valid_shell
+            self.msg_login = msg_login
+            self.msg_quit = msg_quit
+
+            self._dummy_authorizer = DummyAuthorizer()
+            self._dummy_authorizer._check_permissions('', global_perm)
+            _Base.__init__(self)
+            if require_valid_shell:
+                for username in self.allowed_users:
+                    if not self._has_valid_shell(username):
+                        raise AuthorizerError("user %s has not a valid shell"
+                                              % username)
+
+        def override_user(self, username, password=None, homedir=None, perm=None,
+                          msg_login=None, msg_quit=None):
+            """Overrides the options specified in the class constructor
+            for a specific user.
+            """
+            if self.require_valid_shell and username != 'anonymous':
+                if not self._has_valid_shell(username):
+                    raise AuthorizerError(self.msg_invalid_shell % username)
+            _Base.override_user(self, username, password, homedir, perm,
+                                msg_login, msg_quit)
+
+        # --- overridden / private API
+
+        def validate_authentication(self, username, password, handler):
+            if username == "anonymous":
+                if self.anonymous_user is None:
+                    raise AuthenticationFailed(self.msg_anon_not_allowed)
+                return
+            if self._is_rejected_user(username):
+                raise AuthenticationFailed(self.msg_rejected_user % username)
+            overridden_password = self._get_key(username, 'pwd')
+            if overridden_password:
+                if overridden_password != password:
+                    raise AuthenticationFailed(self.msg_wrong_password)
+            else:
+                BaseUnixAuthorizer.validate_authentication(self, username,
+                                                           password, handler)
+            if self.require_valid_shell and username != 'anonymous':
+                if not self._has_valid_shell(username):
+                    raise AuthenticationFailed(self.msg_invalid_shell % username)
+
+        @replace_anonymous
+        def has_user(self, username):
+            if self._is_rejected_user(username):
+                return False
+            return username in self._get_system_users()
+
+        @replace_anonymous
+        def get_home_dir(self, username):
+            overridden_home = self._get_key(username, 'home')
+            if overridden_home:
+                return overridden_home
+            return BaseUnixAuthorizer.get_home_dir(self, username)
+
+        @staticmethod
+        def _has_valid_shell(username):
+            """Return True if the user has a valid shell binary listed
+            in /etc/shells. If /etc/shells can't be found return True.
+            """
+            file = None
+            try:
+                try:
+                    file = open('/etc/shells', 'r')
+                except IOError:
+                    err = sys.exc_info()[1]
+                    if err.errno == errno.ENOENT:
+                        return True
+                    raise
+                else:
+                    try:
+                        shell = pwd.getpwnam(username).pw_shell
+                    except KeyError:  # invalid user
+                        return False
+                    for line in file:
+                        if line.startswith('#'):
+                            continue
+                        line = line.strip()
+                        if line == shell:
+                            return True
+                    return False
+            finally:
+                if file is not None:
+                    file.close()
+
+
+# ===================================================================
+# --- Windows
+# ===================================================================
+
+try:
+    import _winreg as winreg
+except ImportError:
+    try:
+        import winreg  # PY3
+    except ImportError:
+        pass
+# Note: requires pywin32 extension
+try:
+    import win32security, win32net, pywintypes, win32con, win32api
+except ImportError:
+    pass
+else:
+    __all__.extend(['BaseWindowsAuthorizer', 'WindowsAuthorizer'])
+
+    class BaseWindowsAuthorizer(object):
+        """An authorizer compatible with Windows user account and
+        password database.
+        This class should not be used directly unless for subclassing.
+        Use higher-level WinowsAuthorizer class instead.
+        """
+
+        def __init__(self, anonymous_user=None, anonymous_password=None):
+            # actually try to impersonate the user
+            self.anonymous_user = anonymous_user
+            self.anonymous_password = anonymous_password
+            if self.anonymous_user is not None:
+                self.impersonate_user(self.anonymous_user,
+                                      self.anonymous_password)
+                self.terminate_impersonation(None)
+
+        def validate_authentication(self, username, password, handler):
+            if username == "anonymous":
+                if self.anonymous_user is None:
+                    raise AuthenticationFailed(self.msg_anon_not_allowed)
+                return
+            try:
+                win32security.LogonUser(username, None, password,
+                                        win32con.LOGON32_LOGON_INTERACTIVE,
+                                        win32con.LOGON32_PROVIDER_DEFAULT)
+            except pywintypes.error:
+                raise AuthenticationFailed(self.msg_wrong_password)
+
+        @replace_anonymous
+        def impersonate_user(self, username, password):
+            """Impersonate the security context of another user."""
+            handler = win32security.LogonUser(username, None, password,
+                                              win32con.LOGON32_LOGON_INTERACTIVE,
+                                              win32con.LOGON32_PROVIDER_DEFAULT)
+            win32security.ImpersonateLoggedOnUser(handler)
+            handler.Close()
+
+        def terminate_impersonation(self, username):
+            """Terminate the impersonation of another user."""
+            win32security.RevertToSelf()
+
+        @replace_anonymous
+        def has_user(self, username):
+            return username in self._get_system_users()
+
+        @replace_anonymous
+        def get_home_dir(self, username):
+            """Return the user's profile directory, the closest thing
+            to a user home directory we have on Windows.
+            """
+            try:
+                sid = win32security.ConvertSidToStringSid(
+                        win32security.LookupAccountName(None, username)[0])
+            except pywintypes.error:
+                err = sys.exc_info()[1]
+                raise AuthorizerError(err)
+            path = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" + \
+                   "\\" + sid
+            try:
+                key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
+            except WindowsError:
+                raise AuthorizerError("No profile directory defined for user %s"
+                                      % username)
+            value = winreg.QueryValueEx(key, "ProfileImagePath")[0]
+            home = win32api.ExpandEnvironmentStrings(value)
+            if not PY3 and not isinstance(home, unicode):
+                home = home.decode('utf8')
+            return home
+
+        @classmethod
+        def _get_system_users(cls):
+            """Return all users defined on the Windows system."""
+            # XXX - Does Windows allow usernames with chars outside of
+            # ASCII set? In that case we need to convert this to unicode.
+            return [entry['name'] for entry in win32net.NetUserEnum(None, 0)[0]]
+
+        def get_msg_login(self, username):
+            return "Login successful."
+
+        def get_msg_quit(self, username):
+            return "Goodbye."
+
+        def get_perms(self, username):
+            return "elradfmw"
+
+        def has_perm(self, username, perm, path=None):
+            return perm in self.get_perms(username)
+
+
+    class WindowsAuthorizer(_Base, BaseWindowsAuthorizer):
+        """A wrapper on top of BaseWindowsAuthorizer providing options
+        to specify what users should be allowed to login, per-user
+        options, etc.
+
+        Example usages:
+
+         >>> from pyftpdlib.contrib.authorizers import WindowsAuthorizer
+         >>> # accept all except Administrator
+         >>> auth = UnixAuthorizer(rejected_users=["Administrator"])
+         >>>
+         >>> # accept some users only
+         >>> auth = UnixAuthorizer(allowed_users=["matt", "jay"])
+         >>>
+         >>> # set specific options for a user
+         >>> auth.override_user("matt", password="foo", perm="elr")
+        """
+
+        # --- public API
+
+        def __init__(self, global_perm="elradfmw",
+                           allowed_users=None,
+                           rejected_users=None,
+                           anonymous_user=None,
+                           anonymous_password=None,
+                           msg_login="Login successful.",
+                           msg_quit="Goodbye."):
+            """Parameters:
+
+             - (string) global_perm:
+                a series of letters referencing the users permissions;
+                defaults to "elradfmw" which means full read and write
+                access for everybody (except anonymous).
+
+             - (list) allowed_users:
+                a list of users which are accepted for authenticating
+                against the FTP server; defaults to [] (no restrictions).
+
+             - (list) rejected_users:
+                a list of users which are not accepted for authenticating
+                against the FTP server; defaults to [] (no restrictions).
+
+             - (string) anonymous_user:
+                specify it if you intend to provide anonymous access.
+                The value expected is a string representing the system user
+                to use for managing anonymous sessions.
+                As for IIS, it is recommended to use Guest account.
+                The common practice is to first enable the Guest user, which
+                is disabled by default and then assign an empty password.
+                Defaults to None (anonymous access disabled).
+
+             - (string) anonymous_password:
+                the password of the user who has been chosen to manage the
+                anonymous sessions.  Defaults to None (empty password).
+
+             - (string) msg_login:
+                the string sent when client logs in.
+
+             - (string) msg_quit:
+                the string sent when client quits.
+            """
+            if allowed_users is None:
+                allowed_users = []
+            if rejected_users is None:
+                rejected_users = []
+            self.global_perm = global_perm
+            self.allowed_users = allowed_users
+            self.rejected_users = rejected_users
+            self.anonymous_user = anonymous_user
+            self.anonymous_password = anonymous_password
+            self.msg_login = msg_login
+            self.msg_quit = msg_quit
+            self._dummy_authorizer = DummyAuthorizer()
+            self._dummy_authorizer._check_permissions('', global_perm)
+            _Base.__init__(self)
+            # actually try to impersonate the user
+            if self.anonymous_user is not None:
+                self.impersonate_user(self.anonymous_user,
+                                      self.anonymous_password)
+                self.terminate_impersonation(None)
+
+        def override_user(self, username, password=None, homedir=None, perm=None,
+                          msg_login=None, msg_quit=None):
+            """Overrides the options specified in the class constructor
+            for a specific user.
+            """
+            _Base.override_user(self, username, password, homedir, perm,
+                                msg_login, msg_quit)
+
+        # --- overridden / private API
+
+        def validate_authentication(self, username, password, handler):
+            """Authenticates against Windows user database; return
+            True on success.
+            """
+            if username == "anonymous":
+                if self.anonymous_user is None:
+                    raise AuthenticationFailed(self.msg_anon_not_allowed)
+                return
+            if self.allowed_users and username not in self.allowed_users:
+                raise AuthenticationFailed(self.msg_rejected_user % username)
+            if self.rejected_users and username in self.rejected_users:
+                raise AuthenticationFailed(self.msg_rejected_user % username)
+
+            overridden_password = self._get_key(username, 'pwd')
+            if overridden_password:
+                if overridden_password != password:
+                    raise AuthenticationFailed(self.msg_wrong_password)
+            else:
+                BaseWindowsAuthorizer.validate_authentication(self, username,
+                                                              password, handler)
+
+        def impersonate_user(self, username, password):
+            """Impersonate the security context of another user."""
+            if username == "anonymous":
+                username = self.anonymous_user or ""
+                password = self.anonymous_password or ""
+            BaseWindowsAuthorizer.impersonate_user(self, username, password)
+
+        @replace_anonymous
+        def has_user(self, username):
+            if self._is_rejected_user(username):
+                return False
+            return username in self._get_system_users()
+
+        @replace_anonymous
+        def get_home_dir(self, username):
+            overridden_home = self._get_key(username, 'home')
+            if overridden_home:
+                home = overridden_home
+            else:
+                home = BaseWindowsAuthorizer.get_home_dir(self, username)
+            if not PY3 and not isinstance(home, unicode):
+                home = home.decode('utf8')
+            return home

+ 3 - 0
pyftpdlib/contrib/__init__.py

@@ -0,0 +1,3 @@
+from pyftpdlib import _depwarn
+
+_depwarn("pyftpdlib.contrib namespace is deprecated")

+ 46 - 0
pyftpdlib/contrib/authorizers.py

@@ -0,0 +1,46 @@
+#!/usr/bin/env python
+# $Id: authorizers.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  pyftpdlib is released under the MIT license, reproduced below:
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+from pyftpdlib import _depwarn
+
+_depwarn("pyftpdlib.contrib.authorizers module is deprecated; "
+         "use pyftpdlib.authorizers instead")
+
+try:
+    from pyftpdlib.authorizers import BaseUnixAuthorizer, UnixAuthorizer
+except ImportError:
+    pass
+
+try:
+    from pyftpdlib.authorizers import BaseWindowsAuthorizer, WindowsAuthorizer
+except ImportError:
+    pass

+ 41 - 0
pyftpdlib/contrib/filesystems.py

@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+# $Id: filesystems.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  pyftpdlib is released under the MIT license, reproduced below:
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+from pyftpdlib import _depwarn
+
+_depwarn("pyftpdlib.contrib.filesystem module is deprecated; "
+         "use pyftpdlib.filesystems instead")
+
+try:
+    from pyftpdlib.filesystems import UnixFilesystem
+except ImportError:
+    pass

+ 40 - 0
pyftpdlib/contrib/handlers.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python
+# $Id: handlers.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+from pyftpdlib import _depwarn
+
+_depwarn("pyftpdlib.contrib.handlers module is deprecated; "
+         "use pyftpdlib.handlers instead")
+
+try:
+    from pyftpdlib.handlers import SSLConnection, TLS_FTPHandler, TLS_DTPHandler
+except ImportError:
+    pass

+ 667 - 0
pyftpdlib/filesystems.py

@@ -0,0 +1,667 @@
+#!/usr/bin/env python
+# $Id: filesystems.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+import os
+import time
+import tempfile
+import stat
+try:
+    from stat import filemode as _filemode  # PY 3.3
+except ImportError:
+    from tarfile import filemode as _filemode
+try:
+    import pwd
+    import grp
+except ImportError:
+    pwd = grp = None
+
+from pyftpdlib._compat import PY3, u, unicode, property
+
+
+__all__ = ['FilesystemError', 'AbstractedFS']
+
+
+_months_map = {1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun', 7:'Jul',
+               8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'}
+
+
+# ===================================================================
+# --- custom exceptions
+# ===================================================================
+
+class FilesystemError(Exception):
+    """Custom class for filesystem-related exceptions.
+    You can raise this from an AbstractedFS subclass in order to
+    send a customized error string to the client.
+    """
+
+# ===================================================================
+# --- base class
+# ===================================================================
+
+class AbstractedFS(object):
+    """A class used to interact with the file system, providing a
+    cross-platform interface compatible with both Windows and
+    UNIX style filesystems where all paths use "/" separator.
+
+    AbstractedFS distinguishes between "real" filesystem paths and
+    "virtual" ftp paths emulating a UNIX chroot jail where the user
+    can not escape its home directory (example: real "/home/user"
+    path will be seen as "/" by the client)
+
+    It also provides some utility methods and wraps around all os.*
+    calls involving operations against the filesystem like creating
+    files or removing directories.
+
+    FilesystemError exception can be raised from within any of
+    the methods below in order to send a customized error string
+    to the client.
+    """
+
+    def __init__(self, root, cmd_channel):
+        """
+         - (str) root: the user "real" home directory (e.g. '/home/user')
+         - (instance) cmd_channel: the FTPHandler class instance
+        """
+        assert isinstance(root, unicode)
+        # Set initial current working directory.
+        # By default initial cwd is set to "/" to emulate a chroot jail.
+        # If a different behavior is desired (e.g. initial cwd = root,
+        # to reflect the real filesystem) users overriding this class
+        # are responsible to set _cwd attribute as necessary.
+        self._cwd = u('/')
+        self._root = root
+        self.cmd_channel = cmd_channel
+
+    @property
+    def root(self):
+        """The user home directory."""
+        return self._root
+
+    @property
+    def cwd(self):
+        """The user current working directory."""
+        return self._cwd
+
+    @root.setter
+    def root(self, path):
+        assert isinstance(path, unicode), path
+        self._root = path
+
+    @cwd.setter
+    def cwd(self, path):
+        assert isinstance(path, unicode), path
+        self._cwd = path
+
+    # --- Pathname / conversion utilities
+
+    def ftpnorm(self, ftppath):
+        """Normalize a "virtual" ftp pathname (typically the raw string
+        coming from client) depending on the current working directory.
+
+        Example (having "/foo" as current working directory):
+        >>> ftpnorm('bar')
+        '/foo/bar'
+
+        Note: directory separators are system independent ("/").
+        Pathname returned is always absolutized.
+        """
+        assert isinstance(ftppath, unicode), ftppath
+        if os.path.isabs(ftppath):
+            p = os.path.normpath(ftppath)
+        else:
+            p = os.path.normpath(os.path.join(self.cwd, ftppath))
+        # normalize string in a standard web-path notation having '/'
+        # as separator.
+        if os.sep == "\\":
+            p = p.replace("\\", "/")
+        # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we
+        # don't need them.  In case we get an UNC path we collapse
+        # redundant separators appearing at the beginning of the string
+        while p[:2] == '//':
+            p = p[1:]
+        # Anti path traversal: don't trust user input, in the event
+        # that self.cwd is not absolute, return "/" as a safety measure.
+        # This is for extra protection, maybe not really necessary.
+        if not os.path.isabs(p):
+            p = u("/")
+        return p
+
+    def ftp2fs(self, ftppath):
+        """Translate a "virtual" ftp pathname (typically the raw string
+        coming from client) into equivalent absolute "real" filesystem
+        pathname.
+
+        Example (having "/home/user" as root directory):
+        >>> ftp2fs("foo")
+        '/home/user/foo'
+
+        Note: directory separators are system dependent.
+        """
+        assert isinstance(ftppath, unicode), ftppath
+        # as far as I know, it should always be path traversal safe...
+        if os.path.normpath(self.root) == os.sep:
+            return os.path.normpath(self.ftpnorm(ftppath))
+        else:
+            p = self.ftpnorm(ftppath)[1:]
+            return os.path.normpath(os.path.join(self.root, p))
+
+    def fs2ftp(self, fspath):
+        """Translate a "real" filesystem pathname into equivalent
+        absolute "virtual" ftp pathname depending on the user's
+        root directory.
+
+        Example (having "/home/user" as root directory):
+        >>> fs2ftp("/home/user/foo")
+        '/foo'
+
+        As for ftpnorm, directory separators are system independent
+        ("/") and pathname returned is always absolutized.
+
+        On invalid pathnames escaping from user's root directory
+        (e.g. "/home" when root is "/home/user") always return "/".
+        """
+        assert isinstance(fspath, unicode), fspath
+        if os.path.isabs(fspath):
+            p = os.path.normpath(fspath)
+        else:
+            p = os.path.normpath(os.path.join(self.root, fspath))
+        if not self.validpath(p):
+            return u('/')
+        p = p.replace(os.sep, "/")
+        p = p[len(self.root):]
+        if not p.startswith('/'):
+            p = '/' + p
+        return p
+
+    def validpath(self, path):
+        """Check whether the path belongs to user's home directory.
+        Expected argument is a "real" filesystem pathname.
+
+        If path is a symbolic link it is resolved to check its real
+        destination.
+
+        Pathnames escaping from user's root directory are considered
+        not valid.
+        """
+        assert isinstance(path, unicode), path
+        root = self.realpath(self.root)
+        path = self.realpath(path)
+        if not root.endswith(os.sep):
+            root = root + os.sep
+        if not path.endswith(os.sep):
+            path = path + os.sep
+        if path[0:len(root)] == root:
+            return True
+        return False
+
+    # --- Wrapper methods around open() and tempfile.mkstemp
+
+    def open(self, filename, mode):
+        """Open a file returning its handler."""
+        assert isinstance(filename, unicode), filename
+        return open(filename, mode)
+
+    def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
+        """A wrap around tempfile.mkstemp creating a file with a unique
+        name.  Unlike mkstemp it returns an object with a file-like
+        interface.
+        """
+        class FileWrapper:
+            def __init__(self, fd, name):
+                self.file = fd
+                self.name = name
+            def __getattr__(self, attr):
+                return getattr(self.file, attr)
+
+        text = not 'b' in mode
+        # max number of tries to find out a unique file name
+        tempfile.TMP_MAX = 50
+        fd, name = tempfile.mkstemp(suffix, prefix, dir, text=text)
+        file = os.fdopen(fd, mode)
+        return FileWrapper(file, name)
+
+    # --- Wrapper methods around os.* calls
+
+    def chdir(self, path):
+        """Change the current directory."""
+        # note: process cwd will be reset by the caller
+        assert isinstance(path, unicode), path
+        os.chdir(path)
+        self._cwd = self.fs2ftp(path)
+
+    def mkdir(self, path):
+        """Create the specified directory."""
+        assert isinstance(path, unicode), path
+        os.mkdir(path)
+
+    def listdir(self, path):
+        """List the content of a directory."""
+        assert isinstance(path, unicode), path
+        return os.listdir(path)
+
+    def rmdir(self, path):
+        """Remove the specified directory."""
+        assert isinstance(path, unicode), path
+        os.rmdir(path)
+
+    def remove(self, path):
+        """Remove the specified file."""
+        assert isinstance(path, unicode), path
+        os.remove(path)
+
+    def rename(self, src, dst):
+        """Rename the specified src file to the dst filename."""
+        assert isinstance(src, unicode), src
+        assert isinstance(dst, unicode), dst
+        os.rename(src, dst)
+
+    def chmod(self, path, mode):
+        """Change file/directory mode."""
+        assert isinstance(path, unicode), path
+        if not hasattr(os, 'chmod'):
+            raise NotImplementedError
+        os.chmod(path, mode)
+
+    def stat(self, path):
+        """Perform a stat() system call on the given path."""
+        # on python 2 we might also get bytes from os.lisdir()
+        #assert isinstance(path, unicode), path
+        return os.stat(path)
+
+    def lstat(self, path):
+        """Like stat but does not follow symbolic links."""
+        # on python 2 we might also get bytes from os.lisdir()
+        #assert isinstance(path, unicode), path
+        return os.lstat(path)
+
+    if not hasattr(os, 'lstat'):
+        lstat = stat
+
+    if hasattr(os, 'readlink'):
+        def readlink(self, path):
+            """Return a string representing the path to which a
+            symbolic link points.
+            """
+            assert isinstance(path, unicode), path
+            return os.readlink(path)
+
+    # --- Wrapper methods around os.path.* calls
+
+    def isfile(self, path):
+        """Return True if path is a file."""
+        assert isinstance(path, unicode), path
+        return os.path.isfile(path)
+
+    def islink(self, path):
+        """Return True if path is a symbolic link."""
+        assert isinstance(path, unicode), path
+        return os.path.islink(path)
+
+    def isdir(self, path):
+        """Return True if path is a directory."""
+        assert isinstance(path, unicode), path
+        return os.path.isdir(path)
+
+    def getsize(self, path):
+        """Return the size of the specified file in bytes."""
+        assert isinstance(path, unicode), path
+        return os.path.getsize(path)
+
+    def getmtime(self, path):
+        """Return the last modified time as a number of seconds since
+        the epoch."""
+        assert isinstance(path, unicode), path
+        return os.path.getmtime(path)
+
+    def realpath(self, path):
+        """Return the canonical version of path eliminating any
+        symbolic links encountered in the path (if they are
+        supported by the operating system).
+        """
+        assert isinstance(path, unicode), path
+        return os.path.realpath(path)
+
+    def lexists(self, path):
+        """Return True if path refers to an existing path, including
+        a broken or circular symbolic link.
+        """
+        assert isinstance(path, unicode), path
+        return os.path.lexists(path)
+
+    def get_user_by_uid(self, uid):
+        """Return the username associated with user id.
+        If this can't be determined return raw uid instead.
+        On Windows just return "owner".
+        """
+        try:
+            return pwd.getpwuid(uid).pw_name
+        except KeyError:
+            return uid
+
+    def get_group_by_gid(self, gid):
+        """Return the groupname associated with group id.
+        If this can't be determined return raw gid instead.
+        On Windows just return "group".
+        """
+        try:
+            return grp.getgrgid(gid).gr_name
+        except KeyError:
+            return gid
+
+    if pwd is None: get_user_by_uid = lambda x, y: "owner"
+    if grp is None: get_group_by_gid = lambda x, y: "group"
+
+    # --- Listing utilities
+
+    def get_list_dir(self, path):
+        """"Return an iterator object that yields a directory listing
+        in a form suitable for LIST command.
+        """
+        assert isinstance(path, unicode), path
+        if self.isdir(path):
+            listing = self.listdir(path)
+            try:
+                listing.sort()
+            except UnicodeDecodeError:
+                # (Python 2 only) might happen on filesystem not
+                # supporting UTF8 meaning os.listdir() returned a list
+                # of mixed bytes and unicode strings:
+                # http://goo.gl/6DLHD
+                # http://bugs.python.org/issue683592
+                pass
+            return self.format_list(path, listing)
+        # if path is a file or a symlink we return information about it
+        else:
+            basedir, filename = os.path.split(path)
+            self.lstat(path)  # raise exc in case of problems
+            return self.format_list(basedir, [filename])
+
+    def format_list(self, basedir, listing, ignore_err=True):
+        """Return an iterator object that yields the entries of given
+        directory emulating the "/bin/ls -lA" UNIX command output.
+
+         - (str) basedir: the absolute dirname.
+         - (list) listing: the names of the entries in basedir
+         - (bool) ignore_err: when False raise exception if os.lstat()
+         call fails.
+
+        On platforms which do not support the pwd and grp modules (such
+        as Windows), ownership is printed as "owner" and "group" as a
+        default, and number of hard links is always "1". On UNIX
+        systems, the actual owner, group, and number of links are
+        printed.
+
+        This is how output appears to client:
+
+        -rw-rw-rw-   1 owner   group    7045120 Sep 02  3:47 music.mp3
+        drwxrwxrwx   1 owner   group          0 Aug 31 18:50 e-books
+        -rw-rw-rw-   1 owner   group        380 Sep 02  3:40 module.py
+        """
+        assert isinstance(basedir, unicode), basedir
+        if listing:
+            assert isinstance(listing[0], unicode)
+        if self.cmd_channel.use_gmt_times:
+            timefunc = time.gmtime
+        else:
+            timefunc = time.localtime
+        SIX_MONTHS = 180 * 24 * 60 * 60
+        readlink = getattr(self, 'readlink', None)
+        now = time.time()
+        for basename in listing:
+            if not PY3:
+                try:
+                    file = os.path.join(basedir, basename)
+                except UnicodeDecodeError:
+                    # (Python 2 only) might happen on filesystem not
+                    # supporting UTF8 meaning os.listdir() returned a list
+                    # of mixed bytes and unicode strings:
+                    # http://goo.gl/6DLHD
+                    # http://bugs.python.org/issue683592
+                    file = os.path.join(bytes(basedir), bytes(basename))
+                    if not isinstance(basename, unicode):
+                        basename = unicode(basename, 'utf8')
+            else:
+                file = os.path.join(basedir, basename)
+            try:
+                st = self.lstat(file)
+            except (OSError, FilesystemError):
+                if ignore_err:
+                    continue
+                raise
+
+            perms = _filemode(st.st_mode)  # permissions
+            nlinks = st.st_nlink  # number of links to inode
+            if not nlinks:  # non-posix system, let's use a bogus value
+                nlinks = 1
+            size = st.st_size  # file size
+            uname = self.get_user_by_uid(st.st_uid)
+            gname = self.get_group_by_gid(st.st_gid)
+            mtime = timefunc(st.st_mtime)
+            # if modification time > 6 months shows "month year"
+            # else "month hh:mm";  this matches proftpd format, see:
+            # http://code.google.com/p/pyftpdlib/issues/detail?id=187
+            if (now - st.st_mtime) > SIX_MONTHS:
+                fmtstr = "%d  %Y"
+            else:
+                fmtstr = "%d %H:%M"
+            try:
+                mtimestr = "%s %s" % (_months_map[mtime.tm_mon],
+                                      time.strftime(fmtstr, mtime))
+            except ValueError:
+                # It could be raised if last mtime happens to be too
+                # old (prior to year 1900) in which case we return
+                # the current time as last mtime.
+                mtime = timefunc()
+                mtimestr = "%s %s" % (_months_map[mtime.tm_mon],
+                                      time.strftime("%d %H:%M", mtime))
+
+            # same as stat.S_ISLNK(st.st_mode) but slighlty faster
+            islink = (st.st_mode & 61440) == stat.S_IFLNK
+            if islink and readlink is not None:
+                # if the file is a symlink, resolve it, e.g.
+                # "symlink -> realfile"
+                try:
+                    basename = basename + " -> " + readlink(file)
+                except (OSError, FilesystemError):
+                    if not ignore_err:
+                        raise
+
+            # formatting is matched with proftpd ls output
+            line = "%s %3s %-8s %-8s %8s %s %s\r\n" % (perms, nlinks, uname, gname,
+                                                       size, mtimestr, basename)
+            yield line.encode('utf8', self.cmd_channel.unicode_errors)
+
+    def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True):
+        """Return an iterator object that yields the entries of a given
+        directory or of a single file in a form suitable with MLSD and
+        MLST commands.
+
+        Every entry includes a list of "facts" referring the listed
+        element.  See RFC-3659, chapter 7, to see what every single
+        fact stands for.
+
+         - (str) basedir: the absolute dirname.
+         - (list) listing: the names of the entries in basedir
+         - (str) perms: the string referencing the user permissions.
+         - (str) facts: the list of "facts" to be returned.
+         - (bool) ignore_err: when False raise exception if os.stat()
+         call fails.
+
+        Note that "facts" returned may change depending on the platform
+        and on what user specified by using the OPTS command.
+
+        This is how output could appear to the client issuing
+        a MLSD request:
+
+        type=file;size=156;perm=r;modify=20071029155301;unique=801cd2; music.mp3
+        type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
+        type=file;size=211;perm=r;modify=20071103093626;unique=801e32; module.py
+        """
+        assert isinstance(basedir, unicode), basedir
+        if listing:
+            assert isinstance(listing[0], unicode)
+        if self.cmd_channel.use_gmt_times:
+            timefunc = time.gmtime
+        else:
+            timefunc = time.localtime
+        permdir = ''.join([x for x in perms if x not in 'arw'])
+        permfile = ''.join([x for x in perms if x not in 'celmp'])
+        if ('w' in perms) or ('a' in perms) or ('f' in perms):
+            permdir += 'c'
+        if 'd' in perms:
+            permdir += 'p'
+        show_type = 'type' in facts
+        show_perm = 'perm' in facts
+        show_size = 'size' in facts
+        show_modify = 'modify' in facts
+        show_create = 'create' in facts
+        show_mode = 'unix.mode' in facts
+        show_uid = 'unix.uid' in facts
+        show_gid = 'unix.gid' in facts
+        show_unique = 'unique' in facts
+        for basename in listing:
+            retfacts = dict()
+            if not PY3:
+                try:
+                    file = os.path.join(basedir, basename)
+                except UnicodeDecodeError:
+                    # (Python 2 only) might happen on filesystem not
+                    # supporting UTF8 meaning os.listdir() returned a list
+                    # of mixed bytes and unicode strings:
+                    # http://goo.gl/6DLHD
+                    # http://bugs.python.org/issue683592
+                    file = os.path.join(bytes(basedir), bytes(basename))
+                    if not isinstance(basename, unicode):
+                        basename = unicode(basename, 'utf8')
+            else:
+                file = os.path.join(basedir, basename)
+            # in order to properly implement 'unique' fact (RFC-3659,
+            # chapter 7.5.2) we are supposed to follow symlinks, hence
+            # use os.stat() instead of os.lstat()
+            try:
+                st = self.stat(file)
+            except (OSError, FilesystemError):
+                if ignore_err:
+                    continue
+                raise
+            # type + perm
+            # same as stat.S_ISDIR(st.st_mode) but slightly faster
+            isdir = (st.st_mode & 61440) == stat.S_IFDIR
+            if isdir:
+                if show_type:
+                    if basename == '.':
+                        retfacts['type'] = 'cdir'
+                    elif basename == '..':
+                        retfacts['type'] = 'pdir'
+                    else:
+                        retfacts['type'] = 'dir'
+                if show_perm:
+                    retfacts['perm'] = permdir
+            else:
+                if show_type:
+                    retfacts['type'] = 'file'
+                if show_perm:
+                    retfacts['perm'] = permfile
+            if show_size:
+                retfacts['size'] = st.st_size  # file size
+            # last modification time
+            if show_modify:
+                try:
+                    retfacts['modify'] = time.strftime("%Y%m%d%H%M%S",
+                                                       timefunc(st.st_mtime))
+                # it could be raised if last mtime happens to be too old
+                # (prior to year 1900)
+                except ValueError:
+                    pass
+            if show_create:
+                # on Windows we can provide also the creation time
+                try:
+                    retfacts['create'] = time.strftime("%Y%m%d%H%M%S",
+                                                       timefunc(st.st_ctime))
+                except ValueError:
+                    pass
+            # UNIX only
+            if show_mode:
+                retfacts['unix.mode'] = oct(st.st_mode & 511)
+            if show_uid:
+                retfacts['unix.uid'] = st.st_uid
+            if show_gid:
+                retfacts['unix.gid'] = st.st_gid
+
+            # We provide unique fact (see RFC-3659, chapter 7.5.2) on
+            # posix platforms only; we get it by mixing st_dev and
+            # st_ino values which should be enough for granting an
+            # uniqueness for the file listed.
+            # The same approach is used by pure-ftpd.
+            # Implementors who want to provide unique fact on other
+            # platforms should use some platform-specific method (e.g.
+            # on Windows NTFS filesystems MTF records could be used).
+            if show_unique:
+                retfacts['unique'] = "%xg%x" % (st.st_dev, st.st_ino)
+
+            # facts can be in any order but we sort them by name
+            factstring = "".join(["%s=%s;" % (x, retfacts[x]) \
+                                  for x in sorted(retfacts.keys())])
+            line = "%s %s\r\n" % (factstring, basename)
+            yield line.encode('utf8', self.cmd_channel.unicode_errors)
+
+
+# ===================================================================
+# --- platform specific implementation
+# ===================================================================
+
+if os.name == 'posix':
+    __all__.append('UnixFilesystem')
+
+    class UnixFilesystem(AbstractedFS):
+        """Represents the real UNIX filesystem.
+
+        Differently from AbstractedFS the client will login into
+        /home/<username> and will be able to escape its home directory
+        and navigate the real filesystem.
+        """
+
+        def __init__(self, root, cmd_channel):
+            AbstractedFS.__init__(self, root, cmd_channel)
+            # initial cwd was set to "/" to emulate a chroot jail
+            self.cwd = root
+
+        def ftp2fs(self, ftppath):
+            return self.ftpnorm(ftppath)
+
+        def fs2ftp(self, fspath):
+            return fspath
+
+        def validpath(self, path):
+            # validpath was used to check symlinks escaping user home
+            # directory; this is no longer necessary.
+            return True

+ 86 - 0
pyftpdlib/ftpserver.py

@@ -0,0 +1,86 @@
+#!/usr/bin/env python
+# -*- coding:utf8 -*-
+# $Id: ftpserver.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+Note: this module is here only for backward compatibility.
+The new import system which is supposed to be used is:
+
+from pyftpdlib.handlers import FTPHandler, TLS_FTPHandler, ...
+from pyftpdlib.authorizers import DummyAuthorizer, UnixAuthorizer, ...
+from pyftpdlib.servers import FTPServer, ...
+"""
+
+
+from pyftpdlib.log import logger
+from pyftpdlib.handlers import *
+from pyftpdlib.authorizers import *
+from pyftpdlib.servers import *
+
+from pyftpdlib import _depwarn, __ver__
+
+__all__ = ['proto_cmds', 'Error', 'log', 'logline', 'logerror', 'DummyAuthorizer',
+           'AuthorizerError', 'FTPHandler', 'FTPServer', 'PassiveDTP',
+           'ActiveDTP', 'DTPHandler', 'ThrottledDTPHandler', 'FileProducer',
+           'BufferedIteratorProducer', 'AbstractedFS']
+
+_depwarn("pyftpdlib.ftpserver module is deprecated")
+
+
+class CallLater(object):
+    def __init__(self, *args, **kwargs):
+        _depwarn("CallLater is deprecated; use "
+            "pyftpdlib.ioloop.IOLoop.instance().call_later() instead")
+        from pyftpdlib.ioloop import IOLoop
+        IOLoop.instance().call_later(*args, **kwargs)
+
+class CallEvery(object):
+    def __init__(self, *args, **kwargs):
+        _depwarn("CallEvery is deprecated; use "
+            "pyftpdlib.ioloop.IOLoop.instance().call_every() instead")
+        from pyftpdlib.ioloop import IOLoop
+        IOLoop.instance().call_every(*args, **kwargs)
+
+def log(msg):
+    _depwarn("pyftpdlib.ftpserver.log() is deprecated")
+    logger.info(msg)
+
+def logline(msg):
+    _depwarn("pyftpdlib.ftpserver.logline() is deprecated")
+    logger.debug(msg)
+
+def logerror(msg):
+    _depwarn("pyftpdlib.ftpserver.logline() is deprecated")
+    logger.error(msg)
+
+if __name__ == '__main__':
+    from pyftpdlib import main
+    main()

+ 86 - 0
pyftpdlib/ftpserver.py.orig

@@ -0,0 +1,86 @@
+#!/usr/bin/env python
+# -*- coding:utf8 -*-
+# $Id: ftpserver.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+Note: this module is here only for backward compatibility.
+The new import system which is supposed to be used is:
+
+from pyftpdlib.handlers import FTPHandler, TLS_FTPHandler, ...
+from pyftpdlib.authorizers import DummyAuthorizer, UnixAuthorizer, ...
+from pyftpdlib.servers import FTPServer, ...
+"""
+
+
+from pyftpdlib.log import logger
+from pyftpdlib.handlers import *
+from pyftpdlib.authorizers import *
+from pyftpdlib.servers import *
+
+from pyftpdlib import _depwarn, __ver__
+
+__all__ = ['proto_cmds', 'Error', 'log', 'logline', 'logerror', 'DummyAuthorizer',
+           'AuthorizerError', 'FTPHandler', 'FTPServer', 'PassiveDTP',
+           'ActiveDTP', 'DTPHandler', 'ThrottledDTPHandler', 'FileProducer',
+           'BufferedIteratorProducer', 'AbstractedFS']
+
+_depwarn("pyftpdlib.ftpserver module is deprecated")
+
+
+class CallLater(object):
+    def __init__(self, *args, **kwargs):
+        _depwarn("CallLater is deprecated; use "
+            "pyftpdlib.ioloop.IOLoop.instance().call_later() instead")
+        from pyftpdlib.ioloop import IOLoop
+        IOLoop.instance().call_later(*args, **kwargs)
+
+class CallEvery(object):
+    def __init__(self, *args, **kwargs):
+        _depwarn("CallEvery is deprecated; use "
+            "pyftpdlib.ioloop.IOLoop.instance().call_every() instead")
+        from pyftpdlib.ioloop import IOLoop
+        IOLoop.instance().call_every(*args, **kwargs)
+
+def log(msg):
+    _depwarn("pyftpdlib.ftpserver.log() is deprecated")
+    logger.info(msg)
+
+def logline(msg):
+    _depwarn("pyftpdlib.ftpserver.logline() is deprecated")
+    logger.debug(msg)
+
+def logerror(msg):
+    _depwarn("pyftpdlib.ftpserver.logline() is deprecated")
+    logger.error(msg)
+
+if __name__ == '__main__':
+    from pyftpdlib import main
+    main()

+ 207 - 0
pyftpdlib/ftpserver.py.rej

@@ -0,0 +1,207 @@
+--- pyftpdlib/ftpserver.py
++++ pyftpdlib/ftpserver.py
+@@ -709,8 +709,8 @@
+                 self.set_reuse_addr()
+                 try:
+                     self.bind((local_ip, port))
+-                except socket.error, why:
+-                    if why[0] == errno.EADDRINUSE:  # port already in use
++                except socket.error, err:
++                    if err[0] == errno.EADDRINUSE:  # port already in use
+                         if ports:
+                             continue
+                         # If cannot use one of the ports in the configured
+@@ -1306,9 +1306,11 @@
+             if self.read_limit:
+                 while self.ac_in_buffer_size > self.read_limit:
+                     self.ac_in_buffer_size /= 2
++                    self.ac_in_buffer_size = int(self.ac_in_buffer_size)
+             if self.write_limit:
+                 while self.ac_out_buffer_size > self.write_limit:
+                     self.ac_out_buffer_size /= 2
++                    self.ac_out_buffer_size = int(self.ac_out_buffer_size)
+ 
+     def _use_sendfile(self, producer):
+         return False
+@@ -1403,8 +1405,9 @@
+     # returning some data
+     loops = 20
+ 
+-    def __init__(self, iterator):
++    def __init__(self, iterator, encoding=None):
+         self.iterator = iterator
++        self.encoding = encoding
+ 
+     def more(self):
+         """Attempt a chunk of data from iterator by calling
+@@ -1416,7 +1419,12 @@
+                 buffer.append(self.iterator.next())
+             except StopIteration:
+                 break
+-        return ''.join(buffer)
++        if self.encoding is None:
++            return ''.join(buffer)
++        else:
++            data = ''.join(buffer)
++            data = data.encode(self.encoding)
++            return data
+ 
+ 
+ # --- filesystem
+@@ -2007,6 +2015,10 @@
+     use_sendfile = sendfile is not None
+     tcp_no_delay = hasattr(socket, "TCP_NODELAY")
+ 
++    #
++    use_encoding = True
++    encoding = "utf-8"
++
+     def __init__(self, conn, server):
+         """Initialize the command channel.
+ 
+@@ -2014,6 +2026,9 @@
+             established connection.
+          - (instance) server: the ftp server class instance.
+         """
++        asynchat.async_chat.__init__(self, conn)
++        self.set_terminator("\r\n")
++
+         # public session attributes
+         self.server = server
+         self.fs = None
+@@ -2080,6 +2095,7 @@
+             # #100) while EINVAL can occur on OSX (see issue #143).
+             self.connected = False
+             if err[0] in (errno.ENOTCONN, errno.EINVAL):
++#            if err.errno == errno.ENOTCONN:
+                 self.close()
+             else:
+                 self.handle_error()
+@@ -2177,6 +2193,23 @@
+             self._in_buffer = []
+             self._in_buffer_len = 0
+ 
++    def decode_received_line(self, line):
++        """Decode the received cmd + arg from bytes to a unicode string.
++        You might want to override this method to attempt to convert the
++        line by using different encodings in case UTF8 fails for some
++        reason (e.g. clients not following RFC-2640).
++
++        Example:
++
++        try:
++            return line.decode('utf8')
++        except UnicodeDecodeError:
++            return line.decode('latin1')
++        """
++        if not self.encoding:
++            return line
++        return line.decode(self.encoding)
++
+     def found_terminator(self):
+         r"""Called when the incoming data stream matches the \r\n
+         terminator.
+@@ -2187,6 +2220,13 @@
+         line = ''.join(self._in_buffer)
+         self._in_buffer = []
+         self._in_buffer_len = 0
++        try:
++            line = self.decode_received_line(line)
++        except UnicodeDecodeError:
++            self.respond("501 Can't decode the received command. This server "
++                         "is using %s encoding. Make sure your client does "
++                         "the same." %self.encoding)
++            return
+ 
+         cmd = line.split(' ')[0].upper()
+         arg = line[len(cmd)+1:]
+@@ -2317,8 +2357,8 @@
+         if hasattr(socket, 'MSG_OOB'):
+             try:
+                 data = self.socket.recv(1024, socket.MSG_OOB)
+-            except socket.error, why:
+-                if why[0] == errno.EINVAL:
++            except socket.error, err:
++                if err[0] == errno.EINVAL:
+                     return
+             else:
+                 self._in_buffer.append(data)
+@@ -2485,6 +2525,11 @@
+ 
+     # --- utility
+ 
++    def push(self, resp):
++        if self.encoding:
++            resp = resp.encode(self.encoding)
++        asynchat.async_chat.push(self, resp)
++
+     def respond(self, resp):
+         """Send a response to the client using the command channel."""
+         self._last_response = resp
+@@ -2609,7 +2654,7 @@
+         further commands.
+         """
+         if cmd in ("DELE", "RMD", "RNFR", "RNTO", "MKD"):
+-            line = '"%s" %s' % (' '.join([cmd, str(arg)]).strip(), respcode)
++            line = '"%s" %s' % (' '.join([cmd, arg]).strip(), respcode)
+             self.log(line)
+ 
+     def log_transfer(self, cmd, filename, receive, completed, elapsed, bytes):
+@@ -2873,7 +2918,7 @@
+             why = _strerror(err)
+             self.respond('550 %s.' % why)
+         else:
+-            producer = BufferedIteratorProducer(iterator)
++            producer = BufferedIteratorProducer(iterator, self.encoding )
+             self.push_dtp_data(producer, isproducer=True, cmd="LIST")
+ 
+     def ftp_NLST(self, path):
+@@ -2894,7 +2939,10 @@
+             if listing:
+                 listing.sort()
+                 data = '\r\n'.join(listing) + '\r\n'
+-            self.push_dtp_data(data, cmd="NLST")
++            if self.encoding:
++                data = data.encode(self.encoding)
++            self.log('OK NLST "%s". Transfer starting.' % path)
++            self.push_dtp_data(data)
+ 
+         # --- MLST and MLSD commands
+ 
+@@ -2943,7 +2991,7 @@
+             perms = self.authorizer.get_perms(self.username)
+             iterator = self.fs.format_mlsx(path, listing, perms,
+                        self._current_facts)
+-            producer = BufferedIteratorProducer(iterator)
++            producer = BufferedIteratorProducer(iterator, self.encoding)
+             self.push_dtp_data(producer, isproducer=True, cmd="MLSD")
+ 
+     def ftp_RETR(self, file):
+@@ -3489,7 +3537,8 @@
+                 self.respond('550 %s.' %why)
+             else:
+                 self.push('213-Status of "%s":\r\n' % line)
+-                self.push_with_producer(BufferedIteratorProducer(iterator))
++                self.push_with_producer(BufferedIteratorProducer(iterator,
++                                                                 self.encoding))
+                 self.respond('213 End of status.')
+ 
+     def ftp_FEAT(self, line):
+@@ -3497,6 +3546,8 @@
+         features = ['TVFS']
+         features += [feat for feat in ('EPRT', 'EPSV', 'MDTM', 'SIZE') \
+                      if feat in self.proto_cmds]
++        if self.encoding and self.encoding.lower() in ('utf8, utf-8'):
++            features.append('UTF8')
+         features.extend(self._extra_feats)
+         if 'MLST' in self.proto_cmds or 'MLSD' in self.proto_cmds:
+             facts = ''
+@@ -3779,7 +3830,7 @@
+             return
+         except socket.error, err:
+             # ECONNABORTED might be thrown on *BSD (see issue 105)
+-            if err[0] != errno.ECONNABORTED:
++            if err.errno != errno.ECONNABORTED:
+                 logerror(traceback.format_exc())
+             return
+         else:

+ 86 - 0
pyftpdlib/ftpserver.py~

@@ -0,0 +1,86 @@
+#!/usr/bin/env python
+# -*- coding:utf8 -*- 
+# $Id: ftpserver.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+Note: this module is here only for backward compatibility.
+The new import system which is supposed to be used is:
+
+from pyftpdlib.handlers import FTPHandler, TLS_FTPHandler, ...
+from pyftpdlib.authorizers import DummyAuthorizer, UnixAuthorizer, ...
+from pyftpdlib.servers import FTPServer, ...
+"""
+
+
+from pyftpdlib.log import logger
+from pyftpdlib.handlers import *
+from pyftpdlib.authorizers import *
+from pyftpdlib.servers import *
+
+from pyftpdlib import _depwarn, __ver__
+
+__all__ = ['proto_cmds', 'Error', 'log', 'logline', 'logerror', 'DummyAuthorizer',
+           'AuthorizerError', 'FTPHandler', 'FTPServer', 'PassiveDTP',
+           'ActiveDTP', 'DTPHandler', 'ThrottledDTPHandler', 'FileProducer',
+           'BufferedIteratorProducer', 'AbstractedFS']
+
+_depwarn("pyftpdlib.ftpserver module is deprecated")
+
+
+class CallLater(object):
+    def __init__(self, *args, **kwargs):
+        _depwarn("CallLater is deprecated; use "
+            "pyftpdlib.ioloop.IOLoop.instance().call_later() instead")
+        from pyftpdlib.ioloop import IOLoop
+        IOLoop.instance().call_later(*args, **kwargs)
+
+class CallEvery(object):
+    def __init__(self, *args, **kwargs):
+        _depwarn("CallEvery is deprecated; use "
+            "pyftpdlib.ioloop.IOLoop.instance().call_every() instead")
+        from pyftpdlib.ioloop import IOLoop
+        IOLoop.instance().call_every(*args, **kwargs)
+
+def log(msg):
+    _depwarn("pyftpdlib.ftpserver.log() is deprecated")
+    logger.info(msg)
+
+def logline(msg):
+    _depwarn("pyftpdlib.ftpserver.logline() is deprecated")
+    logger.debug(msg)
+
+def logerror(msg):
+    _depwarn("pyftpdlib.ftpserver.logline() is deprecated")
+    logger.error(msg)
+
+if __name__ == '__main__':
+    from pyftpdlib import main
+    main()

+ 3321 - 0
pyftpdlib/handlers.py

@@ -0,0 +1,3321 @@
+#!/usr/bin/env python
+# $Id: handlers.py 1218 2013-04-19 01:48:39Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+import asynchat
+import time
+import sys
+import os
+import errno
+import socket
+import traceback
+import glob
+import random
+import warnings
+import logging
+try:
+    import pwd
+    import grp
+except ImportError:
+    pwd = grp = None
+
+from pyftpdlib import __ver__
+from pyftpdlib.log import logger
+from pyftpdlib.filesystems import FilesystemError, AbstractedFS
+from pyftpdlib._compat import PY3, b, u, getcwdu, unicode, xrange, next
+from pyftpdlib.ioloop import AsyncChat, Connector, Acceptor, timer, _DISCONNECTED
+from pyftpdlib.authorizers import (DummyAuthorizer, AuthenticationFailed,
+                                   AuthorizerError)
+
+def _import_sendfile():
+    # By default attempt to use os.sendfile introduced in Python 3.3:
+    # http://bugs.python.org/issue10882
+    # ...otherwise fallback on using third-party pysendfile module:
+    # http://code.google.com/p/pysendfile/
+    if os.name == 'posix':
+        try:
+            return os.sendfile  # py >= 3.3
+        except AttributeError:
+            try:
+                import sendfile as sf
+                # dirty hack to detect whether old 1.2.4 version is installed
+                if hasattr(sf, 'has_sf_hdtr'):
+                    raise ImportError
+                return sf.sendfile
+            except ImportError:
+                pass
+
+sendfile = _import_sendfile()
+
+
+proto_cmds = {
+    'ABOR' : dict(perm=None, auth=True, arg=False,
+                  help='Syntax: ABOR (abort transfer).'),
+    'ALLO' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: ALLO <SP> bytes (noop; allocate storage).'),
+    'APPE' : dict(perm='a', auth=True, arg=True,
+                  help='Syntax: APPE <SP> file-name (append data to file).'),
+    'CDUP' : dict(perm='e', auth=True, arg=False,
+                  help='Syntax: CDUP (go to parent directory).'),
+    'CWD'  : dict(perm='e', auth=True, arg=None,
+                  help='Syntax: CWD [<SP> dir-name] (change working directory).'),
+    'DELE' : dict(perm='d', auth=True, arg=True,
+                  help='Syntax: DELE <SP> file-name (delete file).'),
+    'EPRT' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: EPRT <SP> |proto|ip|port| (extended active mode).'),
+    'EPSV' : dict(perm=None, auth=True, arg=None,
+                  help='Syntax: EPSV [<SP> proto/"ALL"] (extended passive mode).'),
+    'FEAT' : dict(perm=None, auth=False, arg=False,
+                  help='Syntax: FEAT (list all new features supported).'),
+    'HELP' : dict(perm=None, auth=False, arg=None,
+                  help='Syntax: HELP [<SP> cmd] (show help).'),
+    'LIST' : dict(perm='l', auth=True, arg=None,
+                  help='Syntax: LIST [<SP> path] (list files).'),
+    'MDTM' : dict(perm='l', auth=True, arg=True,
+                  help='Syntax: MDTM [<SP> path] (file last modification time).'),
+    'MLSD' : dict(perm='l', auth=True, arg=None,
+                  help='Syntax: MLSD [<SP> path] (list directory).'),
+    'MLST' : dict(perm='l', auth=True, arg=None,
+                  help='Syntax: MLST [<SP> path] (show information about path).'),
+    'MODE' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: MODE <SP> mode (noop; set data transfer mode).'),
+    'MKD'  : dict(perm='m', auth=True, arg=True,
+                  help='Syntax: MKD <SP> path (create directory).'),
+    'NLST' : dict(perm='l', auth=True, arg=None,
+                  help='Syntax: NLST [<SP> path] (list path in a compact form).'),
+    'NOOP' : dict(perm=None, auth=False, arg=False,
+                  help='Syntax: NOOP (just do nothing).'),
+    'OPTS' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: OPTS <SP> cmd [<SP> option] (set option for command).'),
+    'PASS' : dict(perm=None, auth=False, arg=None,
+                  help='Syntax: PASS [<SP> password] (set user password).'),
+    'PASV' : dict(perm=None, auth=True, arg=False,
+                  help='Syntax: PASV (open passive data connection).'),
+    'PORT' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: PORT <sp> h1,h2,h3,h4,p1,p2 (open active data connection).'),
+    'PWD'  : dict(perm=None, auth=True, arg=False,
+                  help='Syntax: PWD (get current working directory).'),
+    'QUIT' : dict(perm=None, auth=False, arg=False,
+                  help='Syntax: QUIT (quit current session).'),
+    'REIN' : dict(perm=None, auth=True, arg=False,
+                  help='Syntax: REIN (flush account).'),
+    'REST' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: REST <SP> offset (set file offset).'),
+    'RETR' : dict(perm='r', auth=True, arg=True,
+                  help='Syntax: RETR <SP> file-name (retrieve a file).'),
+    'RMD'  : dict(perm='d', auth=True, arg=True,
+                  help='Syntax: RMD <SP> dir-name (remove directory).'),
+    'RNFR' : dict(perm='f', auth=True, arg=True,
+                  help='Syntax: RNFR <SP> file-name (rename (source name)).'),
+    'RNTO' : dict(perm='f', auth=True, arg=True,
+                  help='Syntax: RNTO <SP> file-name (rename (destination name)).'),
+    'SITE' : dict(perm=None, auth=False, arg=True,
+                  help='Syntax: SITE <SP> site-command (execute SITE command).'),
+    'SITE HELP' : dict(perm=None, auth=False, arg=None,
+                       help='Syntax: SITE HELP [<SP> site-command] (show SITE command help).'),
+    'SITE CHMOD': dict(perm='M', auth=True, arg=True,
+                       help='Syntax: SITE CHMOD <SP> mode path (change file mode).'),
+    'SIZE' : dict(perm='l', auth=True, arg=True,
+                  help='Syntax: SIZE <SP> file-name (get file size).'),
+    'STAT' : dict(perm='l', auth=False, arg=None,
+                  help='Syntax: STAT [<SP> path name] (server stats [list files]).'),
+    'STOR' : dict(perm='w', auth=True, arg=True,
+                  help='Syntax: STOR <SP> file-name (store a file).'),
+    'STOU' : dict(perm='w', auth=True, arg=None,
+                  help='Syntax: STOU [<SP> file-name] (store a file with a unique name).'),
+    'STRU' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: STRU <SP> type (noop; set file structure).'),
+    'SYST' : dict(perm=None, auth=False, arg=False,
+                  help='Syntax: SYST (get operating system type).'),
+    'TYPE' : dict(perm=None, auth=True, arg=True,
+                  help='Syntax: TYPE <SP> [A | I] (set transfer type).'),
+    'USER' : dict(perm=None, auth=False, arg=True,
+                  help='Syntax: USER <SP> user-name (set username).'),
+    'XCUP' : dict(perm='e', auth=True, arg=False,
+                  help='Syntax: XCUP (obsolete; go to parent directory).'),
+    'XCWD' : dict(perm='e', auth=True, arg=None,
+                  help='Syntax: XCWD [<SP> dir-name] (obsolete; change directory).'),
+    'XMKD' : dict(perm='m', auth=True, arg=True,
+                  help='Syntax: XMKD <SP> dir-name (obsolete; create directory).'),
+    'XPWD' : dict(perm=None, auth=True, arg=False,
+                  help='Syntax: XPWD (obsolete; get current dir).'),
+    'XRMD' : dict(perm='d', auth=True, arg=True,
+                  help='Syntax: XRMD <SP> dir-name (obsolete; remove directory).'),
+    }
+
+if not hasattr(os, 'chmod'):
+    del proto_cmds['SITE CHMOD']
+
+def _strerror(err):
+    if isinstance(err, EnvironmentError):
+        try:
+            return os.strerror(err.errno)
+        except AttributeError:
+            # not available on PythonCE
+            if not hasattr(os, 'strerror'):
+                return err.strerror
+            raise
+    else:
+        return str(err)
+
+def _support_hybrid_ipv6():
+    """Return True if it is possible to use hybrid IPv6/IPv4 sockets
+    on this platform.
+    """
+    # Note: IPPROTO_IPV6 constant is broken on Windows, see:
+    # http://bugs.python.org/issue6926
+    sock = None
+    try:
+        try:
+            if not socket.has_ipv6:
+                return False
+            sock = socket.socket(socket.AF_INET6)
+            return not sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY)
+        except (socket.error, AttributeError):
+            return False
+    finally:
+        if sock is not None:
+            sock.close()
+
+SUPPORTS_HYBRID_IPV6 = _support_hybrid_ipv6()
+
+class _FileReadWriteError(OSError):
+    """Exception raised when reading or writing a file during a transfer."""
+
+
+# --- DTP classes
+
+class PassiveDTP(Acceptor):
+    """Creates a socket listening on a local port, dispatching the
+    resultant connection to DTPHandler. Used for handling PASV command.
+
+     - (int) timeout: the timeout for a remote client to establish
+       connection with the listening socket. Defaults to 30 seconds.
+
+     - (int) backlog: the maximum number of queued connections passed
+       to listen(). If a connection request arrives when the queue is
+       full the client may raise ECONNRESET. Defaults to 5.
+    """
+    timeout = 30
+    backlog = None
+
+    def __init__(self, cmd_channel, extmode=False):
+        """Initialize the passive data server.
+
+         - (instance) cmd_channel: the command channel class instance.
+         - (bool) extmode: wheter use extended passive mode response type.
+        """
+        self.cmd_channel = cmd_channel
+        self.log = cmd_channel.log
+        self.log_exception = cmd_channel.log_exception
+        self._closed = False
+        self._idler = None
+        Acceptor.__init__(self, ioloop=cmd_channel.ioloop)
+
+        local_ip = self.cmd_channel.socket.getsockname()[0]
+        if local_ip in self.cmd_channel.masquerade_address_map:
+            masqueraded_ip = self.cmd_channel.masquerade_address_map[local_ip]
+        elif self.cmd_channel.masquerade_address:
+            masqueraded_ip = self.cmd_channel.masquerade_address
+        else:
+            masqueraded_ip = None
+
+        if self.cmd_channel.server._af != socket.AF_INET:
+            # dual stack IPv4/IPv6 support
+            af = self.bind_af_unspecified((local_ip, 0))
+            self.socket.close()
+        else:
+            af = self.cmd_channel._af
+
+        self.create_socket(af, socket.SOCK_STREAM)
+
+        if self.cmd_channel.passive_ports is None:
+            # By using 0 as port number value we let kernel choose a
+            # free unprivileged random port.
+            self.bind((local_ip, 0))
+        else:
+            ports = list(self.cmd_channel.passive_ports)
+            while ports:
+                port = ports.pop(random.randint(0, len(ports) - 1))
+                self.set_reuse_addr()
+                try:
+                    self.bind((local_ip, port))
+                except socket.error:
+                    err = sys.exc_info()[1]
+                    if err.args[0] == errno.EADDRINUSE:  # port already in use
+                        if ports:
+                            continue
+                        # If cannot use one of the ports in the configured
+                        # range we'll use a kernel-assigned port, and log
+                        # a message reporting the issue.
+                        # By using 0 as port number value we let kernel
+                        # choose a free unprivileged random port.
+                        else:
+                            self.bind((local_ip, 0))
+                            self.cmd_channel.log(
+                                "Can't find a valid passive port in the "
+                                "configured range. A random kernel-assigned "
+                                "port will be used.",
+                                logfun=logger.warning
+                            )
+                    else:
+                        raise
+                else:
+                    break
+        self.listen(self.backlog or self.cmd_channel.server.backlog)
+
+        port = self.socket.getsockname()[1]
+        if not extmode:
+            ip = masqueraded_ip or local_ip
+            if ip.startswith('::ffff:'):
+                # In this scenario, the server has an IPv6 socket, but
+                # the remote client is using IPv4 and its address is
+                # represented as an IPv4-mapped IPv6 address which
+                # looks like this ::ffff:151.12.5.65, see:
+                # http://en.wikipedia.org/wiki/IPv6#IPv4-mapped_addresses
+                # http://tools.ietf.org/html/rfc3493.html#section-3.7
+                # We truncate the first bytes to make it look like a
+                # common IPv4 address.
+                ip = ip[7:]
+            # The format of 227 response in not standardized.
+            # This is the most expected:
+            self.cmd_channel.respond('227 Entering passive mode (%s,%d,%d).' % (
+                                ip.replace('.', ','), port // 256, port % 256))
+        else:
+            self.cmd_channel.respond('229 Entering extended passive mode '
+                                     '(|||%d|).' % port)
+        if self.timeout:
+            self._idler = self.ioloop.call_later(self.timeout,
+                                                 self.handle_timeout,
+                                                 _errback=self.handle_error)
+
+    # --- connection / overridden
+
+    def handle_accepted(self, sock, addr):
+        """Called when remote client initiates a connection."""
+        if not self.cmd_channel.connected:
+            return self.close()
+
+        # Check the origin of data connection.  If not expressively
+        # configured we drop the incoming data connection if remote
+        # IP address does not match the client's IP address.
+        if self.cmd_channel.remote_ip != addr[0]:
+            if not self.cmd_channel.permit_foreign_addresses:
+                try:
+                    sock.close()
+                except socket.error:
+                    pass
+                msg = '425 Rejected data connection from foreign address %s:%s.' \
+                        %(addr[0], addr[1])
+                self.cmd_channel.respond_w_warning(msg)
+                # do not close listening socket: it couldn't be client's blame
+                return
+            else:
+                # site-to-site FTP allowed
+                msg = 'Established data connection with foreign address %s:%s.'\
+                        % (addr[0], addr[1])
+                self.cmd_channel.log(msg, logfun=logger.warning)
+        # Immediately close the current channel (we accept only one
+        # connection at time) and avoid running out of max connections
+        # limit.
+        self.close()
+        # delegate such connection to DTP handler
+        if self.cmd_channel.connected:
+            handler = self.cmd_channel.dtp_handler(sock, self.cmd_channel)
+            if handler.connected:
+                self.cmd_channel.data_channel = handler
+                self.cmd_channel._on_dtp_connection()
+
+    def handle_timeout(self):
+        if self.cmd_channel.connected:
+            self.cmd_channel.respond("421 Passive data channel timed out.",
+                                     logfun=logging.info)
+        self.close()
+
+    def handle_error(self):
+        """Called to handle any uncaught exceptions."""
+        try:
+            raise
+        except Exception:
+            logger.error(traceback.format_exc())
+        try:
+            self.close()
+        except Exception:
+            logger.critical(traceback.format_exc())
+
+    def close(self):
+        if not self._closed:
+            self._closed = True
+            Acceptor.close(self)
+            if self._idler is not None and not self._idler.cancelled:
+                self._idler.cancel()
+
+
+class ActiveDTP(Connector):
+    """Connects to remote client and dispatches the resulting connection
+    to DTPHandler. Used for handling PORT command.
+
+     - (int) timeout: the timeout for us to establish connection with
+       the client's listening data socket.
+    """
+    timeout = 30
+
+    def __init__(self, ip, port, cmd_channel):
+        """Initialize the active data channel attemping to connect
+        to remote data socket.
+
+         - (str) ip: the remote IP address.
+         - (int) port: the remote port.
+         - (instance) cmd_channel: the command channel class instance.
+        """
+        Connector.__init__(self, ioloop=cmd_channel.ioloop)
+        self.cmd_channel = cmd_channel
+        self.log = cmd_channel.log
+        self.log_exception = cmd_channel.log_exception
+        self._closed = False
+        self._idler = None
+        if self.timeout:
+            self._idler = self.ioloop.call_later(self.timeout,
+                                                 self.handle_timeout,
+                                                 _errback=self.handle_error)
+
+        if ip.count('.') == 4:
+            self._cmd = "PORT"
+            self._normalized_addr = "%s:%s" % (ip, port)
+        else:
+            self._cmd = "EPRT"
+            self._normalized_addr = "[%s]:%s" % (ip, port)
+
+        source_ip = self.cmd_channel.socket.getsockname()[0]
+        # dual stack IPv4/IPv6 support
+        try:
+            self.connect_af_unspecified((ip, port), (source_ip, 0))
+        except (socket.gaierror, socket.error):
+            self.handle_close()
+
+    def readable(self):
+        return False
+
+    def handle_write(self):
+        # overridden to prevent unhandled read/write event messages to
+        # be printed by asyncore on Python < 2.6
+        pass
+
+    def handle_connect(self):
+        """Called when connection is established."""
+        self.del_channel()
+        if self._idler is not None and not self._idler.cancelled:
+            self._idler.cancel()
+        if not self.cmd_channel.connected:
+            return self.close()
+        # fix for asyncore on python < 2.6, meaning we aren't
+        # actually connected.
+        # test_active_conn_error tests this condition
+        err = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
+        if err != 0:
+            raise socket.error(err)
+        #
+        msg = 'Active data connection established.'
+        self.cmd_channel.respond('200 ' + msg)
+        self.cmd_channel.log_cmd(self._cmd, self._normalized_addr, 200, msg)
+        #
+        if not self.cmd_channel.connected:
+            return self.close()
+        # delegate such connection to DTP handler
+        handler = self.cmd_channel.dtp_handler(self.socket, self.cmd_channel)
+        self.cmd_channel.data_channel = handler
+        self.cmd_channel._on_dtp_connection()
+
+    def handle_timeout(self):
+        if self.cmd_channel.connected:
+            msg = "Active data channel timed out."
+            self.cmd_channel.respond("421 " + msg, logfun=logger.info)
+            self.cmd_channel.log_cmd(self._cmd, self._normalized_addr, 421, msg)
+        self.close()
+
+    def handle_close(self):
+        # With the new IO loop, handle_close() gets called in case
+        # the fd appears in the list of exceptional fds.
+        # This means connect() failed.
+        if not self._closed:
+            self.close()
+            if self.cmd_channel.connected:
+                msg = "Can't connect to specified address."
+                self.cmd_channel.respond("425 " + msg)
+                self.cmd_channel.log_cmd(self._cmd, self._normalized_addr, 425, msg)
+
+    def handle_error(self):
+        """Called to handle any uncaught exceptions."""
+        try:
+            raise
+        except (socket.gaierror, socket.error):
+            pass
+        except Exception:
+            self.log_exception(self)
+        try:
+            self.handle_close()
+        except Exception:
+            logger.critical(traceback.format_exc())
+
+    def close(self):
+        if not self._closed:
+            self._closed = True
+            if self.socket is not None:
+                Connector.close(self)
+            if self._idler is not None and not self._idler.cancelled:
+                self._idler.cancel()
+
+
+class DTPHandler(AsyncChat):
+    """Class handling server-data-transfer-process (server-DTP, see
+    RFC-959) managing data-transfer operations involving sending
+    and receiving data.
+
+    Class attributes:
+
+     - (int) timeout: the timeout which roughly is the maximum time we
+       permit data transfers to stall for with no progress. If the
+       timeout triggers, the remote client will be kicked off
+       (defaults 300).
+
+     - (int) ac_in_buffer_size: incoming data buffer size (defaults 65536)
+
+     - (int) ac_out_buffer_size: outgoing data buffer size (defaults 65536)
+    """
+
+    timeout = 300
+    ac_in_buffer_size = 65536
+    ac_out_buffer_size = 65536
+
+    def __init__(self, sock, cmd_channel):
+        """Initialize the command channel.
+
+         - (instance) sock: the socket object instance of the newly
+            established connection.
+         - (instance) cmd_channel: the command channel class instance.
+        """
+        self.cmd_channel = cmd_channel
+        self.file_obj = None
+        self.receive = False
+        self.transfer_finished = False
+        self.tot_bytes_sent = 0
+        self.tot_bytes_received = 0
+        self.cmd = None
+        self.log = cmd_channel.log
+        self.log_exception = cmd_channel.log_exception
+        self._data_wrapper = None
+        self._lastdata = 0
+        self._closed = False
+        self._had_cr = False
+        self._start_time = timer()
+        self._resp = ()
+        self._offset = None
+        self._filefd = None
+        self._idler = None
+        self._initialized = False
+        try:
+            AsyncChat.__init__(self, sock, ioloop=cmd_channel.ioloop)
+        except socket.error:
+            err = sys.exc_info()[1]
+            # if we get an exception here we want the dispatcher
+            # instance to set socket attribute before closing, see:
+            # http://code.google.com/p/pyftpdlib/issues/detail?id=188
+            AsyncChat.__init__(self, socket.socket(), ioloop=cmd_channel.ioloop)
+            # http://code.google.com/p/pyftpdlib/issues/detail?id=143
+            self.close()
+            if err.args[0] == errno.EINVAL:
+                return
+            self.handle_error()
+            return
+
+        # remove this instance from IOLoop's socket map
+        if not self.connected:
+            self.close()
+            return
+        if self.timeout:
+            self._idler = self.ioloop.call_every(self.timeout,
+                                                 self.handle_timeout,
+                                                 _errback=self.handle_error)
+
+    def __repr__(self):
+        try:
+            addr = "%s:%s" % self.socket.getpeername()[:2]
+        except socket.error:
+            addr = None
+        status = [self.__class__.__module__+ "." + self.__class__.__name__]
+        status.append("(addr=%s, user=%r, receive=%r, file=%r)" \
+                      % (addr, self.cmd_channel.username or '',
+                         self.receive, getattr(self.file_obj, 'name', '')))
+        return '<%s at %#x>' % (' '.join(status), id(self))
+
+    __str__ = __repr__
+
+    def _use_sendfile(self, producer):
+        return self.cmd_channel.use_sendfile \
+           and isinstance(producer, FileProducer) \
+           and producer.type == 'i'
+
+    def push(self, data):
+        self._initialized = True
+        self.ioloop.modify(self._fileno, self.ioloop.WRITE)
+        AsyncChat.push(self, data)
+
+    def push_with_producer(self, producer):
+        self._initialized = True
+        self.ioloop.modify(self._fileno, self.ioloop.WRITE)
+        if self._use_sendfile(producer):
+            self._offset = producer.file.tell()
+            self._filefd = self.file_obj.fileno()
+            self.initiate_sendfile()
+            self.initiate_send = self.initiate_sendfile
+        else:
+            AsyncChat.push_with_producer(self, producer)
+
+    def close_when_done(self):
+        asynchat.async_chat.close_when_done(self)
+
+    def initiate_send(self):
+        asynchat.async_chat.initiate_send(self)
+
+    def initiate_sendfile(self):
+        """A wrapper around sendfile."""
+        try:
+            sent = sendfile(self._fileno, self._filefd, self._offset,
+                            self.ac_out_buffer_size)
+        except OSError:
+            err = sys.exc_info()[1]
+            if err.errno in (errno.EAGAIN, errno.EWOULDBLOCK, errno.EBUSY):
+                return
+            elif err.errno in _DISCONNECTED:
+                self.handle_close()
+            else:
+                raise
+        else:
+            if sent == 0:
+                # this signals the channel that the transfer is completed
+                self.discard_buffers()
+                self.handle_close()
+            else:
+                self._offset += sent
+                self.tot_bytes_sent += sent
+
+    # --- utility methods
+
+    def _posix_ascii_data_wrapper(self, chunk):
+        """The data wrapper used for receiving data in ASCII mode on
+        systems using a single line terminator, handling those cases
+        where CRLF ('\r\n') gets delivered in two chunks.
+        """
+        if self._had_cr:
+            chunk = b('\r') + chunk
+
+        if chunk.endswith(b('\r')):
+            self._had_cr = True
+            chunk = chunk[:-1]
+        else:
+            self._had_cr = False
+
+        return chunk.replace(b('\r\n'), b(os.linesep))
+
+    def enable_receiving(self, type, cmd):
+        """Enable receiving of data over the channel. Depending on the
+        TYPE currently in use it creates an appropriate wrapper for the
+        incoming data.
+
+         - (str) type: current transfer type, 'a' (ASCII) or 'i' (binary).
+        """
+        self._initialized = True
+        self.ioloop.modify(self._fileno, self.ioloop.READ)
+        self.cmd = cmd
+        if type == 'a':
+            if os.linesep == '\r\n':
+                self._data_wrapper = None
+            else:
+                self._data_wrapper = self._posix_ascii_data_wrapper
+        elif type == 'i':
+            self._data_wrapper = None
+        else:
+            raise TypeError("unsupported type")
+        self.receive = True
+
+    def get_transmitted_bytes(self):
+        """Return the number of transmitted bytes."""
+        return self.tot_bytes_sent + self.tot_bytes_received
+
+    def get_elapsed_time(self):
+        """Return the transfer elapsed time in seconds."""
+        return timer() - self._start_time
+
+    def transfer_in_progress(self):
+        """Return True if a transfer is in progress, else False."""
+        return self.get_transmitted_bytes() != 0
+
+    # --- connection
+
+    def send(self, data):
+        result = AsyncChat.send(self, data)
+        self.tot_bytes_sent += result
+        return result
+
+    def refill_buffer(self):
+        """Overridden as a fix around http://bugs.python.org/issue1740572
+        (when the producer is consumed, close() was called instead of
+        handle_close()).
+        """
+        while 1:
+            if len(self.producer_fifo):
+                p = self.producer_fifo.first()
+                # a 'None' in the producer fifo is a sentinel,
+                # telling us to close the channel.
+                if p is None:
+                    if not self.ac_out_buffer:
+                        self.producer_fifo.pop()
+                        #self.close()
+                        self.handle_close()
+                    return
+                elif isinstance(p, str):
+                    self.producer_fifo.pop()
+                    self.ac_out_buffer += p
+                    return
+                data = p.more()
+                if data:
+                    self.ac_out_buffer = self.ac_out_buffer + data
+                    return
+                else:
+                    self.producer_fifo.pop()
+            else:
+                return
+
+    def handle_read(self):
+        """Called when there is data waiting to be read."""
+        try:
+            chunk = self.recv(self.ac_in_buffer_size)
+        except socket.error:
+            self.handle_error()
+        else:
+            self.tot_bytes_received += len(chunk)
+            if not chunk:
+                self.transfer_finished = True
+                #self.close()  # <-- asyncore.recv() already do that...
+                return
+            if self._data_wrapper is not None:
+                chunk = self._data_wrapper(chunk)
+            try:
+                self.file_obj.write(chunk)
+            except OSError:
+                err = sys.exc_info()[1]
+                raise _FileReadWriteError(err)
+
+    handle_read_event = handle_read  # small speedup
+
+    def readable(self):
+        """Predicate for inclusion in the readable for select()."""
+        # It the channel is not supposed to be receiving but yet it's
+        # in the list of readable events, that means it has been
+        # disconnected, in which case we explicitly close() it.
+        # This is necessary as differently from FTPHandler this channel
+        # is not supposed to be readable/writable at first, meaning the
+        # upper IOLoop might end up calling readable() repeatedly,
+        # hogging CPU resources.
+        if not self.receive and not self._initialized:
+            return self.close()
+        return self.receive
+
+    def writable(self):
+        """Predicate for inclusion in the writable for select()."""
+        return not self.receive and asynchat.async_chat.writable(self)
+
+    def handle_timeout(self):
+        """Called cyclically to check if data trasfer is stalling with
+        no progress in which case the client is kicked off.
+        """
+        if self.get_transmitted_bytes() > self._lastdata:
+            self._lastdata = self.get_transmitted_bytes()
+        else:
+            msg = "Data connection timed out."
+            self._resp = ("421 " + msg, logger.info)
+            self.close()
+            self.cmd_channel.close_when_done()
+
+    def handle_error(self):
+        """Called when an exception is raised and not otherwise handled."""
+        try:
+            raise
+        # an error could occur in case we fail reading / writing
+        # from / to file (e.g. file system gets full)
+        except _FileReadWriteError:
+            err = sys.exc_info()[1]
+            error = _strerror(err.args[0])
+        except Exception:
+            # some other exception occurred;  we don't want to provide
+            # confidential error messages
+            self.log_exception(self)
+            error = "Internal error"
+        try:
+            self._resp = ("426 %s; transfer aborted." % error, logger.warning)
+            self.close()
+        except Exception:
+            logger.critical(traceback.format_exc())
+
+    def handle_close(self):
+        """Called when the socket is closed."""
+        # If we used channel for receiving we assume that transfer is
+        # finished when client closes the connection, if we used channel
+        # for sending we have to check that all data has been sent
+        # (responding with 226) or not (responding with 426).
+        # In both cases handle_close() is automatically called by the
+        # underlying asynchat module.
+        if not self._closed:
+            if self.receive:
+                self.transfer_finished = True
+            else:
+                self.transfer_finished = len(self.producer_fifo) == 0
+            try:
+                if self.transfer_finished:
+                    self._resp = ("226 Transfer complete.", logger.debug)
+                else:
+                    tot_bytes = self.get_transmitted_bytes()
+                    self._resp = ("426 Transfer aborted; %d bytes transmitted." \
+                                  % tot_bytes, logger.debug)
+            finally:
+                self.close()
+
+    def close(self):
+        """Close the data channel, first attempting to close any remaining
+        file handles."""
+        if not self._closed:
+            self._closed = True
+            # RFC-959 says we must close the connection before replying
+            AsyncChat.close(self)
+            if self._resp:
+                self.cmd_channel.respond(self._resp[0], logfun=self._resp[1])
+
+            if self.file_obj is not None and not self.file_obj.closed:
+                self.file_obj.close()
+            if self._idler is not None and not self._idler.cancelled:
+                self._idler.cancel()
+            if self.file_obj is not None:
+                filename = self.file_obj.name
+                elapsed_time = round(self.get_elapsed_time(), 3)
+                self.cmd_channel.log_transfer(cmd=self.cmd,
+                                              filename=self.file_obj.name,
+                                              receive=self.receive,
+                                              completed=self.transfer_finished,
+                                              elapsed=elapsed_time,
+                                              bytes=self.get_transmitted_bytes())
+                if self.transfer_finished:
+                    if self.receive:
+                        self.cmd_channel.on_file_received(filename)
+                    else:
+                        self.cmd_channel.on_file_sent(filename)
+                else:
+                    if self.receive:
+                        self.cmd_channel.on_incomplete_file_received(filename)
+                    else:
+                        self.cmd_channel.on_incomplete_file_sent(filename)
+            self.cmd_channel._on_dtp_close()
+
+
+# dirty hack in order to turn AsyncChat into a new style class in
+# python 2.x so that we can use super()
+if PY3:
+    class _AsyncChatNewStyle(AsyncChat):
+        pass
+else:
+    class _AsyncChatNewStyle(object, AsyncChat):
+        def __init__(self, *args, **kwargs):
+            super(object, self).__init__(*args, **kwargs)  # bypass object
+
+
+class ThrottledDTPHandler(_AsyncChatNewStyle, DTPHandler):
+    """A DTPHandler subclass which wraps sending and receiving in a data
+    counter and temporarily "sleeps" the channel so that you burst to no
+    more than x Kb/sec average.
+
+     - (int) read_limit: the maximum number of bytes to read (receive)
+       in one second (defaults to 0 == no limit).
+
+     - (int) write_limit: the maximum number of bytes to write (send)
+       in one second (defaults to 0 == no limit).
+
+     - (bool) auto_sized_buffers: this option only applies when read
+       and/or write limits are specified. When enabled it bumps down
+       the data buffer sizes so that they are never greater than read
+       and write limits which results in a less bursty and smoother
+       throughput (default: True).
+    """
+    read_limit = 0
+    write_limit = 0
+    auto_sized_buffers = True
+
+    def __init__(self, sock, cmd_channel):
+        super(ThrottledDTPHandler, self).__init__(sock, cmd_channel)
+        self._timenext = 0
+        self._datacount = 0
+        self.sleeping = False
+        self._throttler = None
+        if self.auto_sized_buffers:
+            if self.read_limit:
+                while self.ac_in_buffer_size > self.read_limit:
+                    self.ac_in_buffer_size /= 2
+            if self.write_limit:
+                while self.ac_out_buffer_size > self.write_limit:
+                    self.ac_out_buffer_size /= 2
+        self.ac_in_buffer_size = int(self.ac_in_buffer_size)
+        self.ac_out_buffer_size = int(self.ac_out_buffer_size)
+
+    def _use_sendfile(self, producer):
+        return False
+
+    def recv(self, buffer_size):
+        chunk = super(ThrottledDTPHandler, self).recv(buffer_size)
+        if self.read_limit:
+            self._throttle_bandwidth(len(chunk), self.read_limit)
+        return chunk
+
+    def send(self, data):
+        num_sent = super(ThrottledDTPHandler, self).send(data)
+        if self.write_limit:
+            self._throttle_bandwidth(num_sent, self.write_limit)
+        return num_sent
+
+    def _cancel_throttler(self):
+        if self._throttler is not None and not self._throttler.cancelled:
+            self._throttler.cancel()
+
+    def _throttle_bandwidth(self, len_chunk, max_speed):
+        """A method which counts data transmitted so that you burst to
+        no more than x Kb/sec average.
+        """
+        self._datacount += len_chunk
+        if self._datacount >= max_speed:
+            self._datacount = 0
+            now = timer()
+            sleepfor = (self._timenext - now) * 2
+            if sleepfor > 0:
+                # we've passed bandwidth limits
+                def unsleep():
+                    if self.receive:
+                        event = self.ioloop.READ
+                    else:
+                        event = self.ioloop.WRITE
+                    self.add_channel(events=event)
+
+                self.del_channel()
+                self._cancel_throttler()
+                self._throttler = self.ioloop.call_later(sleepfor, unsleep,
+                                                     _errback=self.handle_error)
+            self._timenext = now + 1
+
+    def close(self):
+        self._cancel_throttler()
+        super(ThrottledDTPHandler, self).close()
+
+
+# --- producers
+
+
+class FileProducer(object):
+    """Producer wrapper for file[-like] objects."""
+
+    buffer_size = 65536
+
+    def __init__(self, file, type):
+        """Initialize the producer with a data_wrapper appropriate to TYPE.
+
+         - (file) file: the file[-like] object.
+         - (str) type: the current TYPE, 'a' (ASCII) or 'i' (binary).
+        """
+        self.file = file
+        self.type = type
+        if type == 'a' and os.linesep != '\r\n':
+            self._data_wrapper = lambda x: x.replace(b(os.linesep), b('\r\n'))
+        else:
+            self._data_wrapper = None
+
+    def more(self):
+        """Attempt a chunk of data of size self.buffer_size."""
+        try:
+            data = self.file.read(self.buffer_size)
+        except OSError:
+            err = sys.exc_info()[1]
+            raise _FileReadWriteError(err)
+        else:
+            if self._data_wrapper is not None:
+                data = self._data_wrapper(data)
+            return data
+
+
+class BufferedIteratorProducer(object):
+    """Producer for iterator objects with buffer capabilities."""
+    # how many times iterator.next() will be called before
+    # returning some data
+    loops = 20
+
+    def __init__(self, iterator):
+        self.iterator = iterator
+
+    def more(self):
+        """Attempt a chunk of data from iterator by calling
+        its next() method different times.
+        """
+        buffer = []
+        for x in xrange(self.loops):
+            try:
+                buffer.append(next(self.iterator))
+            except StopIteration:
+                break
+        return b('').join(buffer)
+
+
+# --- FTP
+
+class FTPHandler(AsyncChat):
+    """Implements the FTP server Protocol Interpreter (see RFC-959),
+    handling commands received from the client on the control channel.
+
+    All relevant session information is stored in class attributes
+    reproduced below and can be modified before instantiating this
+    class.
+
+     - (int) timeout:
+       The timeout which is the maximum time a remote client may spend
+       between FTP commands. If the timeout triggers, the remote client
+       will be kicked off.  Defaults to 300 seconds.
+
+     - (str) banner: the string sent when client connects.
+
+     - (int) max_login_attempts:
+        the maximum number of wrong authentications before disconnecting
+        the client (default 3).
+
+     - (bool)permit_foreign_addresses:
+        FTP site-to-site transfer feature: also referenced as "FXP" it
+        permits for transferring a file between two remote FTP servers
+        without the transfer going through the client's host (not
+        recommended for security reasons as described in RFC-2577).
+        Having this attribute set to False means that all data
+        connections from/to remote IP addresses which do not match the
+        client's IP address will be dropped (defualt False).
+
+     - (bool) permit_privileged_ports:
+        set to True if you want to permit active data connections (PORT)
+        over privileged ports (not recommended, defaulting to False).
+
+     - (str) masquerade_address:
+        the "masqueraded" IP address to provide along PASV reply when
+        pyftpdlib is running behind a NAT or other types of gateways.
+        When configured pyftpdlib will hide its local address and
+        instead use the public address of your NAT (default None).
+
+     - (dict) masquerade_address_map:
+        in case the server has multiple IP addresses which are all
+        behind a NAT router, you may wish to specify individual
+        masquerade_addresses for each of them. The map expects a
+        dictionary containing private IP addresses as keys, and their
+        corresponding public (masquerade) addresses as values.
+
+     - (list) passive_ports:
+        what ports the ftpd will use for its passive data transfers.
+        Value expected is a list of integers (e.g. range(60000, 65535)).
+        When configured pyftpdlib will no longer use kernel-assigned
+        random ports (default None).
+
+     - (bool) use_gmt_times:
+        when True causes the server to report all ls and MDTM times in
+        GMT and not local time (default True).
+
+     - (bool) use_sendfile: when True uses sendfile() system call to
+        send a file resulting in faster uploads (from server to client).
+        Works on UNIX only and requires pysendfile module to be
+        installed separately:
+        http://code.google.com/p/pysendfile/
+        Automatically defaults to True if pysendfile module is
+        installed.
+
+     - (bool) tcp_no_delay: controls the use of the TCP_NODELAY socket
+        option which disables the Nagle algorithm resulting in
+        significantly better performances (default True on all systems
+        where it is supported).
+
+     - (str) unicode_errors:
+       the error handler passed to ''.encode() and ''.decode():
+       http://docs.python.org/library/stdtypes.html#str.decode
+       (detaults to 'replace').
+
+     - (str) log_prefix:
+       the prefix string preceding any log line; all instance
+       attributes can be used as arguments.
+
+
+    All relevant instance attributes initialized when client connects
+    are reproduced below.  You may be interested in them in case you
+    want to subclass the original FTPHandler.
+
+     - (bool) authenticated: True if client authenticated himself.
+     - (str) username: the name of the connected user (if any).
+     - (int) attempted_logins: number of currently attempted logins.
+     - (str) current_type: the current transfer type (default "a")
+     - (int) af: the connection's address family (IPv4/IPv6)
+     - (instance) server: the FTPServer class instance.
+     - (instance) data_channel: the data channel instance (if any).
+    """
+    # these are overridable defaults
+
+    # default classes
+    authorizer = DummyAuthorizer()
+    active_dtp = ActiveDTP
+    passive_dtp = PassiveDTP
+    dtp_handler = DTPHandler
+    abstracted_fs = AbstractedFS
+    proto_cmds = proto_cmds
+
+    # session attributes (explained in the docstring)
+    timeout = 300
+    banner = "pyftpdlib %s ready." % __ver__
+    max_login_attempts = 3
+    permit_foreign_addresses = False
+    permit_privileged_ports = False
+    masquerade_address = None
+    masquerade_address_map = {}
+    passive_ports = None
+    use_gmt_times = True
+    use_sendfile = sendfile is not None
+    tcp_no_delay = hasattr(socket, "TCP_NODELAY")
+    unicode_errors = 'replace'
+    log_prefix = '%(remote_ip)s:%(remote_port)s-[%(username)s]'
+
+    def __init__(self, conn, server, ioloop=None):
+        """Initialize the command channel.
+
+         - (instance) conn: the socket object instance of the newly
+            established connection.
+         - (instance) server: the ftp server class instance.
+        """
+        # public session attributes
+        self.server = server
+        self.fs = None
+        self.authenticated = False
+        self.username = ""
+        self.password = ""
+        self.attempted_logins = 0
+        self.data_channel = None
+        self.remote_ip = ""
+        self.remote_port = ""
+
+        # private session attributes
+        self._last_response = ""
+        self._current_type = 'a'
+        self._restart_position = 0
+        self._quit_pending = False
+        self._af = -1
+        self._in_buffer = []
+        self._in_buffer_len = 0
+        self._epsvall = False
+        self._dtp_acceptor = None
+        self._dtp_connector = None
+        self._in_dtp_queue = None
+        self._out_dtp_queue = None
+        self._closed = False
+        self._extra_feats = []
+        self._current_facts = ['type', 'perm', 'size', 'modify']
+        self._rnfr = None
+        self._idler = None
+        self._log_debug = logging.getLogger('pyftpdlib').getEffectiveLevel() \
+                          <= logging.DEBUG
+
+        if os.name == 'posix':
+            self._current_facts.append('unique')
+        self._available_facts = self._current_facts[:]
+        if pwd and grp:
+            self._available_facts += ['unix.mode', 'unix.uid', 'unix.gid']
+        if os.name == 'nt':
+            self._available_facts.append('create')
+
+        try:
+            AsyncChat.__init__(self, conn, ioloop=ioloop)
+        except socket.error:
+            err = sys.exc_info()[1]
+            # if we get an exception here we want the dispatcher
+            # instance to set socket attribute before closing, see:
+            # http://code.google.com/p/pyftpdlib/issues/detail?id=188
+            AsyncChat.__init__(self, socket.socket(), ioloop=ioloop)
+            self.close()
+            if err.args[0] == errno.EINVAL:
+                # http://code.google.com/p/pyftpdlib/issues/detail?id=143
+                return
+            self.handle_error()
+            return
+        self.set_terminator(b("\r\n"))
+
+        # connection properties
+        try:
+            self.remote_ip, self.remote_port = self.socket.getpeername()[:2]
+        except socket.error:
+            err = sys.exc_info()[1]
+            # A race condition  may occur if the other end is closing
+            # before we can get the peername, hence ENOTCONN (see issue
+            # #100) while EINVAL can occur on OSX (see issue #143).
+            self.connected = False
+            if err.args[0] in (errno.ENOTCONN, errno.EINVAL):
+                self.close()
+            else:
+                self.handle_error()
+            return
+        else:
+            self.log("FTP session opened (connect)")
+
+        if hasattr(self.socket, 'family'):
+            self._af = self.socket.family
+        else:  # python < 2.5
+            ip, port = self.socket.getsockname()[:2]
+            self._af = socket.getaddrinfo(ip, port, socket.AF_UNSPEC,
+                                         socket.SOCK_STREAM)[0][0]
+
+        # try to handle urgent data inline
+        try:
+            self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_OOBINLINE, 1)
+        except socket.error:
+            pass
+
+        # disable Nagle algorithm for the control socket only, resulting
+        # in significantly better performances
+        if self.tcp_no_delay:
+            try:
+                self.socket.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
+            except socket.error:
+                pass
+
+        # remove this instance from IOLoop's socket_map
+        if not self.connected:
+            self.close()
+            return
+
+        if self.timeout:
+            self._idler = self.ioloop.call_later(self.timeout, self.handle_timeout,
+                                                 _errback=self.handle_error)
+
+    def __repr__(self):
+        status = [self.__class__.__module__+ "." + self.__class__.__name__]
+        status.append("(addr=%s:%s, user=%r)" % (self.remote_ip,
+                      self.remote_port, self.username or ''))
+        return '<%s at %#x>' % (' '.join(status), id(self))
+
+    __str__ = __repr__
+
+    def handle(self):
+        """Return a 220 'ready' response to the client over the command
+        channel.
+        """
+        self.on_connect()
+        if not self._closed and not self._closing:
+            if len(self.banner) <= 75:
+                self.respond("220 %s" % str(self.banner))
+            else:
+                self.push('220-%s\r\n' % str(self.banner))
+                self.respond('220 ')
+
+    def handle_max_cons(self):
+        """Called when limit for maximum number of connections is reached."""
+        msg = "421 Too many connections. Service temporarily unavailable."
+        self.respond_w_warning(msg)
+        # If self.push is used, data could not be sent immediately in
+        # which case a new "loop" will occur exposing us to the risk of
+        # accepting new connections.  Since this could cause asyncore to
+        # run out of fds in case we're using select() on Windows  we
+        # immediately close the channel by using close() instead of
+        # close_when_done(). If data has not been sent yet client will
+        # be silently disconnected.
+        self.close()
+
+    def handle_max_cons_per_ip(self):
+        """Called when too many clients are connected from the same IP."""
+        msg = "421 Too many connections from the same IP address."
+        self.respond_w_warning(msg)
+        self.close_when_done()
+
+    def handle_timeout(self):
+        """Called when client does not send any command within the time
+        specified in <timeout> attribute."""
+        msg = "Control connection timed out."
+        self.respond("421 " + msg, logfun=logger.info)
+        self.close_when_done()
+
+    # --- asyncore / asynchat overridden methods
+
+    def readable(self):
+        # Checking for self.connected seems to be necessary as per:
+        # http://code.google.com/p/pyftpdlib/issues/detail?id=188#c18
+        # In contrast to DTPHandler, here we are not interested in
+        # attempting to receive any further data from a closed socket.
+        return self.connected and AsyncChat.readable(self)
+
+    def writable(self):
+        return self.connected and AsyncChat.writable(self)
+
+    def collect_incoming_data(self, data):
+        """Read incoming data and append to the input buffer."""
+        self._in_buffer.append(data)
+        self._in_buffer_len += len(data)
+        # Flush buffer if it gets too long (possible DoS attacks).
+        # RFC-959 specifies that a 500 response could be given in
+        # such cases
+        buflimit = 2048
+        if self._in_buffer_len > buflimit:
+            self.respond_w_warning('500 Command too long.')
+            self._in_buffer = []
+            self._in_buffer_len = 0
+
+    def decode(self, bytes):
+        return bytes.decode('utf8', self.unicode_errors)
+
+    def found_terminator(self):
+        r"""Called when the incoming data stream matches the \r\n
+        terminator.
+        """
+        if self._idler is not None and not self._idler.cancelled:
+            self._idler.reset()
+
+        line = b('').join(self._in_buffer)
+        try:
+            line = self.decode(line)
+        except UnicodeDecodeError:
+            # By default we'll never get here as we replace errors
+            # but user might want to override this behavior.
+            # RFC-2640 doesn't mention what to do in this case so
+            # we'll just return 501 (bad arg).
+            return self.respond("501 Can't decode command.")
+
+        self._in_buffer = []
+        self._in_buffer_len = 0
+
+        cmd = line.split(' ')[0].upper()
+        arg = line[len(cmd)+1:]
+        try:
+            self.pre_process_command(line, cmd, arg)
+        except UnicodeEncodeError:
+            self.respond("501 can't decode path (server filesystem encoding " \
+                         "is %s)" % sys.getfilesystemencoding())
+
+    def pre_process_command(self, line, cmd, arg):
+        kwargs = {}
+        if cmd == "SITE" and arg:
+            cmd = "SITE %s" % arg.split(' ')[0].upper()
+            arg = line[len(cmd)+1:]
+
+        if cmd != 'PASS':
+            self.logline("<- %s" % line)
+        else:
+            self.logline("<- %s %s" % (line.split(' ')[0], '*' * 6))
+
+        # Recognize those commands having a "special semantic". They
+        # should be sent by following the RFC-959 procedure of sending
+        # Telnet IP/Synch sequence (chr 242 and 255) as OOB data but
+        # since many ftp clients don't do it correctly we check the
+        # last 4 characters only.
+        if not cmd in self.proto_cmds:
+            if cmd[-4:] in ('ABOR', 'STAT', 'QUIT'):
+                cmd = cmd[-4:]
+            else:
+                msg = 'Command "%s" not understood.' % cmd
+                self.respond('500 ' + msg)
+                if cmd:
+                    self.log_cmd(cmd, arg, 500, msg)
+                return
+
+        if not arg and self.proto_cmds[cmd]['arg'] == True:
+            msg = "Syntax error: command needs an argument."
+            self.respond("501 " + msg)
+            self.log_cmd(cmd, "", 501, msg)
+            return
+        if arg and self.proto_cmds[cmd]['arg'] == False:
+            msg = "Syntax error: command does not accept arguments."
+            self.respond("501 " + msg)
+            self.log_cmd(cmd, arg, 501, msg)
+            return
+
+        if not self.authenticated:
+            if self.proto_cmds[cmd]['auth'] or (cmd == 'STAT' and arg):
+                msg = "Log in with USER and PASS first."
+                self.respond("530 " + msg)
+                self.log_cmd(cmd, arg, 530, msg)
+            else:
+                # call the proper ftp_* method
+                self.process_command(cmd, arg)
+                return
+        else:
+            if (cmd == 'STAT') and not arg:
+                self.ftp_STAT(u(''))
+                return
+
+            # for file-system related commands check whether real path
+            # destination is valid
+            if self.proto_cmds[cmd]['perm'] and (cmd != 'STOU'):
+                if cmd in ('CWD', 'XCWD'):
+                    arg = self.fs.ftp2fs(arg or u('/'))
+                elif cmd in ('CDUP', 'XCUP'):
+                    arg = self.fs.ftp2fs(u('..'))
+                elif cmd == 'LIST':
+                    if arg.lower() in ('-a', '-l', '-al', '-la'):
+                        arg = self.fs.ftp2fs(self.fs.cwd)
+                    else:
+                        arg = self.fs.ftp2fs(arg or self.fs.cwd)
+                elif cmd == 'STAT':
+                    if glob.has_magic(arg):
+                        msg = 'Globbing not supported.'
+                        self.respond('550 ' + msg)
+                        self.log_cmd(cmd, arg, 550, msg)
+                        return
+                    arg = self.fs.ftp2fs(arg or self.fs.cwd)
+                elif cmd == 'SITE CHMOD':
+                    if not ' ' in arg:
+                        msg = "Syntax error: command needs two arguments."
+                        self.respond("501 " + msg)
+                        self.log_cmd(cmd, "", 501, msg)
+                        return
+                    else:
+                        mode, arg = arg.split(' ', 1)
+                        arg = self.fs.ftp2fs(arg)
+                        kwargs = dict(mode=mode)
+                else:  # LIST, NLST, MLSD, MLST
+                    arg = self.fs.ftp2fs(arg or self.fs.cwd)
+
+                if not self.fs.validpath(arg):
+                    line = self.fs.fs2ftp(arg)
+                    msg = '"%s" points to a path which is outside ' \
+                          "the user's root directory" % line
+                    self.respond("550 %s." % msg)
+                    self.log_cmd(cmd, arg, 550, msg)
+                    return
+
+            # check permission
+            perm = self.proto_cmds[cmd]['perm']
+            if perm is not None and cmd != 'STOU':
+                if not self.authorizer.has_perm(self.username, perm, arg):
+                    msg = "Not enough privileges."
+                    self.respond("550 " + msg)
+                    self.log_cmd(cmd, arg, 550, msg)
+                    return
+
+            # call the proper ftp_* method
+            self.process_command(cmd, arg, **kwargs)
+
+    def process_command(self, cmd, *args, **kwargs):
+        """Process command by calling the corresponding ftp_* class
+        method (e.g. for received command "MKD pathname", ftp_MKD()
+        method is called with "pathname" as the argument).
+        """
+        if self._closed:
+            return
+        self._last_response = ""
+        method = getattr(self, 'ftp_' + cmd.replace(' ', '_'))
+        method(*args, **kwargs)
+        if self._last_response:
+            code = int(self._last_response[:3])
+            resp = self._last_response[4:]
+            self.log_cmd(cmd, args[0], code, resp)
+
+    def handle_error(self):
+        try:
+            self.log_exception(self)
+            self.close()
+        except Exception:
+            logger.critical(traceback.format_exc())
+
+    def handle_close(self):
+        self.close()
+
+    def close(self):
+        """Close the current channel disconnecting the client."""
+        if not self._closed:
+            self._closed = True
+            self._closing = False
+            self.connected = False
+            AsyncChat.close(self)
+
+            self._shutdown_connecting_dtp()
+
+            if self.data_channel is not None:
+                self.data_channel.close()
+                del self.data_channel
+
+            if self._out_dtp_queue is not None:
+                file = self._out_dtp_queue[2]
+                if file is not None:
+                    file.close()
+            if self._in_dtp_queue is not None:
+                file = self._in_dtp_queue[0]
+                if file is not None:
+                    file.close()
+
+            del self._out_dtp_queue
+            del self._in_dtp_queue
+
+            if self._idler is not None and not self._idler.cancelled:
+                self._idler.cancel()
+
+            # remove client IP address from ip map
+            if self.remote_ip in self.server.ip_map:
+                self.server.ip_map.remove(self.remote_ip)
+
+            if self.fs is not None:
+                self.fs.cmd_channel = None
+                self.fs = None
+            self.log("FTP session closed (disconnect).")
+            # Having self.remote_ip not set means that no connection
+            # actually took place, hence we're not interested in
+            # invoking the callback.
+            if self.remote_ip:
+                self.ioloop.call_later(0, self.on_disconnect,
+                                       _errback=self.handle_error)
+
+    def _shutdown_connecting_dtp(self):
+        """Close any ActiveDTP or PassiveDTP instance waiting to
+        establish a connection (passive or active).
+        """
+        if self._dtp_acceptor is not None:
+            self._dtp_acceptor.close()
+            self._dtp_acceptor = None
+        if self._dtp_connector is not None:
+            self._dtp_connector.close()
+            self._dtp_connector = None
+
+    # --- public callbacks
+    # Note: to run a time consuming task make sure to use a separate
+    # process or thread (see FAQs).
+
+    def on_connect(self):
+        """Called when client connects, *before* sending the initial
+        220 reply.
+        """
+
+    def on_disconnect(self):
+        """Called when connection is closed."""
+
+    def on_login(self, username):
+        """Called on user login."""
+
+    def on_login_failed(self, username, password):
+        """Called on failed login attempt.
+        At this point client might have already been disconnected if it
+        failed too many times.
+        """
+
+    def on_logout(self, username):
+        """Called when user "cleanly" logs out due to QUIT or USER
+        issued twice (re-login). This is not called if the connection
+        is simply closed by client.
+        """
+
+    def on_file_sent(self, file):
+        """Called every time a file has been succesfully sent.
+        "file" is the absolute name of the file just being sent.
+        """
+
+    def on_file_received(self, file):
+        """Called every time a file has been succesfully received.
+        "file" is the absolute name of the file just being received.
+        """
+
+    def on_incomplete_file_sent(self, file):
+        """Called every time a file has not been entirely sent.
+        (e.g. ABOR during transfer or client disconnected).
+        "file" is the absolute name of that file.
+        """
+
+    def on_incomplete_file_received(self, file):
+        """Called every time a file has not been entirely received
+        (e.g. ABOR during transfer or client disconnected).
+        "file" is the absolute name of that file.
+        """
+
+    # --- internal callbacks
+
+    def _on_dtp_connection(self):
+        """Called every time data channel connects, either active or
+        passive.
+
+        Incoming and outgoing queues are checked for pending data.
+        If outbound data is pending, it is pushed into the data channel.
+        If awaiting inbound data, the data channel is enabled for
+        receiving.
+        """
+        # Close accepting DTP only. By closing ActiveDTP DTPHandler
+        # would receive a closed socket object.
+        #self._shutdown_connecting_dtp()
+        if self._dtp_acceptor is not None:
+            self._dtp_acceptor.close()
+            self._dtp_acceptor = None
+
+        # stop the idle timer as long as the data transfer is not finished
+        if self._idler is not None and not self._idler.cancelled:
+            self._idler.cancel()
+
+        # check for data to send
+        if self._out_dtp_queue is not None:
+            data, isproducer, file, cmd = self._out_dtp_queue
+            self._out_dtp_queue = None
+            self.data_channel.cmd = cmd
+            if file:
+                self.data_channel.file_obj = file
+            try:
+                if not isproducer:
+                    self.data_channel.push(data)
+                else:
+                    self.data_channel.push_with_producer(data)
+                if self.data_channel is not None:
+                    self.data_channel.close_when_done()
+            except:
+                # dealing with this exception is up to DTP (see bug #84)
+                self.data_channel.handle_error()
+
+        # check for data to receive
+        elif self._in_dtp_queue is not None:
+            file, cmd = self._in_dtp_queue
+            self.data_channel.file_obj = file
+            self._in_dtp_queue = None
+            self.data_channel.enable_receiving(self._current_type, cmd)
+
+    def _on_dtp_close(self):
+        """Called every time the data channel is closed."""
+        self.data_channel = None
+        if self._quit_pending:
+            self.close()
+        elif self.timeout:
+            # data transfer finished, restart the idle timer
+            if self._idler is not None and not self._idler.cancelled:
+                self._idler.cancel()
+            self._idler = self.ioloop.call_later(self.timeout,
+                                                 self.handle_timeout,
+                                                 _errback=self.handle_error)
+
+    # --- utility
+
+    def push(self, s):
+        asynchat.async_chat.push(self, s.encode('utf8'))
+
+    def respond(self, resp, logfun=logger.debug):
+        """Send a response to the client using the command channel."""
+        self._last_response = resp
+        self.push(resp + '\r\n')
+        if self._log_debug:
+            self.logline('-> %s' % resp, logfun=logfun)
+        else:
+            self.log(resp[4:], logfun=logfun)
+
+    def respond_w_warning(self, resp):
+        self.respond(resp, logfun=logger.warning)
+
+    def push_dtp_data(self, data, isproducer=False, file=None, cmd=None):
+        """Pushes data into the data channel.
+
+        It is usually called for those commands requiring some data to
+        be sent over the data channel (e.g. RETR).
+        If data channel does not exist yet, it queues the data to send
+        later; data will then be pushed into data channel when
+        _on_dtp_connection() will be called.
+
+         - (str/classobj) data: the data to send which may be a string
+            or a producer object).
+         - (bool) isproducer: whether treat data as a producer.
+         - (file) file: the file[-like] object to send (if any).
+        """
+        if self.data_channel is not None:
+            self.respond("125 Data connection already open. Transfer starting.")
+            if file:
+                self.data_channel.file_obj = file
+            try:
+                if not isproducer:
+                    self.data_channel.push(data)
+                else:
+                    self.data_channel.push_with_producer(data)
+                if self.data_channel is not None:
+                    self.data_channel.cmd = cmd
+                    self.data_channel.close_when_done()
+            except:
+                # dealing with this exception is up to DTP (see bug #84)
+                self.data_channel.handle_error()
+        else:
+            self.respond("150 File status okay. About to open data connection.")
+            self._out_dtp_queue = (data, isproducer, file, cmd)
+
+    def flush_account(self):
+        """Flush account information by clearing attributes that need
+        to be reset on a REIN or new USER command.
+        """
+        self._shutdown_connecting_dtp()
+        # if there's a transfer in progress RFC-959 states we are
+        # supposed to let it finish
+        if self.data_channel is not None:
+            if not self.data_channel.transfer_in_progress():
+                self.data_channel.close()
+                self.data_channel = None
+
+        username = self.username
+        if self.authenticated and username:
+            self.on_logout(username)
+        self.authenticated = False
+        self.username = ""
+        self.password = ""
+        self.attempted_logins = 0
+        self._current_type = 'a'
+        self._restart_position = 0
+        self._quit_pending = False
+        self._in_dtp_queue = None
+        self._rnfr = None
+        self._out_dtp_queue = None
+
+    def run_as_current_user(self, function, *args, **kwargs):
+        """Execute a function impersonating the current logged-in user."""
+        self.authorizer.impersonate_user(self.username, self.password)
+        try:
+            return function(*args, **kwargs)
+        finally:
+            self.authorizer.terminate_impersonation(self.username)
+
+    # --- logging wrappers
+
+    # this is defined earlier
+    #log_prefix = '%(remote_ip)s:%(remote_port)s-[%(username)s]'
+
+    def log(self, msg, logfun=logger.info):
+        """Log a message, including additional identifying session data."""
+        prefix = self.log_prefix % self.__dict__
+        logfun("%s %s" % (prefix, msg))
+
+    def logline(self, msg, logfun=logger.debug):
+        """Log a line including additional indentifying session data.
+        By default this is disabled unless logging level == DEBUG.
+        """
+        if self._log_debug:
+            prefix = self.log_prefix % self.__dict__
+            logfun("%s %s" % (prefix, msg))
+
+    def logerror(self, msg):
+        """Log an error including additional indentifying session data."""
+        prefix = self.log_prefix % self.__dict__
+        logger.error("%s %s" % (prefix, msg))
+
+    def log_exception(self, instance):
+        """Log an unhandled exception. 'instance' is the instance
+        where the exception was generated.
+        """
+        logger.exception("unhandled exception in instance %r", instance)
+
+    # the list of commands which gets logged when logging level
+    # is >= logging.INFO
+    log_cmds_list = ["DELE", "RNFR", "RNTO", "MKD", "RMD", "CWD",
+                     "XMKD", "XRMD", "XCWD",
+                     "REIN", "SITE CHMOD"]
+
+    def log_cmd(self, cmd, arg, respcode, respstr):
+        """Log commands and responses in a standardized format.
+        This is disabled in case the logging level is set to DEBUG.
+
+         - (str) cmd:
+            the command sent by client
+
+         - (str) arg:
+            the command argument sent by client.
+            For filesystem commands such as DELE, MKD, etc. this is
+            already represented as an absolute real filesystem path
+            like "/home/user/file.ext".
+
+         - (int) respcode:
+            the response code as being sent by server. Response codes
+            starting with 4xx or 5xx are returned if the command has
+            been rejected for some reason.
+
+         - (str) respstr:
+            the response string as being sent by server.
+
+        By default only DELE, RMD, RNTO, MKD, CWD, ABOR, REIN, SITE CHMOD
+        commands are logged and the output is redirected to self.log
+        method.
+
+        Can be overridden to provide alternate formats or to log
+        further commands.
+        """
+        if not self._log_debug and cmd in self.log_cmds_list:
+            line = '%s %s' % (' '.join([cmd, arg]).strip(), respcode)
+            if str(respcode)[0] in ('4', '5'):
+                line += ' %r' % respstr
+            self.log(line)
+
+    def log_transfer(self, cmd, filename, receive, completed, elapsed, bytes):
+        """Log all file transfers in a standardized format.
+
+         - (str) cmd:
+            the original command who caused the tranfer.
+
+         - (str) filename:
+            the absolutized name of the file on disk.
+
+         - (bool) receive:
+            True if the transfer was used for client uploading (STOR,
+            STOU, APPE), False otherwise (RETR).
+
+         - (bool) completed:
+            True if the file has been entirely sent, else False.
+
+         - (float) elapsed:
+            transfer elapsed time in seconds.
+
+         - (int) bytes:
+            number of bytes transmitted.
+        """
+        line = '%s %s completed=%s bytes=%s seconds=%s' % \
+                (cmd, filename, completed and 1 or 0, bytes, elapsed)
+        self.log(line)
+
+
+    # --- connection
+
+    def _make_eport(self, ip, port):
+        """Establish an active data channel with remote client which
+        issued a PORT or EPRT command.
+        """
+        # FTP bounce attacks protection: according to RFC-2577 it's
+        # recommended to reject PORT if IP address specified in it
+        # does not match client IP address.
+        remote_ip = self.remote_ip
+        if remote_ip.startswith('::ffff:'):
+            # In this scenario, the server has an IPv6 socket, but
+            # the remote client is using IPv4 and its address is
+            # represented as an IPv4-mapped IPv6 address which
+            # looks like this ::ffff:151.12.5.65, see:
+            # http://en.wikipedia.org/wiki/IPv6#IPv4-mapped_addresses
+            # http://tools.ietf.org/html/rfc3493.html#section-3.7
+            # We truncate the first bytes to make it look like a
+            # common IPv4 address.
+            remote_ip = remote_ip[7:]
+        if not self.permit_foreign_addresses and ip != remote_ip:
+            msg = "501 Rejected data connection to foreign address %s:%s." \
+                   % (ip, port)
+            self.respond_w_warning(msg)
+            return
+
+        # ...another RFC-2577 recommendation is rejecting connections
+        # to privileged ports (< 1024) for security reasons.
+        if not self.permit_privileged_ports and port < 1024:
+            msg = '501 PORT against the privileged port "%s" refused.' % port
+            self.respond_w_warning(msg)
+            return
+
+        # close establishing DTP instances, if any
+        self._shutdown_connecting_dtp()
+
+        if self.data_channel is not None:
+            self.data_channel.close()
+            self.data_channel = None
+
+        # make sure we are not hitting the max connections limit
+        if not self.server._accept_new_cons():
+            msg = "425 Too many connections. Can't open data channel."
+            self.respond_w_warning(msg)
+            return
+
+        # open data channel
+        self._dtp_connector = self.active_dtp(ip, port, self)
+
+    def _make_epasv(self, extmode=False):
+        """Initialize a passive data channel with remote client which
+        issued a PASV or EPSV command.
+        If extmode argument is True we assume that client issued EPSV in
+        which case extended passive mode will be used (see RFC-2428).
+        """
+        # close establishing DTP instances, if any
+        self._shutdown_connecting_dtp()
+
+        # close established data connections, if any
+        if self.data_channel is not None:
+            self.data_channel.close()
+            self.data_channel = None
+
+        # make sure we are not hitting the max connections limit
+        if not self.server._accept_new_cons():
+            msg = "425 Too many connections. Can't open data channel."
+            self.respond_w_warning(msg)
+            return
+
+        # open data channel
+        self._dtp_acceptor = self.passive_dtp(self, extmode)
+
+    def ftp_PORT(self, line):
+        """Start an active data channel by using IPv4."""
+        if self._epsvall:
+            self.respond("501 PORT not allowed after EPSV ALL.")
+            return
+        # Parse PORT request for getting IP and PORT.
+        # Request comes in as:
+        # > h1,h2,h3,h4,p1,p2
+        # ...where the client's IP address is h1.h2.h3.h4 and the TCP
+        # port number is (p1 * 256) + p2.
+        try:
+            addr = list(map(int, line.split(',')))
+            if len(addr) != 6:
+                raise ValueError
+            for x in addr[:4]:
+                if not 0 <= x <= 255:
+                    raise ValueError
+            ip = '%d.%d.%d.%d' % tuple(addr[:4])
+            port = (addr[4] * 256) + addr[5]
+            if not 0 <= port <= 65535:
+                raise ValueError
+        except (ValueError, OverflowError):
+            self.respond("501 Invalid PORT format.")
+            return
+        self._make_eport(ip, port)
+
+    def ftp_EPRT(self, line):
+        """Start an active data channel by choosing the network protocol
+        to use (IPv4/IPv6) as defined in RFC-2428.
+        """
+        if self._epsvall:
+            self.respond("501 EPRT not allowed after EPSV ALL.")
+            return
+        # Parse EPRT request for getting protocol, IP and PORT.
+        # Request comes in as:
+        # <d>proto<d>ip<d>port<d>
+        # ...where <d> is an arbitrary delimiter character (usually "|") and
+        # <proto> is the network protocol to use (1 for IPv4, 2 for IPv6).
+        try:
+            af, ip, port = line.split(line[0])[1:-1]
+            port = int(port)
+            if not 0 <= port <= 65535:
+                raise ValueError
+        except (ValueError, IndexError, OverflowError):
+            self.respond("501 Invalid EPRT format.")
+            return
+
+        if af == "1":
+            # test if AF_INET6 and IPV6_V6ONLY
+            if self._af == socket.AF_INET6 and not SUPPORTS_HYBRID_IPV6:
+                self.respond('522 Network protocol not supported (use 2).')
+            else:
+                try:
+                    octs = list(map(int, ip.split('.')))
+                    if len(octs) != 4:
+                        raise ValueError
+                    for x in octs:
+                        if not 0 <= x <= 255:
+                            raise ValueError
+                except (ValueError, OverflowError):
+                    self.respond("501 Invalid EPRT format.")
+                else:
+                    self._make_eport(ip, port)
+        elif af == "2":
+            if self._af == socket.AF_INET:
+                self.respond('522 Network protocol not supported (use 1).')
+            else:
+                self._make_eport(ip, port)
+        else:
+            if self._af == socket.AF_INET:
+                self.respond('501 Unknown network protocol (use 1).')
+            else:
+                self.respond('501 Unknown network protocol (use 2).')
+
+    def ftp_PASV(self, line):
+        """Start a passive data channel by using IPv4."""
+        if self._epsvall:
+            self.respond("501 PASV not allowed after EPSV ALL.")
+            return
+        self._make_epasv(extmode=False)
+
+    def ftp_EPSV(self, line):
+        """Start a passive data channel by using IPv4 or IPv6 as defined
+        in RFC-2428.
+        """
+        # RFC-2428 specifies that if an optional parameter is given,
+        # we have to determine the address family from that otherwise
+        # use the same address family used on the control connection.
+        # In such a scenario a client may use IPv4 on the control channel
+        # and choose to use IPv6 for the data channel.
+        # But how could we use IPv6 on the data channel without knowing
+        # which IPv6 address to use for binding the socket?
+        # Unfortunately RFC-2428 does not provide satisfing information
+        # on how to do that.  The assumption is that we don't have any way
+        # to know wich address to use, hence we just use the same address
+        # family used on the control connection.
+        if not line:
+            self._make_epasv(extmode=True)
+        # IPv4
+        elif line == "1":
+            if self._af != socket.AF_INET:
+                self.respond('522 Network protocol not supported (use 2).')
+            else:
+                self._make_epasv(extmode=True)
+        # IPv6
+        elif line == "2":
+            if self._af == socket.AF_INET:
+                self.respond('522 Network protocol not supported (use 1).')
+            else:
+                self._make_epasv(extmode=True)
+        elif line.lower() == 'all':
+            self._epsvall = True
+            self.respond('220 Other commands other than EPSV are now disabled.')
+        else:
+            if self._af == socket.AF_INET:
+                self.respond('501 Unknown network protocol (use 1).')
+            else:
+                self.respond('501 Unknown network protocol (use 2).')
+
+    def ftp_QUIT(self, line):
+        """Quit the current session disconnecting the client."""
+        if self.authenticated:
+            msg_quit = self.authorizer.get_msg_quit(self.username)
+        else:
+            msg_quit = "Goodbye."
+        if len(msg_quit) <= 75:
+            self.respond("221 %s" % msg_quit)
+        else:
+            self.push("221-%s\r\n" % msg_quit)
+            self.respond("221 ")
+
+        # From RFC-959:
+        # If file transfer is in progress, the connection must remain
+        # open for result response and the server will then close it.
+        # We also stop responding to any further command.
+        if self.data_channel:
+            self._quit_pending = True
+            self.del_channel()
+        else:
+            self._shutdown_connecting_dtp()
+            self.close_when_done()
+        if self.authenticated and self.username:
+            self.on_logout(self.username)
+
+        # --- data transferring
+
+    def ftp_LIST(self, path):
+        """Return a list of files in the specified directory to the
+        client.
+        On success return the directory path, else None.
+        """
+        # - If no argument, fall back on cwd as default.
+        # - Some older FTP clients erroneously issue /bin/ls-like LIST
+        #   formats in which case we fall back on cwd as default.
+        try:
+            iterator = self.run_as_current_user(self.fs.get_list_dir, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            producer = BufferedIteratorProducer(iterator)
+            self.push_dtp_data(producer, isproducer=True, cmd="LIST")
+            return path
+
+    def ftp_NLST(self, path):
+        """Return a list of files in the specified directory in a
+        compact form to the client.
+        On success return the directory path, else None.
+        """
+        try:
+            if self.fs.isdir(path):
+                listing = self.run_as_current_user(self.fs.listdir, path)
+            else:
+                # if path is a file we just list its name
+                self.fs.lstat(path)  # raise exc in case of problems
+                listing = [os.path.basename(path)]
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            self.respond('550 %s.' % _strerror(err))
+        else:
+            data = ''
+            if listing:
+                try:
+                    listing.sort()
+                except UnicodeDecodeError:
+                    # (Python 2 only) might happen on filesystem not
+                    # supporting UTF8 meaning os.listdir() returned a list
+                    # of mixed bytes and unicode strings:
+                    # http://goo.gl/6DLHD
+                    # http://bugs.python.org/issue683592
+                    ls = []
+                    for x in listing:
+                        if not isinstance(x, unicode):
+                            x = unicode(x, 'utf8')
+                        ls.append(x)
+                    listing = sorted(ls)
+                data = '\r\n'.join(listing) + '\r\n'
+            data = data.encode('utf8', self.unicode_errors)
+            self.push_dtp_data(data, cmd="NLST")
+            return path
+
+        # --- MLST and MLSD commands
+
+    # The MLST and MLSD commands are intended to standardize the file and
+    # directory information returned by the server-FTP process.  These
+    # commands differ from the LIST command in that the format of the
+    # replies is strictly defined although extensible.
+
+    def ftp_MLST(self, path):
+        """Return information about a pathname in a machine-processable
+        form as defined in RFC-3659.
+        On success return the path just listed, else None.
+        """
+        line = self.fs.fs2ftp(path)
+        basedir, basename = os.path.split(path)
+        perms = self.authorizer.get_perms(self.username)
+        try:
+            iterator = self.run_as_current_user(self.fs.format_mlsx, basedir,
+                       [basename], perms, self._current_facts, ignore_err=False)
+            data = b('').join(iterator)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            self.respond('550 %s.' % _strerror(err))
+        else:
+            data = data.decode('utf8', self.unicode_errors)
+            # since TVFS is supported (see RFC-3659 chapter 6), a fully
+            # qualified pathname should be returned
+            data = data.split(' ')[0] + ' %s\r\n' % line
+            # response is expected on the command channel
+            self.push('250-Listing "%s":\r\n' % line)
+            # the fact set must be preceded by a space
+            self.push(' ' + data)
+            self.respond('250 End MLST.')
+            return path
+
+    def ftp_MLSD(self, path):
+        """Return contents of a directory in a machine-processable form
+        as defined in RFC-3659.
+        On success return the path just listed, else None.
+        """
+        # RFC-3659 requires 501 response code if path is not a directory
+        if not self.fs.isdir(path):
+            self.respond("501 No such directory.")
+            return
+        try:
+            listing = self.run_as_current_user(self.fs.listdir, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            perms = self.authorizer.get_perms(self.username)
+            iterator = self.fs.format_mlsx(path, listing, perms,
+                       self._current_facts)
+            producer = BufferedIteratorProducer(iterator)
+            self.push_dtp_data(producer, isproducer=True, cmd="MLSD")
+            return path
+
+    def ftp_RETR(self, file):
+        """Retrieve the specified file (transfer from the server to the
+        client).  On success return the file path else None.
+        """
+        rest_pos = self._restart_position
+        self._restart_position = 0
+        try:
+            fd = self.run_as_current_user(self.fs.open, file, 'rb')
+        except (EnvironmentError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+            return
+
+        if rest_pos:
+            # Make sure that the requested offset is valid (within the
+            # size of the file being resumed).
+            # According to RFC-1123 a 554 reply may result in case that
+            # the existing file cannot be repositioned as specified in
+            # the REST.
+            ok = 0
+            try:
+                if rest_pos > self.fs.getsize(file):
+                    raise ValueError
+                fd.seek(rest_pos)
+                ok = 1
+            except ValueError:
+                why = "Invalid REST parameter"
+            except (EnvironmentError, FilesystemError):
+                err = sys.exc_info()[1]
+                why = _strerror(err)
+            if not ok:
+                fd.close()
+                self.respond('554 %s' % why)
+                return
+        producer = FileProducer(fd, self._current_type)
+        self.push_dtp_data(producer, isproducer=True, file=fd, cmd="RETR")
+        return file
+
+    def ftp_STOR(self, file, mode='w'):
+        """Store a file (transfer from the client to the server).
+        On success return the file path, else None.
+        """
+        # A resume could occur in case of APPE or REST commands.
+        # In that case we have to open file object in different ways:
+        # STOR: mode = 'w'
+        # APPE: mode = 'a'
+        # REST: mode = 'r+' (to permit seeking on file object)
+        if 'a' in mode:
+            cmd = 'APPE'
+        else:
+            cmd = 'STOR'
+        rest_pos = self._restart_position
+        self._restart_position = 0
+        if rest_pos:
+            mode = 'r+'
+        try:
+            fd = self.run_as_current_user(self.fs.open, file, mode + 'b')
+        except (EnvironmentError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' %why)
+            return
+
+        if rest_pos:
+            # Make sure that the requested offset is valid (within the
+            # size of the file being resumed).
+            # According to RFC-1123 a 554 reply may result in case
+            # that the existing file cannot be repositioned as
+            # specified in the REST.
+            ok = 0
+            try:
+                if rest_pos > self.fs.getsize(file):
+                    raise ValueError
+                fd.seek(rest_pos)
+                ok = 1
+            except ValueError:
+                why = "Invalid REST parameter"
+            except (EnvironmentError, FilesystemError):
+                err = sys.exc_info()[1]
+                why = _strerror(err)
+            if not ok:
+                fd.close()
+                self.respond('554 %s' % why)
+                return
+
+        if self.data_channel is not None:
+            resp = "Data connection already open. Transfer starting."
+            self.respond("125 " + resp)
+            self.data_channel.file_obj = fd
+            self.data_channel.enable_receiving(self._current_type, cmd)
+        else:
+            resp = "File status okay. About to open data connection."
+            self.respond("150 " + resp)
+            self._in_dtp_queue = (fd, cmd)
+        return file
+
+
+    def ftp_STOU(self, line):
+        """Store a file on the server with a unique name.
+        On success return the file path, else None.
+        """
+        # Note 1: RFC-959 prohibited STOU parameters, but this
+        # prohibition is obsolete.
+        # Note 2: 250 response wanted by RFC-959 has been declared
+        # incorrect in RFC-1123 that wants 125/150 instead.
+        # Note 3: RFC-1123 also provided an exact output format
+        # defined to be as follow:
+        # > 125 FILE: pppp
+        # ...where pppp represents the unique path name of the
+        # file that will be written.
+
+        # watch for STOU preceded by REST, which makes no sense.
+        if self._restart_position:
+            self.respond("450 Can't STOU while REST request is pending.")
+            return
+
+        if line:
+            basedir, prefix = os.path.split(self.fs.ftp2fs(line))
+            prefix = prefix + '.'
+        else:
+            basedir = self.fs.ftp2fs(self.fs.cwd)
+            prefix = 'ftpd.'
+        try:
+            fd = self.run_as_current_user(self.fs.mkstemp, prefix=prefix,
+                                          dir=basedir)
+        except (EnvironmentError, FilesystemError):
+            err = sys.exc_info()[1]
+            # likely, we hit the max number of retries to find out a
+            # file with a unique name
+            if getattr(err, "errno", -1) == errno.EEXIST:
+                why = 'No usable unique file name found'
+            # something else happened
+            else:
+                why = _strerror(err)
+            self.respond("450 %s." % why)
+            return
+
+        if not self.authorizer.has_perm(self.username, 'w', fd.name):
+            try:
+                fd.close()
+                self.run_as_current_user(self.fs.remove, fd.name)
+            except (OSError, FilesystemError):
+                pass
+            self.respond("550 Not enough privileges.")
+            return
+
+        # now just acts like STOR except that restarting isn't allowed
+        filename = os.path.basename(fd.name)
+        if self.data_channel is not None:
+            self.respond("125 FILE: %s" % filename)
+            self.data_channel.file_obj = fd
+            self.data_channel.enable_receiving(self._current_type, "STOU")
+        else:
+            self.respond("150 FILE: %s" % filename)
+            self._in_dtp_queue = (fd, "STOU")
+        return filename
+
+    def ftp_APPE(self, file):
+        """Append data to an existing file on the server.
+        On success return the file path, else None.
+        """
+        # watch for APPE preceded by REST, which makes no sense.
+        if self._restart_position:
+            self.respond("450 Can't APPE while REST request is pending.")
+        else:
+            return self.ftp_STOR(file, mode='a')
+
+    def ftp_REST(self, line):
+        """Restart a file transfer from a previous mark."""
+        if self._current_type == 'a':
+            self.respond('501 Resuming transfers not allowed in ASCII mode.')
+            return
+        try:
+            marker = int(line)
+            if marker < 0:
+                raise ValueError
+        except (ValueError, OverflowError):
+            self.respond("501 Invalid parameter.")
+        else:
+            self.respond("350 Restarting at position %s." % marker)
+            self._restart_position = marker
+
+    def ftp_ABOR(self, line):
+        """Abort the current data transfer."""
+        # ABOR received while no data channel exists
+        if (self._dtp_acceptor is None) and (self._dtp_connector is None) \
+        and (self.data_channel is None):
+            self.respond("225 No transfer to abort.")
+            return
+        else:
+            # a PASV or PORT was received but connection wasn't made yet
+            if self._dtp_acceptor is not None or self._dtp_connector is not None:
+                self._shutdown_connecting_dtp()
+                resp = "225 ABOR command successful; data channel closed."
+
+            # If a data transfer is in progress the server must first
+            # close the data connection, returning a 426 reply to
+            # indicate that the transfer terminated abnormally, then it
+            # must send a 226 reply, indicating that the abort command
+            # was successfully processed.
+            # If no data has been transmitted we just respond with 225
+            # indicating that no transfer was in progress.
+            if self.data_channel is not None:
+                if self.data_channel.transfer_in_progress():
+                    self.data_channel.close()
+                    self.data_channel = None
+                    self.respond("426 Transfer aborted via ABOR.",
+                                 logfun=logging.info)
+                    resp = "226 ABOR command successful."
+                else:
+                    self.data_channel.close()
+                    self.data_channel = None
+                    resp = "225 ABOR command successful; data channel closed."
+        self.respond(resp)
+
+
+        # --- authentication
+
+    def ftp_USER(self, line):
+        """Set the username for the current session."""
+        # RFC-959 specifies a 530 response to the USER command if the
+        # username is not valid.  If the username is valid is required
+        # ftpd returns a 331 response instead.  In order to prevent a
+        # malicious client from determining valid usernames on a server,
+        # it is suggested by RFC-2577 that a server always return 331 to
+        # the USER command and then reject the combination of username
+        # and password for an invalid username when PASS is provided later.
+        if not self.authenticated:
+            self.respond('331 Username ok, send password.')
+        else:
+            # a new USER command could be entered at any point in order
+            # to change the access control flushing any user, password,
+            # and account information already supplied and beginning the
+            # login sequence again.
+            self.flush_account()
+            msg = 'Previous account information was flushed'
+            self.respond('331 %s, send password.' % msg, logfun=logging.info)
+        self.username = line
+
+    _auth_failed_timeout = 5
+
+    def ftp_PASS(self, line):
+        """Check username's password against the authorizer."""
+        if self.authenticated:
+            self.respond("503 User already authenticated.")
+            return
+        if not self.username:
+            self.respond("503 Login with USER first.")
+            return
+
+        try:
+            self.authorizer.validate_authentication(self.username, line, self)
+            home = self.authorizer.get_home_dir(self.username)
+            msg_login = self.authorizer.get_msg_login(self.username)
+        except (AuthenticationFailed, AuthorizerError):
+            def auth_failed(username, password, msg):
+                self.add_channel()
+                if hasattr(self, '_closed') and not self._closed:
+                    self.attempted_logins += 1
+                    if self.attempted_logins >= self.max_login_attempts:
+                        msg += " Disconnecting."
+                        self.respond("530 " + msg)
+                        self.close_when_done()
+                    else:
+                        self.respond("530 " + msg)
+                    self.log("USER '%s' failed login." % username)
+                self.on_login_failed(username, password)
+
+            msg = str(sys.exc_info()[1])
+            if not msg:
+                if self.username == 'anonymous':
+                    msg = "Anonymous access not allowed."
+                else:
+                    msg = "Authentication failed."
+            else:
+                # response string should be capitalized as per RFC-959
+                msg = msg.capitalize()
+            self.del_channel()
+            self.ioloop.call_later(self._auth_failed_timeout, auth_failed,
+                                   self.username, line, msg,
+                                   _errback=self.handle_error)
+            self.username = ""
+        else:
+            if not isinstance(home, unicode):
+                if PY3:
+                    raise ValueError('type(home) != text')
+                else:
+                    warnings.warn(
+                        '%s.get_home_dir returned a non-unicode string; now ' \
+                        'casting to unicode' % self.authorizer.__class__.__name__,
+                         RuntimeWarning)
+                    home = home.decode('utf8')
+
+            if len(msg_login) <= 75:
+                self.respond('230 %s' % msg_login)
+            else:
+                self.push("230-%s\r\n" % msg_login)
+                self.respond("230 ")
+            self.log("USER '%s' logged in." % self.username)
+            self.authenticated = True
+            self.password = line
+            self.attempted_logins = 0
+
+            self.fs = self.abstracted_fs(home, self)
+            self.on_login(self.username)
+
+    def ftp_REIN(self, line):
+        """Reinitialize user's current session."""
+        # From RFC-959:
+        # REIN command terminates a USER, flushing all I/O and account
+        # information, except to allow any transfer in progress to be
+        # completed.  All parameters are reset to the default settings
+        # and the control connection is left open.  This is identical
+        # to the state in which a user finds himself immediately after
+        # the control connection is opened.
+        self.flush_account()
+        # Note: RFC-959 erroneously mention "220" as the correct response
+        # code to be given in this case, but this is wrong...
+        self.respond("230 Ready for new user.")
+
+
+        # --- filesystem operations
+
+    def ftp_PWD(self, line):
+        """Return the name of the current working directory to the client."""
+        # The 257 response is supposed to include the directory
+        # name and in case it contains embedded double-quotes
+        # they must be doubled (see RFC-959, chapter 7, appendix 2).
+        cwd = self.fs.cwd
+        assert isinstance(cwd, unicode), cwd
+        self.respond('257 "%s" is the current directory.'
+                     % cwd.replace('"', '""'))
+
+    def ftp_CWD(self, path):
+        """Change the current working directory.
+        On success return the new directory path, else None.
+        """
+        # Temporarily join the specified directory to see if we have
+        # permissions to do so, then get back to original process's
+        # current working directory.
+        # Note that if for some reason os.getcwd() gets removed after
+        # the process is started we'll get into troubles (os.getcwd()
+        # will fail with ENOENT) but we can't do anything about that
+        # except logging an error.
+        init_cwd = getcwdu()
+        try:
+            self.run_as_current_user(self.fs.chdir, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            cwd = self.fs.cwd
+            assert isinstance(cwd, unicode), cwd
+            self.respond('250 "%s" is the current directory.' % cwd)
+            if getcwdu() != init_cwd:
+                os.chdir(init_cwd)
+            return path
+
+    def ftp_CDUP(self, path):
+        """Change into the parent directory.
+        On success return the new directory, else None.
+        """
+        # Note: RFC-959 says that code 200 is required but it also says
+        # that CDUP uses the same codes as CWD.
+        return self.ftp_CWD(path)
+
+    def ftp_SIZE(self, path):
+        """Return size of file in a format suitable for using with
+        RESTart as defined in RFC-3659."""
+
+        # Implementation note: properly handling the SIZE command when
+        # TYPE ASCII is used would require to scan the entire file to
+        # perform the ASCII translation logic
+        # (file.read().replace(os.linesep, '\r\n')) and then calculating
+        # the len of such data which may be different than the actual
+        # size of the file on the server.  Considering that calculating
+        # such result could be very resource-intensive and also dangerous
+        # (DoS) we reject SIZE when the current TYPE is ASCII.
+        # However, clients in general should not be resuming downloads
+        # in ASCII mode.  Resuming downloads in binary mode is the
+        # recommended way as specified in RFC-3659.
+
+        line = self.fs.fs2ftp(path)
+        if self._current_type == 'a':
+            why = "SIZE not allowed in ASCII mode"
+            self.respond("550 %s." %why)
+            return
+        if not self.fs.isfile(self.fs.realpath(path)):
+            why = "%s is not retrievable" % line
+            self.respond("550 %s." % why)
+            return
+        try:
+            size = self.run_as_current_user(self.fs.getsize, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            self.respond("213 %s" % size)
+
+    def ftp_MDTM(self, path):
+        """Return last modification time of file to the client as an ISO
+        3307 style timestamp (YYYYMMDDHHMMSS) as defined in RFC-3659.
+        On success return the file path, else None.
+        """
+        line = self.fs.fs2ftp(path)
+        if not self.fs.isfile(self.fs.realpath(path)):
+            self.respond("550 %s is not retrievable" % line)
+            return
+        if self.use_gmt_times:
+            timefunc = time.gmtime
+        else:
+            timefunc = time.localtime
+        try:
+            secs = self.run_as_current_user(self.fs.getmtime, path)
+            lmt = time.strftime("%Y%m%d%H%M%S", timefunc(secs))
+        except (ValueError, OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            if isinstance(err, ValueError):
+                # It could happen if file's last modification time
+                # happens to be too old (prior to year 1900)
+                why = "Can't determine file's last modification time"
+            else:
+                why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            self.respond("213 %s" % lmt)
+            return path
+
+    def ftp_MKD(self, path):
+        """Create the specified directory.
+        On success return the directory path, else None.
+        """
+        line = self.fs.fs2ftp(path)
+        try:
+            self.run_as_current_user(self.fs.mkdir, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' %why)
+        else:
+            # The 257 response is supposed to include the directory
+            # name and in case it contains embedded double-quotes
+            # they must be doubled (see RFC-959, chapter 7, appendix 2).
+            self.respond('257 "%s" directory created.' % line.replace('"', '""'))
+            return path
+
+    def ftp_RMD(self, path):
+        """Remove the specified directory.
+        On success return the directory path, else None.
+        """
+        if self.fs.realpath(path) == self.fs.realpath(self.fs.root):
+            msg = "Can't remove root directory."
+            self.respond("550 %s" % msg)
+            return
+        try:
+            self.run_as_current_user(self.fs.rmdir, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            self.respond("250 Directory removed.")
+
+    def ftp_DELE(self, path):
+        """Delete the specified file.
+        On success return the file path, else None.
+        """
+        try:
+            self.run_as_current_user(self.fs.remove, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            self.respond("250 File removed.")
+            return path
+
+    def ftp_RNFR(self, path):
+        """Rename the specified (only the source name is specified
+        here, see RNTO command)"""
+        if not self.fs.lexists(path):
+            self.respond("550 No such file or directory.")
+        elif self.fs.realpath(path) == self.fs.realpath(self.fs.root):
+            self.respond("550 Can't rename home directory.")
+        else:
+            self._rnfr = path
+            self.respond("350 Ready for destination name.")
+
+    def ftp_RNTO(self, path):
+        """Rename file (destination name only, source is specified with
+        RNFR).
+        On success return a (source_path, destination_path) tuple.
+        """
+        if not self._rnfr:
+            self.respond("503 Bad sequence of commands: use RNFR first.")
+            return
+        src = self._rnfr
+        self._rnfr = None
+        try:
+            self.run_as_current_user(self.fs.rename, src, path)
+        except (OSError, FilesystemError):
+            err = sys.exc_info()[1]
+            why = _strerror(err)
+            self.respond('550 %s.' % why)
+        else:
+            self.respond("250 Renaming ok.")
+            return (src, path)
+
+
+        # --- others
+
+    def ftp_TYPE(self, line):
+        """Set current type data type to binary/ascii"""
+        type = line.upper().replace(' ', '')
+        if type in ("A", "L7"):
+            self.respond("200 Type set to: ASCII.")
+            self._current_type = 'a'
+        elif type in ("I", "L8"):
+            self.respond("200 Type set to: Binary.")
+            self._current_type = 'i'
+        else:
+            self.respond('504 Unsupported type "%s".' % line)
+
+    def ftp_STRU(self, line):
+        """Set file structure ("F" is the only one supported (noop))."""
+        stru = line.upper()
+        if stru == 'F':
+            self.respond('200 File transfer structure set to: F.')
+        elif stru in ('P', 'R'):
+           # R is required in minimum implementations by RFC-959, 5.1.
+           # RFC-1123, 4.1.2.13, amends this to only apply to servers
+           # whose file systems support record structures, but also
+           # suggests that such a server "may still accept files with
+           # STRU R, recording the byte stream literally".
+           # Should we accept R but with no operational difference from
+           # F? proftpd and wu-ftpd don't accept STRU R. We just do
+           # the same.
+           #
+           # RFC-1123 recommends against implementing P.
+            self.respond('504 Unimplemented STRU type.')
+        else:
+            self.respond('501 Unrecognized STRU type.')
+
+    def ftp_MODE(self, line):
+        """Set data transfer mode ("S" is the only one supported (noop))."""
+        mode = line.upper()
+        if mode == 'S':
+            self.respond('200 Transfer mode set to: S')
+        elif mode in ('B', 'C'):
+            self.respond('504 Unimplemented MODE type.')
+        else:
+            self.respond('501 Unrecognized MODE type.')
+
+    def ftp_STAT(self, path):
+        """Return statistics about current ftp session. If an argument
+        is provided return directory listing over command channel.
+
+        Implementation note:
+
+        RFC-959 does not explicitly mention globbing but many FTP
+        servers do support it as a measure of convenience for FTP
+        clients and users.
+
+        In order to search for and match the given globbing expression,
+        the code has to search (possibly) many directories, examine
+        each contained filename, and build a list of matching files in
+        memory.  Since this operation can be quite intensive, both CPU-
+        and memory-wise, we do not support globbing.
+        """
+        # return STATus information about ftpd
+        if not path:
+            s = []
+            s.append('Connected to: %s:%s' % self.socket.getsockname()[:2])
+            if self.authenticated:
+                s.append('Logged in as: %s' % self.username)
+            else:
+                if not self.username:
+                    s.append("Waiting for username.")
+                else:
+                    s.append("Waiting for password.")
+            if self._current_type == 'a':
+                type = 'ASCII'
+            else:
+                type = 'Binary'
+            s.append("TYPE: %s; STRUcture: File; MODE: Stream" % type)
+            if self._dtp_acceptor is not None:
+                s.append('Passive data channel waiting for connection.')
+            elif self.data_channel is not None:
+                bytes_sent = self.data_channel.tot_bytes_sent
+                bytes_recv = self.data_channel.tot_bytes_received
+                elapsed_time = self.data_channel.get_elapsed_time()
+                s.append('Data connection open:')
+                s.append('Total bytes sent: %s' % bytes_sent)
+                s.append('Total bytes received: %s' % bytes_recv)
+                s.append('Transfer elapsed time: %s secs' % elapsed_time)
+            else:
+                s.append('Data connection closed.')
+
+            self.push('211-FTP server status:\r\n')
+            self.push(''.join([' %s\r\n' % item for item in s]))
+            self.respond('211 End of status.')
+        # return directory LISTing over the command channel
+        else:
+            line = self.fs.fs2ftp(path)
+            try:
+                iterator = self.run_as_current_user(self.fs.get_list_dir, path)
+            except (OSError, FilesystemError):
+                err = sys.exc_info()[1]
+                why = _strerror(err)
+                self.respond('550 %s.' %why)
+            else:
+                self.push('213-Status of "%s":\r\n' % line)
+                self.push_with_producer(BufferedIteratorProducer(iterator))
+                self.respond('213 End of status.')
+                return path
+
+    def ftp_FEAT(self, line):
+        """List all new features supported as defined in RFC-2398."""
+        features = set(['UTF8', 'TVFS'])
+        features.update([feat for feat in ('EPRT', 'EPSV', 'MDTM', 'SIZE') \
+                        if feat in self.proto_cmds])
+        features.update(self._extra_feats)
+        if 'MLST' in self.proto_cmds or 'MLSD' in self.proto_cmds:
+            facts = ''
+            for fact in self._available_facts:
+                if fact in self._current_facts:
+                    facts += fact + '*;'
+                else:
+                    facts += fact + ';'
+            features.add('MLST ' + facts)
+        if 'REST' in self.proto_cmds:
+            features.add('REST STREAM')
+        features = sorted(features)
+        self.push("211-Features supported:\r\n")
+        self.push("".join([" %s\r\n" % x for x in features]))
+        self.respond('211 End FEAT.')
+
+    def ftp_OPTS(self, line):
+        """Specify options for FTP commands as specified in RFC-2389."""
+        try:
+            if line.count(' ') > 1:
+                raise ValueError('Invalid number of arguments')
+            if ' ' in line:
+                cmd, arg = line.split(' ')
+                if ';' not in arg:
+                    raise ValueError('Invalid argument')
+            else:
+                cmd, arg = line, ''
+            # actually the only command able to accept options is MLST
+            if cmd.upper() != 'MLST' or 'MLST' not in self.proto_cmds:
+                raise ValueError('Unsupported command "%s"' % cmd)
+        except ValueError:
+            err = sys.exc_info()[1]
+            self.respond('501 %s.' % err)
+        else:
+            facts = [x.lower() for x in arg.split(';')]
+            self._current_facts = [x for x in facts if x in self._available_facts]
+            f = ''.join([x + ';' for x in self._current_facts])
+            self.respond('200 MLST OPTS ' + f)
+
+    def ftp_NOOP(self, line):
+        """Do nothing."""
+        self.respond("200 I successfully done nothin'.")
+
+    def ftp_SYST(self, line):
+        """Return system type (always returns UNIX type: L8)."""
+        # This command is used to find out the type of operating system
+        # at the server.  The reply shall have as its first word one of
+        # the system names listed in RFC-943.
+        # Since that we always return a "/bin/ls -lA"-like output on
+        # LIST we  prefer to respond as if we would on Unix in any case.
+        self.respond("215 UNIX Type: L8")
+
+    def ftp_ALLO(self, line):
+        """Allocate bytes for storage (noop)."""
+        # not necessary (always respond with 202)
+        self.respond("202 No storage allocation necessary.")
+
+    def ftp_HELP(self, line):
+        """Return help text to the client."""
+        if line:
+            line = line.upper()
+            if line in self.proto_cmds:
+                self.respond("214 %s" % self.proto_cmds[line]['help'])
+            else:
+                self.respond("501 Unrecognized command.")
+        else:
+            # provide a compact list of recognized commands
+            def formatted_help():
+                cmds = []
+                keys = [x for x in self.proto_cmds.keys() if not x.startswith('SITE ')]
+                keys.sort()
+                while keys:
+                    elems = tuple((keys[0:8]))
+                    cmds.append(' %-6s' * len(elems) % elems + '\r\n')
+                    del keys[0:8]
+                return ''.join(cmds)
+
+            self.push("214-The following commands are recognized:\r\n")
+            self.push(formatted_help())
+            self.respond("214 Help command successful.")
+
+        # --- site commands
+
+    # The user willing to add support for a specific SITE command must
+    # update self.proto_cmds dictionary and define a new ftp_SITE_%CMD%
+    # method in the subclass.
+
+    def ftp_SITE_CHMOD(self, path, mode):
+        """Change file mode.
+        On success return a (file_path, mode) tuple.
+        """
+        # Note: although most UNIX servers implement it, SITE CHMOD is not
+        # defined in any official RFC.
+        try:
+            assert len(mode) in (3, 4)
+            for x in mode:
+                assert 0 <= int(x) <= 7
+            mode = int(mode, 8)
+        except (AssertionError, ValueError):
+            self.respond("501 Invalid SITE CHMOD format.")
+        else:
+            try:
+                self.run_as_current_user(self.fs.chmod, path, mode)
+            except (OSError, FilesystemError):
+                err = sys.exc_info()[1]
+                why = _strerror(err)
+                self.respond('550 %s.' % why)
+            else:
+                self.respond('200 SITE CHMOD successful.')
+                return (path, mode)
+
+    def ftp_SITE_HELP(self, line):
+        """Return help text to the client for a given SITE command."""
+        if line:
+            line = line.upper()
+            if line in self.proto_cmds:
+                self.respond("214 %s" % self.proto_cmds[line]['help'])
+            else:
+                self.respond("501 Unrecognized SITE command.")
+        else:
+            self.push("214-The following SITE commands are recognized:\r\n")
+            site_cmds = []
+            for cmd in sorted(self.proto_cmds.keys()):
+                if cmd.startswith('SITE '):
+                    site_cmds.append(' %s\r\n' % cmd[5:])
+            self.push(''.join(site_cmds))
+            self.respond("214 Help SITE command successful.")
+
+        # --- support for deprecated cmds
+
+    # RFC-1123 requires that the server treat XCUP, XCWD, XMKD, XPWD
+    # and XRMD commands as synonyms for CDUP, CWD, MKD, LIST and RMD.
+    # Such commands are obsoleted but some ftp clients (e.g. Windows
+    # ftp.exe) still use them.
+
+    def ftp_XCUP(self, line):
+        """Change to the parent directory. Synonym for CDUP. Deprecated."""
+        return self.ftp_CDUP(line)
+
+    def ftp_XCWD(self, line):
+        """Change the current working directory. Synonym for CWD. Deprecated."""
+        return self.ftp_CWD(line)
+
+    def ftp_XMKD(self, line):
+        """Create the specified directory. Synonym for MKD. Deprecated."""
+        return self.ftp_MKD(line)
+
+    def ftp_XPWD(self, line):
+        """Return the current working directory. Synonym for PWD. Deprecated."""
+        return self.ftp_PWD(line)
+
+    def ftp_XRMD(self, line):
+        """Remove the specified directory. Synonym for RMD. Deprecated."""
+        return self.ftp_RMD(line)
+
+
+
+# ===================================================================
+# --- FTP over SSL
+# ===================================================================
+
+# requires PyOpenSSL - http://pypi.python.org/pypi/pyOpenSSL
+try:
+    from OpenSSL import SSL
+except ImportError:
+    pass
+else:
+    _ssl_proto_cmds = proto_cmds.copy()
+    _ssl_proto_cmds.update({
+        'AUTH': dict(perm=None, auth=False, arg=True,
+                     help='Syntax: AUTH <SP> TLS|SSL (set up secure control channel).'),
+        'PBSZ': dict(perm=None, auth=False,  arg=True,
+                     help='Syntax: PBSZ <SP> 0 (negotiate TLS buffer).'),
+        'PROT': dict(perm=None, auth=False,  arg=True,
+                     help='Syntax: PROT <SP> [C|P] (set up un/secure data channel).'),
+        })
+
+
+    class SSLConnection(_AsyncChatNewStyle):
+        """An AsyncChat subclass supporting TLS/SSL."""
+
+        _ssl_accepting = False
+        _ssl_established = False
+        _ssl_closing = False
+
+        def __init__(self, *args, **kwargs):
+            super(SSLConnection, self).__init__(*args, **kwargs)
+            self._error = False
+
+        def secure_connection(self, ssl_context):
+            """Secure the connection switching from plain-text to
+            SSL/TLS.
+            """
+            try:
+                self.socket = SSL.Connection(ssl_context, self.socket)
+            except socket.error:
+                self.close()
+            except ValueError:
+                # may happen in case the client connects/disconnects
+                # very quickly
+                if self.socket.fileno() == -1:
+                    return
+                raise
+            else:
+                self.socket.set_accept_state()
+                self._ssl_accepting = True
+
+        def _do_ssl_handshake(self):
+            self._ssl_accepting = True
+            try:
+                self.socket.do_handshake()
+            except (SSL.WantReadError, SSL.WantWriteError):
+                return
+            except SSL.SysCallError:
+                err = sys.exc_info()[1]
+                retval, desc = err.args
+                if (retval == -1 and desc == 'Unexpected EOF') or retval > 0:
+                    return self.handle_close()
+                raise
+            except SSL.Error:
+                return self.handle_failed_ssl_handshake()
+            else:
+                self._ssl_accepting = False
+                self._ssl_established = True
+                self.handle_ssl_established()
+
+        def handle_ssl_established(self):
+            """Called when SSL handshake has completed."""
+            pass
+
+        def handle_ssl_shutdown(self):
+            """Called when SSL shutdown() has completed."""
+            super(SSLConnection, self).close()
+
+        def handle_failed_ssl_handshake(self):
+            raise NotImplementedError("must be implemented in subclass")
+
+        def handle_read_event(self):
+            if self._ssl_accepting:
+                self._do_ssl_handshake()
+            elif self._ssl_closing:
+                self._do_ssl_shutdown()
+            else:
+                super(SSLConnection, self).handle_read_event()
+
+        def handle_write_event(self):
+            if self._ssl_accepting:
+                self._do_ssl_handshake()
+            elif self._ssl_closing:
+                self._do_ssl_shutdown()
+            else:
+                super(SSLConnection, self).handle_write_event()
+
+        def handle_error(self):
+            self._error = True
+            try:
+                raise
+            except (KeyboardInterrupt, SystemExit):
+                raise
+            except:
+                self.log_exception(self)
+            # when facing an unhandled exception in here it's better
+            # to rely on base class (FTPHandler or DTPHandler)
+            # close() method as it does not imply SSL shutdown logic
+            try:
+                super(SSLConnection, self).close()
+            except Exception:
+                logger.critical(traceback.format_exc())
+
+        def send(self, data):
+            try:
+                return super(SSLConnection, self).send(data)
+            except (SSL.WantReadError, SSL.WantWriteError):
+                return 0
+            except SSL.ZeroReturnError:
+                super(SSLConnection, self).handle_close()
+                return 0
+            except SSL.SysCallError:
+                err = sys.exc_info()[1]
+                errnum, errstr = err.args
+                if errnum == errno.EWOULDBLOCK:
+                    return 0
+                elif errnum in _DISCONNECTED or errstr == 'Unexpected EOF':
+                    super(SSLConnection, self).handle_close()
+                    return 0
+                else:
+                    raise
+
+        def recv(self, buffer_size):
+            try:
+                return super(SSLConnection, self).recv(buffer_size)
+            except (SSL.WantReadError, SSL.WantWriteError):
+                return b('')
+            except SSL.ZeroReturnError:
+                super(SSLConnection, self).handle_close()
+                return b('')
+            except SSL.SysCallError:
+                err = sys.exc_info()[1]
+                errnum, errstr = err.args
+                if errnum in _DISCONNECTED or errstr == 'Unexpected EOF':
+                    super(SSLConnection, self).handle_close()
+                    return b('')
+                else:
+                    raise
+
+        def _do_ssl_shutdown(self):
+            """Executes a SSL_shutdown() call to revert the connection
+            back to clear-text.
+            twisted/internet/tcp.py code has been used as an example.
+            """
+            self._ssl_closing = True
+            # since SSL_shutdown() doesn't report errors, an empty
+            # write call is done first, to try to detect if the
+            # connection has gone away
+            try:
+                os.write(self.socket.fileno(), b(''))
+            except (OSError, socket.error):
+                err = sys.exc_info()[1]
+                if err.args[0] in (errno.EINTR, errno.EWOULDBLOCK, errno.ENOBUFS):
+                    return
+                elif err.args[0] in _DISCONNECTED:
+                    return super(SSLConnection, self).close()
+                else:
+                    raise
+            # Ok, this a mess, but the underlying OpenSSL API simply
+            # *SUCKS* and I really couldn't do any better.
+            #
+            # Here we just want to shutdown() the SSL layer and then
+            # close() the connection so we're not interested in a
+            # complete SSL shutdown() handshake, so let's pretend
+            # we already received a "RECEIVED" shutdown notification
+            # from the client.
+            # Once the client received our "SENT" shutdown notification
+            # then we close() the connection.
+            #
+            # Since it is not clear what errors to expect during the
+            # entire procedure we catch them all and assume the
+            # following:
+            # - WantReadError and WantWriteError means "retry"
+            # - ZeroReturnError, SysCallError[EOF], Error[] are all
+            #   aliases for disconnection
+            try:
+                laststate = self.socket.get_shutdown()
+                self.socket.set_shutdown(laststate | SSL.RECEIVED_SHUTDOWN)
+                done = self.socket.shutdown()
+                if not (laststate & SSL.RECEIVED_SHUTDOWN):
+                    self.socket.set_shutdown(SSL.SENT_SHUTDOWN)
+            except (SSL.WantReadError, SSL.WantWriteError):
+                pass
+            except SSL.ZeroReturnError:
+                super(SSLConnection, self).close()
+            except SSL.SysCallError:
+                err = sys.exc_info()[1]
+                errnum, errstr = err.args
+                if errnum in _DISCONNECTED or errstr == 'Unexpected EOF':
+                    super(SSLConnection, self).close()
+                else:
+                    raise
+            except SSL.Error:
+                # see:
+                # http://code.google.com/p/pyftpdlib/issues/detail?id=171
+                # https://bugs.launchpad.net/pyopenssl/+bug/785985
+                err = sys.exc_info()[1]
+                if err.args and not err.args[0]:
+                    pass
+                else:
+                    raise
+            except socket.error:
+                err = sys.exc_info()[1]
+                if err.args[0] in _DISCONNECTED:
+                    super(SSLConnection, self).close()
+                else:
+                    raise
+            else:
+                if done:
+                    self._ssl_established = False
+                    self._ssl_closing = False
+                    self.handle_ssl_shutdown()
+
+        def close(self):
+            if self._ssl_established and not self._error:
+                self._do_ssl_shutdown()
+            else:
+                self._ssl_accepting = False
+                self._ssl_established = False
+                self._ssl_closing = False
+                super(SSLConnection, self).close()
+
+
+    class TLS_DTPHandler(SSLConnection, DTPHandler):
+        """A DTPHandler subclass supporting TLS/SSL."""
+
+        def __init__(self, sock, cmd_channel):
+            super(TLS_DTPHandler, self).__init__(sock, cmd_channel)
+            if self.cmd_channel._prot:
+                self.secure_connection(self.cmd_channel.ssl_context)
+
+        def _use_sendfile(self, producer):
+            return False
+
+        def handle_failed_ssl_handshake(self):
+            # TLS/SSL handshake failure, probably client's fault which
+            # used a SSL version different from server's.
+            # RFC-4217, chapter 10.2 expects us to return 522 over the
+            # command channel.
+            self.cmd_channel.respond("522 SSL handshake failed.")
+            self.cmd_channel.log_cmd("PROT", "P", 522, "SSL handshake failed.")
+            self.close()
+
+
+    class TLS_FTPHandler(SSLConnection, FTPHandler):
+        """A FTPHandler subclass supporting TLS/SSL.
+        Implements AUTH, PBSZ and PROT commands (RFC-2228 and RFC-4217).
+
+        Configurable attributes:
+
+         - (bool) tls_control_required:
+            When True requires SSL/TLS to be established on the control
+            channel, before logging in.  This means the user will have
+            to issue AUTH before USER/PASS (default False).
+
+         - (bool) tls_data_required:
+            When True requires SSL/TLS to be established on the data
+            channel.  This means the user will have to issue PROT
+            before PASV or PORT (default False).
+
+        SSL-specific options:
+
+         - (string) certfile:
+            the path to the file which contains a certificate to be
+            used to identify the local side of the connection.
+            This  must always be specified, unless context is provided
+            instead.
+
+         - (string) keyfile:
+            the path to the file containing the private RSA key;
+            can be omitted if certfile already contains the private
+            key (defaults: None).
+
+         - (int) protocol:
+            specifies which version of the SSL protocol to use when
+            establishing SSL/TLS sessions; clients can then only
+            connect using the configured protocol (defaults to SSLv23,
+            allowing SSLv3 and TLSv1 protocols).
+
+            Possible values:
+            * SSL.SSLv2_METHOD - allow only SSLv2
+            * SSL.SSLv3_METHOD - allow only SSLv3
+            * SSL.SSLv23_METHOD - allow both SSLv3 and TLSv1
+            * SSL.TLSv1_METHOD - allow only TLSv1
+
+          - (instance) context:
+            a SSL Context object previously configured; if specified
+            all other parameters will be ignored.
+            (default None).
+        """
+
+        # configurable attributes
+        tls_control_required = False
+        tls_data_required = False
+        certfile = None
+        keyfile = None
+        ssl_protocol = SSL.SSLv23_METHOD
+        ssl_context = None
+
+        # overridden attributes
+        proto_cmds = _ssl_proto_cmds
+        dtp_handler = TLS_DTPHandler
+
+        def __init__(self, conn, server, ioloop=None):
+            super(TLS_FTPHandler, self).__init__(conn, server, ioloop)
+            if not self.connected:
+                return
+            self._extra_feats = ['AUTH TLS', 'AUTH SSL', 'PBSZ', 'PROT']
+            self._pbsz = False
+            self._prot = False
+            self.ssl_context = self.get_ssl_context()
+
+        @classmethod
+        def get_ssl_context(cls):
+            if cls.ssl_context is None:
+                if cls.certfile is None:
+                    raise ValueError("at least certfile must be specified")
+                cls.ssl_context = SSL.Context(cls.ssl_protocol)
+                if cls.ssl_protocol != SSL.SSLv2_METHOD:
+                    cls.ssl_context.set_options(SSL.OP_NO_SSLv2)
+                else:
+                    warnings.warn("SSLv2 protocol is insecure", RuntimeWarning)
+                cls.ssl_context.use_certificate_file(cls.certfile)
+                if not cls.keyfile:
+                    cls.keyfile = cls.certfile
+                cls.ssl_context.use_privatekey_file(cls.keyfile)
+            return cls.ssl_context
+
+        # --- overridden methods
+
+        def flush_account(self):
+            FTPHandler.flush_account(self)
+            self._pbsz = False
+            self._prot = False
+
+        def process_command(self, cmd, *args, **kwargs):
+            if cmd in ('USER', 'PASS'):
+                if self.tls_control_required and not self._ssl_established:
+                    msg = "SSL/TLS required on the control channel."
+                    self.respond("550 " + msg)
+                    self.log_cmd(cmd, args[0], 550, msg)
+                    return
+            elif cmd in ('PASV', 'EPSV', 'PORT', 'EPRT'):
+                if self.tls_data_required and not self._prot:
+                    msg = "SSL/TLS required on the data channel."
+                    self.respond("550 " + msg)
+                    self.log_cmd(cmd, args[0], 550, msg)
+                    return
+            FTPHandler.process_command(self, cmd, *args, **kwargs)
+
+        # --- new methods
+
+        def handle_failed_ssl_handshake(self):
+            # TLS/SSL handshake failure, probably client's fault which
+            # used a SSL version different from server's.
+            # We can't rely on the control connection anymore so we just
+            # disconnect the client without sending any response.
+            self.log("SSL handshake failed.")
+            self.close()
+
+        def ftp_AUTH(self, line):
+            """Set up secure control channel."""
+            arg = line.upper()
+            if isinstance(self.socket, SSL.Connection):
+                self.respond("503 Already using TLS.")
+            elif arg in ('TLS', 'TLS-C', 'SSL', 'TLS-P'):
+                # From RFC-4217: "As the SSL/TLS protocols self-negotiate
+                # their levels, there is no need to distinguish between SSL
+                # and TLS in the application layer".
+                self.respond('234 AUTH %s successful.' %arg)
+                self.secure_connection(self.ssl_context)
+            else:
+                self.respond("502 Unrecognized encryption type (use TLS or SSL).")
+
+        def ftp_PBSZ(self, line):
+            """Negotiate size of buffer for secure data transfer.
+            For TLS/SSL the only valid value for the parameter is '0'.
+            Any other value is accepted but ignored.
+            """
+            if not isinstance(self.socket, SSL.Connection):
+                self.respond("503 PBSZ not allowed on insecure control connection.")
+            else:
+                self.respond('200 PBSZ=0 successful.')
+                self._pbsz = True
+
+        def ftp_PROT(self, line):
+            """Setup un/secure data channel."""
+            arg = line.upper()
+            if not isinstance(self.socket, SSL.Connection):
+                self.respond("503 PROT not allowed on insecure control connection.")
+            elif not self._pbsz:
+                self.respond("503 You must issue the PBSZ command prior to PROT.")
+            elif arg == 'C':
+                self.respond('200 Protection set to Clear')
+                self._prot = False
+            elif arg == 'P':
+                self.respond('200 Protection set to Private')
+                self._prot = True
+            elif arg in ('S', 'E'):
+                self.respond('521 PROT %s unsupported (use C or P).' %arg)
+            else:
+                self.respond("502 Unrecognized PROT type (use C or P).")

+ 900 - 0
pyftpdlib/ioloop.py

@@ -0,0 +1,900 @@
+#!/usr/bin/env python
+# $Id: ioloop.py 1217 2013-04-18 18:21:44Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+A specialized IO loop on top of asyncore adding support for epoll()
+on Linux and kqueue() and OSX/BSD, dramatically increasing performances
+offered by base asyncore module.
+
+poll() and select() loops are also reimplemented and are an order of
+magnitude faster as they support fd un/registration and modification.
+
+This module is not supposed to be used directly unless you want to
+include a new dispatcher which runs within the main FTP server loop,
+in which case:
+  __________________________________________________________________
+ |                      |                                           |
+ | INSTEAD OF           | ...USE:                                   |
+ |______________________|___________________________________________|
+ |                      |                                           |
+ | asyncore.dispacher   | Acceptor (for servers)                    |
+ | asyncore.dispacher   | Connector (for clients)                   |
+ | asynchat.async_chat  | AsyncChat (for a full duplex connection ) |
+ | asyncore.loop        | FTPServer.server_forever()                |
+ |______________________|___________________________________________|
+
+asyncore.dispatcher_with_send is not supported, same for "map" argument
+for asyncore.loop and asyncore.dispatcher and asynchat.async_chat
+constructors.
+
+Follows a server example:
+
+import socket
+from pyftpdlib.ioloop import IOLoop, Acceptor, AsyncChat
+
+class Handler(AsyncChat):
+
+    def __init__(self, sock):
+        AsyncChat.__init__(self, sock)
+        self.push('200 hello\r\n')
+        self.close_when_done()
+
+class Server(Acceptor):
+
+    def __init__(self, host, port):
+        Acceptor.__init__(self)
+        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.set_reuse_addr()
+        self.bind((host, port))
+        self.listen(5)
+
+    def handle_accepted(self, sock, addr):
+        Handler(sock)
+
+server = Server('localhost', 8021)
+IOLoop.instance().loop()
+"""
+
+import asyncore
+import asynchat
+import errno
+import select
+import os
+import sys
+import traceback
+import time
+import heapq
+import socket
+import logging
+try:
+    import threading
+except ImportError:
+    import dummy_threading as threading
+
+from pyftpdlib._compat import MAXSIZE, callable, b
+from pyftpdlib.log import logger, _config_logging
+
+
+timer = getattr(time, 'monotonic', time.time)
+_read = asyncore.read
+_write = asyncore.write
+
+
+# ===================================================================
+# --- scheduler
+# ===================================================================
+
+class _Scheduler(object):
+    """Run the scheduled functions due to expire soonest (if any)."""
+
+    def __init__(self):
+        # the heap used for the scheduled tasks
+        self._tasks = []
+        self._cancellations = 0
+
+    def poll(self):
+        """Run the scheduled functions due to expire soonest and
+        return the timeout of the next one (if any, else None).
+        """
+        now = timer()
+        calls = []
+        while self._tasks:
+            if now < self._tasks[0].timeout:
+                break
+            call = heapq.heappop(self._tasks)
+            if call.cancelled:
+                self._cancellations -= 1
+            else:
+                calls.append(call)
+
+        for call in calls:
+            if call._repush:
+                heapq.heappush(self._tasks, call)
+                call._repush = False
+                continue
+            try:
+                call.call()
+            except Exception:
+                logger.error(traceback.format_exc())
+
+        # remove cancelled tasks and re-heapify the queue if the
+        # number of cancelled tasks is more than the half of the
+        # entire queue
+        if self._cancellations > 512 \
+          and self._cancellations > (len(self._tasks) >> 1):
+            self.reheapify()
+
+        try:
+            return max(0, self._tasks[0].timeout - now)
+        except IndexError:
+            pass
+
+    def register(self, what):
+        """Register a _CallLater instance."""
+        heapq.heappush(self._tasks, what)
+
+    def unregister(self, what):
+        """Unregister a _CallLater instance.
+        The actual unregistration will happen at a later time though.
+        """
+        self._cancellations += 1
+
+    def reheapify(self):
+        """Get rid of cancelled calls and reinitialize the internal heap."""
+        self._cancellations = 0
+        self._tasks = [x for x in self._tasks if not x.cancelled]
+        heapq.heapify(self._tasks)
+
+
+class _CallLater(object):
+    """Container object which instance is returned by ioloop.call_later()."""
+
+    __slots__ = ('_delay', '_target', '_args', '_kwargs', '_errback', '_sched',
+                 '_repush', 'timeout', 'cancelled')
+
+    def __init__(self, seconds, target, *args, **kwargs):
+        assert callable(target), "%s is not callable" % target
+        assert MAXSIZE >= seconds >= 0, "%s is not greater than or equal " \
+                                        "to 0 seconds" % seconds
+        self._delay = seconds
+        self._target = target
+        self._args = args
+        self._kwargs = kwargs
+        self._errback = kwargs.pop('_errback', None)
+        self._sched = kwargs.pop('_scheduler')
+        self._repush = False
+        # seconds from the epoch at which to call the function
+        if not seconds:
+            self.timeout = 0
+        else:
+            self.timeout = timer() + self._delay
+        self.cancelled = False
+        self._sched.register(self)
+
+    def __lt__(self, other):
+        return self.timeout < other.timeout
+
+    def __le__(self, other):
+        return self.timeout <= other.timeout
+
+    def __repr__(self):
+        if self._target is None:
+            sig = object.__repr__(self)
+        else:
+            sig = repr(self._target)
+        sig += ' args=%s, kwargs=%s, cancelled=%s, secs=%s' \
+                % (self._args or '[]',  self._kwargs or '{}', self.cancelled,
+                   self._delay)
+        return '<%s>' % sig
+
+    __str__ = __repr__
+
+    def _post_call(self, exc):
+        if not self.cancelled:
+            self.cancel()
+
+    def call(self):
+        """Call this scheduled function."""
+        assert not self.cancelled, "already cancelled"
+        exc = None
+        try:
+            try:
+                self._target(*self._args, **self._kwargs)
+            except Exception:
+                exc = sys.exc_info()[1]
+                if self._errback is not None:
+                    self._errback()
+                else:
+                    raise
+        finally:
+            self._post_call(exc)
+
+    def reset(self):
+        """Reschedule this call resetting the current countdown."""
+        assert not self.cancelled, "already cancelled"
+        self.timeout = timer() + self._delay
+        self._repush = True
+
+    def cancel(self):
+        """Unschedule this call."""
+        assert not self.cancelled, "already cancelled"
+        self.cancelled = True
+        self._target = self._args = self._kwargs = self._errback = None
+        self._sched.unregister(self)
+
+
+class _CallEvery(_CallLater):
+    """Container object which instance is returned by IOLoop.call_every()."""
+
+    def _post_call(self, exc):
+        if not self.cancelled:
+            if exc:
+                self.cancel()
+            else:
+                self.timeout = timer() + self._delay
+                self._sched.register(self)
+
+
+class _IOLoop(object):
+    """Base class which will later be referred as IOLoop."""
+
+    READ = 1
+    WRITE = 2
+    _instance = None
+    _lock = threading.Lock()
+    _started_once = False
+
+    def __init__(self):
+        self.socket_map = {}
+        self.sched = _Scheduler()
+
+    @classmethod
+    def instance(cls):
+        """Return a global IOLoop instance."""
+        if cls._instance is None:
+            cls._lock.acquire()
+            try:
+                if cls._instance is None:
+                    cls._instance = cls()
+            finally:
+                cls._lock.release()
+        return cls._instance
+
+    def register(self, fd, instance, events):
+        """Register a fd, handled by instance for the given events."""
+        raise NotImplementedError('must be implemented in subclass')
+
+    def unregister(self, fd):
+        """Register fd."""
+        raise NotImplementedError('must be implemented in subclass')
+
+    def modify(self, fd, events):
+        """Changes the events assigned for fd."""
+        raise NotImplementedError('must be implemented in subclass')
+
+    def poll(self, timeout):
+        """Poll once.  The subclass overriding this method is supposed
+        to poll over the registered handlers and the scheduled functions
+        and then return.
+        """
+        raise NotImplementedError('must be implemented in subclass')
+
+    def loop(self, timeout=None, blocking=True):
+        """Start the asynchronous IO loop.
+
+         - (float) timeout: the timeout passed to the underlying
+           multiplex syscall (select(), epoll() etc.).
+
+         - (bool) blocking: if True poll repeatedly, as long as there
+           are registered handlers and/or scheduled functions.
+           If False poll only once and return the timeout of the next
+           scheduled call (if any, else None).
+        """
+        if not _IOLoop._started_once:
+            _IOLoop._started_once = True
+            if not logging.getLogger().handlers:
+                # If we get to this point it means the user hasn't
+                # configured logging. We want to log by default so
+                # we configure logging ourselves so that it will
+                # print to stderr.
+                _config_logging()
+
+        if blocking:
+            # localize variable access to minimize overhead
+            poll = self.poll
+            socket_map = self.socket_map
+            tasks = self.sched._tasks
+            sched_poll = self.sched.poll
+
+            if timeout is not None:
+                while socket_map:
+                    poll(timeout)
+                    sched_poll()
+            else:
+                soonest_timeout = None
+                while socket_map:
+                    poll(soonest_timeout)
+                    soonest_timeout = sched_poll()
+        else:
+            sched = self.sched
+            if self.socket_map:
+                self.poll(timeout)
+            if sched._tasks:
+                return sched.poll()
+
+    def call_later(self, seconds, target, *args, **kwargs):
+        """Calls a function at a later time.
+        It can be used to asynchronously schedule a call within the polling
+        loop without blocking it. The instance returned is an object that
+        can be used to cancel or reschedule the call.
+
+         - (int) seconds: the number of seconds to wait
+         - (obj) target: the callable object to call later
+         - args: the arguments to call it with
+         - kwargs: the keyword arguments to call it with; a special
+           '_errback' parameter can be passed: it is a callable
+           called in case target function raises an exception.
+       """
+        kwargs['_scheduler'] = self.sched
+        return _CallLater(seconds, target, *args, **kwargs)
+
+    def call_every(self, seconds, target, *args, **kwargs):
+        """Schedules the given callback to be called periodically."""
+        kwargs['_scheduler'] = self.sched
+        return _CallEvery(seconds, target, *args, **kwargs)
+
+    def close(self):
+        """Closes the IOLoop, freeing any resources used."""
+        self.__class__._instance = None
+
+        # free connections
+        instances = sorted(self.socket_map.values(), key=lambda x: x._fileno)
+        for inst in instances:
+            try:
+                inst.close()
+            except OSError:
+                err = sys.exc_info()[1]
+                if err.args[0] != errno.EBADF:
+                    logger.error(traceback.format_exc())
+            except Exception:
+                logger.error(traceback.format_exc())
+        self.socket_map.clear()
+
+        # free scheduled functions
+        for x in self.sched._tasks:
+            try:
+                if not x.cancelled:
+                    x.cancel()
+            except Exception:
+                logger.error(traceback.format_exc())
+        del self.sched._tasks[:]
+
+
+# ===================================================================
+# --- select() - POSIX / Windows
+# ===================================================================
+
+class Select(_IOLoop):
+    """select()-based poller."""
+
+    def __init__(self):
+        _IOLoop.__init__(self)
+        self._r = []
+        self._w = []
+
+    def register(self, fd, instance, events):
+        if fd not in self.socket_map:
+            self.socket_map[fd] = instance
+            if events & self.READ:
+                self._r.append(fd)
+            if events & self.WRITE:
+                self._w.append(fd)
+
+    def unregister(self, fd):
+        try:
+            del self.socket_map[fd]
+        except KeyError:
+            pass
+        for l in (self._r, self._w):
+            try:
+                l.remove(fd)
+            except ValueError:
+                pass
+
+    def modify(self, fd, events):
+        inst = self.socket_map.get(fd)
+        if inst is not None:
+            self.unregister(fd)
+            self.register(fd, inst, events)
+
+    def poll(self, timeout):
+        try:
+            r, w, e = select.select(self._r, self._w, [], timeout)
+        except select.error:
+            err = sys.exc_info()[1]
+            if err.args[0] == errno.EINTR:
+                return
+            raise
+
+        smap_get = self.socket_map.get
+        for fd in r:
+            obj = smap_get(fd)
+            if obj is None or not obj.readable():
+                continue
+            _read(obj)
+        for fd in w:
+            obj = smap_get(fd)
+            if obj is None or not obj.writable():
+                continue
+            _write(obj)
+
+
+# ===================================================================
+# --- poll() / epoll()
+# ===================================================================
+
+class _BasePollEpoll(_IOLoop):
+    """This is common to both poll/epoll implementations which
+    almost share the same interface.
+    Not supposed to be used directly.
+    """
+
+    def __init__(self):
+        _IOLoop.__init__(self)
+        self._poller = self._poller()
+
+    def register(self, fd, instance, events):
+        self._poller.register(fd, events)
+        self.socket_map[fd] = instance
+
+    def unregister(self, fd):
+        try:
+            del self.socket_map[fd]
+        except KeyError:
+            pass
+        else:
+            self._poller.unregister(fd)
+
+    def modify(self, fd, events):
+        self._poller.modify(fd, events)
+
+    def poll(self, timeout):
+        try:
+            events = self._poller.poll(timeout or -1)  # -1 waits indefinitely
+        except (IOError, select.error):  # for epoll() and poll() respectively
+            err = sys.exc_info()[1]
+            if err.args[0] == errno.EINTR:
+                return
+            raise
+        # localize variable access to minimize overhead
+        smap_get = self.socket_map.get
+        for fd, event in events:
+            inst = smap_get(fd)
+            if inst is None:
+                continue
+            if event & self._ERROR and not event & self.READ:
+                inst.handle_close()
+            else:
+                if event & self.READ:
+                    if inst.readable():
+                        _read(inst)
+                if event & self.WRITE:
+                    if inst.writable():
+                        _write(inst)
+
+
+# ===================================================================
+# --- poll() - POSIX
+# ===================================================================
+
+if hasattr(select, 'poll'):
+
+    class Poll(_BasePollEpoll):
+        """poll() based poller."""
+
+        READ = select.POLLIN
+        WRITE = select.POLLOUT
+        _ERROR = select.POLLERR | select.POLLHUP | select.POLLNVAL
+        _poller = select.poll
+
+        # select.poll() on py < 2.6 has no 'modify' method
+        if not hasattr(select.poll(), 'modify'):
+            def modify(self, fd, events):
+                inst = self.socket_map[fd]
+                self.unregister(fd)
+                self.register(fd, inst, events)
+
+        def poll(self, timeout):
+            # poll() timeout is expressed in milliseconds
+            if timeout is not None:
+                timeout = int(timeout * 1000)
+            _BasePollEpoll.poll(self, timeout)
+
+
+# ===================================================================
+# --- epoll() - Linux
+# ===================================================================
+
+if hasattr(select, 'epoll'):
+
+    class Epoll(_BasePollEpoll):
+        """epoll() based poller."""
+
+        READ = select.EPOLLIN
+        WRITE = select.EPOLLOUT
+        _ERROR = select.EPOLLERR | select.EPOLLHUP
+        _poller = select.epoll
+
+        def fileno(self):
+            """Return epoll() fd."""
+            return self._poller.fileno()
+
+        def close(self):
+            _IOLoop.close(self)
+            self._poller.close()
+
+
+# ===================================================================
+# --- kqueue() - BSD / OSX
+# ===================================================================
+
+if hasattr(select, 'kqueue'):
+
+    class Kqueue(_IOLoop):
+        """kqueue() based poller."""
+
+        def __init__(self):
+            _IOLoop.__init__(self)
+            self._kqueue = select.kqueue()
+            self._active = {}
+
+        def fileno(self):
+            """Return kqueue() fd."""
+            return self._poller.fileno()
+
+        def close(self):
+            _IOLoop.close(self)
+            self._kqueue.close()
+
+        def register(self, fd, instance, events):
+            self.socket_map[fd] = instance
+            self._control(fd, events, select.KQ_EV_ADD)
+            self._active[fd] = events
+
+        def unregister(self, fd):
+            try:
+                del self.socket_map[fd]
+                events = self._active.pop(fd)
+            except KeyError:
+                pass
+            else:
+                try:
+                    self._control(fd, events, select.KQ_EV_DELETE)
+                except OSError:
+                    err = sys.exc_info()[1]
+                    if err.errno != errno.EBADF:
+                        raise
+
+        def modify(self, fd, events):
+            instance = self.socket_map[fd]
+            self.unregister(fd)
+            self.register(fd, instance, events)
+
+        def _control(self, fd, events, flags):
+            kevents = []
+            if events & self.WRITE:
+                kevents.append(select.kevent(
+                        fd, filter=select.KQ_FILTER_WRITE, flags=flags))
+            if events & self.READ or not kevents:
+                # always read when there is not a write
+                kevents.append(select.kevent(
+                        fd, filter=select.KQ_FILTER_READ, flags=flags))
+            # even though control() takes a list, it seems to return
+            # EINVAL on Mac OS X (10.6) when there is more than one
+            # event in the list
+            for kevent in kevents:
+                self._kqueue.control([kevent], 0)
+
+        # localize variable access to minimize overhead
+        def poll(self, timeout,
+                       _len=len,
+                       _READ=select.KQ_FILTER_READ,
+                       _WRITE=select.KQ_FILTER_WRITE,
+                       _EOF=select.KQ_EV_EOF,
+                       _ERROR=select.KQ_EV_ERROR):
+            try:
+                kevents = self._kqueue.control(None, _len(self.socket_map),
+                                               timeout)
+            except OSError:
+                err = sys.exc_info()[1]
+                if err.args[0] == errno.EINTR:
+                    return
+                raise
+            for kevent in kevents:
+                inst = self.socket_map.get(kevent.ident)
+                if inst is None:
+                    continue
+                if kevent.filter == _READ:
+                    if inst.readable():
+                        _read(inst)
+                if kevent.filter == _WRITE:
+                    if kevent.flags & _EOF:
+                        # If an asynchronous connection is refused,
+                        # kqueue returns a write event with the EOF
+                        # flag set.
+                        # Note that for read events, EOF may be returned
+                        # before all data has been consumed from the
+                        # socket buffer, so we only check for EOF on
+                        # write events.
+                        inst.handle_close()
+                    else:
+                        if inst.writable():
+                            _write(inst)
+                if kevent.flags & _ERROR:
+                    inst.handle_close()
+
+
+# ===================================================================
+# --- choose the better poller for this platform
+# ===================================================================
+
+if hasattr(select, 'epoll'):     # epoll() - Linux only
+    IOLoop = Epoll
+elif hasattr(select, 'kqueue'):  # kqueue() - BSD / OSX
+    IOLoop = Kqueue
+elif hasattr(select, 'poll'):    # poll() - POSIX
+    IOLoop = Poll
+else:                            # select() - POSIX and Windows
+    IOLoop = Select
+
+
+# ===================================================================
+# --- asyncore dispatchers
+# ===================================================================
+
+# these are overridden in order to register() and unregister()
+# file descriptors against the new pollers
+
+_DISCONNECTED = frozenset((errno.ECONNRESET, errno.ENOTCONN, errno.ESHUTDOWN,
+                           errno.ECONNABORTED, errno.EPIPE, errno.EBADF))
+
+class Acceptor(asyncore.dispatcher):
+    """Same as base asyncore.dispatcher and supposed to be used to
+    accept new connections.
+    """
+
+    def __init__(self, ioloop=None):
+        self.ioloop = ioloop or IOLoop.instance()
+        self._fileno = None  # py < 2.6
+        asyncore.dispatcher.__init__(self)
+
+    def bind_af_unspecified(self, addr):
+        """Same as bind() but guesses address family from addr.
+        Return the address family just determined.
+        """
+        assert self.socket is None
+        host, port = addr
+        if host == "":
+            # When using bind() "" is a symbolic name meaning all
+            # available interfaces. People might not know we're
+            # using getaddrinfo() internally, which uses None
+            # instead of "", so we'll make the conversion for them.
+            host = None
+        err = "getaddrinfo() returned an empty list"
+        info = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
+                                  socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
+        for res in info:
+            self.socket = None
+            self.del_channel()
+            af, socktype, proto, canonname, sa = res
+            try:
+                self.create_socket(af, socktype)
+                self.set_reuse_addr()
+                self.bind(sa)
+            except socket.error:
+                err = sys.exc_info()[1]
+                if self.socket is not None:
+                    self.socket.close()
+                    self.del_channel()
+                    self.socket = None
+                continue
+            break
+        if self.socket is None:
+            self.del_channel()
+            raise socket.error(err)
+        return af
+
+    def add_channel(self, map=None):
+        self.ioloop.register(self._fileno, self, self.ioloop.READ)
+
+    def del_channel(self, map=None):
+        self.ioloop.unregister(self._fileno)
+
+    def listen(self, num):
+        asyncore.dispatcher.listen(self, num)
+        # XXX - this seems to be necessary, otherwise kqueue.control()
+        # won't return listening fd events
+        try:
+            if isinstance(self.ioloop, Kqueue):
+                self.ioloop.modify(self._fileno, self.ioloop.READ)
+        except NameError:
+            pass
+
+    def handle_accept(self):
+        try:
+            sock, addr = self.accept()
+        except TypeError:
+            # sometimes accept() might return None (see issue 91)
+            return
+        except socket.error:
+            err = sys.exc_info()[1]
+            # ECONNABORTED might be thrown on *BSD (see issue 105)
+            if err.args[0] != errno.ECONNABORTED:
+                raise
+        else:
+            # sometimes addr == None instead of (ip, port) (see issue 104)
+            if addr is not None:
+                self.handle_accepted(sock, addr)
+
+    def handle_accepted(self, sock, addr):
+        sock.close()
+        self.log_info('unhandled accepted event', 'warning')
+
+    # overridden for convenience; avoid to reuse address on Windows
+    if (os.name in ('nt', 'ce')) or (sys.platform == 'cygwin'):
+        def set_reuse_addr(self):
+            pass
+
+
+class Connector(Acceptor):
+    """Same as base asyncore.dispatcher and supposed to be used for
+    clients.
+    """
+
+    def connect_af_unspecified(self, addr, source_address=None):
+        """Same as connect() but guesses address family from addr.
+        Return the address family just determined.
+        """
+        assert self.socket is None
+        host, port = addr
+        err = "getaddrinfo() returned an empty list"
+        info = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
+                                  socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
+        for res in info:
+            self.socket = None
+            af, socktype, proto, canonname, sa = res
+            try:
+                self.create_socket(af, socktype)
+                if source_address:
+                    if source_address[0].startswith('::ffff:'):
+                        # In this scenario, the server has an IPv6 socket, but
+                        # the remote client is using IPv4 and its address is
+                        # represented as an IPv4-mapped IPv6 address which
+                        # looks like this ::ffff:151.12.5.65, see:
+                        # http://en.wikipedia.org/wiki/IPv6#IPv4-mapped_addresses
+                        # http://tools.ietf.org/html/rfc3493.html#section-3.7
+                        # We truncate the first bytes to make it look like a
+                        # common IPv4 address.
+                        source_address = (source_address[0][7:],
+                                          source_address[1])
+                    self.bind(source_address)
+                self.connect((host, port))
+            except socket.error:
+                err = sys.exc_info()[1]
+                if self.socket is not None:
+                    self.socket.close()
+                    self.del_channel()
+                    self.socket = None
+                continue
+            break
+        if self.socket is None:
+            self.del_channel()
+            raise socket.error(err)
+        return af
+
+    def add_channel(self, map=None):
+        self.ioloop.register(self._fileno, self, self.ioloop.WRITE)
+
+
+class AsyncChat(asynchat.async_chat):
+    """Same as asynchat.async_chat, only working with the new IO poller
+    and being more clever in avoid registering for read events when
+    it shouldn't.
+    """
+
+    def __init__(self, sock, ioloop=None):
+        self.ioloop = ioloop or IOLoop.instance()
+        self._current_io_events = self.ioloop.READ
+        self._closed = False
+        self._closing = False
+        asynchat.async_chat.__init__(self, sock)
+
+    def add_channel(self, map=None, events=None):
+        self.ioloop.register(self._fileno, self, events or self.ioloop.READ)
+
+    def del_channel(self, map=None):
+        self.ioloop.unregister(self._fileno)
+
+    # send() and recv() overridden as a fix around various bugs:
+    # - http://bugs.python.org/issue1736101
+    # - http://code.google.com/p/pyftpdlib/issues/detail?id=104
+    # - http://code.google.com/p/pyftpdlib/issues/detail?id=109
+
+    def send(self, data):
+        try:
+            return self.socket.send(data)
+        except socket.error:
+            why = sys.exc_info()[1]
+            if why.args[0] in (errno.EWOULDBLOCK, errno.EAGAIN):
+                return 0
+            elif why.args[0] in _DISCONNECTED:
+                self.handle_close()
+                return 0
+            else:
+                raise
+
+    def recv(self, buffer_size):
+        try:
+            data = self.socket.recv(buffer_size)
+            if not data:
+                # a closed connection is indicated by signaling
+                # a read condition, and having recv() return 0.
+                self.handle_close()
+                return b('')
+            else:
+                return data
+        except socket.error:
+            why = sys.exc_info()[1]
+            if why.args[0] in _DISCONNECTED:
+                self.handle_close()
+                return b('')
+            else:
+                raise
+
+    def initiate_send(self):
+        asynchat.async_chat.initiate_send(self)
+        if not self._closed:
+            # if there's still data to send we want to be ready
+            # for writing, else we're only intereseted in reading
+            if not self.producer_fifo:
+                wanted = self.ioloop.READ
+            else:
+                wanted = self.ioloop.READ | self.ioloop.WRITE
+            if self._current_io_events != wanted:
+                self.ioloop.modify(self._fileno, wanted)
+                self._current_io_events = wanted
+
+    def close_when_done(self):
+        if len(self.producer_fifo) == 0:
+            self.handle_close()
+        else:
+            self._closing = True
+            asynchat.async_chat.close_when_done(self)

+ 151 - 0
pyftpdlib/log.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python
+# $Id: log.py 1171 2013-02-19 10:13:09Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+
+"""
+Logging support for pyftpdlib, inspired from Tornado's
+(http://www.tornadoweb.org/).
+
+This is not supposed to be imported/used directly.
+Instead you should use logging.basicConfig before serve_forever().
+"""
+
+import logging
+import sys
+import time
+try:
+    import curses
+except ImportError:
+    curses = None
+    
+from pyftpdlib._compat import unicode
+
+# default logger
+
+logger = logging.getLogger('pyftpdlib')
+
+def _stderr_supports_color():
+    color = False
+    if curses is not None and sys.stderr.isatty():
+        try:
+            curses.setupterm()
+            if curses.tigetnum("colors") > 0:
+                color = True
+        except Exception:
+            pass
+    return color
+
+# configurable options
+LEVEL = logging.INFO
+PREFIX = '[%(levelname)1.1s %(asctime)s]'
+COLOURED = _stderr_supports_color()
+TIME_FORMAT = "%y-%m-%d %H:%M:%S"
+
+
+# taken and adapted from Tornado
+class LogFormatter(logging.Formatter):
+    """Log formatter used in pyftpdlib.
+    Key features of this formatter are:
+
+    * Color support when logging to a terminal that supports it.
+    * Timestamps on every log line.
+    * Robust against str/bytes encoding problems.
+    """
+    def __init__(self, *args, **kwargs):
+        logging.Formatter.__init__(self, *args, **kwargs)
+        self._coloured = COLOURED and _stderr_supports_color()
+        if self._coloured:
+            curses.setupterm()
+            # The curses module has some str/bytes confusion in
+            # python3.  Until version 3.2.3, most methods return
+            # bytes, but only accept strings.  In addition, we want to
+            # output these strings with the logging module, which
+            # works with unicode strings.  The explicit calls to
+            # unicode() below are harmless in python2 but will do the
+            # right conversion in python 3.
+            fg_color = (curses.tigetstr("setaf") or curses.tigetstr("setf") or "")
+            if (3, 0) < sys.version_info < (3, 2, 3):
+                fg_color = unicode(fg_color, "ascii")
+            self._colors = {
+                logging.DEBUG: unicode(curses.tparm(fg_color, 4), "ascii"),   # blue
+                logging.INFO: unicode(curses.tparm(fg_color, 2),  "ascii"),   # green
+                logging.WARNING: unicode(curses.tparm(fg_color, 3), "ascii"), # yellow
+                logging.ERROR: unicode(curses.tparm(fg_color, 1), "ascii")    # red
+            }
+            self._normal = unicode(curses.tigetstr("sgr0"), "ascii")
+
+    def format(self, record):
+        try:
+            record.message = record.getMessage()
+        except Exception:
+            err = sys.exc_info()[1]
+            record.message = "Bad message (%r): %r" % (err, record.__dict__)
+
+        record.asctime = time.strftime(TIME_FORMAT,
+                                       self.converter(record.created))
+        prefix = PREFIX % record.__dict__
+        if self._coloured:
+            prefix = (self._colors.get(record.levelno, self._normal) +
+                      prefix + self._normal)
+
+        # Encoding notes:  The logging module prefers to work with character
+        # strings, but only enforces that log messages are instances of
+        # basestring.  In python 2, non-ascii bytestrings will make
+        # their way through the logging framework until they blow up with
+        # an unhelpful decoding error (with this formatter it happens
+        # when we attach the prefix, but there are other opportunities for
+        # exceptions further along in the framework).
+        #
+        # If a byte string makes it this far, convert it to unicode to
+        # ensure it will make it out to the logs.  Use repr() as a fallback
+        # to ensure that all byte strings can be converted successfully,
+        # but don't do it by default so we don't add extra quotes to ascii
+        # bytestrings.  This is a bit of a hacky place to do this, but
+        # it's worth it since the encoding errors that would otherwise
+        # result are so useless (and tornado is fond of using utf8-encoded
+        # byte strings wherever possible).
+        try:
+            message = unicode(record.message)
+        except UnicodeDecodeError:
+            message = repr(record.message)
+
+        formatted = prefix + " " + message
+        if record.exc_info:
+            if not record.exc_text:
+                record.exc_text = self.formatException(record.exc_info)
+        if record.exc_text:
+            formatted = formatted.rstrip() + "\n" + record.exc_text
+        return formatted.replace("\n", "\n    ")
+
+def _config_logging():
+    channel = logging.StreamHandler()
+    channel.setFormatter(LogFormatter())
+    logger = logging.getLogger()
+    logger.setLevel(LEVEL)
+    logger.addHandler(channel)

+ 535 - 0
pyftpdlib/servers.py

@@ -0,0 +1,535 @@
+#!/usr/bin/env python
+# $Id: servers.py 1219 2013-04-19 14:35:41Z g.rodola $
+
+#  ======================================================================
+#  Copyright (C) 2007-2013 Giampaolo Rodola' <g.rodola@gmail.com>
+#
+#                         All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#  ======================================================================
+
+"""
+This module contains the main FTPServer class which listens on a
+host:port and dispatches the incoming connections to a handler.
+The concurrency is handled asynchronously by the main process thread,
+meaning the handler cannot block otherwise the whole server will hang.
+
+Other than that we have 2 subclasses changing the asynchronous concurrency
+model using multiple threads or processes.
+
+You might be interested in these in case your code contains blocking
+parts which cannot be adapted to the base async model or if the
+underlying filesystem is particularly slow, see:
+
+https://code.google.com/p/pyftpdlib/issues/detail?id=197
+https://code.google.com/p/pyftpdlib/issues/detail?id=212
+
+Two classes are provided:
+
+ - ThreadingFTPServer
+ - MultiprocessFTPServer
+
+...spawning a new thread or process every time a client connects.
+
+The main thread will be async-based and be used only to accept new
+connections.
+Every time a new connection comes in that will be dispatched to a
+separate thread/process which internally will run its own IO loop.
+This way the handler handling that connections will be free to block
+without hanging the whole FTP server.
+"""
+
+import os
+import socket
+import traceback
+import sys
+import errno
+import select
+import logging
+import signal
+import time
+
+from pyftpdlib.log import logger
+from pyftpdlib.ioloop import Acceptor, IOLoop
+
+
+__all__ = ['FTPServer']
+_BSD = 'bsd' in sys.platform
+
+# ===================================================================
+# --- base class
+# ===================================================================
+
+class FTPServer(Acceptor):
+    """Creates a socket listening on <address>, dispatching the requests
+    to a <handler> (typically FTPHandler class).
+
+    Depending on the type of address specified IPv4 or IPv6 connections
+    (or both, depending from the underlying system) will be accepted.
+
+    All relevant session information is stored in class attributes
+    described below.
+
+     - (int) max_cons:
+        number of maximum simultaneous connections accepted (defaults
+        to 512). Can be set to 0 for unlimited but it is recommended
+        to always have a limit to avoid running out of file descriptors
+        (DoS).
+
+     - (int) max_cons_per_ip:
+        number of maximum connections accepted for the same IP address
+        (defaults to 0 == unlimited).
+    """
+
+    max_cons = 512
+    max_cons_per_ip = 0
+
+    def __init__(self, address_or_socket, handler, ioloop=None, backlog=5):
+        """Creates a socket listening on 'address' dispatching
+        connections to a 'handler'.
+
+         - (tuple) address_or_socket: the (host, port) pair on which
+           the command channel will listen for incoming connections or
+           an existent socket object.
+
+         - (instance) handler: the handler class to use.
+
+         - (instance) ioloop: a pyftpdlib.ioloop.IOLoop instance
+
+         - (int) backlog: the maximum number of queued connections
+           passed to listen(). If a connection request arrives when
+           the queue is full the client may raise ECONNRESET.
+           Defaults to 5.
+        """
+        Acceptor.__init__(self, ioloop=ioloop)
+        self.handler = handler
+        self.backlog = backlog
+        self.ip_map = []
+        # in case of FTPS class not properly configured we want errors
+        # to be raised here rather than later, when client connects
+        if hasattr(handler, 'get_ssl_context'):
+            handler.get_ssl_context()
+        if isinstance(address_or_socket, socket.socket):
+            sock = address_or_socket
+            sock.setblocking(0)
+            self.set_socket(sock)
+            if hasattr(sock, 'family'):
+                self._af = sock.family
+            else:
+                # python 2.4
+                ip, port = self.socket.getsockname()[:2]
+                self._af = socket.getaddrinfo(ip, port, socket.AF_UNSPEC,
+                                              socket.SOCK_STREAM)[0][0]
+        else:
+            self._af = self.bind_af_unspecified(address_or_socket)
+        self.listen(backlog)
+
+    @property
+    def address(self):
+        return self.socket.getsockname()[:2]
+
+    def _map_len(self):
+        return len(self.ioloop.socket_map)
+
+    def _accept_new_cons(self):
+        """Return True if the server is willing to accept new connections."""
+        if not self.max_cons:
+            return True
+        else:
+            return self._map_len() <= self.max_cons
+
+    def _log_start(self):
+        if not logging.getLogger().handlers:
+            # If we get to this point it means the user hasn't
+            # configured logger. We want to log by default so
+            # we configure logging ourselves so that it will
+            # print to stderr.
+            from pyftpdlib.ioloop import _config_logging
+            _config_logging()
+
+        if self.handler.passive_ports:
+            pasv_ports = "%s->%s" % (self.handler.passive_ports[0],
+                                     self.handler.passive_ports[-1])
+        else:
+            pasv_ports = None
+        addr = self.address
+        logger.info(">>> starting FTP server on %s:%s, pid=%i <<<"
+                    % (addr[0], addr[1], os.getpid()))
+        logger.info("poller: %r", self.ioloop.__class__)
+        logger.info("masquerade (NAT) address: %s",
+                    self.handler.masquerade_address)
+        logger.info("passive ports: %s", pasv_ports)
+        if os.name == 'posix':
+            logger.info("use sendfile(2): %s", self.handler.use_sendfile)
+
+    def serve_forever(self, timeout=None, blocking=True, handle_exit=True):
+        """Start serving.
+
+         - (float) timeout: the timeout passed to the underlying IO
+           loop expressed in seconds (default 1.0).
+
+         - (bool) blocking: if False loop once and then return the
+           timeout of the next scheduled call next to expire soonest
+           (if any).
+
+         - (bool) handle_exit: when True catches KeyboardInterrupt and
+           SystemExit exceptions (generally caused by SIGTERM / SIGINT
+           signals) and gracefully exits after cleaning up resources.
+           Also, logs server start and stop.
+        """
+        if handle_exit:
+            log = handle_exit and blocking == True
+            if log:
+                self._log_start()
+            try:
+                self.ioloop.loop(timeout, blocking)
+            except (KeyboardInterrupt, SystemExit):
+                pass
+            if blocking:
+                if log:
+                    logger.info(">>> shutting down FTP server (%s active fds) <<<",
+                                self._map_len())
+                self.close_all()
+        else:
+            self.ioloop.loop(timeout, blocking)
+
+    def handle_accepted(self, sock, addr):
+        """Called when remote client initiates a connection."""
+        handler = None
+        ip = None
+        try:
+            handler = self.handler(sock, self, ioloop=self.ioloop)
+            if not handler.connected:
+                return
+
+            ip = addr[0]
+            self.ip_map.append(ip)
+
+            # For performance and security reasons we should always set a
+            # limit for the number of file descriptors that socket_map
+            # should contain.  When we're running out of such limit we'll
+            # use the last available channel for sending a 421 response
+            # to the client before disconnecting it.
+            if not self._accept_new_cons():
+                handler.handle_max_cons()
+                return
+
+            # accept only a limited number of connections from the same
+            # source address.
+            if self.max_cons_per_ip:
+                if self.ip_map.count(ip) > self.max_cons_per_ip:
+                    handler.handle_max_cons_per_ip()
+                    return
+
+            try:
+                handler.handle()
+            except:
+                handler.handle_error()
+            else:
+                return handler
+        except Exception:
+            # This is supposed to be an application bug that should
+            # be fixed. We do not want to tear down the server though
+            # (DoS). We just log the exception, hoping that someone
+            # will eventually file a bug. References:
+            # - http://code.google.com/p/pyftpdlib/issues/detail?id=143
+            # - http://code.google.com/p/pyftpdlib/issues/detail?id=166
+            # - https://groups.google.com/forum/#!topic/pyftpdlib/h7pPybzAx14
+            logger.error(traceback.format_exc())
+            if handler is not None:
+                handler.close()
+            else:
+                if ip is not None and ip in self.ip_map:
+                    self.ip_map.remove(ip)
+
+    def handle_error(self):
+        """Called to handle any uncaught exceptions."""
+        try:
+            raise
+        except Exception:
+            logger.error(traceback.format_exc())
+        self.close()
+
+    def close_all(self):
+        """Stop serving and also disconnects all currently connected
+        clients.
+        """
+        return self.ioloop.close()
+
+
+# ===================================================================
+# --- extra implementations
+# ===================================================================
+
+class _SpawnerBase(FTPServer):
+    """Base class shared by multiple threads/process dispatcher.
+    Not supposed to be used.
+    """
+
+    # how many seconds to wait when join()ing parent's threads
+    # or processes
+    join_timeout = 5
+    _lock = None
+    _exit = None
+
+    def __init__(self, address, handler, ioloop=None):
+        FTPServer.__init__(self, address, handler, ioloop)
+        self._active_tasks = []
+
+    def _start_task(self, *args, **kwargs):
+        raise NotImplementedError('must be implemented in subclass')
+
+    def _current_task(self):
+        raise NotImplementedError('must be implemented in subclass')
+
+    def _map_len(self):
+        raise NotImplementedError('must be implemented in subclass')
+
+    def _loop(self, handler):
+        """Serve handler's IO loop in a separate thread or process."""
+        ioloop = IOLoop()
+        try:
+            handler.ioloop = ioloop
+            try:
+                handler.add_channel()
+            except EnvironmentError:
+                err = sys.exc_info()[1]
+                if err.errno == errno.EBADF:
+                    # we might get here in case the other end quickly
+                    # disconnected (see test_quick_connect())
+                    return
+                else:
+                    raise
+
+            # Here we localize variable access to minimize overhead.
+            poll = ioloop.poll
+            sched_poll = ioloop.sched.poll
+            poll_timeout = getattr(self, 'poll_timeout', None)
+            soonest_timeout = poll_timeout
+
+            while (ioloop.socket_map or ioloop.sched._tasks) and not \
+              self._exit.is_set():
+                try:
+                    if ioloop.socket_map:
+                        poll(timeout=soonest_timeout)
+                    if ioloop.sched._tasks:
+                        soonest_timeout = sched_poll()
+                        # Handle the case where socket_map is emty but some
+                        # cancelled scheduled calls are still around causing
+                        # this while loop to hog CPU resources.
+                        # In theory this should never happen as all the sched
+                        # functions are supposed to be cancel()ed on close()
+                        # but by using threads we can incur into
+                        # synchronization issues such as this one.
+                        # https://code.google.com/p/pyftpdlib/issues/detail?id=245
+                        if not ioloop.socket_map:
+                            ioloop.sched.reheapify() # get rid of cancel()led calls
+                            soonest_timeout = sched_poll()
+                            if soonest_timeout:
+                                time.sleep(min(soonest_timeout, 1))
+                    else:
+                        soonest_timeout = None
+                except (KeyboardInterrupt, SystemExit):
+                    # note: these two exceptions are raised in all sub
+                    # processes
+                    self._exit.set()
+                except select.error:
+                    # on Windows we can get WSAENOTSOCK if the client
+                    # rapidly connect and disconnects
+                    err = sys.exc_info()[1]
+                    if os.name == 'nt' and err.args[0] == 10038:
+                        for fd in list(ioloop.socket_map.keys()):
+                            try:
+                                select.select([fd], [], [], 0)
+                            except select.error:
+                                try:
+                                    logger.info("discarding broken socket %r",
+                                                ioloop.socket_map[fd])
+                                    del ioloop.socket_map[fd]
+                                except KeyError:
+                                    # dict changed during iteration
+                                    pass
+                    else:
+                        raise
+                else:
+                    if poll_timeout:
+                        if soonest_timeout is None \
+                        or soonest_timeout > poll_timeout:
+                            soonest_timeout = poll_timeout
+        finally:
+            try:
+                self._active_tasks.remove(self._current_task())
+            except ValueError:
+                pass
+            ioloop.close()
+
+    def handle_accepted(self, sock, addr):
+        handler = FTPServer.handle_accepted(self, sock, addr)
+        if handler is not None:
+            # unregister the handler from the main IOLoop used by the
+            # main thread to accept connections
+            self.ioloop.unregister(handler._fileno)
+
+            t = self._start_task(target=self._loop, args=(handler,))
+            t.name = repr(addr)
+            t.start()
+
+            self._lock.acquire()
+            try:
+                self._active_tasks.append(t)
+            finally:
+                self._lock.release()
+
+    def _log_start(self):
+        FTPServer._log_start(self)
+        logger.info("dispatcher: %r", self.__class__)
+
+    def serve_forever(self, timeout=None, blocking=True, handle_exit=True):
+        self._exit.clear()
+        if handle_exit:
+            log = handle_exit and blocking == True
+            if log:
+                self._log_start()
+            try:
+                self.ioloop.loop(timeout, blocking)
+            except (KeyboardInterrupt, SystemExit):
+                pass
+            if blocking:
+                if log:
+                    logger.info(">>> shutting down FTP server (%s active " \
+                                "workers) <<<", self._map_len())
+                self.close_all()
+        else:
+            self.ioloop.loop(timeout, blocking)
+
+    def close_all(self):
+        tasks = self._active_tasks[:]
+        # this must be set after getting active tasks as it causes
+        # thread objects to get out of the list too soon
+        self._exit.set()
+        if tasks and hasattr(tasks[0], 'terminate'):
+            # we're dealing with subprocesses
+            for t in tasks:
+                try:
+                    if not _BSD:
+                        t.terminate()
+                    else:
+                        # XXX - On FreeBSD using SIGTERM doesn't work
+                        # as the process hangs on kqueue.control() or
+                        # select.select(). Use SIGKILL instead.
+                        os.kill(t.pid, signal.SIGKILL)
+                except OSError:
+                    err = sys.exc_info()[1]
+                    if err.errno != errno.ESRCH:
+                        raise
+
+        self._wait_for_tasks(tasks)
+        del self._active_tasks[:]
+        FTPServer.close_all(self)
+
+    def _wait_for_tasks(self, tasks):
+        """Wait for threads or subprocesses to terminate."""
+        warn = logger.warning
+        for t in tasks:
+            t.join(self.join_timeout)
+            if t.is_alive():
+                # Thread or process is still alive. If it's a process
+                # attempt to send SIGKILL as last resort.
+                # Set timeout to None so that we will exit immediately
+                # in case also other threads/processes are hanging.
+                self.join_timeout = None
+                if hasattr(t, 'terminate'):
+                    msg = "could not terminate process %r" % t
+                    if not _BSD:
+                        warn(msg + "; sending SIGKILL as last resort")
+                        try:
+                            os.kill(t.pid, signal.SIGKILL)
+                        except OSError:
+                            err = sys.exc_info()[1]
+                            if err.errno != errno.ESRCH:
+                                raise
+                    else:
+                        warn(msg)
+                else:
+                    warn("thread %r didn't terminate; ignoring it", t)
+
+
+try:
+    import threading
+except ImportError:
+    pass
+else:
+    __all__ += ['ThreadedFTPServer']
+
+    # compatibility with python <= 2.6
+    if not hasattr(threading.Thread, 'is_alive'):
+        threading.Thread.is_alive = threading.Thread.isAlive
+
+    class ThreadedFTPServer(_SpawnerBase):
+        """A modified version of base FTPServer class which spawns a
+        thread every time a new connection is established.
+        """
+        # The timeout passed to thread's IOLoop.poll() call on every
+        # loop. Necessary since threads ignore KeyboardInterrupt.
+        poll_timeout = 1.0
+        _lock = threading.Lock()
+        _exit = threading.Event()
+
+        # compatibility with python <= 2.6
+        if not hasattr(_exit, 'is_set'):
+            _exit.is_set = _exit.isSet
+
+        def _start_task(self, *args, **kwargs):
+            return threading.Thread(*args, **kwargs)
+
+        def _current_task(self):
+            return threading.currentThread()
+
+        def _map_len(self):
+            return threading.activeCount()
+
+
+if os.name == 'posix':
+    try:
+        import multiprocessing
+    except ImportError:
+        pass
+    else:
+        __all__ += ['MultiprocessFTPServer']
+
+        class MultiprocessFTPServer(_SpawnerBase):
+            """A modified version of base FTPServer class which spawns a
+            process every time a new connection is established.
+            """
+            _lock = multiprocessing.Lock()
+            _exit = multiprocessing.Event()
+
+            def _start_task(self, *args, **kwargs):
+                return multiprocessing.Process(*args, **kwargs)
+
+            def _current_task(self):
+                return multiprocessing.current_process()
+
+            def _map_len(self):
+                return len(multiprocessing.active_children())

+ 31 - 0
zftpd

@@ -0,0 +1,31 @@
+#! /bin/sh
+# chkconfig: 2345 85 15 
+### BEGIN INIT INFO
+# chkconfig: 345 80 20
+# description: Tomcat is the Apache Servlet Engine
+# processname: tomcat
+# pidfile: /home/zhry077/ZPCDBSER/ZPC_DB_SER.pid
+### END INIT INFO
+zftpPath="/home/ftpd/"
+start(){
+    python ${zftpPath}zftpd.py start ${zftpPath}
+}
+stop(){
+    python ${zftpPath}zftpd.py stop ${zftpPath}
+}
+restart(){
+    python ${zftpPath}zftpd.py restart ${zftpPath}
+}
+case "$1" in
+  start)
+  #start
+  start
+  ;;
+  stop)
+  stop
+  ;;
+  restart)
+  restart
+  ;;
+esac
+exit 0 

+ 28 - 0
zftpd.conf

@@ -0,0 +1,28 @@
+[!ftpd]
+servername=ZPC ZRY FTP Server.
+#FTP欢迎消息中显示的FTP服务器名称
+ttl=10
+#自动刷新配置文件的时间间隔
+host=0.0.0.0
+port=21
+#上面两行应该不要我说
+maxconnect=256
+maxconperuser=5
+#最大线程数和连接数
+logfile=/home/ftpd/ftpd.log
+lineslogfile=/home/ftpd/ftpd.lines.log
+#日志,详细日志和PIDFILE
+
+[home]
+#账户名
+dir=/home/
+#目录
+password=sdhfhbdwtffd
+#密码
+access=elradfmw
+#权限(详见readme)
+
+[etc]
+dir=/etc/
+password=mndfertggvsfdgd
+access=elradfmw

+ 0 - 0
zftpd.err


+ 0 - 0
zftpd.in


+ 0 - 0
zftpd.out


+ 151 - 0
zftpd.py

@@ -0,0 +1,151 @@
+#!/usr/bin/ python
+# -*- coding: utf-8 -*-
+import sys
+import os
+import traceback
+import time
+import ConfigParser
+from threading import Thread as thread
+from pyftpdlib import ftpserver
+from daemonlib import Daemon
+now = lambda: time.strftime("[%Y-%b-%d %H:%M:%S]")
+
+def standard_logger(msg):
+    f1.write("%s %s\n" %(now(), msg))
+    f1.flush()
+
+def line_logger(msg):
+    f2.write("%s %s\n" %(now(), msg))
+    f2.flush()
+
+class UpConf(thread):
+    def run(self):
+        while 1:
+            time.sleep(ttl)
+            user=[]
+            __user_table = {}
+            conf=ConfigParser.ConfigParser()
+            conf.read(currdir+currname+".conf")
+            sections=conf.sections()
+            for i in sections:
+                if i != '!ftpd':
+                    if not os.path.isdir(conf.get(i,'dir')):
+                        continue
+                    else:
+                        user.append(i)
+            for i in user:
+                __dir=conf.get(i,'dir')
+                __password=conf.get(i,'password')
+                __power=conf.get(i,'access')
+                __dir = os.path.realpath(__dir)
+                authorizer._check_permissions(i, __power)
+                dic = {'pwd': str(__password),
+                       'home': __dir,
+                       'perm': __power,
+                       'operms': {},
+                       'msg_login': str("Login successful."),
+                       'msg_quit': str("Goodbye.")
+                       }
+                __user_table[i] = dic
+            authorizer.user_table=__user_table
+def mainfunc():
+    global authorizer
+    global ttl
+    user=[]
+    __user_table = {}
+    addr=("",21)
+    ttl=60
+    ftpserver.log = standard_logger
+    ftpserver.logline = line_logger
+    authorizer = ftpserver.DummyAuthorizer()
+    conf=ConfigParser.ConfigParser()
+    conf.read(currdir+currname+".conf")
+    sections=conf.sections()
+    global f1,f2
+    for i in sections:
+        if i != '!ftpd':
+	    if not os.path.isdir(conf.get(i,'dir')):
+                print('No such directory: "%s"' % conf.get(i,'dir'))
+                continue
+            else:
+                user.append(i)
+        if i == '!ftpd':
+            addr=(conf.get('!ftpd','host'),int(conf.get('!ftpd','port')))
+            ttl=int(conf.get('!ftpd','ttl'))
+            _servername=conf.get('!ftpd','servername')
+	    sys.stdout.write("Server Name: %s\n"%_servername)
+	    sys.stdout.flush()
+            _maxcon=int(conf.get('!ftpd','maxconnect'))
+            _maxconperu=int(conf.get('!ftpd','maxconperuser'))
+            f1 = open(conf.get('!ftpd','logfile'), 'a')
+            f2 = open(conf.get('!ftpd','lineslogfile'), 'a')
+            if ttl==0:ttl=60
+    for i in user:
+        __dir=conf.get(i,'dir')
+        __password=conf.get(i,'password')
+        __power=conf.get(i,'access')
+        __dir = os.path.realpath(__dir)
+        authorizer._check_permissions(i, __power)
+        dic = {'pwd': str(__password),
+               'home': __dir,
+               'perm': __power,
+               'operms': {},
+               'msg_login': str("Login successful."),
+               'msg_quit': str("Goodbye.")
+               }
+        __user_table[i] = dic
+    authorizer.user_table=__user_table
+    ftp_handler = ftpserver.FTPHandler
+    ftp_handler.authorizer = authorizer
+    ftp_handler.banner = _servername
+    ftpd = ftpserver.FTPServer(addr, ftp_handler)
+    ftpd.max_cons = _maxcon
+    ftpd.max_cons_per_ip = _maxconperu
+    UpConf().start()
+    line_logger('~~~~~~~~~Serve forever......')
+    ftpd.serve_forever()
+
+class MyDaemon(Daemon):
+    def _run(self):
+        #while True:
+            reload(sys)
+            sys.setdefaultencoding('utf-8')
+	    #mainfunc()
+            try:
+            	mainfunc()
+            except Exception,e:
+                time.sleep(1)
+		s=traceback.format_exc()
+                sys.stderr.write("\n"+now()+"Application was shutdown by a fatal error.\n%s\n"%s)
+		sys.stderr.flush()
+            sys.stdout.write("A Server Session End. Restart a new session...\n")
+            sys.stdout.flush()
+
+if __name__ == '__main__':
+    global currdir,currname
+    currname='zftpd'
+    print "ZFTP Server Init!"
+    if len(sys.argv) == 3:
+        if 'start' == sys.argv[1]:
+			currdir=sys.argv[2]
+            print "Starting...                                            [\033[1;32;40mOK\033[0m]"
+            daemon = MyDaemon('/dev/shm/'+currname+'.pid',currdir+currname+'.in',currdir+currname+'.out',currdir+currname+'.err')
+			daemon.start()
+        elif 'stop' == sys.argv[1]:
+			currdir=sys.argv[2]
+            print "Stopping...                                            [\033[1;32;40mOK\033[0m]"
+            daemon = MyDaemon('/dev/shm/'+currname+'.pid',currdir+currname+'.in',currdir+currname+'.out',currdir+currname+'.err')
+            daemon.stop()
+        elif 'restart' == sys.argv[1]:
+			currdir=sys.argv[2]
+            print "Stopping...                                            [\033[1;32;40mOK\033[0m]"
+            print "Starting...                                            [\033[1;32;40mOK\033[0m]"
+            daemon = MyDaemon('/dev/shm/'+currname+'.pid',currdir+currname+'.in',currdir+currname+'.out',currdir+currname+'.err')
+            daemon.restart()
+        else:
+            print "Unknown command"
+            sys.exit(2)
+        sys.exit(0)
+    else:
+        print "usage: zftpd start|stop|restart path" % sys.argv[0]
+        sys.exit(2)