pax_global_header00006660000000000000000000000064147057257760014536gustar00rootroot0000000000000052 comment=f155ab72b4113db9f2663b42361dfd89db43a78d pyftpdlib-release-2.0.1/000077500000000000000000000000001470572577600151515ustar00rootroot00000000000000pyftpdlib-release-2.0.1/.github/000077500000000000000000000000001470572577600165115ustar00rootroot00000000000000pyftpdlib-release-2.0.1/.github/FUNDING.yml000066400000000000000000000005531470572577600203310ustar00rootroot00000000000000# These are supported funding model platforms github: giampaolo patreon: # Replace with a single Patreon username open_collective: # ko_fi: # Replace with a single Ko-fi username community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry custom: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=ZSSF7G42VA2XE pyftpdlib-release-2.0.1/.github/workflows/000077500000000000000000000000001470572577600205465ustar00rootroot00000000000000pyftpdlib-release-2.0.1/.github/workflows/tests.yml000066400000000000000000000032141470572577600224330ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions on: [push, pull_request] name: tests concurrency: group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.sha || '' }} cancel-in-progress: true jobs: # Run unit tests tests: name: "py-${{ matrix.python-version }}, ${{ matrix.os }}" runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false matrix: python-version: ["3.13"] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install pydeps ${{ matrix.os }} if: matrix.os == 'windows-latest' run: | python.exe -m pip install --upgrade pypiwin32 wmi pyopenssl psutil pytest pyasyncore pyasynchat - name: Install pydeps ${{ matrix.os }} if: matrix.os != 'windows-latest' run: | make install-pydeps-test - name: Tests run: | make test # Run linters linters: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - name: 'Run linters' run: | python3 -m pip install --upgrade black ruff rstcheck toml-sort sphinx-rtd-theme python3 -m pip freeze make lint-all pyftpdlib-release-2.0.1/.gitignore000066400000000000000000000001671470572577600171450ustar00rootroot00000000000000syntax: glob *.al *.bak *.egg-info *.la *.lo *.o *.orig *.pyc *.pyd *.rej *.so *.swp .cache/ .idea/ .tox/ build/ dist/ pyftpdlib-release-2.0.1/CREDITS000066400000000000000000000106471470572577600162010ustar00rootroot00000000000000 Intro ===== I would like to recognize some of the people who have been instrumental in the development of pyftpdlib. I'm sure I am forgetting some people (feel free to email me), but here is a short list. It's modeled after the Linux CREDITS file where the fields are: name (N), e-mail (E), web-address (W), country (C) description (D). Really thanks to all of you. Maintainers =========== N: Giampaolo Rodola' C: Italy E: g.rodola@gmail.com D: Original pyftpdlib author and maintainer N: Jay Loden C: NJ, USA E: jloden@gmail.com W: http://jayloden.com/About.htm D: OS X and Linux platform development/testing N: Silas Sewell C: Denver, USA E: silas@sewell.ch W: http://www.silassewell.com D: Fedora port maintainer N: Li-Wen Hsu C: Taiwan E: lwhsu@lwhsu.org W: http://lwhsu.org D: FreeBSD port maintainer Contributors ============ N: hakai W: https://github.com/hakai D: 613 N: Benedikt McMullin W: https://github.com/moben D: 612 N: Tailing Yuan W: https://github.com/yuantailing D: 505 N: Anatoly Techtonik C: Belarus E: techtonik@gmail.com D: Inclusion of pyftpdlib in Far Manager, a file and archive manager for Windows http://www.farmanager.com/enforum/viewtopic.php?t=640&highlight=&sid=12d4d90f27f421243bcf7a0e3c516efb. N: Andrew Shulgin C: Ukraine E: andrewshulginua@gmail.com D: Fixing CR duplication in ASCII mode downloads. https://github.com/giampaolo/pyftpdlib/pull/492 N: Arkadiusz Wahlig C: Germany W: http://arkadiusz-wahlig.blogspot.com D: Inclusion of pyftpdlib in gpftpd project, an FTP daemon for managing files hosted on Google Pages (http://arkadiusz-wahlig.blogspot.com/2008/04/hosting-files-on-google.html). N: Walco van Loon C: Netherlands E: walco@n--tree.net D: Inclusion of pyftpdlib in aksy project (http://walco.n--tree.net/projects/aksy). N: Stephane Travostino E: stephane.travostino@combo.cc D: Inclusion of pyftpdlib in Shareme project (http://bbs.archlinux.org/viewtopic.php?pid=431474). N: Shinya Okano C: Japan E: xxshss@yahoo.co.jp D: Japanese translation of pyftpdlib announces. Inclusion of pyftpdlib in unbox-ftpd project (http://code.google.com/p/unboxftpd). N: Yan Raber C: Italy E: yanraber@gmail.com D: Fix of Issue #9 (Path traversal vulnerability) N: Alex Martelli C: Italy E: aleax@gmail.com D: Various useful suggestions N: Knic C: Redmond, USA E: oneeyedelf1@googlemail.com D: Bug report #24 (some troubles on PythonCE), tester for various platforms including Windows Mobile, Windows Server 2008 and various 64 bit OSes. N: Greg Copeland E: gcopeland@efjohnson.com D: Bug report #16 (Extending compatibility with older python versions) N: Roger Erens E: rogererens@gmail.com D: Bug report affecting unix_ftpd.py's authorizer N: Coronado Ivan D: Bug report #70 (Wrong NOOP response code) N: Rauli Ruohonen D: Bug report #71 (Socket handles are leaked when a data transfer is in progress and user QUITs) N: Equand E: equand@gmail.com D: Bug report #77 (incorrect OOB data management on FreeBSD). N: fogwraith E: fogwraith@gmail.com D: Bug report #80 (demo/md5_ftpd.py should use hashlib module instead of the deprecated md5 module) N: Bram Neijt E: bneijt@gmail.com D: Bug report #100, author of ShareFTP project: http://git.logfish.net/shareftp.git/ N: Michele Petrazzo C: Italy E: michele.petrazzo@gmail.com D: Creation of the demo/unix_daemon.py code. N: Wentao Han D: Bug report #104 (socket.accept() might return None instead of a valid address and EPIPE might be thrown by asyncore on OS X). N: Ben Timby E: btimby@gmail.com C: USA D: issues 127, 229, 265 N: Bernd Deichmann W: http://deichmann-edv.de/ D: issue 156 N: Andrew Scheller E: gcode@loowis.durge.org C: UK D: issue 158, 161, 163, 167, 175 N: guppyism E: guppyism@gmail.com D: issue 187 N: Darren Worrall E: darren.worrall@gmail.com D: issue 198 N: Suzan Shakya E: suzan.shakya@gmail.com D: issue 211 N: Claus Klein E: claus.klein.sha@googlemail.com D: issue 232 N: Arfrever Frehtes Taifersar Arahesis E: arfrever.fta@gmail.com D: issue 239 N: tlockert D: issue 238 N: Juan J. Martinez E: juan@memset.com D: issue 263 N: Michael Ross E: michaelross757@gmail.com D: issue 273 E: dn@devicenull.org D: issue 280 N: Dmitry Panov C: UK E: dop251@gmail.com D: issue 262 M: PonyPC W: https://github.com/PonyPC D: issue 403 N: Tory Law E: git@torypages.com C: Canada D: Command line argument enhancements N: Tahir Ijaz W: https://github.com/tahirijaz24 D: issue 201, 435 N: Sandro W: https://github.com/penguinpee D: issue 637 pyftpdlib-release-2.0.1/HISTORY.rst000066400000000000000000001152641470572577600170550ustar00rootroot00000000000000Bug tracker at https://github.com/giampaolo/pyftpdlib/issues Version: 2.0.1 - 2024-10-22 =========================== **Enhancements** * #651: Add ``make install-pydeps-test`` and ``make install-pydeps-dev`` targets. They can be used to install dependencies meant for running tests and for local development. They can also be installed via ``pip install .[test]`` and ``pip install .[dev]``. **Bug fixes** * #650: file operations on Windows with Python 3.13 give "Permission denied". Version: 2.0.0 - 2024-09-04 =========================== **Enhancements** * #625: exposed a new ``FTPHandler.encoding`` attribute defaulting to ``'utf-8'``. It can be used to change the encoding used for client / server communication. * #629: removed Python 2.7 support. * #637: remove copies of asyncore.py and asynchat.py. Use backports from PYPI instead. (patch by @penguinpee) * #639: set default SSL version from deprecated ``SSLv23_METHOD`` to newer ``TLS_SERVER_METHOD``. This is the setting recommended by latest OpenSSL doc, and includes the TLSv1, TLSv1.1, TLSv1.2, TLSv1.3. Versions SSLv2 and SSLv3 are disabled. **Notes about backward compatibility** * #629: Python 2.7 is no longer supported. * #629: pysendfile module is no longer a required dependency, because we ceased support for Python 2. * #639: (FTPS) SSLv2 and SSLv3 connections are no longer accepted when client connects. Version: 1.5.10 - 2024-06-23 ============================ **Enhancements** * #621: use black formatter. * #626: use argparse instead of deprecated optparse. * #628: use pytest instead of unittest. * #632: add ability to run tests in parallel with `make test-parallel`. **Bug fixes** * #627: PermissionError may occur on Windows when binding ports from a pre-configured PASV range. Version: 1.5.9 - 2023-10-25 =========================== **Enhancements** - #611: use `ruff` code style checker instead of flake8 + isort (much faster + makes many more code quality checks). **Bug fixes** - #604: client connection may be reset in PASV/EPSV mode during TLS handshake. (patch by Benedikt McMullin) - #607: possible infinite wait in Epoll (patch by @stat1c-void) - #607: possible infinite traceback printing in DTPHandler (patch by @stat1c-void) - #613: (CRITICAL) bugfix for TLS disconnect causing 100% CPU usage. (patch by @hakai) - #614: close connection on SSL EOF error, instead of erroneously replying with "226 Transfer completed." Version: 1.5.8 - 2023-10-02 =========================== **Enhancements** - #586: removed Python 2.6 support. - #591: speedup logging by 28% by using `logging._srcfile = None` trick. This avoids calling `calling sys._getframe()` for each log record. - #605: added support for Python 3.12. Version: 1.5.7 - 2022-10-04 =========================== **Enhancements** - #544: replace Travis with Github Actions for CI testing. **Bug fixes** - #481: fix [WinError 10038] an operation was attempted on something that is not a socket. (patch by Tailing Yuan) - #578, [critical]: FTPS broke with PyOpenSSL version 22.1.0. Version: 1.5.6 - 2020-02-16 =========================== **Enhancements** - #467: added pre-fork concurrency model, spawn()ing worker processes to split load. - #520: directory LISTing is now 3.7x times faster. Version: 1.5.5 - 2019-04-04 =========================== **Enhancements** - #495: colored test output. **Bug fixes** - #492: CRLF line endings are replaced with CRCRLF in ASCII mode downloads. - #496: import error due to multiprocessing.Lock() bug. Version: 1.5.4 - 2018-05-04 =========================== **Enhancements** - #463: FTPServer class can now be used as a context manager. **Bug fixes** - #431: Ctrl-C doesn't exit `python -m pyftpdlib` on Windows. - #436: ThreadedFTPServer.max_cons is evaluated threading.activeCount(). If the user uses threads of its own it will consume the number of max_cons. - #447: ThreadedFTPServer and MultiprocessFTPServer do not join() tasks which are no longer consuming resources. Version: 1.5.3 - 2017-11-04 =========================== **Enhancements** - #201: implemented SITE MFMT command which changes file modification time. (patch by Tahir Ijaz) - #327: add username and password command line options - #433: documentation moved to readthedocs: https://pyftpdlib.readthedocs.io **Bug fixes** - #403: fix duplicated output log. (path by PonyPC) - #414: Respond successfully to STOR only after closing file handle. Version: 1.5.2 - 2017-04-06 =========================== **Enhancements** - #378: SSL security was improved by disabling SSLv2, SSLv3 and SSL_COMPRESSION features. New TLS_FTPHandler's ssl_options class attribute was added. - #380: AbstractedFS.listdir() can now return also a generator (not only a list). **Bug fixes** - #367: ThreadedFTPServer no longer hangs if close_all() is called. - #394: ETIMEDOUT is not treated as an alias for "connection lost". - #400: QUIT can raise KeyError in case the user hasn't logged in yet and sends QUIT command. Version: 1.5.1 - 2016-05-02 =========================== **Bug fixes** - #381: an extraneous file was accidentally added to the tarball, causing issues with Python 3. Version: 1.5.0 - 2015-12-13 =========================== **Enhancements** - #304: remove deprecated items from 1.0.0 which were left in place for backward compatibility - #324: FTPHandler.started attribute, to figure out when client connected. - #340: dropped python 2.4 and 2.5 support. - #344: bench.py script --ssl option. - #346: provide more debugging info. - #348: FTPHandler has a new "auth_failed_timeout" class attribute (previously this was called _auth_failed_timeout). - #350: tests now live in pyftpdlib module namespace. - #351: fallback on using plain send() if sendfile() fails and no data has been transmitted yet. - #356: sendfile() is now used in case we're using SSL but data connection is in clear text. - #361: benchmark script now allows to benchmark downloads and uploads only (instead of both). - #362: 'ftpbench' script is now installed as a system script on 'setup.py install'. - #365: TLS FTP server is now 25% faster when dealing with clear-text connections. **Bug fixes** - #302: setup.py should not require pysendfile on Python >= 3.3. - #313: configuring root logger has no effect on pyftpdlib logging. - #329: IOLoop throws OSError on Linux. - #337: MultiprocessFTPServer and ThreadedFTPServer do not accept backlog argument. - #338: benchmark script uses old psutil API. - #343: recv() does not handle EBUSY. - #347: SSL WantReadError and WantWriteError errors are not properly taken into account. - #357: python -m pyftpdlib --verbose option doesn't work **Incompatible API changes** - FTPHandler._auth_failed_timeout has been renamed to FTPHandler.auth_failed_timeout. Version: 1.4.0 - Date: 2014-06-03 ================================= **Enhancements** - #284: documentation was turned into RsT and hosted on pythonhosted.org - #293: project was migrated from Google Code to Github. Code was migrated from SVN to GIT. - #294: use tox to automate testing on multiple python versions. - #295: use travis-ci for continuous test integration. - #298: pysendfile and PyOpenSSL are now listed as extra deps in setup.py. **Bug fixes** - #296: TypeError when using recent version of PyOpenSSL. - #297: listen() may raise EBADF in case of many connections. Version: 1.3.1 - Date: 2014-04-12 ================================= **Enhancements** - #262: FTPS is now able to load a certificate chain file. (patch by Dmitry Panov) - #277: added a make file for running tests and for other repetitive tasks (also for Windows). - #281: tarballs are now hosted on PYPI. - #282: support for /dev/poll on Solaris. - #285: test suite requires unittest2 module on python < 2.7. **Bug fixes** - #261: (FTPS) SSL shutdown does not properly work on Windows. - #280: (Python 2) unable to complete directory listing with invalid UTF8 characters. (patch by dn@devicenull.org) - #283: always use a single 'pyftpdlib' logger. Version: 1.3.0 - Date: 2013-11-07 ================================= **Enhancements** - #253: benchmark script's new --timeout option. - #270: new -V / --verbose cmdline option to enable a more verbose logging. **Bug fixes** - #254: bench.py script hadn't been ported to Python 3. - #263: MultiprocessFTPServer leaks memory and file descriptors. (patch by Juan J. Martinez) - #265: FTPServer class cannot be used with Circus. - #272: pyftpdlib fails when imported on OpenBSD because of Python bug https://bugs.python.org/issue3770 - #273: IOLoop.fileno() on BSD systems raises AttributeError. (patch by Michael Ross) Version: 1.2.0 - Date: 2013-04-22 ================================= **Enhancements** - #250: added FTPServer's backlog argument controlling the queue of accepted connections. - #251: IOLoop.fileno() method for epoll() and kqueue() pollers. - #252: FTPServer 'address' parameter can also be an existent socket object. **Bug fixes** - #245: ThreadedFTPServer hogs all CPU resources after a client connects. Version: 1.1.0 - Date: 2013-04-09 ================================= **Enhancements** - #240: enabled "python -m pyftpdlib" cmdline syntax and got rid of "python -m pyftpdlib.ftpserver" syntax which was deprecated in 1.0.0. - #241: empty passwords are now allowed for anonymous and other users. - #244: pysendfile is no longer a dependency if we're on Python >= 3.3 as os.sendfile() will be used instead. - #247: on python 3.3 use time.monotonic() instead of time.time() so that the scheduler won't break in case of system clock updates. - #248: bench.py memory usage is highly overestimated. **Bug fixes** - #238: username is not logged in case of failed authentication. (patch by tlockert) - #243: an erroneous error message is given in case the address passed to bind() is already in use. - #245: ThreadedFTPServer hogs all CPU resources after a client connects. - #246: ThrottledDTPHandler was broken. **Incompatible API changes** - "python -m pyftpdlib.ftpserver" cmdline syntax doesn't work anymore Version: 1.0.1 - Date: 2013-02-22 ================================= **Bug fixes** - #236: MultiprocessFTPServer and ThreadedFTPServer hanging in case of failed authentication. Version: 1.0.0 - Date: 2013-02-19 ================================= **Enhancements** - #76: python 3.x porting. - #198: full unicode support (RFC-2640). - #203: asyncore IO loop has been rewritten from scratch and now supports epoll() on Linux and kqueue() on OSX/BSD. Also select() (Windows) and poll() pollers have been rewritten resulting in pyftpdlib being an order of magnitude faster and more scalable than ever. - #204: a new FilesystemError exception class is available in order send custom error strings to client from an AbstracteFS subclass. - #207: added on_connect() and on_disconnect() callback methods to FTPHandler class. - #212: provided two new classes: Logging_managementpyftpdlib.servers.ThreadedFTPServer and pyftpdlib.servers.MultiprocessFTPServer (POSIX only). They can be used to change the base async-based concurrecy model and use a multiple threads / processes based approach instead. Your FTPHandler subclasses will finally be free to block! ;) - #219: it is not possible to instantiate different FPTS classes using different SSL certificates. - #213: DummyAuthorizer.validate_authentication() has changed in that it no longer returns a bool but instead raises AuthenticationFailed() exception to signal a failed authentication. This has been done in order allow customized error messages on failed auth. Also it now expects a third 'handler' argument which is passed in order to allow IP-based authentication logic. Existing code overriding validate_authentication() must be changed in accordance. - #223: ftpserver.py has been split in submodules. - #225: logging module is now used for logging. ftpserver.py's log(), logline() and logerror() functions are deprecated. - #231: FTPHandler.ftp_* methods implementing filesystem-related commands now return a meaningful value on success (tipically the path name). - #234: FTPHandler and DTPHandler class provide a nice __repr__. - #235: FTPServer.serve_forever() has a new handle_exit parameter which can be set to False in order to avoid handling SIGTERM/SIGINT signals and logging server start and stop. - #236: big logging refactoring; by default only useful messages are logged (as opposed to *all* commands and responses exchanged by client and server). Also, FTPHandler has a new 'log_prefix' attribute which can be used to format every line logged. **Bug fixes** - #131: IPv6 dual-stack support was broken. - #206: can't change directory (CWD) when using UnixAuthorizer and process cwd is == "/root". - #211: pyftpdlib doesn't work if deprecated py-sendfile 1.2.4 module is installed. - #215: usage of FTPHandler.sleeping attribute could lead to 100% CPU usage. FTPHandler.sleeping is now removed. self.add_channel() / self.del_channel() should be used instead. - #222: an unhandled exception in handle_error() or close() can cause server to crash. - #229: backslashes on UNIX are not handled properly. - #232: hybrid IPv4/IPv6 support is broken. (patch by Claus Klein) **New modules** All the code contained in pyftpdlib/ftpserver.py and pyftpdlib/contrib namespaces has been moved here: - pyftpdlib.authorizers - pyftpdlib.filesystems - pyftpdlib.servers - pyftpdlib.handlers - pyftpdlib.log **New APIs** - pyftpdlib.authorizers.AuthenticationFailed - pyftpdlib.filesystems.FilesystemError - pyftpdlib.servers.ThreadedFTPServer - pyftpdlib.servers.MultiprocessFTPServer - pyftpdlib.handlers.FTPHandler's on_connect() and on_disconnect() callbacks. - pyftpdlib.handlers.FTPHandler.ftp_* methods return a meaningful value on success. - FTPServer, FTPHandler, DTPHandler new ioloop attribute. - pyftpdlib.lib.ioloop.IOLoop class (not supposed to be used directly) - pyftpdlib.handlers.FTPHandler.log_prefix **Deprecated name spaces** - pyftpdlib.ftpserver - pyftpdlib.contrib.* **Incompatible API changes** - All the main classes have been extracted from ftpserver.py and split into sub modules. +-------------------------------------+---------------------------------------+ | Before | After | +=====================================+=======================================+ | pyftpdlib.ftpserver.FTPServer | pyftpdlib.servers.FTPServer | +-------------------------------------+---------------------------------------+ | pyftpdlib.ftpserver.FTPHandler | pyftpdlib.handlers.FTPHandler | +-------------------------------------+---------------------------------------+ | pyftpdlib.ftpserver.DTPHandler | pyftpdlib.handlers.DTPHandler | +-------------------------------------+---------------------------------------+ | pyftpdlib.ftpserver.DummyAuthorizer | pyftpdlib.authorizers.DummyAuthorizer | +-------------------------------------+---------------------------------------+ | pyftpdlib.ftpserver.AbstractedFS | pyftpdlib.filesystems.AbstractedFS | +-------------------------------------+---------------------------------------+ Same for pyftpflib.contribs namespace which is deprecated. +-------------------------------------------------+-----------------------------------------+ | Before | After | +=================================================+=========================================+ | pyftpdlib.contrib.handlers.TLS_FTPHandler | pyftpdlib.handlers.TLS_FTPHandler | +-------------------------------------------------+-----------------------------------------+ | pyftpdlib.contrib.authorizers.UnixAuthorizer | pyftpdlib.authorizers.UnixAuthorizer | +-------------------------------------------------+-----------------------------------------+ | pyftpdlib.contrib.authorizers.WindowsAuthorizer | pyftpdlib.authorizers.WindowsAuthorizer | +-------------------------------------------------+-----------------------------------------+ | pyftpdlib.contrib.filesystems.UnixFilesystem | pyftpdlib.filesystems.UnixFilesystem | +-------------------------------------------------+-----------------------------------------+ Both imports from pyftpdlib.ftpserver and pyftpdlib.contrib.* will still work though and will raise a DeprecationWarning exception. **Other incompatible API changes** - DummyAuthorizer.validate_authentication() signature has changed. A third 'handler' argument is now expected. - DummyAuthorizer.validate_authentication() is no longer expected to return a bool. Instead it is supposed to raise AuthenticationFailed(msg) in case of failed authentication and return None otherwise. (see issue 213) - ftpserver.py's log(), logline() and logerror() functions are deprecated. logging module is now used instead. See: https://pyftpdlib.readthedocs.io/en/latest/tutorial.html#logging-management - Unicode is now used instead of bytes pretty much everywhere. - FTPHandler.__init__() and TLS_FTPHandler.__init__() signatures have changed: from __init__(conn, server) to __init__(conn, server, ioloop=None) - FTPServer.server_forever() signature has changed: from serve_forever(timeout=1.0, use_poll=False, count=None) to serve_forever(timeout=1.0, blocking=True, handle_exit=True) - FTPServer.close_all() signature has changed: from close_all(ignore_all=False) to close_all() - FTPServer.serve_forever() and FTPServer.close_all() are no longer class methods. - asyncore.dispatcher and asynchat.async_chat classes has been replaced by: pyftpdlib.ioloop.Acceptor pyftpdlib.ioloop.Connector pyftpdlib.ioloop.AsyncChat Any customization relying on asyncore (e.g. use of asyncore.socket_map to figure out the number of connected clients) will no longer work. - pyftpdlib.ftpserver.CallLater and pyftpdlib.ftpserver.CallEvery are deprecated. Instead, use self.ioloop.call_later() and self.ioloop.call_every() from within the FTPHandler. Also delay() method of the returned object has been removed. - FTPHandler.sleeping attribute is removed. self.add_channel() and self.del_channel() should be used to pause and restart the handler. **Minor incompatible API changes** - FTPHandler.respond(resp) -> FTPHandler.respond(resp, logfun=logger.debug) - FTPHandler.log(resp) -> FTPHandler.log(resp, logfun=logger.info) - FTPHandler.logline(resp) -> FTPHandler.logline(resp, logfun=logger.debug) Version: 0.7.0 - Date: 2012-01-25 ================================= **Enhancements** - #152: uploads (from server to client) on UNIX are now from 2x (Linux) to 3x (OSX) faster because of sendfile(2) system call usage. - #155: AbstractedFS "root" and "cwd" are no longer read-only properties but can be set via setattr(). - #168: added FTPHandler.logerror() method. It can be overridden to provide more information (e.g. username) when logging exception tracebacks. - #174: added support for SITE CHMOD command (change file mode). - #177: setuptools is now used in setup.py - #178: added anti flood script in demo directory. - #181: added CallEvery class to call a function every x seconds. - #185: pass Debian licenscheck tool. - #189: the internal scheduler has been rewritten from scratch and it is an order of magnitude faster, especially for operations like cancel() which are involved when clients are disconnected (hence invoked very often). Some benchmarks: schedule: +0.5x, reschedule: +1.7x, cancel: +477x (with 1 million scheduled functions), run: +8x Also, a single scheduled function now consumes 1/3 of the memory thanks to ``__slots__`` usage. - #195: enhanced unix_daemon.py script which (now uses python-daemon library). - #196: added callback for failed login attempt. - #200: FTPServer.server_forever() is now a class method. - #202: added benchmark script. **Bug fixes** - #156: data connection must be closed before sending 226/426 reply. This was against RFC-959 and was causing problems with older FTP clients. - #161: MLSD 'unique' fact can provide the same value for files having a similar device/inode but that in fact are different. (patch by Andrew Scheller) - #162: (FTPS) SSL shutdown() is not invoked for the control connection. - #163: FEAT erroneously reports MLSD. (patch by Andrew Scheller) - #166: (FTPS) an exception on send() can cause server to crash (DoS). - #167: fix some typos returned on HELP. - #170: PBSZ and PROT commands are now allowed before authentication fixing problems with non-compliant FTPS clients. - #171: (FTPS) an exception when shutting down the SSL layer can cause server to crash (DoS). - #173: file last modification time shown in LIST response might be in a language different than English causing problems with some clients. - #175: FEAT response now omits to show those commands which are removed from proto_cmds map. - #176: SO_REUSEADDR option is now used for passive data sockets to prevent server running out of free ports when using passive_ports directive. - #187: match proftpd LIST format for files having last modification time > 6 months. - #188: fix maximum recursion depth exceeded exception occurring if client quickly connects and disconnects data channel. - #191: (FTPS) during SSL shutdown() operation the server can end up in an infinite loop hogging CPU resources. - #199: UnixAuthorizer with require_valid_shell option is broken. **Major API changes since 0.6.0** - New FTPHandler.use_sendfile attribute. - sendfile() is now automatically used instead of plain send() if pysendfile module is installed. - FTPServer.serve_forever() is a classmethod. - AbstractedFS root and cwd properties can now be set via setattr(). - New CallLater class. - New FTPHandler.on_login_failed(username, password) method. - New FTPHandler.logerror(msg) method. - New FTPHandler.log_exception(instance) method. Version: 0.6.0 - Date: 2011-01-24 ================================= **Enhancements** - #68: added full FTPS (FTP over SSL/TLS) support provided by new TLS_FTPHandler class defined in pyftpdlib.contrib.handlers module. - #86: pyftpdlib now reports all ls and MDTM timestamps as GMT times, as recommended in RFC-3659. A FTPHandler.use_gmt_times attributed has been added and can be set to False in case local times are desired instead. - #124: pyftpdlib now accepts command line options to configure a stand alone anonymous FTP server when running pyftpdlib with python's -m option. - #125: logs are now provided in a standardized format parsable by log analyzers. FTPHandler class provides two new methods to standardize both commands and transfers logging: log_cmd() and log_transfer(). - #127: added FTPHandler.masquerade_address_map option which allows you to define multiple 1 to 1 mappings in case you run a FTP server with multiple private IP addresses behind a NAT firewall with multiple public IP addresses. - #128: files and directories owner and group names and os.readlink are now resolved via AbstractedFS methods instead of in format_list(). - #129, #139: added 4 new callbacks to FTPHandler class: on_incomplete_file_sent(), on_incomplete_file_received(), on_login() and on_logout(). - #130: added UnixAuthorizer and WindowsAuthorizer classes defined in the new pyftpdlib.contrib.authorizers module. - #131: pyftpdlib is now able to serve both IPv4 and IPv6 at the same time by using a single socket. - #133: AbstractedFS constructor now accepts two argumets: root and cmd_channel breaking compatibility with previous version. Also, root and and cwd attributes became properties. The previous bug consisting in resetting the root from the ftp handler after user login has been fixed to ease the development of subclasses. - #134: enabled TCP_NODELAY socket option for the FTP command channels resulting in pyftpdlib being twice faster. - #135: Python 2.3 support has been dropped. - #137: added new pyftpdlib.contrib.filesystems module within UnixFilesystem class which permits the client to escape its home directory and navigate the real filesystem. - #138: added DTPHandler.get_elapsed_time() method which returns the transfer elapsed time in seconds. - #144: a "username" parameter is now passed to authorizer's terminate_impersonation() method. - #149: ftpserver.proto_cmds dictionary refactoring and get rid of _CommandProperty class. **Bug fixes** - #120: an ActiveDTP() instance is not garbage collected in case a client issuing PORT disconnects before establishing the data connection. - #122: a wrong variable name was used in AbstractedFS.validpath method. - #123: PORT command doesn't bind to correct address in case an alias is created for the local network interface. - #140: pathnames returned in PWD response should have double-quotes '"' escaped. - #143: EINVAL not properly handled causes server crash on OSX. - #146: SIZE and MDTM commands are now rejected unless the "l" permission has been specified for the user. - #150: path traversal bug: it is possible to move/rename a file outside of the user home directory. **Major API changes since 0.5.2** - dropped Python 2.3 support. - all classes are now new-style classes. - AbstractedFS class: - __init__ now accepts two arguments: root and cmd_channel. - root and cwd attributes are now read-only properties. - 3 new methods have been added: - get_user_by_uid() - get_group_by_gid() - readlink() - FTPHandler class: - new class attributes: - use_gmt_times - tcp_no_delay - masquerade_address_map - new methods: - on_incomplete_file_sent() - on_incomplete_file_received() - on_login() - on_logout() - log_cmd() - log_transfer() - proto_cmds class attribute has been added. The FTPHandler class no longer relies on "ftpserver.proto_cmds" global dictionary but on "ftpserver.FTPHandler.proto_cmds" instead. - FTPServer class: - max_cons attribute defaults to 512 by default instead of 0 (unlimited). - server_forever()'s map argument is gone. - DummyAuthorizer: - ValueError exceptions are now raised instead of AuthorizerError. - terminate_impersonation() method now expects a "username" parameter. - DTPHandler.get_elapsed_time() method has been added. - Added a new package in pyftpdlib namespace: "contrib". Modules (and classes) defined here: - pyftpdlib.contrib.handlers.py (TLS_FTPHandler) - pyftpdlib.contrib.authorizers.py (UnixAuthorizer, WindowsAuthorizer) - pyftpdlib.contrib.filesystems (UnixFilesystem) **Minor API changes since 0.5.2** - FTPHandler renamed objects: - data_server -> _dtp_acceptor - current_type -> _current_type - restart_position -> _restart_position - quit_pending -> _quit_pending - af -> _af - on_dtp_connection -> _on_dtp_connection - on_dtp_close -> _on_dtp_close - idler -> _idler - AbstractedFS.rnfr attribute moved to FTPHandler._rnfr. Version: 0.5.2 - Date: 2009-09-14 ================================= **Enhancements** - #103: added unix_daemon.py script. - #108: a new ThrottledDTPHandler class has been added for limiting the speed of downloads and uploads. **Bug fixes** - #100: fixed a race condition in FTPHandler constructor which could throw an exception in case of connection bashing (DoS). (thanks Bram Neijt) - #102: FTPServer.close_all() now removes any unfired delayed call left behind to prevent potential memory leaks. - #104: fixed a bug in FTPServer.handle_accept() where socket.accept() could return None instead of a valid address causing the server to crash. (OS X only, reported by Wentao Han) - #104: an unhandled EPIPE exception might be thrown by asyncore.recv() when dealing with ill-behaved clients on OS X . (reported by Wentao Han) - #105: ECONNABORTED might be thrown by socket.accept() on FreeBSD causing the server to crash. - #109: an unhandled EBADF exception might be thrown when using poll() on OSX and FreeBSD. - #111: the license used was not MIT as stated in source files. - #112: fixed a MDTM related test case failure occurring on 64 bit OSes. - #113: fixed unix_ftp.py which was treating anonymous as a normal user. - #114: MLST is now denied unless the "l" permission has been specified for the user. - #115: asyncore.dispatcher.close() is now called before doing any other cleanup operation when client disconnects. This way we avoid an endless loop which hangs the server in case an exception is raised in close() method. (thanks Arkadiusz Wahlig) - #116: extra carriage returns were added to files transferred in ASCII mode. - #118: CDUP always changes to "/". - #119: QUIT sent during a transfer caused a memory leak. **API changes since 0.5.1** - ThrottledDTPHandler class has been added. - FTPHandler.process_command() method has been added. Version: 0.5.1 - Date: 2009-01-21 ================================= **Enhancements** - #79: added two new callback methods to FTPHandler class to handle "on_file_sent" and "on_file_received" events. - #82: added table of contents in documentation. - #92: ASCII transfers are now 200% faster on those systems using "\r\n" as line separator (typically Windows). - #94: a bigger buffer size for send() and recv() has been set resulting in a considerable speedup (about 40% faster) for both incoming and outgoing data transfers. - #98: added preliminary support for SITE command. - #99: a new script implementing FTPS (FTP over TLS/SSL) has been added to the demo directory. See: https://code.google.com/p/pyftpdlib/source/browse/trunk/demo/tls_ftpd.py **Bug fixes** - #78: the idle timeout of passive data connections gets stopped in case of rejected "site-to-site" connections. - #80: demo/md5_ftpd.py should use hashlib module instead of the deprecated md5 module. - #81: fixed some tests which were failing on SunOS. - #84: fixed a very rare unhandled exception which could occur when retrieving the first bytes of a corrupted file. - #85: a positive MKD response is supposed to include the name of the new directory. - #87: SIZE should be rejected when the current TYPE is ASCII. - #88: REST should be rejected when the current TYPE is ASCII. - #89: "TYPE AN" was erroneously treated as synonym for "TYPE A" when "TYPE L7" should have been used instead. - #90: an unhandled exception can occur when using MDTM against a file modified before year 1900. - #91: an unhandled exception can occur in case accept() returns None instead of a socket (it happens sometimes). - #95: anonymous is now treated as any other case-sensitive user. **API changes since 0.5.0** - FTPHandler gained a new "_extra_feats" private attribute. - FTPHandler gained two new methods: "on_file_sent" and "on_file_received". Version: 0.5.0 - Date: 2008-09-20 ================================= **Enhancements** - #72: pyftpdlib now provides configurable idle timeouts to disconnect client after a long time of inactivity. - #73: imposed a delay before replying for invalid credentials to minimize the risk of brute force password guessing (RFC-1123). - #74: it is now possible to define permission exceptions for certain directories (e.g. creating a user which does not have write permission except for one sub-directory in FTP root). - #: Improved bandwidth throttling capabilities of demo/throttled_ftpd.py script by having used the new CallLater class which drastically reduces the number of time.time() calls. **Bug fixes** - #62: some unit tests were failing on certain dual core machines. - #71: socket handles are leaked when a data transfer is in progress and user QUITs. - #75: orphaned file was left behind in case STOU failed for insufficient user permissions. - #77: incorrect OOB data management on FreeBSD. **API changes since 0.4.0** - FTPHandler, DTPHandler, PassiveDTP and ActiveDTP classes gained a new timeout class attribute. - DummyAuthorizer class gained a new override_perm method. - A new class called CallLater has been added. - AbstractedFS.get_stat_dir method has been removed. Version: 0.4.0 - Date: 2008-05-16 ================================= **Enhancements** - #65: It is now possible to assume the id of real users when using system dependent authorizers. - #67: added IPv6 support. **Bug fixes** - #64: Issue #when authenticating as anonymous user when using UNIX and Windows authorizers. - #66: WinNTAuthorizer does not determine the real user home directory. - #69: DummyAuthorizer incorrectly uses class attribute instead of instance attribute for user_table dictionary. - #70: a wrong NOOP response code was given. **API changes since 0.3.0** - DummyAuthorizer class has now two new methods: impersonate_user() and terminate_impersonation(). Version: 0.3.0 - Date: 2008-01-17 ================================= **Enhancements** - #42: implemented FEAT command (RFC-2389). - #48: real permissions, owner, and group for files on UNIX platforms are now provided when processing LIST command. - #51: added the new demo/throttled_ftpd.py script. - #52: implemented MLST and MLSD commands (RFC-3659). - #58: implemented OPTS command (RFC-2389). - #59: iterators are now used for calculating requests requiring long time to complete (LIST and MLSD commands) drastically increasing the daemon scalability when dealing with many connected clients. - #61: extended the set of assignable user permissions. **Bug fixes** - #41: an unhandled exception occurred on QUIT if user was not yet authenticated. - #43: hidden the server identifier returned in STAT response. - #44: a wrong response code was given on PORT in case of failed connection attempt. - #45: a wrong response code was given on HELP if the provided argument wasn't recognized as valid command. - #46: a wrong response code was given on PASV in case of unauthorized FXP connection attempt. - #47: can't use FTPServer.max_cons option on Python 2.3. - #49: a "550 No such file or directory" was returned when LISTing a directory containing a broken symbolic link. - #50: DTPHandler class did not respect what specified in ac_out_buffer_size attribute. - #53: received strings having trailing white spaces was erroneously stripped. - #54: LIST/NLST/STAT outputs are now sorted by file name. - #55: path traversal vulnerability in case of symbolic links escaping user's home directory. - #56: can't rename broken symbolic links. - #57: invoking LIST/NLST over a symbolic link which points to a direoctory shouldn't list its content. - #60: an unhandled IndexError exception error was raised in case of certain bad formatted PORT requests. **API changes since 0.2.0** - New IteratorProducer and BufferedIteratorProducer classes have been added. - DummyAuthorizer class changes: - The permissions management has been changed and the set of available permissions have been extended (see Issue #61). add_user() method now accepts "eladfm" permissions beyond the old "r" and "w". - r_perm() and w_perm() methods have been removed. - New has_perm() and get_perms() methods have been added. - AbstractedFS class changes: - normalize() method has been renamed in ftpnorm(). - translate() method has been renamed in ftp2fs(). - New methods: fs2ftp(), stat(), lstat(), islink(), realpath(), lexists(), validpath(). - get_list_dir(), get_stat_dir() and format_list() methods now return an iterator object instead of a string. - format_list() method has a new "ignore_err" keyword argument. - global debug() function has been removed. Version: 0.2.0 - Date: 2007-09-17 ================================= **Major enhancements** - #5: it is now possible to set a maximum number of connections and a maximum number of connections from the same IP address. - #36: added support for FXP site-to-site transfer. - #39: added NAT/Firewall support with PASV (passive) mode connections. - #40: it is now possible to set a range of ports to use for passive connections. **RFC-related enhancements** - #6: accept TYPE AN and TYPE L8 as synonyms for TYPE ASCII and TYPE Binary. - #7: a new USER command can now be entered at any point to begin the login sequence again. - #10: HELP command arguments are now accepted. - #12: 554 error response is now returned on RETR/STOR if RESTart fails. - #15: STAT used with an argument now returns directory LISTing over the command channel (RFC-959). **Security Enhancements** - #3: stop buffering when extremely long lines are received over the command channel. - #11: data connection is now rejected in case a privileged port is specified in PORT command. - #25: limited the number of attempts to find a unique filename when processing STOU command. **Usability enhancements** - #: Provided an overridable attribute to easily set number of maximum login attempts before disconnecting. - #: Docstrings are now provided for almost every method and function. - #30: HELP response now includes the command syntax. - #31: a compact list of recognized commands is now provided on HELP. - #32: a detailed error message response is not returned to client in case the transfer is interrupted for some unexpected reason. - #38: write access can now be optionally granted for anonymous user. **Test suite enhancements** - # File creation/removal moved into setUp and tearDown methods to avoid leaving behind orphaned temporary files in the event of a test suite failure. - #7: added test case for USER provided while already authenticated. - #7: added test case for REIN while a transfer is in progress. - #28: added ABOR tests. **Bug fixes** - #4: socket's "reuse_address" feature was used after the socket's binding. - #8: STOU string response didn't follow RFC-1123 specifications. - #9: corrected path traversal vulnerability affecting file-system path translations. - #14: a wrong response code was returned on CDUP. - #17: SIZE is now rejected for not regular files. - #18: a wrong ABOR response code type was returned. - #19: watch for STOU preceded by REST which makes no sense. - #20: "attempted login" counter wasn't incremented on wrong username. - #21: STAT wasn't permitted if user wasn't authenticated yet. - #22: corrected memory leaks occurring on KeyboardInterrupt/SIGTERM. - #23: PASS wasn't rejected when user was already authenticated. - #24: Implemented a workaround over os.strerror() for those systems where it is not available (Python CE). - #24: problem occurred on Windows when using '\\' as user's home directory. - #26: select() in now used by default instead of poll() because of a bug inherited from asyncore. - #33: some FTPHandler class attributes wasn't resetted on REIN. - #35: watch for APPE preceded by REST which makes no sense. Version: 0.1.1 - Date: 2007-03-27 ================================= - Port selection on PASV command has been randomized to prevent a remote user to guess how many data connections are in progress on the server. - Fixed bug in demo/unix_ftpd.py script. - ftp_server.serve_forever now automatically re-use address if current system is posix. - License changed to MIT. Version: 0.1.0 - Date: 2007-02-26 ================================= - First proof of concept beta release. pyftpdlib-release-2.0.1/LICENSE000066400000000000000000000020621470572577600161560ustar00rootroot00000000000000MIT License Copyright (c) 2007 Giampaolo Rodola' 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-release-2.0.1/MANIFEST.in000066400000000000000000000036501470572577600167130ustar00rootroot00000000000000include .gitignore include CREDITS include HISTORY.rst include LICENSE include MANIFEST.in include Makefile include README.rst include demo/anti_flood_ftpd.py include demo/basic_ftpd.py include demo/keycert.pem include demo/md5_ftpd.py include demo/multi_proc_ftp.py include demo/multi_thread_ftp.py include demo/throttled_ftpd.py include demo/tls_ftpd.py include demo/unix_daemon.py include demo/unix_ftpd.py include demo/win_ftpd.py include docs/.readthedocs.yaml include docs/Makefile include docs/README include docs/adoptions.rst include docs/api.rst include docs/benchmarks.rst include docs/conf.py include docs/faqs.rst include docs/images/freebsd.gif include docs/images/google-pages.gif include docs/images/peerscape.gif include docs/index.rst include docs/install.rst include docs/make.bat include docs/requirements.txt include docs/rfc-compliance.rst include docs/tutorial.rst include make.bat include pyftpdlib/__init__.py include pyftpdlib/__main__.py include pyftpdlib/authorizers.py include pyftpdlib/filesystems.py include pyftpdlib/handlers.py include pyftpdlib/ioloop.py include pyftpdlib/log.py include pyftpdlib/prefork.py include pyftpdlib/servers.py include pyftpdlib/test/README include pyftpdlib/test/__init__.py include pyftpdlib/test/conftest.py include pyftpdlib/test/keycert.pem include pyftpdlib/test/test_authorizers.py include pyftpdlib/test/test_cli.py include pyftpdlib/test/test_filesystems.py include pyftpdlib/test/test_functional.py include pyftpdlib/test/test_functional_ssl.py include pyftpdlib/test/test_ioloop.py include pyftpdlib/test/test_servers.py include pyproject.toml include scripts/ftpbench include scripts/internal/check_broken_links.py include scripts/internal/generate_manifest.py include scripts/internal/git_pre_commit.py include scripts/internal/install_pip.py include scripts/internal/print_announce.py include scripts/internal/purge_installation.py include scripts/internal/winmake.py include setup.py pyftpdlib-release-2.0.1/Makefile000066400000000000000000000201361470572577600166130ustar00rootroot00000000000000# Shortcuts for various tasks (UNIX only). # To use a specific Python version run: # $ make install PYTHON=python3.7 # To run a specific test: # $ make test ARGS="-v -s pyftpdlib/test/test_functional.py::TestIPv6MixedEnvironment::test_port_v4" # Configurable PYTHON = python3 ARGS = # In not in a virtualenv, add --user options for install commands. SETUP_INSTALL_ARGS = `$(PYTHON) -c \ "import sys; print('' if hasattr(sys, 'real_prefix') or hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix else '--user')"` PYTHON_ENV_VARS = PYTHONWARNINGS=always PYTHONUNBUFFERED=1 PYTEST_ARGS = -v -s --tb=short NUM_WORKERS = `$(PYTHON) -c "import os; print(os.cpu_count() or 1)"` PIP_INSTALL_ARGS = --trusted-host files.pythonhosted.org --trusted-host pypi.org --upgrade # if make is invoked with no arg, default to `make help` .DEFAULT_GOAL := help # install git hook _ := $(shell mkdir -p .git/hooks/ && ln -sf ../../scripts/internal/git_pre_commit.py .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit) # =================================================================== # Install # =================================================================== all: test clean: ## Remove all build files. @rm -rfv `find . \ -type d -name __pycache__ \ -o -type f -name \*.bak \ -o -type f -name \*.orig \ -o -type f -name \*.pyc \ -o -type f -name \*.pyd \ -o -type f -name \*.pyo \ -o -type f -name \*.rej \ -o -type f -name \*.so \ -o -type f -name \*.~ \ -o -type f -name \*\$testfn` @rm -rfv \ *.core \ *.egg-info \ *\$testfile* \ .coverage \ .failed-tests.txt \ .pytest_cache \ .ruff_cache/ \ build/ \ dist/ \ docs/_build/ \ htmlcov/ \ pyftpd-tmp-* \ tmp/ install: ## Install this package. # make sure setuptools is installed (needed for 'develop' / edit mode) $(PYTHON) -c "import setuptools" $(PYTHON) setup.py develop $(SETUP_INSTALL_ARGS) uninstall: ## Uninstall this package. cd ..; $(PYTHON) -m pip uninstall -y -v pyftpdlib || true $(PYTHON) scripts/internal/purge_installation.py install-pip: ## Install pip (no-op if already installed). $(PYTHON) scripts/internal/install_pip.py install-pydeps-test: ## Install python deps necessary to run unit tests. ${MAKE} install-pip $(PYTHON) -m pip install $(PIP_INSTALL_ARGS) pip setuptools $(PYTHON) -m pip install $(PIP_INSTALL_ARGS) `$(PYTHON) -c "import setup; print(' '.join(setup.TEST_DEPS))"` install-pydeps-dev: ## Install python deps meant for local development. ${MAKE} install-git-hooks ${MAKE} install-pip $(PYTHON) -m pip install $(PIP_INSTALL_ARGS) pip setuptools $(PYTHON) -m pip install $(PIP_INSTALL_ARGS) `$(PYTHON) -c "import setup; print(' '.join(setup.TEST_DEPS + setup.DEV_DEPS))"` install-git-hooks: ## Install GIT pre-commit hook. ln -sf ../../scripts/internal/git_pre_commit.py .git/hooks/pre-commit chmod +x .git/hooks/pre-commit # =================================================================== # Tests # =================================================================== test: ## Run all tests. To run a specific test: do "make test ARGS=pyftpdlib.test.test_functional.TestFtpStoreData" $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) test-parallel: ## Run all tests in parallel. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) -n auto --dist loadgroup $(ARGS) test-functional: ## Run functional FTP tests. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_functional.py test-functional-ssl: ## Run functional FTPS tests. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_functional_ssl.py test-servers: ## Run tests for FTPServer and its subclasses. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_servers.py test-authorizers: ## Run tests for authorizers. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_authorizers.py test-filesystems: ## Run filesystem tests. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_filesystems.py test-ioloop: ## Run IOLoop tests. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_ioloop.py test-cli: ## Run miscellaneous tests. $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) $(ARGS) pyftpdlib/test/test_cli.py test-lastfailed: ## Run previously failed tests $(PYTHON_ENV_VARS) $(PYTHON) -m pytest $(PYTEST_ARGS) --last-failed $(ARGS) test-coverage: ## Run test coverage. rm -rf .coverage htmlcov $(PYTHON_ENV_VARS) $(PYTHON) -m coverage run -m pytest $(PYTEST_ARGS) $(ARGS) $(PYTHON) -m coverage report @echo "writing results to htmlcov/index.html" $(PYTHON) -m coverage html $(PYTHON) -m webbrowser -t htmlcov/index.html # =================================================================== # Linters # =================================================================== black: ## Python files linting (via black) @git ls-files '*.py' | xargs $(PYTHON) -m black --check --safe ruff: ## Run ruff linter. @git ls-files '*.py' | xargs $(PYTHON) -m ruff check --output-format=concise _pylint: ## Python pylint (not mandatory, just run it from time to time) @git ls-files '*.py' | xargs $(PYTHON) -m pylint --rcfile=pyproject.toml --jobs=${NUM_WORKERS} lint-rst: ## Run RsT linter. @git ls-files '*.rst' | xargs rstcheck --config=pyproject.toml lint-toml: ## Linter for pyproject.toml @git ls-files '*.toml' | xargs toml-sort --check lint-all: ## Run all linters ${MAKE} black ${MAKE} ruff ${MAKE} lint-rst ${MAKE} lint-toml # =================================================================== # Fixers # =================================================================== fix-black: git ls-files '*.py' | xargs $(PYTHON) -m black fix-ruff: @git ls-files '*.py' | xargs $(PYTHON) -m ruff check --fix --output-format=concise $(ARGS) fix-toml: ## Fix pyproject.toml @git ls-files '*.toml' | xargs toml-sort fix-all: ## Run all code fixers. ${MAKE} fix-black ${MAKE} fix-ruff ${MAKE} fix-toml # =================================================================== # Distribution # =================================================================== sdist: ## Create tar.gz source distribution. ${MAKE} generate-manifest $(PYTHON_ENV_VARS) $(PYTHON) setup.py sdist # Check sanity of source distribution. $(PYTHON_ENV_VARS) $(PYTHON) -m virtualenv --clear --no-wheel --quiet build/venv $(PYTHON_ENV_VARS) build/venv/bin/python -m pip install -v --isolated --quiet dist/*.tar.gz $(PYTHON_ENV_VARS) build/venv/bin/python -c "import os; os.chdir('build/venv'); import pyftpdlib" $(PYTHON) -m twine check --strict dist/*.tar.gz pre-release: ## All the necessary steps before making a release. ${MAKE} clean ${MAKE} sdist $(PYTHON) -c \ "from pyftpdlib import __ver__ as ver; \ doc = open('docs/index.rst').read(); \ history = open('HISTORY.rst').read(); \ assert ver in history, '%r not in HISTORY.rst' % ver; \ assert 'XXXX' not in history; \ " release: ## Creates a release (tar.gz + upload + git tag release). ${MAKE} pre-release $(PYTHON) -m twine upload dist/*.tar.gz # upload tar on PYPI ${MAKE} git-tag-release generate-manifest: ## Generates MANIFEST.in file. $(PYTHON) scripts/internal/generate_manifest.py > MANIFEST.in git-tag-release: ## Git-tag a new release. git tag -a release-`python3 -c "import setup; print(setup.VERSION)"` -m `git rev-list HEAD --count`:`git rev-parse --short HEAD` git push --follow-tags print-announce: ## Print announce of new release. @$(PYTHON) scripts/internal/print_announce.py # =================================================================== # Misc # =================================================================== grep-todos: ## Look for TODOs in source files. git grep -EIn "TODO|FIXME|XXX" check-manifest: ## Inspect MANIFEST.in file. $(PYTHON) -m check_manifest -v $(ARGS) check-broken-links: ## Look for broken links in source files. git ls-files | xargs $(PYTHON) -Wa scripts/internal/check_broken_links.py help: ## Display callable targets. @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' pyftpdlib-release-2.0.1/README.rst000066400000000000000000000242561470572577600166510ustar00rootroot00000000000000| |downloads| |stars| |forks| |contributors| | |version| |packages| |license| | |github-actions| |doc| |twitter| .. |downloads| image:: https://img.shields.io/pypi/dm/pyftpdlib.svg :target: https://pepy.tech/project/pyftpdlib :alt: Downloads .. |stars| image:: https://img.shields.io/github/stars/giampaolo/pyftpdlib.svg :target: https://github.com/giampaolo/pyftpdlib/stargazers :alt: Github stars .. |forks| image:: https://img.shields.io/github/forks/giampaolo/pyftpdlib.svg :target: https://github.com/giampaolo/pyftpdlib/network/members :alt: Github forks .. |contributors| image:: https://img.shields.io/github/contributors/giampaolo/pyftpdlib.svg :target: https://github.com/giampaolo/pyftpdlib/graphs/contributors :alt: Contributors .. |github-actions| image:: https://img.shields.io/github/actions/workflow/status/giampaolo/pyftpdlib/.github/workflows/tests.yml :target: https://github.com/giampaolo/pyftpdlib/actions :alt: GH actions .. |doc| image:: https://readthedocs.org/projects/pyftpdlib/badge/?version=latest :target: https://pyftpdlib.readthedocs.io/en/latest/ :alt: Documentation Status .. |version| image:: https://img.shields.io/pypi/v/pyftpdlib.svg?label=pypi :target: https://pypi.org/project/pyftpdlib :alt: Latest version .. |py-versions| image:: https://img.shields.io/pypi/pyversions/psutil.svg :alt: Supported Python versions .. |packages| image:: https://repology.org/badge/tiny-repos/python:pyftpdlib.svg :target: https://repology.org/metapackage/python:pyftpdlib/versions :alt: Binary packages .. |license| image:: https://img.shields.io/pypi/l/pyftpdlib.svg :target: https://github.com/giampaolo/pyftpdlib/blob/master/LICENSE :alt: License .. |twitter| image:: https://img.shields.io/twitter/follow/grodola.svg?label=follow&style=flat&logo=twitter&logoColor=4FADFF :target: https://twitter.com/grodola :alt: Twitter Follow Quick links =========== - `Home`_ - `Documentation`_ - `Download`_ - `Mailing list`_ - `What's new`_ About ===== Python FTP server library provides a high-level portable interface to easily write very efficient, scalable and asynchronous FTP servers with Python. It is the most complete `RFC-959`_ FTP server implementation available for `Python`_ programming language. Features ======== - Extremely **lightweight**, **fast** and **scalable** (see `why `__ and `benchmarks`_). - Uses **sendfile(2)** (see `pysendfile `__) system call for uploads (Linux only). - Uses ``epoll()`` / ``kqueue()`` / ``select()`` to handle concurrency asynchronously. - ...But can optionally skip to a `multiple thread / process`_ model (as in: you'll be free to block or use slow filesystems). - Portable: entirely written in pure Python. - Supports **FTPS** (`RFC-4217`_), **IPv6** (`RFC-2428`_), **Unicode** file names (`RFC-2640`_), **MLSD/MLST** commands (`RFC-3659`_). - Support for virtual users and virtual filesystem. - Flexible system of "authorizers" able to manage both "virtual" and "real" users on on both `UNIX `__ and `Windows `__. Performances ============ Despite being written in an interpreted language, pyftpdlib has transfer rates comparable or superior to common UNIX FTP servers written in C. It usually tends to scale better (see `benchmarks`_) because whereas vsftpd and proftpd use multiple processes to achieve concurrency, pyftpdlib only uses one (see `the C10K problem`_). pyftpdlib vs. proftpd 1.3.4 --------------------------- +-----------------------------------------+----------------+----------------+-------------+ | **benchmark type** | **pyftpdlib** | **proftpd** | **speedup** | +-----------------------------------------+----------------+----------------+-------------+ | STOR (client -> server) | 585.90 MB/sec | 600.49 MB/sec | -0.02x | +-----------------------------------------+----------------+----------------+-------------+ | RETR (server -> client) | 1652.72 MB/sec | 1524.05 MB/sec | **+0.08** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (connect, login) | 0.19 secs | 9.98 secs | **+51x** | +-----------------------------------------+----------------+----------------+-------------+ | STOR (1 file with 300 idle clients) | 585.59 MB/sec | 518.55 MB/sec | **+0.1x** | +-----------------------------------------+----------------+----------------+-------------+ | RETR (1 file with 300 idle clients) | 1497.58 MB/sec | 1478.19 MB/sec | 0x | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (RETR 10M file) | 3.41 secs | 3.60 secs | **+0.05x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (STOR 10M file) | 8.60 secs | 11.56 secs | **+0.3x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (QUIT) | 0.03 secs | 0.39 secs | **+12x** | +-----------------------------------------+----------------+----------------+-------------+ pyftpdlib vs. vsftpd 2.3.5 -------------------------- +-----------------------------------------+----------------+----------------+-------------+ | **benchmark type** | **pyftpdlib** | **vsftpd** | **speedup** | +-----------------------------------------+----------------+----------------+-------------+ | STOR (client -> server) | 585.90 MB/sec | 611.73 MB/sec | -0.04x | +-----------------------------------------+----------------+----------------+-------------+ | RETR (server -> client) | 1652.72 MB/sec | 1512.92 MB/sec | **+0.09** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (connect, login) | 0.19 secs | 20.39 secs | **+106x** | +-----------------------------------------+----------------+----------------+-------------+ | STOR (1 file with 300 idle clients) | 585.59 MB/sec | 610.23 MB/sec | -0.04x | +-----------------------------------------+----------------+----------------+-------------+ | RETR (1 file with 300 idle clients) | 1497.58 MB/sec | 1493.01 MB/sec | 0x | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (RETR 10M file) | 3.41 secs | 3.67 secs | **+0.07x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (STOR 10M file) | 8.60 secs | 9.82 secs | **+0.07x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (QUIT) | 0.03 secs | 0.01 secs | +0.14x | +-----------------------------------------+----------------+----------------+-------------+ For more benchmarks see `here `__. Command line usage ================== Start a FTP server, with an anonymous user with write permissions, on port 2121: .. code-block:: sh $ python3 -m pyftpdlib --write RuntimeWarning: write permissions assigned to anonymous user. self._check_permissions(username, perm) [I 2024-06-23 13:49:35] concurrency model: async [I 2024-06-23 13:49:35] masquerade (NAT) address: None [I 2024-06-23 13:49:35] passive ports: None [I 2024-06-23 13:49:35] >>> starting FTP server on 0.0.0.0:2121, pid=763634 <<< API usage ========= .. code-block:: python >>> 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="elradfmwMT") >>> 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: [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). For other code samples read the `tutorial `__ Donate ====== A lot of time and effort went into making pyftpdlib as it is right now. If you feel pyftpdlib is useful to you or your business and want to support its future development please consider `donating`_ me some money. .. _`benchmarks`: https://pyftpdlib.readthedocs.io/en/latest/benchmarks.html .. _`Documentation`: https://pyftpdlib.readthedocs.io .. _`donating`: https://gmpy.dev/donate .. _`Download`: https://pypi.org/project/pyftpdlib/ .. _`Home`: https://github.com/giampaolo/pyftpdlib .. _`Mailing list`: https://groups.google.com/group/pyftpdlib/topics .. _`multiple thread / process`: https://pyftpdlib.readthedocs.io/en/latest/tutorial.html#changing-the-concurrency-model .. _`Python`: https://www.python.org/ .. _`RFC-2428`: https://datatracker.ietf.org/doc/html/rfc2428 .. _`RFC-2640`: https://datatracker.ietf.org/doc/html/rfc2640 .. _`RFC-3659`: https://datatracker.ietf.org/doc/html/rfc3659 .. _`RFC-4217`: https://datatracker.ietf.org/doc/html/rfc4217 .. _`RFC-959`: https://datatracker.ietf.org/doc/html/rfc959.html .. _`the C10K problem`: http://www.kegel.com/c10k.html .. _`What's new`: https://github.com/giampaolo/pyftpdlib/blob/master/HISTORY.rst pyftpdlib-release-2.0.1/demo/000077500000000000000000000000001470572577600160755ustar00rootroot00000000000000pyftpdlib-release-2.0.1/demo/anti_flood_ftpd.py000077500000000000000000000047161470572577600216150ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ A FTP server banning clients in case of commands flood. If client sends more than 300 requests per-second it will be disconnected and won't be able to re-connect for 1 hour. """ from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer class AntiFloodHandler(FTPHandler): cmds_per_second = 300 # max number of cmds per second ban_for = 60 * 60 # 1 hour banned_ips = [] def __init__(self, *args, **kwargs): FTPHandler.__init__(self, *args, **kwargs) self.processed_cmds = 0 self.pcmds_callback = self.ioloop.call_every( 1, self.check_processed_cmds ) def on_connect(self): # called when client connects. if self.remote_ip in self.banned_ips: self.respond('550 You are banned.') self.close_when_done() def check_processed_cmds(self): # called every second; checks for the number of commands # sent in the last second. if self.processed_cmds > self.cmds_per_second: self.ban(self.remote_ip) else: self.processed_cmds = 0 def process_command(self, *args, **kwargs): # increase counter for every received command self.processed_cmds += 1 FTPHandler.process_command(self, *args, **kwargs) def ban(self, ip): # ban ip and schedule next un-ban if ip not in self.banned_ips: self.log(f'banned IP {ip} for command flooding') self.respond(f'550 You are banned for {self.ban_for} seconds.') self.close() self.banned_ips.append(ip) def unban(self, ip): # unban ip try: self.banned_ips.remove(ip) except ValueError: pass else: self.log(f'unbanning IP {ip}') def close(self): FTPHandler.close(self) if not self.pcmds_callback.cancelled: self.pcmds_callback.cancel() def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.', perm='elradfmwMT') authorizer.add_anonymous('.') handler = AntiFloodHandler handler.authorizer = authorizer server = FTPServer(('', 2121), handler) server.serve_forever(timeout=1) if __name__ == '__main__': main() pyftpdlib-release-2.0.1/demo/basic_ftpd.py000077500000000000000000000030411470572577600205460ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """A basic FTP server which uses a DummyAuthorizer for managing 'virtual users', setting a limit for incoming connections and a range of passive ports. """ import os from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer def main(): # Instantiate a dummy authorizer for managing 'virtual' users authorizer = DummyAuthorizer() # Define a new user having full r/w permissions and a read-only # anonymous user authorizer.add_user('user', '12345', os.getcwd(), perm='elradfmwMT') authorizer.add_anonymous(os.getcwd()) # Instantiate FTP handler class handler = FTPHandler handler.authorizer = authorizer # Define a customized banner (string returned when client connects) handler.banner = "pyftpdlib based ftpd ready." # Specify a masquerade address and the range of ports to use for # passive connections. Decomment in case you're behind a NAT. # handler.masquerade_address = '151.25.42.11' handler.passive_ports = range(60000, 65535) # Instantiate FTP server class and listen on 0.0.0.0:2121 address = ('', 2121) server = FTPServer(address, handler) # set a limit for connections server.max_cons = 256 server.max_cons_per_ip = 5 # start ftp server server.serve_forever() if __name__ == '__main__': main() pyftpdlib-release-2.0.1/demo/keycert.pem000066400000000000000000000056661470572577600202630ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDZwgTzjduhKarf kaGsAuBSHALVpsUH/S5iujPu+Cp/9UrBJaVjGxqLGI3Tnquv8yrQJE8KkkOHNWY5 teZKuKleysTP1veKGXavSaCzw4d0Jj/xioVod2eNHRXBR9WafbO1RZxiUtMZVyIp yeMF74Ue+xrRNsFH1R9g535oGe4Lq2oLDVqLiELzp9VtlAL6Wko1b45r+hoOZgoz 68Gv6wqyzZZCZR61NXJ0riuia7rAcMnJn3pkOqfcdH/wbMzlb+eJM32aICDNQSL/ FYDXdGF01rd6k8LWL++X97PLwhbWax0wSFwM+Je9CHjDM4jL8de3FH/Ux84x5wtl MB2HOKKxAgMBAAECggEABo9gGM+VkJWxw8nJBhE8HXJ3P2UmcGbXMTriJPktm+yR fkoaYRGdoMJyRgV0r03zDzwuHr0TSGJ6cZNM8cP9uOZWPDCsr74JTknjNnyAeyZK gjClyOTipg9R6ssK4KbtdVuN1NnL6ZUvaUo0taa76u1Y/HkwJMWDNtHNLr5WkTFg PDCJCdMutGE0lu0Rt0gc2Izwe5g/zLoUBwLkwhtY26qAeLz5pIGJMM8JF42gYbvS QFvMfWTUXnzUkOLey/7com853fEZxSx1f+1T+KDfOzME79uK5+gd7NenoEBNCnKF F2vPsNHOk3BmhlJf521NRNX3y7WUc/w2KWdk/MS25QKBgQDqMgF3O0OUaVQIdQHC ItaIe/NuFRSZ1OnkpuBG26xWy2XZzD4HqLYGR0m2QsMOhZwEbsTHg3a+SCUCvKfM QDBVBoOAYJrrRmd9rvMFZoksm7Ei1qeMdX0iRM2DOFNO+lAEi7zEDWzG1oy7v20b YFwYPwRCh7nbRQ6eHDQWYruPvwKBgQDuCDsOW8ed9PdPnK8Kufk3PmsXwW26A7vB 0+UV/l9AwrT0cf0dr4x4tcUHIEon+BIwvJI8eEIFEfjo50Eh0Ew9q/tfDlivayin S1wF4ntIB32l5s91Kc3scxLKHDzdi9uIxD04DS7Lro7YzIIQsWdpLm1Bnytnos65 7FUObQJpjwKBgG6NhoWbU06G3iVT3q2fNnidUo+foebwTC0k3XB1mIgsYfsLYCjL aonSMyi3oU6Eod6xz3CDTZWLhvUgy3Eux+ILPh5m/BqeVJJO+OeOvKhzIo5YmCVE /PolUoJkH2eD4CwVLtm5oKTIeQzT05R9y1uiu8cQPRsWIU1f8PK0TugPAoGAB9hd meuMeLhKLmWLn17hx+BWx0GozCizV4AUXNU1bnz8WdIn9YKDrrbO950o1IhokRKl /zg3dNNS0NpOWz7yRFYWwttGMQHnJRxmvArq5UTZ703cKJBoKRLh26dymhqx8aAG JILKuAvYyWx0HPi738uX7kHAvHmxNo+DfiY5niECgYBGt4rjQBBEN+gBlis0nFvR KkbY2MN21Bf2qe8r7x+NIrBrpRol3HMkdsTLyP8vuij4qspMcgdBj9YRbdRlwnhC oK4JOGaI0kWBQo8XouXFTgRp9awS0U7prdkaOu1uTwggKSaenAxhqj8zWI9yedAV 2uqDMbuV1S3mMZlgXfYSpA== -----END PRIVATE KEY----- -----BEGIN CERTIFICATE----- MIIDazCCAlOgAwIBAgIUESOrlenrlOmk3SphhWJeEVX5prowDQYJKoZIhvcNAQEL BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMDgxMTM2MDNaFw0yNDAz MDkxMTM2MDNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQDZwgTzjduhKarfkaGsAuBSHALVpsUH/S5iujPu+Cp/ 9UrBJaVjGxqLGI3Tnquv8yrQJE8KkkOHNWY5teZKuKleysTP1veKGXavSaCzw4d0 Jj/xioVod2eNHRXBR9WafbO1RZxiUtMZVyIpyeMF74Ue+xrRNsFH1R9g535oGe4L q2oLDVqLiELzp9VtlAL6Wko1b45r+hoOZgoz68Gv6wqyzZZCZR61NXJ0riuia7rA cMnJn3pkOqfcdH/wbMzlb+eJM32aICDNQSL/FYDXdGF01rd6k8LWL++X97PLwhbW ax0wSFwM+Je9CHjDM4jL8de3FH/Ux84x5wtlMB2HOKKxAgMBAAGjUzBRMB0GA1Ud DgQWBBQSbuaoS2+bNEg4+g7oso21c5sBHzAfBgNVHSMEGDAWgBQSbuaoS2+bNEg4 +g7oso21c5sBHzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBi 9ZZQ4ZdVvew/iWrWv3TxC7Sgt/hYLHtPjrs2PZVtG2Z+W+tol/Y1ysgzvSATSAbY GbxzHwi6yM7DXn4kZC/LNWXgQyrSf/XhOj/FkBR2bwp4iHfjP3589rU5yb1jrUwO v2WqVPWV3+b7RAeCR0aUBBdLXkQF0ET1UIsV968GT7HDdb4UCMnoif4ms1XuVZw/ t+YFMxqsPgCBkH2lUSFKcMBwA9qOU9K9GtxjFNhVBF1lzDjBhBL99+TZDLLrtKsp 2d1C4Cm5e590LuaUBijUEQH0ZK3e9UtbkNYtA7JYlmq+oQH7eS0fh+eOIOa3hDfm xHGDmUjpQuWawJuuUXZF -----END CERTIFICATE----- pyftpdlib-release-2.0.1/demo/md5_ftpd.py000077500000000000000000000024161470572577600201570ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ A basic ftpd storing passwords as hash digests (platform independent). """ import hashlib import os from pyftpdlib.authorizers import AuthenticationFailed from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer class DummyMD5Authorizer(DummyAuthorizer): def validate_authentication(self, username, password, handler): hash_ = hashlib.md5(password.encode('latin1')).hexdigest() try: if self.user_table[username]['pwd'] != hash_: raise KeyError except KeyError: raise AuthenticationFailed def main(): # get a hash digest from a clear-text password password = '12345' hash_ = hashlib.md5(password.encode('latin1')).hexdigest() authorizer = DummyMD5Authorizer() authorizer.add_user('user', hash_, os.getcwd(), perm='elradfmwMT') authorizer.add_anonymous(os.getcwd()) handler = FTPHandler handler.authorizer = authorizer server = FTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() pyftpdlib-release-2.0.1/demo/multi_proc_ftp.py000077500000000000000000000014111470572577600214750ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ A FTP server which handles every connection in a separate process. Useful if your handler class contains blocking calls or your filesystem is too slow. POSIX only. """ from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import MultiprocessFTPServer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.') handler = FTPHandler handler.authorizer = authorizer server = MultiprocessFTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() pyftpdlib-release-2.0.1/demo/multi_thread_ftp.py000077500000000000000000000013631470572577600220070ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ A FTP server which handles every connection in a separate thread. Useful if your handler class contains blocking calls or your filesystem is too slow. """ from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import ThreadedFTPServer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.') handler = FTPHandler handler.authorizer = authorizer server = ThreadedFTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() pyftpdlib-release-2.0.1/demo/throttled_ftpd.py000077500000000000000000000021201470572577600214730ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ An FTP server which uses the ThrottledDTPHandler class for limiting the speed of downloads and uploads. """ import os from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.handlers import ThrottledDTPHandler from pyftpdlib.servers import FTPServer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', os.getcwd(), perm='elradfmwMT') authorizer.add_anonymous(os.getcwd()) dtp_handler = ThrottledDTPHandler dtp_handler.read_limit = 30720 # 30 Kb/sec (30 * 1024) dtp_handler.write_limit = 30720 # 30 Kb/sec (30 * 1024) ftp_handler = FTPHandler ftp_handler.authorizer = authorizer # have the ftp handler use the alternative dtp handler class ftp_handler.dtp_handler = dtp_handler server = FTPServer(('', 2121), ftp_handler) server.serve_forever() if __name__ == '__main__': main() pyftpdlib-release-2.0.1/demo/tls_ftpd.py000077500000000000000000000020271470572577600202720ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ An RFC-4217 asynchronous FTPS server supporting both SSL and TLS. Requires PyOpenSSL module (https://pypi.org/project/pyOpenSSL). """ import os from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import TLS_FTPHandler from pyftpdlib.servers import FTPServer CERTFILE = os.path.abspath( os.path.join(os.path.dirname(__file__), "keycert.pem") ) def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.', perm='elradfmwMT') authorizer.add_anonymous('.') handler = TLS_FTPHandler handler.certfile = CERTFILE handler.authorizer = authorizer # requires SSL for both control and data channel # handler.tls_control_required = True # handler.tls_data_required = True server = FTPServer(('', 2121), handler) server.serve_forever() if __name__ == '__main__': main() pyftpdlib-release-2.0.1/demo/unix_daemon.py000077500000000000000000000125371470572577600207700ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """A basic unix daemon using the python-daemon library: https://pypi.org/project/python-daemon. Example usages: $ python3 unix_daemon.py start $ python3 unix_daemon.py stop $ python3 unix_daemon.py status $ python3 unix_daemon.py # foreground (no daemon) $ python3 unix_daemon.py --logfile /var/log/ftpd.log start $ python3 unix_daemon.py --pidfile /var/run/ftpd.pid start This is just a proof of concept which demonstrates how to daemonize the FTP server. You might want to use this as an example and provide the necessary customizations. Parts you might want to customize are: - UMASK, WORKDIR, HOST, PORT constants - get_server() function (to define users and customize FTP handler) Authors: - Ben Timby - btimby gmail.com - Giampaolo Rodola' - g.rodola gmail.com """ import argparse import atexit import os import signal import sys import time from pyftpdlib.authorizers import UnixAuthorizer from pyftpdlib.filesystems import UnixFilesystem from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer # overridable options HOST = "" PORT = 21 PID_FILE = "/var/run/pyftpdlib.pid" LOG_FILE = "/var/log/pyftpdlib.log" WORKDIR = os.getcwd() UMASK = 0 def pid_exists(pid): """Return True if a process with the given PID is currently running.""" try: os.kill(pid, 0) except PermissionError: # EPERM clearly means there's a process to deny access to return True else: return True def get_pid(): """Return the PID saved in the pid file if possible, else None.""" try: with open(PID_FILE) as f: return int(f.read().strip()) except FileNotFoundError: pass def stop(): """Keep attempting to stop the daemon for 5 seconds, first using SIGTERM, then using SIGKILL. """ pid = get_pid() if not pid or not pid_exists(pid): sys.exit("daemon not running") sig = signal.SIGTERM i = 0 while True: sys.stdout.write('.') sys.stdout.flush() try: os.kill(pid, sig) except ProcessLookupError: print(f"\nstopped (pid {pid})") i += 1 if i == 25: sig = signal.SIGKILL elif i == 50: sys.exit(f"\ncould not kill daemon (pid {pid})") time.sleep(0.1) def status(): """Print daemon status and exit.""" pid = get_pid() if not pid or not pid_exists(pid): print("daemon not running") else: print(f"daemon running with pid {pid}") sys.exit(0) def get_server(): """Return a pre-configured FTP server instance.""" handler = FTPHandler handler.authorizer = UnixAuthorizer() handler.abstracted_fs = UnixFilesystem server = FTPServer((HOST, PORT), handler) return server def daemonize(): """A wrapper around python-daemonize context manager.""" def _daemonize(): pid = os.fork() if pid > 0: # exit first parent sys.exit(0) # decouple from parent environment os.chdir(WORKDIR) os.setsid() os.umask(0) # do second fork pid = os.fork() if pid > 0: # exit from second parent sys.exit(0) # redirect standard file descriptors sys.stdout.flush() sys.stderr.flush() si = open(LOG_FILE) so = open(LOG_FILE, 'a+') se = open(LOG_FILE, 'a+', 0) os.dup2(si.fileno(), sys.stdin.fileno()) os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(se.fileno(), sys.stderr.fileno()) # write pidfile pid = str(os.getpid()) with open(PID_FILE, 'w') as f: f.write(f"{pid}\n") atexit.register(lambda: os.remove(PID_FILE)) pid = get_pid() if pid and pid_exists(pid): sys.exit(f'daemon already running (pid {pid})') # instance FTPd before daemonizing, so that in case of problems we # get an exception here and exit immediately server = get_server() _daemonize() server.serve_forever() def main(): global PID_FILE, LOG_FILE USAGE = "python3 [-p PIDFILE] [-l LOGFILE]\n\n" USAGE += "Commands:\n - start\n - stop\n - status" parser = argparse.ArgumentParser(usage=USAGE) parser.add_argument( '-l', '--logfile', dest='logfile', help='the log file location' ) parser.add_argument( '-p', '--pidfile', dest='pidfile', default=PID_FILE, help='file to store/retrieve daemon pid', ) parser.add_argument( 'command', nargs='?', help='command to execute: start, stop, restart, status', ) options = parser.parse_args() if options.pidfile: PID_FILE = options.pidfile if options.logfile: LOG_FILE = options.logfile if not options.command: server = get_server() server.serve_forever() elif options.command == 'start': daemonize() elif options.command == 'stop': stop() elif options.command == 'restart': try: stop() finally: daemonize() elif options.command == 'status': status() else: sys.exit('invalid command') if __name__ == '__main__': sys.exit(main()) pyftpdlib-release-2.0.1/demo/unix_ftpd.py000077500000000000000000000015371470572577600204600ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """A FTPd using local UNIX account database to authenticate users. It temporarily impersonate the system users every time they are going to perform a filesystem operations. """ from pyftpdlib.authorizers import UnixAuthorizer from pyftpdlib.filesystems import UnixFilesystem from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer def main(): authorizer = UnixAuthorizer( rejected_users=["root"], require_valid_shell=True ) handler = FTPHandler handler.authorizer = authorizer handler.abstracted_fs = UnixFilesystem server = FTPServer(('', 21), handler) server.serve_forever() if __name__ == "__main__": main() pyftpdlib-release-2.0.1/demo/win_ftpd.py000077500000000000000000000020461470572577600202660ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """A ftpd using local Windows NT account database to authenticate users (users must already exist). It also provides a mechanism to (temporarily) impersonate the system users every time they are going to perform filesystem operations. """ from pyftpdlib.authorizers import WindowsAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer def main(): authorizer = WindowsAuthorizer() # Use Guest user with empty password to handle anonymous sessions. # Guest user must be enabled first, empty password set and profile # directory specified. # authorizer = WindowsAuthorizer(anonymous_user="Guest", # anonymous_password="") handler = FTPHandler handler.authorizer = authorizer ftpd = FTPServer(('', 21), handler) ftpd.serve_forever() if __name__ == "__main__": main() pyftpdlib-release-2.0.1/docs/000077500000000000000000000000001470572577600161015ustar00rootroot00000000000000pyftpdlib-release-2.0.1/docs/.readthedocs.yaml000066400000000000000000000007671470572577600213420ustar00rootroot00000000000000# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: python: "3.11" # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py # RTD recommends specifying your dependencies to enable reproducible builds: # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - requirements: docs/requirements.txt pyftpdlib-release-2.0.1/docs/Makefile000066400000000000000000000167231470572577600175520ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. PYTHON = python3 SPHINXOPTS = SPHINXBUILD = $(PYTHON) -m sphinx PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " epub3 to make an epub3" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " dummy to check syntax errors of document sources" .PHONY: clean clean: rm -rf $(BUILDDIR) .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." .PHONY: qthelp qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/psutil.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/psutil.qhc" .PHONY: applehelp applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." .PHONY: devhelp devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/psutil" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/psutil" @echo "# devhelp" .PHONY: epub epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: epub3 epub3: $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 @echo @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." .PHONY: latex latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." .PHONY: latexpdf latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: latexpdfja latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: text text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." .PHONY: info info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." .PHONY: gettext gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." .PHONY: coverage coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." .PHONY: xml xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." .PHONY: pseudoxml pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." .PHONY: dummy dummy: $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy @echo @echo "Build finished. Dummy builder generates no files." pyftpdlib-release-2.0.1/docs/README000066400000000000000000000006101470572577600167560ustar00rootroot00000000000000About ===== This directory contains the reStructuredText (reST) sources to the pyftpdlib documentation. You don't need to build them yourself, prebuilt versions are available at https://pyftpdlib.readthedocs.io. In case you want, you need to install sphinx first: $ pip install sphinx Then run: $ make html You'll then have an HTML version of the doc at _build/html/index.html. pyftpdlib-release-2.0.1/docs/adoptions.rst000066400000000000000000000240531470572577600206370ustar00rootroot00000000000000========= Adoptions ========= .. contents:: Table of Contents Here comes a (mostly outdated) list of softwares and systems using pyftpdlib. In case you want to add your software to such list, make a PR or create a ticket on the bug tracker. Please help us in keeping such list updated. Packages ======== Following lists the packages of pyftpdlib from various platforms. Various Linux Distros --------------------- pyftpdlib has been packaged for different Linux distros, see `repology.org `__. .. image:: https://repology.org/badge/vertical-allrepos/python:pyftpdlib.svg FreeBSD ------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/freebsd.gif?raw=true A `freshport `__ is available. Softwares ========= Following lists the softwares adopting pyftpdlib. Google Chromium --------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/chrome.jpg?raw=true `Google Chromium `__, the open source project behind Google Chrome, uses pyftpdlib for unit tests of the FTP client included in the browser. Smartfile --------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/smartfile.png?raw=true `Smartfile `__ is a market leader in FTP and online file storage that has a robust and easy-to-use web interface. We utilize pyftpdlib as the underpinnings of our FTP service. Pyftpdlib gives us the flexibility we require to integrate FTP with the rest of our application. Pyfilesystem ------------ .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/images/pyfilesystem.svg?raw=true `Pyfilesystem `__ is a Python module that provides a common interface to many types of filesystem, and provides some powerful features such as exposing filesystems over an internet connection, or to the native filesystem. It uses pyftpdlib as a backend for testing its FTP component. Bazaar ------ .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/bazaar.jpg?raw=true `Bazaar `__ is a distributed version control system similar to GIT which supports different protocols among which FTP. Same as Google Chromium, Bazaar uses pyftpdlib as the base FTP server to implement internal FTP unit tests. Python for OpenVMS ------------------ .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/openvms.png?raw=true `OpenVMS `__ is an operating system that runs on the `VAX `__ and `Alpha `__ computer families, now owned by Hewlett-Packard. `vmspython `__ is a porting of the original cPython interpreter that runs on OpenVMS platforms. pyftpdlib became a standard library module installed by default on every new vmspython installation. OpenERP ------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/openerp.jpg?raw=true `OpenERP `__ is an Open Source enterprise management software. It covers and integrates most enterprise needs and processes: accounting, hr, sales, crm, purchase, stock, production, services management, project management, marketing campaign, management by affairs. OpenERP included pyftpdlib as plug in to serve documents via FTP. Plumi ----- `Plumi `__ is a video sharing Content Management System based on `Plone `__ that enables you to create your own sophisticated video sharing site. pyftpdlib has been included in Plumi to allow resumable large video file uploads into `Zope `__. put.io FTP connector -------------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/putio.png?raw=true `put.io `__ is a storage service that fetches media files remotely and lets you stream them immediately. They wrote a PoC based on pyftplidb that proxies FTP clients requests to put.io via HTTP. More info can be found `here `__. See https://github.com/ybrs/putio-ftp-connector. Rackspace Cloud's FTP --------------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/rackspace-cloud-hosting.jpg?raw=true `ftp-cloudfs `__ is a FTP server acting as a proxy to `Rackspace Cloud `__. It allows you to connect via any FTP client to do upload/download or create containers. Far Manager ----------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/farmanager.png?raw=true `Far Manager `__ is a program for managing files and archives on Windows. Far Manager included pyftpdlib as a plug-in for making the current directory accessible through FTP, which is convenient for exchanging files with virtual machines. Google Pages FTPd ----------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/google-pages.gif?raw=true `gpftpd `__ is a pyftpdlib based FTP server you can connect to using your Google e-mail account. It redirects you to all files hosted on your `Google Pages `__ account giving you access to download them and upload new ones. Peerscape --------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/peerscape.gif?raw=true `Peerscape `__ is an experimental peer-to-peer social network implemented as an extension to the Firefox web browser. It implements a kind of serverless read-write web supporting third-party AJAX application development. Under the hood, your computer stores copies of your data, the data of your friends and the groups you have joined, and some data about, e.g., friends of friends. It also caches copies of other data that you navigate to. Computers that store the same data establish connections among themselves to keep it in sync. feitp-server ------------ An `extra layer `__ on top of pyftpdlib introducing multi processing capabilities and overall higher performances. Symbian Python FTP server ------------------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/symbianftp.png?raw=true An FTP server for Symbian OS: http://code.google.com/p/sypftp/ Sierramobilepos --------------- The goal of this project is to extend Openbravo POS to use Windows Mobile Professional or Standard devices. It will import the data from Ob POS (originally in Postgres, later MySql). This data will reside in a database using sqlite3. Later a program will allow to sync by FTP or using a USB cable connected to the WinMob device. `link `__ Faetus ------ `Faetus `__ is a FTP server that translates FTP commands into Amazon S3 API calls providing an FTP interface on top of Amazon S3 storage. Manent ------ `Manent `__ is an algorithmically strong backup and archival program which can offer remote backup via a pyftpdlib-based S/FTP server. Aksy ---- `Aksy `__ is a Python module to control S5000/S6000, Z4/Z8 and MPC4000 Akai sampler models with System Exclusive over USB. Aksy introduced the possibility to mount samplers as web folders and manage files on the sampler via FTP. Imgserve -------- `Imgserve `__ is a python image processing server designed to provide image processing service. It can utilize modern multicore CPU to achieve higher throughput and possibly better performance. It uses pyftpdlib to permit image downloading/uploading through FTP/FTPS. Shareme ------- Ever needed to share a directory between two computers? Usually this is done using NFS, FTP or Samba, which could be a pain to setup when you just want to move some files around. `Shareme `__ is a small FTP server that, without configuration files or manuals to learn, will publish your directory, and users can download from it and upload files and directory. Just open a shell and run ``shareme -d ~/incoming/`` ...and that's it! Zenftp ------ A simple service that bridges an FTP client with zenfolio via SOAP. Start zenftp.py, providing the name of the target photoset on Zenfolio, and then connect to localhost with your FTP client. `link `__ ftpmaster --------- A very simple FTP-based content management system (CMS) including an LDAP authorizer. `link `__ ShareFTP -------- A program functionally equivalent to Shareme project. `link `__ EasyFTPd -------- An end-user UNIX FTP server with focus on simplicity. It basically provides a configuration file interface over pyftpdlib to easily set up an FTP daemon. `link `__. Eframe ------ `Eframe `__ offers Python support for the BT EFrame 1000 digital photo frame. Fastersync ---------- A tool to synchronize data between desktop PCs, laptops, USB drives, remote FTP/SFTP servers, and different online data storages. `link `__ bftpd ----- A small easy to configure FTP server. `link `__ Web sites using pyftpdlib ========================= www.bitsontherun.com -------------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/bitsontherun.png?raw=true http://www.bitsontherun.com www.adcast.tv ------------- .. image:: https://github.com/giampaolo/pyftpdlib/blob/master/docs/images/adcast.png?raw=true http://www.adcast.tv http://www.adcast.tv www.netplay.it -------------- .. image:: http://pyftpdlib.googlecode.com/svn/wiki/images/netplay.jpg http://netplay.it/ pyftpdlib-release-2.0.1/docs/api.rst000066400000000000000000000645531470572577600174210ustar00rootroot00000000000000============= API reference ============= .. contents:: Table of Contents pyftpdlib implements the server side of the FTP protocol as defined in `RFC-959 `_. This document is intended to serve as a simple API reference of most important classes and functions. Also see the `tutorial `_ document. Modules and classes hierarchy ============================= :: pyftpdlib.authorizers.AuthenticationFailed pyftpdlib.authorizers.DummyAuthorizer pyftpdlib.authorizers.UnixAuthorizer pyftpdlib.authorizers.WindowsAuthorizer pyftpdlib.handlers.FTPHandler pyftpdlib.handlers.TLS_FTPHandler pyftpdlib.handlers.DTPHandler pyftpdlib.handlers.TLS_DTPHandler pyftpdlib.handlers.ThrottledDTPHandler pyftpdlib.filesystems.FilesystemError pyftpdlib.filesystems.AbstractedFS pyftpdlib.filesystems.UnixFilesystem pyftpdlib.servers.FTPServer pyftpdlib.servers.ThreadedFTPServer pyftpdlib.servers.MultiprocessFTPServer pyftpdlib.ioloop.IOLoop pyftpdlib.ioloop.Connector pyftpdlib.ioloop.Acceptor pyftpdlib.ioloop.AsyncChat Users ===== .. class:: pyftpdlib.authorizers.DummyAuthorizer() Basic "dummy" authorizer class which lets you create virtual users. It is also suitable for subclassing to create your own custom authorizer. The "authorizer" is a class handling authentications and permissions of the FTP server. It is used by :class:`pyftpdlib.handlers.FTPHandler` class for verifying user passwords, getting users home directory and checking user permissions when a filesystem event occurs. Example usage: >>> from pyftpdlib.authorizers import DummyAuthorizer >>> authorizer = DummyAuthorizer() >>> authorizer.add_user('user', 'password', '/home/user', perm='elradfmwMT') >>> authorizer.add_anonymous('/home/nobody') .. method:: add_user(username, password, homedir, perm="elr", msg_login="Login successful.", msg_quit="Goodbye.") Add a user to the virtual users table. ``AuthorizerError`` exception is raised on error conditions such as insufficient permissions or duplicate usernames. The *perm* argument is a set of letters indicating the user's permissions: Read permissions: - ``"e"`` = change directory (CWD, CDUP commands) - ``"l"`` = list files (LIST, NLST, STAT, MLSD, MLST, SIZE 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 / permission (SITE CHMOD command) *New in 0.7.0* - ``"T"`` = change file modification time (SITE MFMT command) *New in 1.5.3* *msg_login* and *msg_quit* arguments can be specified to provide customized response strings when the user logs in and quits. .. method:: add_anonymous(homedir, **kwargs) Add an anonymous user to the virtual users table. The keyword arguments are the same expected by :meth:`add_user()` method. The only difference is that if write permissions are passed as *perm* a ``RuntimeWarning`` will be raised. .. method:: override_perm(username, directory, perm, recursive=False) Override user permissions for a specific directory. .. method:: validate_authentication(username, password, handler) Raises :class:`pyftpdlib.authorizers.AuthenticationFailed` if the supplied username and password don't match the stored credentials. *Changed in 1.0.0: new handler parameter.* *Changed in 1.0.0: an exception is now raised for signaling a failed authenticaiton as opposed to returning a bool.* .. method:: impersonate_user(username, password) Impersonate another user (noop). It is always called before accessing the filesystem. By default it does nothing. The subclass overriding this method may provide a mechanism to change the current user. .. method:: terminate_impersonation(username) Terminate impersonation (noop). It is always called after having accessed the filesystem. By default it does nothing. The subclass overriding this method may provide a mechanism to switch back to the original user. .. method:: remove_user(username) Remove a user from the virtual user table. Control connection ================== .. class:: pyftpdlib.handlers.FTPHandler(conn, server) This class implements the "FTP server Protocol Interpreter" as defined in `RFC-959 `_, commonly known as the FTP "control connection". It handles the commands received from the client. E.g. if command "MKD pathname" is received, ``ftp_MKD()`` method is called with ``pathname`` as the argument. ``conn`` argument is a socket object instance of the newly established connection. ``server`` is a reference to the :class:`pyftpdlib.servers.FTPServer` class instance. Basic usage requires creating an instance of this class and specify which authorizer it is going to use: >>> from pyftpdlib.handlers import FTPHandler >>> handler = FTPHandler >>> handler.authorizer = authorizer Configurable class attributes: .. data:: 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. Default: ``300`` seconds. *New in version 5.0* .. data:: banner The string sent when client connects. The default is ``"pyftpdlib %s ready." %__ver__``. If you want to make this dynamic you can define this as a `property `__. .. data:: max_login_attempts Maximum number of wrong authentications before disconnecting (default ``3``). .. data:: permit_foreign_addresses Also known as "FXP" or "site-to-site transfer feature". If ``True`` it allows for transferring a file between two remote FTP servers, without the transfer going through the client's host. This is 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. Default: ``False``. .. data:: permit_privileged_ports Set to ``True`` if you want to permit active connections (PORT) over privileged ports. Not recommended for security reason. Default: ``False``. .. data:: 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. Use this if you're behing a NAT. Default: ``None``. .. data:: masquerade_address_map In case the server has multiple IP addresses which are all behind a NAT, 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. Default: ``{}`` (empty dict). *New in version 0.6.0* .. data:: passive_ports What TCP ports the FTP server will use for passive (PASV) data transfers. The value expected is a list of integers (e.g. ``list(range(60000, 65535))``). When configured, pyftpdlib will no longer use kernel-assigned random TCP ports. Default: ``None``. .. data:: use_gmt_times When ``True`` causes the FTP server to report all times as GMT. This affects MDTM, MFMT, LIST, MLSD and MLST commands. If set to ``False``, the times will be expressed in the server local time (not recommended). Default: ``True``. *New in version 0.6.0* .. data:: tcp_no_delay Controls the use of the TCP_NODELAY socket option, which disables the Nagle algorithm. It usually result in significantly better performances. Default ``True`` on all platforms where it is supported. *New in version 0.6.0* .. data:: use_sendfile When ``True`` uses the ``sendfile(2)`` system call when sending file, resulting in considerable faster uploads (from server to client). Works on Linux only, and only for clear-text (non FTPS) transfers. Default: ``True`` on Linux. *New in version 0.7.0* .. data:: encoding The encoding used for client / server communication. Defaults to ``'utf-8'``. *New in version 2.0.0* .. data:: auth_failed_timeout The amount of time the server waits before sending a response in case of failed authentication. This is useful to prevent password-guessing attacks. Default: ``3`` seconds. *New in version 1.5.0* Follows a list of callback methods that can be overridden in a subclass. For blocking operations read the FAQ on how to run time consuming tasks. .. method:: on_connect() Called when client connects. *New in version 1.0.0* .. method:: on_disconnect() Called when connection is closed. *New in version 1.0.0* .. method:: on_login(username) Called on user login. *New in version 0.6.0* .. method:: on_login_failed(username, password) Called on failed user login. *New in version 0.7.0* .. method:: on_logout(username) Called when user logs out due to QUIT or USER commands issued twice. This is not called if the client just disconnects without issuing QUIT first. *New in version 0.6.0* .. method:: on_file_sent(file) Called when a file has been successfully sent. ``file`` is the absolute path of that file. .. method:: on_file_received(file) Called when a file has been successfully received. ``file`` is the absolute path of that file. .. method:: on_incomplete_file_sent(file) Called when time a file has not been entirely sent (e.g. transfer aborted by client). ``file`` is the absolute path of that file. *New in version 0.6.0* .. method:: on_incomplete_file_received(file) Called when a file has not been entirely received (e.g. transfer aborted by client). *file* is the absolute path of that file. *New in version 0.6.0* Data connection =============== .. class:: pyftpdlib.handlers.DTPHandler(sock_obj, cmd_channel) This class handles the server-data-transfer-process (server-DTP) as defined in `RFC-959 `_, commonly known as "data connection". It manages all the transfer operations like sending or receiving files and also transmitting the directory listing. ``sock_obj`` is the underlying socket object instance of the newly established connection, ``cmd_channel`` is the corresponding :class:`pyftpdlib.handlers.FTPHandler` class instance. *Changed in version 1.0.0: added ioloop argument.* .. data:: 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. Default: ``300`` seconds. .. data:: ac_in_buffer_size .. data:: ac_out_buffer_size The buffer sizes to use when receiving and sending data (both defaulting to ``65536`` bytes). For LANs you may want this to be fairly large. Depending on available memory and number of connected clients, setting them to a lower value can result in better performances. .. class:: pyftpdlib.handlers.ThrottledDTPHandler(sock_obj, cmd_channel) A :class:`pyftpdlib.handlers.DTPHandler` subclass which wraps sending and receiving in a data counter, and temporarily "sleeps" the transmission of data so that you burst to no more than x Kb/sec average. Use it instead of :class:`pyftpdlib.handlers.DTPHandler` to set transfer rates limits for both downloads and/or uploads (see the `demo script `__ showing the example usage). .. data:: read_limit The maximum number of bytes to read (receive) in one second. Defaults to ``0``, meaning no limit. .. data:: write_limit The maximum number of bytes to write (send) in one second. Defaults to ``0``, meaning no limit. Server (acceptor) ================= .. class:: pyftpdlib.servers.FTPServer(address_or_socket, handler, ioloop=None, backlog=100) Creates a socket listening on ``address`` (an ``(host, port)`` tuple) or a pre-existing socket object, dispatching the requests to ``handler`` (typically a :class:`pyftpdlib.handlers.FTPHandler` class). Also, it starts the main asynchronous IO loop. ``backlog`` is the maximum number of queued connections passed to `socket.listen() `_. *Changed in version 1.0.0: added ioloop argument.* *Changed in version 1.2.0: address can also be a pre-existing socket object.* *Changed in version 1.2.0: Added backlog argument.* *Changed in version 1.5.4: Support for the context manager protocol was added. Exiting the context manager is equivalent to calling :meth:`close_all`.* >>> from pyftpdlib.servers import FTPServer >>> address = ('127.0.0.1', 21) >>> server = FTPServer(address, handler) >>> server.serve_forever() ``FTPServer`` can also be used as a context manager. Exiting the context manager is equivalent to calling :meth:`close_all`. >>> with FTPServer(address, handler) as server: ... server.serve_forever() .. data:: max_cons The number of maximum simultaneous connections accepted by the server (both control and data connections). Default: ``512``. .. data:: max_cons_per_ip Then number of maximum connections accepted for the same IP address. Default: ``0``, meaning no limit. .. method:: serve_forever(timeout=None, blocking=True, handle_exit=True, worker_processes=1) Starts the asynchronous IO loop. - ``timeout``: the timeout passed to the underlying IO loop expressed in seconds. - ``blocking``: if ``False`` loop once and then return the timeout of the next scheduled call next to expire soonest (if any). - ``handle_exit``: when ``True`` catches ``KeyboardInterrupt`` and ``SystemExit`` exceptions (caused by SIGTERM / SIGINT signals) and gracefully exits after cleaning up resources. Also, logs server start and stop. - ``worker_processes``: pre-forks a certain number of child processes before starting. See: :ref:`pre-fork-model` for more info. Each child process will keep using a 1-thread, async concurrency model, handling multiple concurrent connections. If the number is ``None`` or <= ``0``, the number of usable CPUs available on this machine is detected and used. It is a good idea to use this option in case the server risks blocking for too long on a single function call, typically if the filesystem is slow or the are long DB query executed on user login. By splitting the work load over multiple processes the delay introduced by a blocking function call is amortized and divided by the number of the worker processes. *Changed in version 1.0.0*: no longer a classmethod *Changed in version 1.0.0*: ``use_poll`` and ``count`` parameters were removed *Changed in version 1.0.0*: ``blocking`` and ``handle_exit`` parameters were added .. method:: close() Stop accepting connections without disconnecting the clients currently connected. :meth:`server_forever` loop will automatically stop when the last client disconnects. .. method:: close_all() Disconnect all clients, tell :meth:`server_forever` loop to stop and wait until it does. *Changed in version 1.0.0: ``map`` and ``ignore_all`` parameters were removed.* Filesystem ========== .. class:: pyftpdlib.filesystems.FilesystemError Exception class which can be raised from within :class:`pyftpdlib.filesystems.AbstractedFS` in order to send a custom error messages to the client. *New in version 1.0.0* .. class:: pyftpdlib.filesystems.AbstractedFS(root, cmd_channel) A class used to interact with the filesystem, providing a cross-platform interface compatible with both Windows and UNIX paths. All paths use ``"/"`` as the separator, including on Windows. ``AbstractedFS`` distinguishes between "real" filesystem paths and "virtual" FTP paths, emulating a UNIX chroot jail where the user can not escape his/her home directory (example: real "/home/user" path will be seen as "/" by the client). It also provides wrappers around all ``os.*`` calls (``mkdir``, ``rename``, etc) and ``open`` builtin. The contructor accepts two arguments which are passed by the ``FTPHandler``: ``root``, which is the user "real" home directory (e.g. '/home/user') and ``cmd_channel`` which is a :class:`pyftpdlib.handlers.FTPHandler` class instance. *Changed in version 0.6.0: root and cmd_channel arguments were added.* .. data:: root User's home directory ("real"). *Changed in version 0.7.0: support setattr()* .. data:: cwd User's current working directory ("virtual"). *Changed in version 0.7.0: support setattr()* .. method:: ftpnorm(ftppath) Normalize a "virtual" FTP pathname depending on the current working directory. E.g. having ``"/foo"`` as current working directory, ``"bar"`` is translated to ``"/foo/bar"``. .. method:: ftp2fs(ftppath) Translate a "virtual" FTP pathname into the equivalent absolute "real" filesystem pathname. E.g. having ``"/home/user"`` as the root directory, ``"foo"`` is translated to ``"/home/user/foo"``. .. method:: fs2ftp(fspath) Translate a "real" filesystem pathname into equivalent absolute "virtual" FTP pathname depending on the user's root directory. E.g. having ``"/home/user"`` as root directory, ``"/home/user/foo"`` is translated to ``"/foo"``. .. method:: validpath(path) Check whether the path belongs to the user's home directory. Expected argument is a "real" filesystem path. If path is a symbolic link it is resolved to check its real destination. Resolved symlinks which escape the user's root directory are considered not valid (return ``False``). .. method:: open(filename, mode) Wrapper around `open() `_ builtin. .. method:: mkdir(path) .. method:: chdir(path) .. method:: rmdir(path) .. method:: remove(path) .. method:: rename(src, dst) .. method:: chmod(path, mode) .. method:: stat(path) .. method:: lstat(path) .. method:: readlink(path) Wrappers around the corresponding `os `_ module functions. .. method:: isfile(path) .. method:: islink(path) .. method:: isdir(path) .. method:: getsize(path) .. method:: getmtime(path) .. method:: realpath(path) .. method:: lexists(path) Wrappers around the corresponding `os.path `_ module functions. .. method:: mkstemp(suffix='', prefix='', dir=None, mode='wb') Wrapper around `tempfile.mkstemp `_. .. method:: listdir(path) Wrapper around `os.listdir `_. It is expected to return a list of strings or a generator yielding strings. .. versionchanged:: 1.6.0 can also return a generator. Extended classes ================ Classes that require third-party modules to be installed separately, or a specific to a given operating system. Extended handlers ----------------- .. class:: pyftpdlib.handlers.TLS_FTPHandler(conn, server) A :class:`pyftpdlib.handlers.FTPHandler` subclass implementing FTPS (FTP over SSL/TLS) as described in `RFC-4217 `_. Implements AUTH, PBSZ and PROT commands. `PyOpenSSL `_ module is required to be installed. See :ref:`ftps-server` tutorial. Configurable attributes: .. data:: certfile The path to a file which contains a certificate to be used to identify the local side of the connection. This must always be specified, unless a :ref`:`ssl_context` is provided instead. See :ref:`ftps-server` on how to generate SSL certificates. Default: ``None``. .. data:: keyfile The path of the file containing the private RSA key. It can be omittetted if the :ref`:`certfile` already contains the private key. See :ref:`ftps-server` on how to generate SSL certificates. Default: ``None``. .. data:: ssl_protocol The desired SSL protocol version to use. This defaults to ``TLS_SERVER_METHOD``, which at the time of writing (year 2024) includes TLSv1, TLSv1.1, TLSv1.2 and TLSv1.3. The actual protocol version used will be negotiated to the highest version mutually supported by the client and the server when the client connects. .. versionchanged:: 2.0.0 set default to ``TLS_SERVER_METHOD`` .. data:: ssl_options Specific OpenSSL options. This defaults to: ``OP_NO_SSLv2 | OP_NO_SSLv3 | OP_NO_COMPRESSION``, which are all considered unsecure settings. It can be set to ``None`` in order to improve compatibilty with older (insecure) FTP clients (not recommended). .. versionadded:: 1.6.0 .. data:: ssl_context A `SSL.Context `__ instance which was previously configured. When specified, :data:`ssl_protocol` and :data:`ssl_options` parameters are ignored. .. data:: tls_control_required If ``True`` it requires the client to secure the control connection with TLS before logging in. This means the client will have to issue the AUTH command before USER and PASS. Default: ``False``. .. data:: tls_data_required If ``True`` it requires the client to secure the data connection with TLS before logging in. This means the clie will have to issue the PROT command before PASV or PORT. Default: ``False``. Extended authorizers -------------------- .. class:: pyftpdlib.authorizers.UnixAuthorizer(global_perm="elradfmwMT", allowed_users=None, rejected_users=None, require_valid_shell=True, anonymous_user=None, ,msg_login="Login successful.", msg_quit="Goodbye.") An authorizer which interacts with the UNIX password database. Users are no longer supposed to be explicitly added as when using the :class:`pyftpdlib.authorizers.DummyAuthorizer`. All FTP users (and passwords) are the ones already defined on the UNIX system. The user home directory is automatically determined when user logins. Every time a filesystem operation occurs (e.g. a file is created or deleted) the ID of the process is temporarily changed to the effective user ID. In order to use this class super user privileges (root) are required. ``global_perm`` is a series of letters indicating the users permissions. It defaults to ``"elradfmwMT"`` which means full read and write access are granted to everybody (except the anonymous user). ``allowed_users`` and ``rejected_users`` are a list of users which are accepted or rejected for authenticating against the FTP server. Both parameters default to to ``[]`` (no restrictions). ``require_valid_shell`` denies 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``, meaning a valid shell is required for login). ``anonymous_user`` can be specified if you intend to provide anonymous access. The value expected is a string representing the system user to use for managing anonymous sessions. It defaults to ``None``, meaning anonymous access is disabled. *New in version 0.6.0* .. method:: override_user(username=None, password=None, homedir=None, perm=None, anonymous_user=None, msg_login=None, msg_quit=None) Overrides one or more options specified in the class constructor for a specific user. Example: >>> from pyftpdlib.authorizers import UnixAuthorizer >>> auth = UnixAuthorizer(rejected_users=["root"]) >>> auth = UnixAuthorizer(allowed_users=["matt", "jay"]) >>> auth = UnixAuthorizer(require_valid_shell=False) >>> auth.override_user("matt", password="foo", perm="elr") .. class:: pyftpdlib.authorizers.WindowsAuthorizer(global_perm="elradfmwMT", allowed_users=None, rejected_users=None, anonymous_user=None, anonymous_password="", msg_login="Login successful.", msg_quit="Goodbye.") Same as :class:`pyftpdlib.authorizers.UnixAuthorizer` except for ``anonymous_password`` argument which must be specified when defining the ``anonymous_user``. Also, ``requires_valid_shell`` option is not available. In order to use this class ``pywin32`` extension must be installed. *New in version 0.6.0* Extended filesystems -------------------- .. class:: pyftpdlib.filesystems.UnixFilesystem(root, cmd_channel) Represents the real UNIX filesystem. Differently from :class:`pyftpdlib.filesystems.AbstractedFS` the client will login into /home/ and will be able to escape its home directory and navigate the real filesystem. Use it in conjuction with :class:`pyftpdlib.authorizers.UnixAuthorizer` to implement a "real" UNIX FTP server (see `demo/unix_ftpd.py `__). *New in version 0.6.0* Extended servers ---------------- .. class:: pyftpdlib.servers.ThreadedFTPServer(address_or_socket, handler, ioloop=None, backlog=5) A modified version of base :class:`pyftpdlib.servers.FTPServer` class which spawns a thread every time a new connection is established. Differently from base FTPServer class, the handler will be free to block without hanging the whole IO loop. See :ref:`changing-the-concurrency-model`. *New in version 1.0.0* *Changed in 1.2.0: added ioloop parameter; address can also be a pre-existing *socket.* .. class:: pyftpdlib.servers.MultiprocessFTPServer(address_or_socket, handler, ioloop=None, backlog=5) A modified version of base :class:`pyftpdlib.servers.FTPServer` class which spawns a process every time a new connection is established. Differently from base FTPServer class, the handler will be free to block without hanging the whole IO loop. See :ref:`changing-the-concurrency-model`. *New in version 1.0.0* *Changed in 1.2.0: added ioloop parameter; address can also be a pre-existing socket.* *Availability: POSIX* pyftpdlib-release-2.0.1/docs/benchmarks.rst000066400000000000000000000324211470572577600207520ustar00rootroot00000000000000========== Benchmarks ========== pyftpdlib 0.7.0 vs. pyftpdlib 1.0.0 ----------------------------------- +-----------------------------------------+-----------------+----------------+------------+ | *benchmark type* | *0.7.0* | *1.0.0* | *speedup* | +=========================================+=================+================+============+ | STOR (client -> server) | 528.63 MB/sec | 585.90 MB/sec | **+0.1x** | +-----------------------------------------+-----------------+----------------+------------+ | RETR (server -> client) | 1702.07 MB/sec | 1652.72 MB/sec | -0.02x | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (connect, login) | 1.70 secs | 0.19 secs | **+8x** | +-----------------------------------------+-----------------+----------------+------------+ | STOR (1 file with 300 idle clients) | 60.77 MB/sec | 585.59 MB/sec | **+8.6x** | +-----------------------------------------+-----------------+----------------+------------+ | RETR (1 file with 300 idle clients) | 63.46 MB/sec | 1497.58 MB/sec | **+22.5x** | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (RETR 10M file) | 4.68 secs | 3.41 secs | **+0.3x** | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (STOR 10M file) | 10.13 secs | 8.78 secs | **+0.1x** | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (QUIT) | 0.02 secs | 0.02 secs | 0x | +-----------------------------------------+-----------------+----------------+------------+ pyftpdlib vs. proftpd 1.3.4 --------------------------- +-----------------------------------------+-----------------+----------------+------------+ | *benchmark type* | *pyftpdlib* | *proftpd* | *speedup* | +=========================================+=================+================+============+ | STOR (client -> server) | 585.90 MB/sec | 600.49 MB/sec | -0.02x | +-----------------------------------------+-----------------+----------------+------------+ | RETR (server -> client) | 1652.72 MB/sec | 1524.05 MB/sec | **+0.08** | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (connect, login) | 0.19 secs | 9.98 secs | **+51x** | +-----------------------------------------+-----------------+----------------+------------+ | STOR (1 file with 300 idle clients) | 585.59 MB/sec | 518.55 MB/sec | **+0.1x** | +-----------------------------------------+-----------------+----------------+------------+ | RETR (1 file with 300 idle clients) | 1497.58 MB/sec | 1478.19 MB/sec | 0x | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (RETR 10M file) | 3.41 secs | 3.60 secs | **+0.05x** | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (STOR 10M file) | 8.60 secs | 11.56 secs | **+0.3x** | +-----------------------------------------+-----------------+----------------+------------+ | 300 concurrent clients (QUIT) | 0.03 secs | 0.39 secs | **+12x** | +-----------------------------------------+-----------------+----------------+------------+ pyftpdlib vs. vsftpd 2.3.5 -------------------------- +-----------------------------------------+----------------+----------------+-------------+ | *benchmark type* | *pyftpdlib* | *vsftpd* | *speedup* | +=========================================+================+================+=============+ | STOR (client -> server) | 585.90 MB/sec | 611.73 MB/sec | -0.04x | +-----------------------------------------+----------------+----------------+-------------+ | RETR (server -> client) | 1652.72 MB/sec | 1512.92 MB/sec | **+0.09** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (connect, login) | 0.19 secs | 20.39 secs | **+106x** | +-----------------------------------------+----------------+----------------+-------------+ | STOR (1 file with 300 idle clients) | 585.59 MB/sec | 610.23 MB/sec | -0.04x | +-----------------------------------------+----------------+----------------+-------------+ | RETR (1 file with 300 idle clients) | 1497.58 MB/sec | 1493.01 MB/sec | 0x | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (RETR 10M file) | 3.41 secs | 3.67 secs | **+0.07x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (STOR 10M file) | 8.60 secs | 9.82 secs | **+0.07x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (QUIT) | 0.03 secs | 0.01 secs | +0.14x | +-----------------------------------------+----------------+----------------+-------------+ pyftpdlib vs. Twisted 12.3 -------------------------- By using *sendfile()* (Twisted *does not* support sendfile()): +-----------------------------------------+----------------+----------------+-------------+ | *benchmark type* | *pyftpdlib* | *twisted* | *speedup* | +=========================================+================+================+=============+ | STOR (client -> server) | 585.90 MB/sec | 496.44 MB/sec | **+0.01x** | +-----------------------------------------+----------------+----------------+-------------+ | RETR (server -> client) | 1652.72 MB/sec | 283.24 MB/sec | **+4.8x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (connect, login) | 0.19 secs | 0.19 secs | +0x | +-----------------------------------------+----------------+----------------+-------------+ | STOR (1 file with 300 idle clients) | 585.59 MB/sec | 506.55 MB/sec | **+0.16x** | +-----------------------------------------+----------------+----------------+-------------+ | RETR (1 file with 300 idle clients) | 1497.58 MB/sec | 280.63 MB/sec | **+4.3x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (RETR 10M file) | 3.41 secs | 11.40 secs | **+2.3x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (STOR 10M file) | 8.60 secs | 9.22 secs | **+0.07x** | +-----------------------------------------+----------------+----------------+-------------+ | 300 concurrent clients (QUIT) | 0.03 secs | 0.09 secs | **+2x** | +-----------------------------------------+----------------+----------------+-------------+ By using plain *send()*: +-----------------------------------------+----------------+---------------+--------------+ | *benchmark type* | *tpdlib* | *twisted* | *speedup* | +=========================================+================+===============+==============+ | RETR (server -> client) | 894.29 MB/sec | 283.24 MB/sec | **+2.1x** | +-----------------------------------------+----------------+---------------+--------------+ | RETR (1 file with 300 idle clients) | 900.98 MB/sec | 280.63 MB/sec | **+2.1x** | +-----------------------------------------+----------------+---------------+--------------+ Memory usage ------------ *Values on UNIX are calculated as (rss - shared).* +------------------------------------------+-------------+-----------------+----------------+----------------+ | *benchmark type* | *pyftpdlib* | *proftpd 1.3.4* | *vsftpd 2.3.5* | *twisted 12.3* | +==========================================+=============+=================+================+================+ | Starting with | 6.7M | 1.4M | 352.0K | 13.4M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | STOR (1 client) | 6.7M | 8.5M | 816.0K | 13.5M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | RETR (1 client) | 6.8M | 8.5M | 816.0K | 13.5M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | 300 concurrent clients (connect, login) | **8.8M** | 568.6M | 140.9M | 13.5M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | STOR (1 file with 300 idle clients) | **8.8M** | 570.6M | 141.4M | 13.5M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | RETR (1 file with 300 idle clients) | **8.8M** | 570.6M | 141.4M | 13.5M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | 300 concurrent clients (RETR 10.0M file) | **10.8M** | 568.6M | 140.9M | 24.5M | +------------------------------------------+-------------+-----------------+----------------+----------------+ | 300 concurrent clients (STOR 10.0M file) | **12.6** | 568.7M | 140.9M | 24.7M | +------------------------------------------+-------------+-----------------+----------------+----------------+ Interpreting the results ------------------------ pyftpdlib, `proftpd`_ and `vsftpd`_ look pretty much equally fast. The huge difference is noticeable in scalability though, because of the concurrency model adopted. Proftpd and vsftpd spawn a new process for every connected client, whereas pyftpdlib doesn't (see `the C10k problem`_). The difference can be noticed on connect/login benchmarks and memory benchmarks. The huge differences between 0.7.0 and 1.0.0 versions of pyftpdlib are due to fix of `issue 203`_ . On Linux we now use `epoll()`_ which scales considerably better than `select()`_. The fact that we're downloading a file with 300 idle clients doesn't make any difference for `epoll()`. We might as well had 5000 idle clients and the result would have been the same. On Windows, where we still use select(), 1.0.0 still wins hands down as the asyncore loop was reimplemented from scratch in order to support fd un/registration and modification. Benchmarks were conducted on Linux Ubuntu 12.04, Intel core duo - 3.1 Ghz box. Setup ----- The following setup was used before running every benchmark: proftpd config ^^^^^^^^^^^^^^ :: # /etc/proftpd/proftpd.conf MaxInstances 2000 ...followed by: :: $ sudo service proftpd restart vsftpd config ^^^^^^^^^^^^^ :: # /etc/vsftpd.conf local_enable=YES write_enable=YES max_clients=2000 max_per_ip=2000 ...followed by: :: $ sudo service vsftpd restart twisted FTP server ^^^^^^^^^^^^^^^^^^ :: from twisted.protocols.ftp import FTPFactory, FTPRealm from twisted.cred.portal import Portal from twisted.cred.checkers import AllowAnonymousAccess, FilePasswordDB from twisted.internet import reactor import resource soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard)) open('pass.dat', 'w').write('user:some-passwd') p = Portal(FTPRealm('./'), [AllowAnonymousAccess(), FilePasswordDB("pass.dat")]) f = FTPFactory(p) reactor.listenTCP(21, f) reactor.run() ...followed by: :: $ sudo python3 twist_ftpd.py pyftpdlib ^^^^^^^^^ The following patch was applied first: :: Index: pyftpdlib/servers.py =================================================================== --- pyftpdlib/servers.py (revisione 1154) +++ pyftpdlib/servers.py (copia locale) @@ -494,3 +494,10 @@ def _map_len(self): return len(multiprocessing.active_children()) + +import resource +soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) +resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard)) +FTPServer.max_cons = 0 ...followed by: :: $ sudo python3 demo/unix_daemon.py The `benchmark script`_ was run as: :: python3 scripts/ftpbench -u USERNAME -p PASSWORD -b all -n 300 ...and for the memory test: :: python3 scripts/ftpbench -u USERNAME -p PASSWORD -b all -n 300 -k FTP_SERVER_PID .. _`benchmark script`: https://github.com/giampaolo/pyftpdlib/blob/master/scripts/ftpbench .. _`epoll()`: https://linux.die.net/man/4/epoll .. _`issue 203`: https://github.com/giampaolo/pyftpdlib/issues/203 .. _`proftpd`: http://www.proftpd.org/ .. _`select()`: https://linux.die.net/man/2/select .. _`the C10k problem`: http://www.kegel.com/c10k.html .. _`vsftpd`: https://security.appspot.com/vsftpd.html pyftpdlib-release-2.0.1/docs/conf.py000066400000000000000000000020741470572577600174030ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'pyftpdlib' copyright = '2007, Giampaolo Rodola' author = 'Giampaolo Rodola' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'sphinx_rtd_theme' pyftpdlib-release-2.0.1/docs/faqs.rst000066400000000000000000000301031470572577600175620ustar00rootroot00000000000000==== FAQs ==== .. contents:: Table of Contents Introduction ============ What is pyftpdlib? ------------------ pyftpdlib is a high-level library to easily write asynchronous portable FTP servers with `Python`_. I'm not a python programmer. Can I use it anyway? ------------------------------------------------- Yes. Pyftpdlib is a fully working FTP server implementation that can be run "as is". For example, you could run an anonymous FTP server with write access from command line by running: .. code-block:: sh $ python3 -m pyftpdlib -w RuntimeWarning: write permissions assigned to anonymous user. [I 13-02-20 14:16:36] >>> starting FTP server on 0.0.0.0:2121 <<< [I 13-02-20 14:16:36] poller: [I 13-02-20 14:16:36] masquerade (NAT) address: None [I 13-02-20 14:16:36] passive ports: None [I 13-02-20 14:16:36] use sendfile(2): True This is useful in case you want a quick and dirty way to share a directory without, say, installing and configuring Samba. Installing and compatibility ============================ How do I install pyftpdlib? --------------------------- .. code-block:: sh $ python3 -m pip install pyftpdlib Also see `install instructions`_. Which Python versions are compatible? ------------------------------------- Python *3.X*. Anything above 3.8 should be good to go. Pypy should also work. What about Python 2.7? ---------------------- Latest pyftpdlib version supporting Python 2.7 is 1.5.10. You can install it with: .. code-block:: sh python2 -m pip install pyftpdlib==1.5.10 On which platforms can pyftpdlib be used? ----------------------------------------- pyftpdlib should work on any platform where **select()**, **poll()**, **epoll()** or **kqueue()** system calls are available, namely UNIX and Windows. Usage ===== How can I run long-running tasks without blocking the server? ------------------------------------------------------------- pyftpdlib is an *asynchronous* FTP server. That means that if you need to run a time consuming task you have to use a separate Python process or thread, otherwise the entire asynchronous loop will be blocked. Let's suppose you want to implement a long-running task every time the server receives a file. The code snippet below shows how. With ``self.del_channel()`` we temporarily "sleep" the connection handler which will be removed from the async IO poller loop and won't be able to send or receive any more data. It won't be closed (disconnected) as long as we don't invoke ``self.add_channel()``. This is fundamental when working with threads to avoid race conditions, dead locks etc. .. code-block:: python class MyHandler(FTPHandler): def on_file_received(self, file): def blocking_task(): time.sleep(5) self.add_channel() self.del_channel() threading.Thread(target=blocking_task).start() Another possibility is to `change the default concurrency model`_. Why do I get "Permission denied" error on startup? -------------------------------------------------- Probably because you're on a UNIX system and you're trying to start the FTP server as an unprivileged user. FTP servers bind on port 21 by default, and only the root user can bind sockets on such ports. If you want to bind the socket as non-privileged user you should set a port higher than 1024. Can I control upload/download ratios? ------------------------------------- Yes. Pyftpdlib provides a new class called `ThrottledDTPHandler`_. You can set speed limits by modifying `ThrottledDTPHandler.read_limit`_ and `ThrottledDTPHandler.write_limit`_ class attributes as it is shown in `demo/throttled_ftpd.py`_ script. Are there ways to limit connections? ------------------------------------ The `FTPServer`_. class comes with two overridable attributes defaulting to zero (no limit): `FTPServer.max_cons`_, which sets a limit for maximum simultaneous connection to handle, and `FTPServer.max_cons_per_ip`_ which sets a limit for the connections from the same IP address. I'm behind a NAT / gateway -------------------------- The FTP protocol uses 2 TCP connections: a "control" connection to exchange protocol messages (LIST, RETR, etc.), and a "data" connection for transfering data (files). In order to open the data connection the FTP server must communicate its **public** IP address in the PASV response. If you're behind a NAT, this address must be explicitly configured by setting the `FTPHandler.masquerade_address`_ attribute. You can get your public IP address by using services like https://www.whatismyip.com/. In addition, you also probably want to configure a given range of TCP ports for such incoming "data" connections, otherwise a random TCP port will be picked up every time. You can do so by using the `FTPHandler.passive_ports`_ attribute. The value expected by `FTPHandler.passive_ports`_ attribute is a list of integers (e.g. ``range(60000, 65535)``). This also means that you must configure your router so the it will forward the incoming connections to such TCP ports from the router to your FTP server behind the NAT. Why timestamps shown by MDTM and ls commands (LIST, MLSD, MLST) are wrong? -------------------------------------------------------------------------- If by "wrong" you mean "different from the timestamp of that file on my client machine", then that is the expected behavior. pyftpdlib uses `GMT times`_ as recommended in `RFC-3659`_. Any client complying with RFC-3659 should be able to convert the GMT time to your local time and show the correct timestamp. In case you want LIST, MLSD, MLST commands to report local times instead, just set the `FTPHandler.use_gmt_times`_ attribute to ``False``. For further information you might want to take a look at http://www.proftpd.org/docs/howto/Timestamps.html. Implementation ============== sendfile() ---------- On Linux, and only when doing transfer in clear text (aka no FTPS), the ``sendfile(2)`` system call be used when uploading files (from server to client) via RETR command. Using ``sendfile(2)`` is more efficient, and usually results in transfer rates that are from 2x to 3x faster. In the past some cases were reported that using ``sendfile(2)`` with "non regular" filesystems such as NFS, SMBFS/Samba, CIFS or network mounts in general may cause some issues, see http://www.proftpd.org/docs/howto/Sendfile.html. If you bump into one these issues you can set `FTPHandler.use_sendfile`_ to ``False``: .. code-block:: python from pyftpdlib.handlers import FTPHandler handler = FTPHandler handler.use_senfile = False ... Globbing / STAT command implementation -------------------------------------- Globbing is a common UNIX shell mechanism for expanding wildcard patterns to match multiple filenames. When an argument is provided to the *STAT* command, the FTP server should return a directory listing over the command channel. `RFC-959`_ does not explicitly mention globbing; this means that FTP servers are not required to support globbing in order to be compliant. However, many FTP servers do support globbing 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 (and slow) pyftpdlib *does not* support globbing. ASCII transfers / SIZE command implementation --------------------------------------------- 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 length of such data which may be different than the actual size of the file on the server. Considering that calculating such a result could be resource-intensive, it could be easy for a malicious client to use this as a DoS attack. As such thus pyftpdlib rejects 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`_. IPv6 support ------------ Pyftpdlib does support IPv6 (`RFC-2428`_). If you want your FTP server to explicitly use IPv6 you can do so by passing a valid IPv6 address to the `FTPServer`_ class constructor. Example: .. code-block:: python >>> from pyftpdlib.servers import FTPServer >>> address = ("::1", 21) # listen on localhost, port 21 >>> ftpd = FTPServer(address, FTPHandler) >>> ftpd.serve_forever() Serving FTP on ::1:21 If the OS supports an hybrid dual-stack IPv6/IPv4 implementation (e.g. Linux), the code above will automatically listen on both IPv4 and IPv6 by using the same TCP socket. Can pyftpdlib be integrated with "real" users existing on the system? --------------------------------------------------------------------- Yes. See `UnixAuthorizer`_ and `WindowsAuthorizer`_ classes. By using them you can authenticate to the FTP server by using the credentials of the users defined on the operating system Furthermore: every time the FTP server accesses the filesystem (e.g. for creating or renaming a file) the authorizer will temporarily impersonate the currently logged on user, execute the filesystem call and then switch back to the user who originally started the server. It will do so by setting the effective user or group ID of the current process. That means that you probably want to run the FTP as root. See: * https://github.com/giampaolo/pyftpdlib/blob/master/demo/unix_ftpd.py * https://github.com/giampaolo/pyftpdlib/blob/master/demo/win_ftpd.py Does pyftpdlib support FTP over TLS/SSL (FTPS)? ----------------------------------------------- Yes. Checkout `TLS_FTPHandler`_. What about SITE commands? ------------------------- The only supported SITE command is *SITE CHMOD* (change file mode). The user willing to add support for other specific SITE commands has to define a new ``ftp_SITE_CMD`` method in the `FTPHandler`_ subclass and add a new entry in ``proto_cmds`` dictionary. Example: .. code-block:: python from pyftpdlib.handlers import FTPHandler proto_cmds = FTPHandler.proto_cmds.copy() proto_cmds.update( {'SITE RMTREE': dict(perm='R', auth=True, arg=True, help='Syntax: SITE RMTREE path (remove directory tree).')} ) class CustomizedFTPHandler(FTPHandler): proto_cmds = proto_cmds def ftp_SITE_RMTREE(self, line): """Recursively remove a directory tree.""" # implementation here # ... .. _`change the default concurrency model`: tutorial.html#changing-the-concurrency-model .. _`demo/throttled_ftpd.py`: https://github.com/giampaolo/pyftpdlib/blob/master/demo/throttled_ftpd.py .. _`FTPHandler.masquerade_address`: api.html#pyftpdlib.handlers.FTPHandler.masquerade_address .. _`FTPHandler.passive_ports`: api.html#pyftpdlib.handlers.FTPHandler.passive_ports .. _`FTPHandler.use_gmt_times`: api.html#pyftpdlib.handlers.FTPHandler.use_gmt_times .. _`FTPHandler.use_sendfile`: api.html#pyftpdlib.handlers.FTPHandler.use_sendfile .. _`FTPHandler`: api.html#pyftpdlib.handlers.FTPHandler .. _`FTPServer.max_cons_per_ip`: api.html#pyftpdlib.servers.FTPServer.max_cons_per_ip .. _`FTPServer.max_cons`: api.html#pyftpdlib.servers.FTPServer.max_cons .. _`FTPServer`: api.html#pyftpdlib.servers.FTPServer .. _`GMT times`: https://en.wikipedia.org/wiki/Greenwich_Mean_Time .. _`install instructions`: install.html .. _`Python`: https://www.python.org/ .. _`RFC-2428`: https://datatracker.ietf.org/doc/html/rfc2428 .. _`RFC-3659`: https://datatracker.ietf.org/doc/html/rfc3659 .. _`RFC-959`: https://datatracker.ietf.org/doc/html/rfc959 .. _`ThrottledDTPHandler.read_limit`: api.html#pyftpdlib.handlers.ThrottledDTPHandler.read_limit .. _`ThrottledDTPHandler.write_limit`: api.html#pyftpdlib.handlers.ThrottledDTPHandler.write_limit .. _`ThrottledDTPHandler`: api.html#pyftpdlib.handlers.ThrottledDTPHandler .. _`TLS_FTPHandler`: api.html#pyftpdlib.handlers.TLS_FTPHandler .. _`UnixAuthorizer`: api.html#pyftpdlib.authorizers.UnixAuthorizer .. _`WindowsAuthorizer`: api.html#pyftpdlib.authorizers.WindowsAuthorizer pyftpdlib-release-2.0.1/docs/images/000077500000000000000000000000001470572577600173465ustar00rootroot00000000000000pyftpdlib-release-2.0.1/docs/images/adcast.png000066400000000000000000000151611470572577600213170ustar00rootroot00000000000000‰PNG  IHDR±8=ôŽ‚tEXtSoftwareAdobe ImageReadyqÉe<IDATxÚì\ XSçýþB.$@B¸ßD@A¼€ŠvŠ÷Û¬ÒU»V«ÿm­ëÖõéºg{të.}Öö¿u[o[ÛuÿÖ®k«ukíÅ[½VAQ‹‚‚.! àÿ~9Î9 0ö6~ÏyxÈÉÉwNÎ÷~ïï}ßw"èïï'ñ5 tVoo_ooOggÉdÒéôUU­µµÍÚ²²–ÚÚ¶†KWŽ…R™ŸŸ_D„jòd¿ÈHÿ¨(UB‚oX˜J%‹=D¢áÎ"˜ÀÄW9z-kW`¬«k­®6j4-55A›Fƒî7éõ@éëyz ±I$b™LªTzùûK}}=år‰··§BôY­Z­®¼Ül4ÊÕêà””àädurr@\œL©ô '0ñÕÆAO©¹¹K¯×^½Zé’®¬¬¹¬¬­®ÎÚÝ=Ðg„ˆd2™¿?ºS˜à+÷ ÷ Èå‰cËUYY^}µöìYôºH*U'%Å­\9eýúà™3 L|µ¢]£1ÖÔh‹Š òó›KJ;òÐPt¼2.Î?1Q5uj@b¢26àǹÎ?ÿ|Ö“OZ-æ%Ð0óá‡çmßî=‰/9Àä-ååÍEE çÏ7—–ꊋ-È608PÄÄøÆÆ¦¤¨gÎô›4É?!Aà–Sç<ùdÞÿÈô=óW5}úú>ÂY&0ñ%DgccCn®|pîœáêUSCvz R‚ØÛ[5{vàŒª´´€äd`B,—»ýº´ÚRS;êê†,!±™™«>üp_\´UV6åäÔŸ<©/.n-+ƒx Bᔞ²d‰rêTßÉ“¥*ÕU-ÝÝgzèÆûïsö …Köì³É¾>ª°Gô‘èŽ]{/=‹ýŒ®¨Yߊ­ÕØÑe6ãÅ"¡ÜÇKé' r5öƲ²ÆcǚΜi)(訯ïíêÂN‰mÃS¦¦ª32TwÝ¥LI‘…„ˆ¼½¿ ËdP£w»òõ×ÇÞ—V+ùÁÈÕ«Ä®lõz’™Iž}– W·Å“O’O>!Jå8V®$O?=2ò ®Ý¸©ÑµÍæ«^,Ø/DB)‘ÂÇ+:2döôĸ˜°;ž jjê÷ík?ÃÍŸ9îË´ µ:rÇ‘¿¿4–mJ‚ÎJttxÆÆzM›6î–›š =V‹ý¥·Ì_©pË­mxî¹¾òrIHcbÔ<€txÏ?¶öð„Á@ÜTQ¡Á&$@ $Á2¸çÊv ?E¤ˆI1aJ_¹D,‘p úŠ›u×*jß,7"T=):t|—é=k¶;¡R!™­Ö>ûK_…*Àïö›mýôSÃË/óÊŸ½Q/½$±:—1qêùðÃQX¤©i ˜0›‰VKL&Úßþþ„gÇ[[)O°11u*ûý3Š.\v’ŦOŸ—–ÊÏ»Óc—|kVAIÅ¡çt#‡H{,ç>²uÔS2ÂõB¢v˜ºzûúÀ=²`òqô‡ÅjmkïoI$"_¹ÏÈRF«ke¿ Qâ· ˆî‚ícIûúxY#`ûvÅúõÌK×ÎÑÞNI¢“+âБ †!¦k"uu¼žs˜Š‚ÚñÈèoÈ®èh²d Áe…„ÐÃÐTM ÇZ$&Ú_Á; ky £G7¬Í€fîÌ"‘pöŒÄðÕ[{5j ì·*k4%e7Ó¦'8QJ­í¥å7+kêõc—™š¡‡@*õ Q$ÅG§LÄV×Ð ÀÁhBšôX¬Þ2éœYSü>övèÅU5õ¦Î.koŸÈV2‰‹‚Ù‡Aåäæ— ÐÛ-MûJ›tŸŸ¹áœš?ŽBKOqqó¦MbF2XÏfáµqcàSO Ý(—;p€?ÎÙ³lY»–<ñgçåËdÅŠ‘ÚùôSªR/]¢hàÅÅ‹´ñÚkdûv²e ­¬²+-QÄÄØ]åÉ3—xú@,múβYÓâGý*èË­Wýmç^Œ{V®ÿbáµäÄ6UàD'Î\ºXpM×bDñÕNMýå¢ëaA™«ÀGOåÙßÉÏ54ýÑÈÛoÓ\ÉS¿þ5ÁUŠÅdp2—’„íºqg/W`³?196|Ù‚Ô1Ý£³‘)ƒ.©ßvohÒ¿ýŸÃõ:—$›±ãÝÂÝ0…~Fü[LÛÈ's.óó²§™ SÀ ýà^ìlÐêG>RFpÐXi__ïîÝ–ŸýL¬×K¸á±aƒdçNƒI ••”$¸4MIB&£Ì£ÅÆþoh aa¼k"¿úÙ¹“ß² §2 ÞºEnÞ$mƒÔ ²a´§=¦L!¶ô ú-ãzÄò…iø;&L@Ü §á1úßÿä„# ¼½¤J…ÂÇ©ŽÀØn²CÊ@a° QÛº­¯¯/'¯ˆ¤dRÏéS')ÞÀ ªÅÔ5V×6 ¯Aµàšû-6’O‰¤¥ô“Cˆ¸ú=M¦þ?ÿ™<û¬„+*é=ß¶Íã…ˆTêDxÒ(¤%r;|Ì›guRЉƒ‡ÞB§Â@¦¥9ÑÜn$‹“ÿ˜|ûÛ×Ü>Lþö7r옮d0Áè,­Aßbä&ulÔ8m¤Ó8xüº‡§^g$O¾kNrLDˆùWʲr ÙÆ~½èÔPu þ¹y«±±ÙÀ¹k—Ï˘7ƒr[Lø?ÞÝß;Hœf³¥ìÆ-¤•‹æ€r€ËOŸ±7ŽLQ KÕnê”{Ë\u¤Ÿ;vÞ{¯E¡è1J± 'ÆGj4/¼ógüñÇ ³¶ס°H«øžvIŒTòÜsÄ68ˆ;È3϶lÆNàcéR€—ìÚÅoòg°ò£ilæ¥Ûĸ±ÈmS7o5@l²OÛyߺÅ<±‚‹æÏŒxkÏgMÍ^#^^RU í¶c;»â.‹§ÄG$ÃÈiSbªk…6†cÖÞ2yNh´VËЂX$JNŒŽÃ7fŸ>í ¦"Éþ@6oá£#bâé§9݉€,˜1ƒÕ!‰¸ZTD?â3h®öí£ÇŽG¥×ä4À/¿L] €ÅŽÐP¦‚ ®niíà}((P)p_ñ4ûÜ•Î.3»3 톓¡Á÷ݽèÍÝÙÙ¬RÚ{š-QÍ=='Nç?¹ × îa/ÙÆŽ”Ë‡tX¦©µÝÄvUj•r _ãŸÿ¤SÊMMüýù"uí5|>tˆò9结‡æ÷Vt4g0a÷™ÝÝ´vÄÅ‘ßÿ~¤ËÝUð‰Ã6/Dòt’Ä]€€w¨ªÑ°ëd`ìáÁDü¤l<}¬¬¨Ê½dRví5÷bÉîŽÁÔàe ¿oTxpXˆ [xh/ :=Ç«£‘ @×òEc#yì1òÐCNñóŸS·Ÿ:º÷¶²ôÔS|ÿ¹};íT^òÔC]U‹L@9òHâ‘Gh¥käX²„ðª1H6Ù1g±ð§dÄm$q½ªÖÈ艥£ÙPã!Y B2hh#à ÿüå«oì:}®pøY„ÞÆæö„Ø•ÅD»ï¦5^¤¤P†4TºD6Ã`â­·(‡³º’! …Ùu1|§O綸xÐz×SX°eLì¨ÉÂÃÍàL®‡Àá¨Ñë.Lhu­l©®µ÷îË®€çícBgÍÒ¹Œà f­aïÁ, ñŠE5X›‰]í@ã!A£]FK ùéOÉÖ­$?ŸÿöïßOÖ­sý>8CÆúßÿÎß .Ú²…Øž0âVXÇY1€‰‘™:‡i n‚\˜þÇgÙŸ&Oðc2ÿþê[ÚÜ5ÛÖab'Ë0œìbƒÒWήUGG„lÙ°â£CÙ¸N^ªèjeC“þÁ{—OŠ㥰ֶ!L=„£@óƒÈŸþD‹È¼…CU¬];ÖõoÎ0@8.’@F¨ªr©É‚‚LðæG`\éºæfŠz{ÄÄ Ì€Ð!(Pò¤Tܬ[|—{–£Y8”#yJl¨ vHàû$x„"A§9•w±°¬›Ž›õ­ï|pô‡® gMÚŒíìep°Ã ÌÒRjë L&>×Âê?ñQ«ÇqDN<ÌûïÇúªëË»l ì: £Ñ¥:& 0ûÆÅÇ??û tu ÍQá®~yŒéÒòj¶Ç ã[¬Ößi‰±Ü©Îþ®®WZ«­obË •31¨ ðL:|ò¼+û^âJŽŸ¾´uãJ;¬›´œ|>à'#çwÈ‹/r–ÛÊ£ùóéÚØ¹sÇMž"þŒÃ+¯pJ“ãˆövJÀd½ÞËHN|‘‘ Üüç?ÜúCâ³%$"<ÈÏ×§Õ8äH¡:Ÿ<¿móÝ.RÅÙ‹%Èå<²0}:0áã%c†¦ÑkÛÕµÕ·9¦c˜Ü¸¤¦$LŽ ßôl^á5¶G-ºV 'Â$­ž#0AœÂ%HôøqçÉÚîñÇé êÛ î}üì³QI¸ ƒ+Wl"-„ƒ‹…ðž&pŒÿ›äæröLšÄ¶!~ФøhÞ‡JÊnúüœ+ªœË/ýøP6j•ÿŠŒÙ6)àÃ.u4h Uu#“Äç9—ÙVLL™¬¶^{úü8Oœ1çBQñµ*&k(äÞ›¾³ààŽD«Ð4†–!1!èïWûûyû#Ghù»ßå"6–üö·´BuÛ€àòNùòËœån wÈ~XS§wwdMŠŒÃÎ LÑ)*Š"—½Îvæ¾ûøöÕ€ÿo~Û–`/›`’kzZRþ•rs…½ÿXÖE„…¿ŸbOÑ’“W|2ç²ã7X¾0ÜC¥KT¨ÂÇËÞÇ]Ýf@íGaë‡H…%ì=èrUÍýç/•fŸ»Â²µžÿû«m̤ RÖ‚¹)Ð\¡:p{ÛÚ;u†V ýýf™Tf+ž¿°¬ôzµÞ`¤ëiôÛAµ/z@ ÄÅ„MOŠ›:9JâÂ2|°Â•«•°*µ­…yÙ¶ºíLþ¾K¤vw÷<žKga<ý³àž‡Æ[O‰ZÅ©}»Ç¼š[¨`Nw2NUžB…AÚñƒÒßø'ÏüCÀðQ?ƒ©âo„w&ÛR³Û±¢Ü0û^•1uÀûÈpJÈ8`;@§×­~{þØŸ±·?a¯ŒíûHÁ5ckëÓø«Â–èM®¡nNé^8ïFpKÄ9R7¦ ~eáÇ‹’Åb~£šrÂr~ì’P„®ô„’²ŒºB}tŒï¤—«šd¾ÊÒÚ[­Úó]ü×ͲWÏðNŸø(Ç‚à£añ'ÃY–Ë[²U‹\Ðå*r:üq¶ IÃØ‚Еý;‡ÄCxz5Õ>©®+(¸»Q[ˆ(¢Š(¢Š+ósþ ÕÿfÖ¼9ã4ýŸ?àŸHþ#øÁâ6ûíí–$_«˜û¢m¹bÌvÄ ³v{þ óÿ]Õ¾xŠ€°¤oâ/>+"Òil± ðò:ç$ýÑ9\·ÍòÆ Èø£ÿßÿ‚si±‚gÔ¼Q<~"ø™âEóµývBd}Ìw´;üÞXnYÍ# ÍÑU?&ñÄ<' aÓ”®¡ùøömö¤žïzÝ^íÛõ²Ü¶xÉÙh–ï·§÷¿-÷þ ÇÿâÑaÿO¨ø†tñÄ¿¯™¯kÒæF,Ç{A?Ì# ÉcóHÃsc ©©ÿý»,ÿc?Z¦ƒ®«ãMréV3a…în|µ$ ‚ì@̾ÉñkâžðK᮵âψ7"ÓGЭZêáøÜÀp¨€‘¹ÝŠ¢¯ñ3(šü9ý¡>SARI¾RH(Ïô]~üøÑ®þÎÿ´ü7”&©£K»Êf"+è[‰måÿa×cµ‡*+÷àGƽöˆøK¢xÇáìÆ]3[·ª¶<ËwlÈ;:8daꦼÏxpv=bpqÿd¬ß/÷%Öc~—Zò¶o’æ_^¥Ë7ïÇ5ßüÿàŸþÙÿ±§¿bMûIÿÁ6ƒØê,×)ð½ºm;¦‘ _¿c2D9SûÄÁ~†ÿÁ8ÿࣾ ÿ‚|‹Ä_¦[ zÁR-sC–@nt¹ÈÿÇâl’†³P•ùÑûl~É~2ÿ‚üpÿ†šÿ‚vÆlþÄÍ?‹¼3n‡ìÓÀNé¥XWï@ØÌˆ9Œ"à³î|*ñF£<«3Ÿï4Œ&Þ“[*soít§7þifyÙÆP¢z+N«·šòî¾hýŸ¢¼'þ õûx/þ ð.ÓÅß g[{øBÁ¬i¸7:MÎ9Çu=UÇ 9õÝ«ú›ˆ†& pÿ‚ŸT×Fº£äåfQEl ¯?à¯ðUÛßÙÚê×à×ìyx—ã—ŒBÚÛÁj‚oìq+¯O8Œ²«p o|(»¯ø+üîÓö øak¢|5€kÿû‡†š§MY#ó7þ eûOO¯|EÓ>xzåâÓt(£Õu•¯Ún¤\ÁqÊÇó:•-2÷ŒcáPC”æ¿wþ/þÌøõwmsñoº>·uh†8§¸·V‘œíÝŒ‘ž@=2qÔ×ã÷íõð×DøCûcøóÃßôø4½ÂâÓìö°¨T‹}…³¶é–f?Rø'ÆX,mpö á*tÜå>dÔ¥Í'k'«–—½’K¡ñüA€6ñSîì•¶Väy }‹ÿmýªßáwÇ ~øŽï:Ù¦±›"ÓQDÉÚ; cRmÑ©êN|ö?ðw‡þ!þÓþÐ~'[Áu¤k7FÑ’uVˆ;.C2·ÊpªøÜÜWŒâ¿^~þÂÿ ¾\Å7„<+akýªàøÏû)ÛÝ_|ñ5Ò[ø‡@G>M vÿw b`›ø˜Û†ÿ©|&ñ6¦>PÊó _•¢Ûþ*[E·ÿ/b¾ ?{’wå‘ñùÎT¨Þµ%îu_Ëçþë¦ëª?qè®#ösý¡ŽŸ?fö_|gðʭťͼŸgþÚÉ’.6L¤ÝAÀ'¯˜ÿÁ1?à¢òþÓ:]çÃßÚðÿÆ_îµÕ,.£û;ê‹ÚÓ¤gdbHÀàüÊ6¶ù7Æn ÆóÇ7¤Ü•8F#«åQÑT÷%öÖñ›mßšç×äXø+Ж—m§ÞýšéÝz^QEüì}@Wâ÷üþOïâ?ý|Xÿé¶Ò¿hkñƒþ ž_íýñÓ{ùé–gú×î¿Gßù(«ÿ׉éÊgÎñ7û¬Ä¿&x=Ž£u¢ê6×Úßg¿°™.­eÆ|¹Qƒ#c¸ Çûû$~Ж?µÀøÃFdY¯ _ÀZÒí>Y¢oB{{×áe}9ÿ¶ý±¿á˜>7aøÖëÊðO¦ŽÞíœüšmï ײ·¹ôØÇ„5úçŒü.%ÊÖ? ׿췔>ÒójÜËæ–²Xݺ €UÛ†#åH+÷ýôÜ1´ù%¿Gkz4Ö=ќП+?4?à›_ðQ«_ÛÃ÷žø©j<3ñsÂa ×tYÐÀÓ˜ÛcÜC|Ànáã<ÆÇ‚¤ýO_=ÿÁ\¿à“ڟů[üvý†¥o |mð¡l¶„D\ÐÛ¤.¯HF„¸9àðq‡ù¶ 'ÏêÕÇV8JŒ¢œä¢¯ÏM¥vÒ»I•Ä*b0Ñ(¹>dì•ÞÌðOØsàÖ‡ñ÷ö–Òü1ñÓíš]휦E Qã&kx÷Æã”p²¾r v?¶Gü+dzg‰Ý<#a¨øÛÁÚœ†;FÚg·ÝÀ†õW  ãÎâ6ëònÿÁ1¾øŸÂ?¶>‡âýRÒìŤ–â[” ¦FžÝ‚ŒÎ#søWëœÐ¥Äl“ªº0ÁVê+êø×Åw ñMjÙMxÖ¡(A8ósÓ½ž«•èÓzÙ«ìï¡ÍÉ©âpPŽ"2»ÖÖ{ù¯Ìó/ØÎo7ìÍá~(H×ÕžŸ­ÅÃgý(¢…ó9 àààd`÷¬¯ÛöØðŸì1ðnãŤ7× Ñô˜ä q«\‹×j.Ay!AîJ«]ý°ÿl?þÄ¿®|Wñ^çÖ7M…‡Úµ[ŒdEŸÀ³žr{óü“þ ÷ã?ø)_Ç[Ú[þ +dÑøf6Y|á9”‹y!VÝ´MÒÝ~ðff%ÛåÆÿˆðóÃúÜa‰úö2-a¹¶^ë©-Ü!Ú+íËh­½c»5Í#ƒ³¥ñýö]ߟeÔ×ÿ‚XÿÁ7ü[ûd|c‹ö¤ÿ‚ÛµÝÝë%Ï„<5qX-!SºšÎÈÓ9Š3É$ÈÙfÍ~²ª„PÀµ$Q%¼J*¢ ª£@ì:¿µ²¼²–YJ4ᬒÑY$¶ŒWHÇ¢õní¶|JŽ£»þ¼ß˜QEé™…Q@~zÿÁV¿à“ŸÄßCñßöŸþo¾"íãµ+^#T\p~_8®W-òȧcäWô*ŠâÇ`)ãé¸Mkf¶¾ú4ÓÑÅ­%£_&®pz_ðOÎ_ø'ü+Lý³|)y¤xÂÏþŠÍ¿ˆü=:´RE"6Æš›Ë/ÁSóFÇkuV¤«Ã¿à©ŸðJ}K⿊íþ9~Ã+á?Ž^ÅÈû9X¡ñ*"àÃ0?)”®P3|®§cü¸)ûþßšgí‰á{Ý'ÅÖG¿<*M·‰<7r S[J‡cM ?Ìb-ÁæŽÖþã|4©ÃUgÀCýŸíGg~©îé·ðËx¿vZÙËî2œÕbR¥UûÝø=×]×—Ñ5å¶/í‹àÿØŸá׋>,ÝdœÅ§iÑ0ûV­qŒˆ¢Sù³žr{SöÑý·|û|-“Ä_îüÛËÑéZD>Ù«LÝOÝA‘ºCò¨#©*­à°ü‹ÆÿðPÿv¿´_üÊÅàÑ#"_ø&u+Pgto4MÒ!ÃaºVùŸŒ)ó|;ðæ¿VŽ+°©ôÑÔkxDz_n{Gey4sLÒ84áMûÿ—›ý_B‡üïþ ÝãOø)—ÆkOÚ;þ Ghñø]—Áþ •X[´·FòDÝ èÀ0Ý1ù›åÀo×[khì­Ò8Ò(¢P¨ˆ0ª@è(‚µ…#¶EŽ8ÀUU @>¿µòœ¢ŽSF4©E+$’JÊ)m®‰}íêîÏ„«UÕm·ÿ÷aEW¬dQEQEQEñ¿ü7þ ;¥~Ó^+²øŸû×嬽ñk¢†ím .;LÓíò]¸'¤KØG>°ƒ¬J)ii4˜ìõTzúB§Y–62[¢š4K³Ê·$JûÀ±•J•Î 5Qb•.FãÒä…Š*¬© í¥ŠÒÒLx# jÊn®Éóaw±cûòääÕ«WÌzÆÍ—£·ßÞ|ˉuX<¨M‘¨@S'‹©4,, 7ƒr„*³‡G«—V8öÞ¸f:°‹E–ûªŒMc7õ<2¤pßi4çh…Þ’1ÃÞØ©qEVF룹]-·ÄŠŽ"­ÿ-±‹‰”+[¶¢_&„¹Á±Ù ` }`ÕĪmKOò§°Ã"=dÕÆVÔÇÂ>½˜Âo³6 ôÏ€ª-³¢iC\™•QÖü¨Md3»ÀÉHEw8PRÆ"™Lv¹-•‡KÎညöWÃbÖ6­ªŠY¤gí³!Ë2Fc%;Öª©†VxCék Ú#°ò‘Õ±íÀöC“GY…‰bÖJ7ÍÀ@ó¬ÙYGüu%(¡4•—ÄɰËÍÈš•ÖÚžfTžµî“¤Ï‰_î’$©¹·Ï;£ÄÅb õ6”úì™ý‰5oÀ~‚µ•gmEž[‘ú%ͰB_íY›/ç÷¨;ÔùÅuŒë%ü]æî•Â? $³6±¥BÀa††CW œBÖ Zõ¿Âг*.‹ó¬myÀúuw¨WW¯Ã~Ý¡¡Þ|%VNE*GÅ!kO¬‚aÐä*ÏJ็E׸j ¬ Vù|Ö[\ñ+«š°^¼¾¸¿CÔëk{²ßÐþ0ëHŽ©š²Ž‘•÷L«’Üx ÊÖ Ü–j¹æO³ŽÆKβrË“qeÉI\‘õüþ X– S9emµÒºiiÞ9V_˜3-¸â¹˜j$ÚEЬ©éa rO±fý˜ìa\hYKãLR=¹_« mˬ·!‡1ª ‚¥¼ÁÚ ºDÖ(½Ÿeå—Þ7¥óY›v&HžÕ™‚·Q>ËÚ¦iFJ‡P‡ÓUU‚YAºP*å-9W‡ Nñº€wõW)ïÊóóóR\+ÃÞ*ªÛÀ+èdìñô3Ëjvš"öR®YÛE&€µÊËàWî̾™cˆYƒŠÀ¥›aU†’²\}úíÓo«Õéécþ\ŸËqýb´[.÷Vœe–Õìâ‘ÂÖa\·±š²Åmäª9Ö¡ JC7…"•?±V£©gYE‹# b==>>ýðÇ®KQ½5Sñ~-$ü!jÝͰ¢Qµþ,Cû5ÛNs˜Xy±CnfYScjе)úˆ¬ìIY3˪ýÐìÓ§¶YƒÖ+¼žÞìÕëÕŸ±rNÈìÅb†•Zï!Éu8u¸=`Å“¹Þ=U‡ÿ÷ûUKE¿ôJÅ^w±–ч˜geg4ª@ºø……țijR¼¢xÖûUKA•‡¸ŠXGb%O¬ª¾9=&ÜãÕŒ;¿@èõïÏc5"°TnYµF×"°ºKë3Ï9pÂP›H´^›ã‡ì|ö·kJàÕwhþþ™íî'VŽ=ß&3¬¥Jvd(Ïj3ï?<«˜5VÛg²š.ó%è´ÄŠlùÄÍqž* +¢f—n¨ÆûŸâZH)‹Š¿9ÈÇYå"Õ²LJÕ†DPðÙÅ2¼H˜5P<ŸÕ(¬ß•¬R·| pÄê¢'úNg)½V§”Æ&¬á{4Iífê°¤QkÂIª¢,î𯳲ªg³†³šÀB ‹fG¬<ï•ð;œa gqn¯äÐ×iÆÐ º–ÀA]ÿO¾½*{Çnöµ((õʵ8Í\V¡:–Z•Í–aH…"ˆR!H‰¶õHk]›&;î0ôf”ëjmE7)O@mÖÕä‘l‘ÀJ»ÒE¦ Ð#^Êå²Ú#¤§%ÊóÊS®©KUƒ„ ‰°XM­}°I¬ÎξҒI'œŸTòÌ)¤B‘`vOårÙìcµü,¤EîråÜæÂåk?îZÝjÍí¾„F¯¬WÆ’4z’9GuZI D)¤B‘ D)¤JGÅÓÏù‹lÊ>y¹ µ+ô¤-iU‡â+³Õ-›imxi”vm©œñ¬»«/L¤¢Pô>âô‰_ì;fK#¼1¦aß1‚Tœ”¦Èîq%-¡D+‡Sõ/ì-U‹—} Ð çQ,-›ë LçË\€{{QNþÎó¥AodÂežŸ*Ò28É‹SÍ©µ¸)µ§¯Ccïz‡rÐÖ¤Óˆ8ËÝ,Úݱ±uT3+( kË Ê<™äÈ2µ\NK®ÜœûEPù€¾Ê€²¹BP¿§ñ©.î‚…ÿtòýBøÚÜÐØTùJy[’îä[Ùf‡”ß$©°¢8ömÕ=-é{Tßù›ŸgÝÖ2­;>“Ô¿Ò}ákÙòT.®5úË7Vò¦GÿIËMÙb–3ø$ÿ’ÂYî¥cûNp?mú…[§NŸ ÏHcº`¼øŒG„çõë6ͪÃk´F‡‘¯õI«ãÈ9};.70¶rOFýãq”vJJyöОBâIQ'Þ»{¶Eí ƒEiƽó…qECTøEúFÁ3;â|ÖRl×åLàôyMÈY_Z<:$¨ú~}*—]Olºá@8 ^ëjÚ±ÇÓ9ô]´jž‹•J²y:ÔVÍì][ª åoa\¥»¢ÊÓ3„ìt= ¼J®£Ü9O}’^f>÷6ioí‰B^#+¼I¥ÖSý†Q×§ ?KÊiEf/ýf2µ³p[UÛH»sÌPÿŽL~ `h¹æS^‡‘Ͳ¨y7á2±l8“nh°½–:Úý=*âV˜ç8·´k:+§…bý¿%™±cu,$³Žv[ÎÉd’”­Ö¢´¤¶Ð]ÓÍÇk‘Ð émm¢Ûk®5PÐg·ºT¸Ä°Q„ñ…Ý5Í‹ÊrRö- VÌÃ--—á¸T‡ƒ¥»¶¢8GN•³Ü³vÛ"Û®TÉ»;æeaAˆ0Øm-¼nñáåׇÅ# Ÿv"Peºé„ÈâàZ[BR/Ñ7&¬Ý»m,Ú%u¶“ž^£#UbíCA_²HtéyØncUÉä\˱4iðg>”%ðÜ…8…4ál%*⦺U]Ò£Z[ªºI$2Â’Kd†*Mg&îÙÜd\þc.„@mÉM¦ L7wÐÛÎ6yÛ]ÚÌ3ÕYØÑ?Å^XVÐ#­íc2¥ l^Áå8`ÛŸZõ*Ê2ïvá+K©ÏŒ®Àظ[û^˜Œî?0ó›•Ôý/0 T’G¸ ñPø(Wí—´ÔqC: á…A”ÿ•2[¯—Àcâgå¨e§4io¯¸[î¤) ‚IÆÀW_lUì\r«UËÊ%; ­ÅšñšgeÈÒ¼ã`Ëž Ó7ïÍÃm—I >‰âêo×ô›ükÉãjñcéií.ïd¨Ÿk‡1ÙðñwäýŸ Ö™6;/7:nE®Ül).8ò”EŠR’~‘êTzM¹`ÈTbL¡Ó-µ»Â㨘’pøwžR³Éè9ü75™QÒ§dâ’³-¤’KêîrߥWþ•[y¶:3Yæ#nÝQÄü§V @ÃJ g&ºâËå2—Šaßðÿqî$ ¨òû ¯\.§ü¯qeÒØ$å_¸gL©ô.Ò…ºä3çQ& ëÛ±ù˜¦£Ic°™ ëÅР´¨~*OZ»ü7mwe[çÔÜ.Þùg©•º4p‘w6mÇÆÚt}k8Û¥kýŽ+.ŽE-°£Ä­ÔZÄ´•\]@üëßÙ±orÍuAq+ÌöwÎ+Ük@!#+$Ñu|^Ÿâ\›(”•´û ìµ-%+S¶<¹‹$•íùW©ÝgWgõÞ(]»•$ %íƒÿÚ‡Šq2÷W%è­(-ÞÕÜqáV<‡N¶®{¡{ §5ïâÛËv˜>V]'²¼ä߯~GñþWS$ÃÆåã6:4•¡„w,§ )A õøz±gt„btŸ”µ¿éí¿”‰ÀŒp–d)±fÆD˜«Gp]· (|S{\cïVTNC¡SCœ„í“ZÖ÷üVÏ>íá$Áw6e‰Dw ©y•9oµ <“ËØÚº›d7l5¥õ†Ô<(e[‡K†9R“«þF…”Ùq˜mYÆ3 qJw1-•‹(6=Ä}Å®Á)½iú–ÙîÕ?Ò8“÷M½àXÆk¢6Íè‘`rŒ[çþ çåZüüŒÅ¯SwýÇ$ÿ̳nŸÂÀÿäH¥ÏÚ/ão …üÃÜ&¾!kŸŽ ´«§šæ¤üle¼+=PÿØ'ðÿˆ¿¢)Ðw=XÀ§UÙfµˆÊàJãå«´a+%—™*°ZV‚>ßz›{´¹yýÛcR¾8pñ–º±ý¢n·¹­ú§î_¾coèÞ%2|M…žüäbá³r'Ï€[î÷#&l„=ý²>¾(s•¾UÔ7iºeItÝ"’¾šÛ•¡ûcÄß»–u,>5Ÿž¶Û¸Í9bµk}(O&Є\«•ª+I» MÖtEÌÔøs›1·O(Ë[!Ž‘ˆä9ñÛ“áÅÆ]HRT>`׉rBÓá5$É‹\G¨Žó­§ð²UV†þøÖd'oo:Ö'tÈ®!Ôc›S­žIubë¸÷+ûæj—ÝýLL‘QW!d±8ÜœECŸc+Õ·?­E7ƒQã4}_ÒšMïÁ)JSÓÒá _ó­B¤Ýï;ú‰=æ±ÚÛmh-­!H"Å$\ðµm#‰‘¦kí­e¸å´¸nãIQQù§Ò£k(MJŠ÷BùrÂDü½µÉÔ°Xèx…¦òRSJY–Ê’¤ß PJ‰¹øW[§í…ÍlF­ s2®æéZ ÓQÎtHðþ±•ņrÅÉo¸›þín-×R£ú’ëŠZ¯ùÔ ¾¼­PiÝ€ùIÝ¡?áhRã/jÓ¦½üž/³yvRH#û¨OÈ*× UêÇTuvV¥¨-ß"Ú­€p–v'^Ãâ´ã¢¢2`´6, ¾UÌ–éJñž“'"r.bÙʹ—„‚Uñôõ­tŒé$œ TÓ¾I#Çj;Ii¤ñB}©üÉ­¤sgmÔ)·… ) ‚>`ÖA¤O8°¡DAn$vã¶MÊBP/ø$ Ë9lÍf{¤@ŸSñ­faÄ^öëézDX €¤NyXì|µ!R¢µ!Mý…Ô%e?‡ m[­Æ\‰=ÂRÄÆÖ·µ«I™`="a)J@ èAH™ X …"sÇÇcã:·cÅi—\ÿqÆÛJT¯Ä€ ­ÚãBLÀNŠÒfˆR!H…"ˆR"§UÂìø‡1Yvñ\!CÙHPôR:¤ˆ©-]km©M ÕÐ0¡‘,oˆZÇ´"3œÉ+‘Å0•.@l'ýû,²£˜Ù©<¶ˆÅ9§³SÙbîñ¶L!GĈ¯ˆ 3¹œ,ß±FŠ0•+â7âØT3IlpX‰"61‰ä"âåàH _qÜW,àd Ä—rIKÏást–.ÝÔÚšA÷äd¥pÃ&+™ÉgÓ]ÒRÓ™¼ïüY2âÚÒEE¶4µ¶´4432ýªPÿuóoJÜÛEzø¹g­ÿ‹í¯üÒ`̉j³ó‹-® €Î-ÈÝûbÓ8€¤¨o׿ºMîòÐ]9ñLaŠ€.®+-%Mȧg¤3YºáŸ‡øþuAœxŸÃE„‰¦ŒËKµ›Çæ ¸i<:—÷ŸšøÃþ¤Å¹‰ÒøPcŒ€Ôu*@~í(  ÑûÅ]ÿ£o¾ø0 ~yá*“‹sÿï7ýgÁ¥â%ƒ›ð9Î%(„Îò3÷ÄÏ H*Ê@èC`¬€-pnÀøƒ VH©€²@Ø A1Ø ö€jPA3hÇA'8΃Kà¸nƒû`L€g`¼ a!2Dä!HÒ‡Ì d¹A¾P ÅB ByÐf¨*ƒª¡z¨ú: ‡®@ƒÐ]h š†~‡ÞÁL‚©°¬Ã Ø öCàUp¼Î… àp%Ü…;àóð5ø6< ?ƒç€¢Š" ÄñG¢x„¬GŠ ¤iEº‘>ä&2ŠÌ oQEG¢lQž¨P µµU‚ªFFu zQ7Qc¨YÔG4­ˆÖGÛ ½Ðètº]nB·£/¢o£'Я1 £±Âxb"1I˜µ˜Ì>Læf3Ž™Ãb±òX}¬ÖËÄ °…Ø*ìQìYìvûGÄ©àÌpî¸(—«ÀÁÁ á&q x)¼&Þïgãsð¥øF|7þ:~¿@&hì!„$Â&B%¡•p‘ð€ð’H$ª­‰D.q#±’xŒx™8F|K’!é‘\HÑ$!iééé.é%™LÖ";’£Èòr3ùùùEÂHÂK‚-±A¢F¢CbHâ¹$^RSÒIrµd®d…ä Éë’3Rx)-))¦Ôz©©“R#RsÒiSiéTéé#ÒW¤§d°2Z2n2l™™ƒ2dÆ)EâBaQ6S))T U›êEM¢S¿£Pgeed—ɆÉfËÖÈž–¥!4-š-…VJ;N¦½[¢´Äi gÉö%­K†–ÌË-•s”ãÈɵÉÝ–{'O—w“O–ß%ß)ÿP¥ §¨¥°_á¢ÂÌRêRÛ¥¬¥EK/½§+ê))®U<¨Ø¯8§¤¬ä¡”®T¥tAiF™¦ì¨œ¤\®|FyZ…¢b¯ÂU)W9«ò”.Kw¢§Ð+é½ôYUEUOU¡j½ê€ê‚š¶Z¨Z¾Z›ÚCu‚:C=^½\½G}VCEÃO#O£Eãž&^“¡™¨¹W³Os^K[+\k«V§Ö”¶œ¶—v®v‹ö²ŽƒÎ[º]†n²î>Ýz°ž…^¢^Þu}XßRŸ«¿OÐm`mÀ3h01$:f¶ŽÑŒ|ò:žkGï2î3þhba’bÒhrßTÆÔÛ4ß´Ûôw3=3–YÙ-s²¹»ùó.óËô—q–í_vÇ‚bág±Õ¢Ç⃥•%ß²ÕrÚJÃ*ÖªÖj„Ae0J—­ÑÖÎÖ¬OY¿µ±´Ø·ùÍÖÐ6ÙöˆíÔríåœåËÇíÔì˜võv£ötûXûö£ªL‡‡ÇŽêŽlÇ&ÇI']§$§£NÏMœùÎíÎó.6.ë\ι"®®E®n2n¡nÕnÜÕÜÜ[Üg=,<ÖzœóD{úxîòñRòby5{Íz[y¯óîõ!ùûTû<öÕóåûvûÁ~Þ~»ý¬Ð\Á[Ñéü½üwû? ÐXðc &0 °&ðIiP^P_0%8&øHðëçÒû¡:¡ÂО0É°è°æ°ùp×ð²ðÑãˆu×""¹‘]Qب°¨¦¨¹•n+÷¬œˆ¶ˆ.Œ^¥½*{Õ•Õ «SVŸŽ‘ŒaÆœˆEdžÇ‰}Ïôg60çâ¼âjãfY.¬½¬glGv9{šcÇ)ãLÆÛÅ—ÅO%Ø%ìN˜NtH¬Hœáºp«¹/’<“ê’æ“ý“%J OiKťƦžäÉð’y½iÊiÙiƒéúé…é£klÖìY3Ë÷á7e@«2ºTÑÏT¿PG¸E8–iŸY“ù&+,ëD¶t6/»?G/g{Îd®{î·kQkYk{òTó6å­sZW¿Z·¾gƒú†‚ =6ÞDØ”¼é§|“ü²üW›Ã7w(l,ßâ±¥¥P¢_8²ÕvkÝ6Ô6î¶íæÛ«¶,b]-6)®(~_Â*¹úé7•ß|Ú¿c Ô²tÿNÌNÞÎá]»—I—å–ïöÛÝQN//*µ'fÏ•Šeu{ {…{G+}+»ª4ªvV½¯N¬¾]ã\ÓV«X»½v~{ßÐ~Çý­uJuÅuïpÜ©÷¨ïhÐj¨8ˆ9˜yðIcXcß·Œo››šŠ›>â=t¸·Ùª¹ùˆâ‘Ò¸EØ2}4úèï\¿ëj5l­o£µDŽǞ~ûýðqŸã=''ZÐü¡¶Ò^ÔuätÌv&vŽvEv žô>ÙÓmÛÝþ£Ñ‡N©žª9-{ºô áLÁ™OgsÏÎK?7s>áüxOLÏý nõö\ô¹xù’û¥ }N}g/Û]>uÅæÊÉ«Œ«×,¯uô[ô·ÿdñSû€å@Çu«ë]7¬ot.<3ä0tþ¦ëÍK·¼n]»½âöàpèð‘è‘Ñ;ì;SwS—yoáþÆèE¥VÞ÷î™3³÷Ú{¯Ù³gÎù>òæ»—Ë3?_–__6ù|ì£Ëò/,Ë'>¾Iw÷ª“Wþº,Ïвä÷½öTác=|­ç/w;ÝF&¯íFŸ=MöC`’ÚNX&‘Ÿ=Z/ J Ù IÛ­yö0ØIj;a>JjȰÖ~P˜¤¶ÅyÿY¸ ©mY : NÎQR¡¶æóÕLR[ƒß¼÷\XRC0öüLB«;€#µˆxÖ,AaV–æÈî\®<¥üKj¨mp¤-±™3úùö¯Ú¤²Œ–¥£cÎû&G"0Im'ô©a‰ Šý#ŸÖ1diN¦‘ñæ=£8ŒÔ¤ÈPG&R >|óRÈbF‚D1pÄ„?øY£BßCeÀ¸Ï½²y2‚1[Ä6röòGýedêAK]È =jG…€/¾Ö5;dÆÊÚ}¡-ä)ý Ge 㨠¥œ{è]Æã2† 'àïÐÏ•ŽzËEˆb Õ¿h_¬Fc‡ýkÌÖú‚^/8@Ÿ]Iéj9­@„ÁzŒÐ" ÝQŒ–`[žËÈÖ²D$ [ddrD'„–žÝm…-z6B26…Ì)sØmFpo½Õð“ѪÙ%ShÓ{¦>Ñ[nÙLû‰Î\âÚn¤ÇÉ8Y&²@µŒÀ•«)A–­>qKÐŒóQÞèlŸÈÈéâÈØËµéyzÂÙz|Ö§H˜N§Ìõ½™yöKFwX~­!svâjÉKÝ!rŒµ ©E;pÐkmî€ hŽéÆÈ^w㱟(»BZŸýD à¬Lµ1GϾµìÏ8~dSøÙš‰3ãSGé lFÈ:_©9Éô6=c߈Ô2亩e„É‚Ãv™¥a+pÖù³$âÚe $Ê!söÓZ*ydeªyŒ!sи5.ôÍÚ´åkk|ª×µ}Fïh²[36îm‘Z´s>2ff?*=eƼ¤Æâ¢aäLÆÕB¶¼,‰¸vYqKP§3äˆ2c  ¬L½¤ÆB¼ÚA™!7›oaÓ(8Ü24ÿZz;;A&n À?@F¬‰C­¬áÆDÿ‡Ùq¾âŠÚìFjµ”;¸Ô×-ÇÖ@fævdÆëÎI´ŸhVÎÌênJŠ=2•z–2ÂÉAbÑ.uf åH»×¦ÀrÁW2¥{;ÉzGþÐ"‰h)ŽþHF­:m„V:-;ãûè^SkI-µQ ³mæ÷ÚL«Ek8WÏñŒÈ‰]:› $ˆ—²ÃÐÓÍ*YBC»‰ŽbÀèQ6½b¨\¾öÈTêÊer‰ÃĽÉ­³6EàÖüÌM”.èŽÐ;Z’ExGeŸÈ‡\ ÄeÓ®†mxeI vÒ ¿§tda»VöEzȌƊ ³®Æ” €Þ(´­ë½-"²²´M¯L¥n¸ß9x (óp™RƦ.›u›³ñžzGáÎ.F;à‘Eöq“±í#Ã7nÓÁî~fÑ6.{rS œH†¨¿Lô“è•]Û÷H”QDG:—ºöÊ´F½7Ê œÿ8›:Bƒn3³ :‚ňޑ2rF»‰5\M7›˜Œ>öçø’›HošÔ²³Îˆ¯%g·­9W43×–U÷ÔÜÒ‘6qË£ YŒøÄH-ò ·2ÊNâÈ´ZGjÛÜ,©e`Äyk÷ŒH´­-=¢ ©øˆL[à1ÜwMUe‚'8#8Œè°›”Ýcr5FdÜÊßÑOd—LŽ>6'5·#Iøì'@„£ºeEKÙÑå§[æŒ8mtÏDKÐ)Gµ¢Zf7"S¤#ŸE„ߑâÎ.w‘Ô®­wt®.ZŠEXµˆ{ë³i½q‘š#qú¦%5Nö_W£Âì1J`5…ï3©¹%¨bµm¥û[‘²FW|wË ^?©í¥wTt¯¯ÀÝ-#·ŒÍ‘Zø.¤¶UÆ‚å€ÝgRƒnÑT³â(«kí’­%5êVdv&RÛ[og'`;d³ã(ã¹FŒö$#§!5w6);‹÷€BØj©²±;Çl-8㺫ÖeT&Èêîµé]ÏÔŽÒ{«e¡Û=܃Ô"Ÿ†Ô²@ñ‘)]꺺Ì}ÏÔÜN–ÑÙ´èÜ— ж=“3µi”ÝÝeR;RïÌeÜD’9“Åj->³e)¶s¯š:©¹ºO¤·Î¿ŒÞ}ÉÔ GDp’Ñ¿o0Š­Ë OôÆ5;l[eß#»ŸGéíìäÈ ×[æŒYT‹sÎ+šSš+XºgÐûž©A?L-Gp¯óÅ6ªó¹ÓÞÐ笤v”Þ­I $ã\ç~³„ÙfcP§ µ(ËÈlÑŽÞ}ÊÔÜ´åî\϶î#gÎx‘ÔŽÒ»µìÔL`®Ïeccß¹“÷5¢sËÜ‘>³„Šv§ µ‘4_A <Þ¿ÕR¥Ç(­¶kôpKÐÆî#2ÜSbrFR;JïÖ®öµ–‚nÝò%µX¹ RsÏéÝÂòsd šY*ŒêÈ=¥ÖÔm¶š¨z'Û£ôné›)úNÆÑ’6ãW£ãž&SËž³I…ᘷBjn-ƒ4ódÇH :9\vè^ýã2£Hí(½Ýn$ß§ËOØU2G®†ëʵ˜äªq5ôSdjî8G-źÝKåo…Ô §;Þ¢áœýš›M[œÚùwéö°io¦v”Þn¥’ÙùÔƒÍúÞÀή~ˆþà‹ÑÆ¿V²U¿u¯–:©eŒ¥9Ó`p;:ªø°&æ½£¢cg°6Ù%¨L™÷å£ mši¯·‡MGH-£ÇÖz÷œËâËeÔÙ¤‚§çÕ¢øu™ø)HíF¹URËÌ Àƽ@Zp|Íð]ËKÛdvl1îV6-1ÌŽ¯º_Sï(“v–Êèˆ-óˆöÇ'Êø¥Ýaó5O5¬õí'¤6ó¾‰ÀDà:DYZÏÄãÊ ½¤vm·ëu’ÚvXΞ&›"èÈmÔd¨=ãïÝv’ÚÞˆÏñ&I¢¢yO¦æÞ”“ç4Í&©ÆTSÐ[C "µÌ+žP{t;µn³áŒ˜OR;£Õ¦Ì7€{‚ËS‹òÜ8Â1(ñ3Oæôd|g}’ÚY,5å¼9\?s.,j“}òälÀOR;›Å¦¼7…€[>ŽÛ}%48Ç$µ› ‘©ìˆÈ÷’Jß·#¥M'©Ñ˧Ì7‡ŸÈ<½Q#ºÞ¿Upf€'©ÙzSö›D‡Í€Úút®#+}jå¬àNR;ØrïÿóƒËo^üóå—/¼üøçoïþë‚ï^zõíÇ¿óóè¥7_GÛ3}(7ô9ò,‰1ð>¸þ§×Þ¹üå÷ŽyŽ=ˆÀ$µAයí'¿xtùáO>ù! ð; ’ßm5öýPnü{ä¸?GVÏÿöÕ'm©Ó»ŽÀ$µ=ÁÅ@{ðÜ/¾ç2¹uomS Äò±%ðÀGï#Vzo„ÞKòSü0m¦micàWb¨DR#5î£~*cm9Êv¼WqåJ¨øN1PÒÔš2õPÝF/MRÛ˜¨zº[Cjš Иå)áDµÖK îÕûè`-ùÊïÕiHµ Ä-¿Xœg]F³'|9>eÉêR’ïW‚Àxš¡pRÑ̃öÐ Íš¶Qëò¸¥ kd5 £Œ×({)#냬½–ä§Ä‹ßµ @²Ïø*õ«Mè=1…¶“ÔzÛ°ýRÓY®Uªó!>‚2ªé9)ånŸDÐ5]œ<Ñø[‘ZY?Ól„MËü¾E¸-œzHÍaèHdB;µj”%©é„VêÏIUõhaÄìt’Ú†sDWkH-È-4ëà“ ÚŸ#‘(S+É«•¨lÌA´º+-‡ui­™Zkɲ6SÓà…¼ÔK—£ÔC¿ËúUI*¸¯‡Ôty ¹3Ëï{IMI˜ï’Ô8¹Âv¬²žÈ1{|u’ZÖcîh»5¤¦Œ ÒÝ(ü±¢ãh¡_3^R¼šù1¨´ej .WÐ_mDZ…W9>åÇ¿$˜µ¤VÊD™µž©2—›%:È»†Ôj5«2Óê%5Å‹¿úíI¹3ÚAOü”þ¨¤«8”¾:I펒UV¬5¤†1Êmþ²6–ÉÔj;•#¤Ö:ÊP+@—ri=O³ l¦†þÊQ]æ”וðk6hVm‰§Åôò¨ kåŽaÍ.kH­$uôU[é%5­bÂRL I-*ƒ  ¬ô–¯NR˲Çm·–Ô0ËÕÎ/©ÓÕT¯)Sgê!55dÑL2€ èÌQ¦V«ñ©^5yj;s5,jÁçH­¶kËúQm™ 2¨Õ+¹´n¹âZR«ëv?U–Úø¥=¡/¿#®Jô<–¤ú³Žì2gø&©ÝQ²ê V Ðr¹Â4½¶„á5¦þÙçËö£\¶Öäƒ~­ïy^‹2Dr+Nªï-åa›/ׯSßÒ>¥.Ñ8*§.±Ê>){ §²}‰¯×lÒ¿&[K·ÒGÜøô ŽAÝI`º cq¾šõ—(ÆæîgͶ‰ÀSèù@,EA~º=k¶æIjkЛ÷NnVùƒå‡(›½t“Ô®…ììw"p#0;Ó—*D„]–ÿ:&ÓŠžPIEND®B`‚pyftpdlib-release-2.0.1/docs/images/freebsd.gif000066400000000000000000000076121470572577600214550ustar00rootroot00000000000000GIF89aPX÷ÿÿÿïïïïçç÷ïïÿ÷÷÷ççïÞÞÆµµ÷ÞÞ¥””ïÖÖŒ{{εµ½¥¥ïÎÎ¥ŒŒÞ½½œ„„ÖµµÎ­­cRRÖ­­J99kRRœssZBB{ZZ!9)))!k!!µ11„!!Æ11!Œ!!Ö11Þ11¥!!ç))1Î!!Þ!!ç!!½BÖç÷çÞscZ½ÆÆÎÖç½½Ö¥¥ÖRJ¥B9Œµ­ÞZJœRBœJ9”{kµRBŒB1{9)kƽÞkR­{cµsZ­B1kÞÖïZJ{)!99)ZkRœsZœcJŒ¥ŒÆB)c­¥µRBc¥„ÆŒk­„c¥9)JZ9{)9cZk­ŒÆœ{µkJ„½¥Î¥„½„cœcB{9!JJ1Zµ”ÆR1c¥”­­Œ½œs­œ„¥Œs”sZ{19½œÆÆ¥Î{R„B!J”cœçÞç½­½Î½ÎÖ½Öενœ½µ”µ„Z„cBcB)B{J{))J)J!!Z)Zœs”sJkkJcÆ¥½B!9k)Z1))!½¥µœ„””c„9!1k9Zc1RZ)JµŒ¥ç­Îk1R÷çïÞÎÖsckÖµÆ1!)Z)Bk)J1!έ½cRZ¥„”B!1ÆŒ¥„9ZZ1BZ!9”{„„)Jœ„ŒZBJ„RcJ)çÎÖŒs{„ks”csB)1)k!9­s„k1B„1J޽ƽœ¥„JZZ!1„)BçÆÎÆ¥­µ”œ­Œ”„ckkJRÆ„””Rc1)ç½ÆÆœ¥ï¥µÞ”¥µk{s)9œ1Jk!1”)Bc)Z!­„Œç”¥­ZkœJZJ!)„1B­1J1Þ{ŒÆk{Ös„½cs½ZkµRcœ9J”1BJ!„)9ÆZk½Rc­9J¥1B”)9„!1Œ!1Æ{„½9JZ!½1B¥)9J¥!1BÖŒ”„19k!)k!µ)9{!”!Æ19Ö19½)1Æ)1µ!)¥!½!„Ö)1Ö!)!ù,PX@þH° Áƒ*\ȰáÂÆî…8&ŠÏ‡ tCXŒ a"Šq°€u¬7] 4{w­cÇÈéL–¬\9+V¸pA*Cš4uX²©ÀÉ¢µZd(”´h«:- % @\låc±/Ÿˆy˺i¢jÈ>‡4é™äÔaœDaV „¶‚…?³+üÝSGÊ‘#rÊÆ¡`Ç.ß½|òØ› /ªEk aáé®È(fД00€*=JQ ,_´f | +–(Q¶P-#çóثۢ¬ɜÀ³qƒµFà“'Í¡>}„{:&o¾|ôDx+çíØvä¢þy‚K÷¸ù„º<½Š„)ÎÛ„.Zƒ93C‡]<Ïßà/åøˆ:ãÜBÑ%}èA\JbHgýaD’L2"ht +Ä"Í/Ðhà 4ÐlóŠ-µ@cŒ6Û$ãM,NáÄ@+÷eF•&®p`JraAˆäBÇ$©ÐQÇT4ÁD=ø å”RêpEx”G]ØÒ iAÐ :ݘÒ´øæI(å9•-CJøŽ_÷¼‚ <ìÈã,êˆó:dFs›x„´ÅG/€ ÝâJ[ÆyÐä°2Øb -˜`€q˜É=øH&9óä”Ì<É€€*þ–XÒVR¦€)©@ ˆÀB þ °Ì2"°£Ž'ž,²H,])PG ã°p åÌ*GÙgÁ­•AH/‘P†™Lò 2¤ˆâ<õ`?ÊŽ8ã(ÓÝo„Ì7œ!”`Ë$¢3;Ç\²†lMòÎ8‘å“;(ä³OYÉxbÈ"ú:5-¹¬Rï‹,ƒ_,°Ô=Ðzs‰(„kŸ!{6 ,¤PÏ8ô¦lÈQI!5܇tðQËÆEr 2y‚Š'‚ÚÖĆzŸ[Ò€¾´Tr"•Y‡]L±Ç(  €©“@Ç ŠÄ 7ñÞr )¤ü]K¼!Ü  Ëþ(h²Î˜ÝtL4¼Tôqs4À‹%ä£ ôD^B<‚w 0pk ˜b¸‡KJËd¢,Òf]䃎¡t;Î’#J+Äò²J(ä¨#¬ðY¢ àÐ&©ek@ORN þŒÉäôB #Œ`B +x#(9<åFˆ%‹”2€¥ôbÒ>%4S@—’Œ:ðà‚©ã¤óL§L<ý¨£N9:!GNxCÈMΞÐR: ‰nU,EH…#V1: €P€ògA @0ã8<@` tHli(ÔD Bg°|mš€Ç;.c,TÌaÀ….²}°£þ" œ×ÀMðÁˆ¨Xê ‚l#,h öÑ‚*Ö¤–éÅ$¾‚†@”£þˆÌ“‘•¹…úšÄ$â ¤\LB¦Pã;ê‘oŒãäpÛ-d1Žzøã1«" À˜o$TYÃføp:IňèƒÄ€@è… 5`E(^Á yxRþ)"!Šnˆâ#„„CŸ|U¬БÒ„> bª0„’²ìõa®CON„ A½â˜¹ÉL)Z§PÄ,ŒÇ¨×pôp;iÜã+؇?²é°|À`ú°Å"Á>fdH<މ>¼abž@‡ ú‚o:'þ[9^1·°Îœ IÀ+εl®`aä°…(¢Q¯DÚç™X!@òŒˆâ˜p&Nf̰Å-ù9Ä a«‰*¤þ0?üQuH㱸„#.q²"ÑA|°DIMš¸G·ð&2:ÓèÜ˲ºO±ž„¼ø6´‘wØ"ÛØ7Èø6èjœhKfe ]4ò<6E%⇀­uØÂ"! YüÍÀ@ b! Yèð€G2râ[dTAox0 RZ¬Â Ô„Z’¢ `h¢¥8«B@ 9II]8C èP‰MÌ¢s„̰…&1 }(þD!ሹ… BBް1$Ñ3ÀÀ<æA&`4Õ)’¸"h†UàA jHD°Ü(h6$h7P zh`º¸]4Èä WשaE<ÈŠPôBªE)"]4 r8@ &¡9l`èåLá Bp%ÀÆ!@ÈñBXຠ@4â¡ä”°P‡P¬e(Cð€¼m„Œ ‹óòa "ËB¬ÅB-u@‹Ë'ŽÐG4ô¢e€*ä qÀC¾…#D‘ŒzXtÖɱ ñÔ§ˆ¾Õ$¢†4€ijÈ×$¶/Tx2òHG:Âè¯t¥ýÌ8V-žµÅÚ·J€ú †DFI H'@á[(ƒø¨€îákn9‚ Xél/ˆ'Ÿg5¨„þÑ&9! ‹C 2 Øâk#Lqotg£òÿ.}«$B ‚(€4  y°Ûr Ç!  “ ¼QãàÉð'#ž 40 Ôea¥@èþ òqe0¨$-Ët¢ ø‚` €0åP4¡°£@@€Ž‘M÷Pùi"P(‡Àc&5P4‚˜ o`a¨àÉ@­æ ÃôÀ0+À˰šÐ 6 µx¢à Ž`7Tô-ËpQ'ó 5 ™á¶†^ð êà αˆ äð1gwPì@yÅþNsׂ`¡ € ó€MÛÔE35c,\xŒ8 îq 2ÝÒd{pq|Љ¡ è0ES(Sc…ù†¬8€pì 2U[ ‚K“ð4‡`8¹Ð,+53ÞP†¨`be˜65VC—ŒÐ( R, â òò 4u ­€ªx‡ð[É(Ð'^&Ýa^%––PDÇŠÐ 3¥xŠw2–2lƒna‹šp^¦ |¹ ·0{¤xzàN¡PŽ|ªø3NEzÓ µ€ á° ïðŒÉ ï Ûp 5øN@‹ÐPþ ²Âu†²0 s`}žü‡‰àYsp{ ‘ ! áà ï u ½ µPË LÇ`XÀd2B"ÙVmA3©š`p:©´0k5 oÅV“Ðr ´À– *Á½ð ïܰïà6¤P°î´x  °—8~“ÀpfÅ“° tÀVq0 H€^Ð l5nn9u`gÀ±  ä2J…àbO¸Pp“Ä8K±0  }_@ e“ `x°jpÐybÐ[b P: >s @ I† Q¥Ñ ü@&PॠYÓfà?ð$P²ºuG jÇ1ÒPôqP ¡@^µ‰\¹ ýé8‘8ë` `ɈQÐ Ðj9g1B¯Â@Ð3ÐfÉ86æ;pyftpdlib-release-2.0.1/docs/images/google-pages.gif000066400000000000000000000111731470572577600224110ustar00rootroot00000000000000GIF89aŒ2÷а­ÒÒÓ¯‹ûº±4eÎÚÛÛ‰­ïTË_äìùÕ«—«éóú 4ïÆô ¨i½¼¼dSùùùlŒÓØæùØI6ÊÕ×(nØæçi•욣¨FwÜ~€…µ–“øÈ¡¡¡òûþ¬¬­PÐööö)T²jw–ííî¼ÂŬËïÕp†·ñññŽ ùöé¼Ô©äää.N“¨´¹"Ä3Ï)On°Zˆè‰méééöÚhJ·¾¿ÀÓËÆÐ‰õkXþØÓ‡Ïþó®Ç½ÖÒÌššœôÓQmnq—xÏÏ϶·¸¤3%òïèéºÿó0²³´ò”‡E©ÖÝ㔤ǵæÃÁÓ÷ ;¢øýþ³(ÿ÷ŽwÔ€Ç7$Š{sÉÉÊõzh·£•£ï¯ûôòz¢îýúóà̺ëØÖÇæ÷æãß×ʲU&YÇïõüüüüþêq¨»Ã¦‚àááó…vÑdV´'’a]²” ´ª¨«vo’’•ˆ‰ŒÊÎÔÃÆÊQf”¼×ÖÄÕæÉ¼Ï®5ÿåN„/%ÑÓÜ­®°x(ò]HÍxÜYHÎÈ´±­•UPÚth€ˆ›Y€Ó5·Eµ§qÿþ÷6kÞâëî¸Í×õ©ž”ØžÑÞø÷úþÂÌË\e|ÒØÜ¢»ï¢«­ÇÅĵ¹»HWwž†…¹¶³A˜†"ìäÃ¬ÄÆýÊÃ)±8vwze|¯ÝÙѲSIµÕËÿÿû¼¹·ûÿÿ¯?4‹“—€™Î©¥£ B¼BZ÷ø÷×ÖÖ­º×üúùž9^°ÄœÖùêÅáæE®V¤¥§ÉÊÏŒX轫©©çåá–•˜òÛ„öôñýýýÿÿá?9þßÿýüÌÌÍP½îíëùüù›†3ÍëöÞÞÞÿýÿëëëà:#Û1{edöÐÊóó󸸹 @±ÿÿþþÿÿþþþþÿþÿþÿÿþþþþÿçççóôôûûû#å¬ñIð ”dðJ1œ†ÿ¨a ˜A"Ü ñÆ æØb ,|…ˆ%X˜ ÐxÈbOø"Ijøâ O^ò"™$BL'0SAàLp©` ¡¼¡Ö‘+qâKVßT ƒ‘ýÕcƒ(è€@ 'À‰Ð ©02 1Îäš]”„/¯„'gªg‰`Ð1éä³¥Bk°0DHXJ¨IyÂG¢ÿ,À¨zÞ¬!'DpSÎ¥ ‘ÿð ƒúgZˆ]ôßa¦eh7㛘iáàªÐ“¼r‘©~¨¯‘óOaDš‰<Ç9ç`K­ Ô%]øÐÅ”übiî IÇ!/A¸ bãðwob—,€À¿T °%}HòÏmÀ§†ŠI?<ÑŹ”hC DÎ Ôõ” i倻‘7û@ƒqÄa Ø\sÈ0ùúM+XìÃËh ‘L쓃½»ô p ´aØI݈²B;ÿ8QJVàX±Á(}óÍ*[à2ÌØÒJD¼1K 蛫”sbpæ¬tQ3”\£Dÿq&hQÆà=Àâ7Ê‹;CD!H;<òsòÛ! Á3²Èƒ DBÀ3+l ÃÅŒ$U©N$Î7\ QhÂEàƒËA†@ ”S€34{aÀXç-ém„˜@xë-ä8ã &uÈ1x †|ƒXFÊ4ÁË#‚è±Îƒ‚ä ðB‡å¥‘T %ÈSÌÛ)@a1 "D(€ ^ÁŽd# Ø 2 ( Ôãàh@ƒÀb±D"ÁtƒzªH8C»BÈ(pÅ-pÀ‚m@#^Ø^ À.q@¢ L 7’Ð j@c=„Ïÿ :äÃ?¥ CLÀƒ !€CX-*T MÀ øFL¼a+”bÿ›Ü€‚Üù°FQ†´À¯0ÖBªC€Š&›È7RÒ.X`ÀHBH0¨sH"ÛSàƒf@ƒhå  !íÀ#róU1Â`ÁÎŒ[`P`€ šA‚ „€wùÇ%B€€ €á 7¨¡­dÄ!€À&I<­ èFÉJKJø@Ëàr¢“W ƒeHA ¼•ð1á[HÂŒ”5؃ D$5Æá"u ' ˆd1`À`H 2ÿ…,¨À ÕxEÐ-ΨÛ?À@ ¤`ÙÀ1²E‘MŒ ¢ÀЫРp(D Öá‰7tT ,8D[ I$#ÌHÀ Ž  ä¤@§nˆ P+R‚¶üÓ‡H<Ÿ(@‰Ìð,\aðç4ìðÌ@ Â`ý€ƒä`ì€Àp/)B…‹¦ÒÅ!E.ò?¶`(øNþñŠ'\£À%ØÀ ^Ì”¢ùÆöÁ„áT€TŒTѼÌÙ HM„ Th`˜…j/@q9Œ‚ˆ `у!hBÜh@Ê¡%Š à”Td¬I¾a ÿXà­(ˆç¯h÷²@ jÄüzA4o $¹D˜àWaPƒ Hj!ÔðD €l&îÀ üC™Åì<¾P%xÖ†èÞpÚ8ƒ'ø–FÎÁzVž´R„Á›pÀ&ÂÐ)¼¢¶ZhA "Ïv D…H0œ€á6WÁÕEØ‘ƒáÞ@hÇ)S鄜”3nxPÀ¼›…ðb6 ˆX†6‰IhBú¸ XpiiÄ4Á3R™Š%€c¿¥zvѦq€¢ÈÃ% Œ `ÖA·é‡ˆ$H¢Ã2%Drâ6$~ƒ ÕÀœ L±ÿ 5XBÍþa ká¡ ñ0C<®ðÔÌ^a/pƒç  j€Ö°ÅI5…4ØWzhB¾±K´# ‚,¢Ë€= á dx™6Ý ‰<þCËZ€Ip ¢b)ÈEÎüHÁ AJP(TAˆl@¥YàñØó–Íì\D FA·QÚM pó3tg›þ*Å@B¦e {~B¬¶8ÄàXj&„üÀ„ÂÜÁ øN¿záP*È™¿)L3òˆP…FP¡Õ#›¡ÍŽøÆ07Ú!Rñ†&¶½¿HºW‡-ò,a6ÿ8ê'„poPB†®àä@nÕ@ß‚ Û¸„=2)v„e­hB$ ŽAàk€GLQ”`06P8"B!L¡©ºÐEÄ—íˆx`)H"JCþ\äyƒºA!nËâãqXhR£òN‘Õæ* kÀAÂ󾡊öñB~ù½¾áÜ@­ÂAJ&P Dˆ€³ÅcŒPÜ"¥¨D` žØ]ro 1;€ ®Ðš˜o€,` k`jÉ0æÀ'p¿‰„±¶Àù€œæ0µÉ# €­™þx  ©Àv oÿD' 8På€'¿y)]² '` Öp$›?p ·p#Öpž8` ù° ¶ ŸHøIMZÁ«’ ¡0\_:¶€C ´€L;`O1ê-£´hêqF0¢FÀ&x0¢¨`¢ÀÀÈÃ’’lBÉð@X(øP „rwD€ À±IÀ¢$°òV\X&x€ôIFpœ0`T‚4Mº$pQÚIº¥ÿP'0X‘‚ðt¦!6€L"`R,;!&z DP‚ò Dк¤1Fð@J¨&z0¨ÿP¨w€„ZÿFñ¥ÿ¨°¨šÚ©™Úš \€ ¨0$¥ˆúF€ w¦œfª0TÂ.Þ  ð ‰@§vú, /z H ƒ0зð" WZq¬š¢ƒˆWjhZ©ÿ F@©‚„/*`’*IP¥ÿ€ DPÀÐÀŸ¤‚ÊàZ®ªš­–ÏÀ?Î i!Göèp¡P2u: 0 §{j"p‹ ªG*ÊJ­‚Ä­™Ê­F€‡:”Ê¢'`©ß*F®‚ú­D®†¤;±ªš±ÀvpF53—jÐ ÀWÿÂÞ°qyDÀÀú·¨2x¬¢!¨±­ƒ­'P¢†Z¥L»M1QJŸ"J'Û/– ­ÿ`±%û ±š²¡ »ÐkÝa` p kp °  à N@ j¡³Ö“ ?{—0ÄZ´A:$°©€µÿ@¨xШÛ¸–б ¢&z²€ )ª²ÊЉ;­¨ð¢À€¹¨Ð\+È{ W¡#:Û– Á0u™³§ "þ¶§ ÀÖà§" «@Qk¸f~“*¼š*ᇠ:HÊ+©$`¦G›%Î+½(©Ö»ø€D`µ° »XPà› R ªÐ–·° >ú) Á¹ÊÚ¬ì{ç€Y `'‰à Bà;à ]¹¾ñËøù¤¼vÉ4©›Î c, œüÀ+Ñ%<ŸÐ)|Á¬;pyftpdlib-release-2.0.1/docs/images/logo.png000066400000000000000000000313201470572577600210130ustar00rootroot00000000000000‰PNG  IHDR“7hxísBIT|dˆ IDATxœÍyœU™÷¿Ï©åÞÛk’Ξ@è°„UÃ.ã´32‚. (ŒŽã,êkhõUDgttt7ÜF'- 0‚,¾5K MH:!„t’Nzï¾KÕ9ÏûGÕí¾ét6ç}ò©ôíêªS§ÎùgÎ^-Zv¿¿’ÍÍ®|î´{,W;40ÝxnzŒ4àl=V³gÄidqƒ@¯:¶­ÙñÄ×ß»}´E]ã±²9ÞÏ£ éï(¤Ÿ3éçÊëËçŒ.ÇÑ´˜­ƒ¯}jÅ+“ž+кðµ·U¦Û[‘æÝOé0µþÀî¯ïK~6¶#BÄ€¾†žx$ãû©ù‡dþåÙÙ«o­Š×¶nØlß]ÒÐÀÿìÚÅàä5<€¦å˽–¥K-ÀyÿöØ"‡=ÛÙègãFuv*H•x> ´ª¢êPçp6ÂÅq¬ª} ¯:u/€ÜúðÚßÞLK‹eÙ2Ÿæýêµ ìáÀy@CzÞ°ç€ïï÷ˆ Ï«€¿%´ÿÚ*ŸÓ´¿6VôíBàè´ms}e{èÖÏêÓ »ä¤Ör# þÈèý8YLã;O¯é7ÿ~´_ˆ†¡ˆ³à5øî²+_27ÞÚÕ2oº݆mñ3€óâ£Ô”é]߸ûí™lÕ7{’ˆ€Aœs[pšê\%¨ ê|Ä4ˆ1 ¾¯Î}è-ó?ø\ñê%Ÿzºù#²ðò€Ö¢1O.ƒapð4p.0xøUú·÷ÿ”ÙÎ,à¼åËùñÒ¥Ì=j^¶õ‹WΩŸRçc(÷9iYÇγî9óÖ*^ÖðÊÆ<÷õ Ï—bn¹hI×>úáY{"‚ÀŒÜ4ÚžŒ4VÙž*ˆ@)AÏ·~¸‰÷³&óó-;iÏøüôs—ÎúðY§OÀÄŠ12ÒOÕÑ6b«ìì‹x~í0÷>ÒÛ)'µ¶ÿ®Ê‘ƒ”!ßè }ÆÑ‡åü© ª‡é,f1Pê‰É9UçTyƒÃñÄòM ¦EËî÷W6/Ž—\wßÙ~&w‡³‘‹¥°ªÆUuq”4.*fAŒªú)‚ÊC—¾’`Ê:#§ªU'žbXUwß)Ÿûåûžü×ßÅ¢eþ‘gHVßtàgÀVàY`¸øÉ*¾>½î§é}—_kªiü´¿ÿŸ™[sþGfïbs¡šœwðB V¨ñÜÖ—Ìg®•Ði¬Ê¿cÉde[É5צESBwómÛÍ xëô©Ù;dg¡÷¬j?ðõo1LO¤xÆÛo[ª‚SSv3~u뎫?í†÷øâ®ž;—”80@Årn;ðäOnÚþp.kÎ<òð*=îð*Þtl#""€z~Y¾0­ä·ðú§éø¾sV\ ‹‘ªŽÇn£4ØËÔcÞBvâ4âBÁ©#A·€óHd3ˆ`£RQ<ß¼–Æ%WÖ~ç5mÐæA‹-Uús(ÿü2=w" °àGÀ•Œ‚éàw,iè¸åÞnïŒÓ'L -’Í;L*8¬K8g™ý™”a9EÊçpÂÈé¶‘¨jë´¹RwD¡7" Î&7•ï1F’߬ÓYå8½ñäSë•2‡L®Q•L©.3ùº·žˆçÏóÎËVçZöv¼øð ðÛMÞª'}â; «'ÔØ¨`Æ)áVn,¸TD2¶”/ùÙÚZõô÷ Y®E–î6—¤€ It•Lz~#‰²yðCà$âo'p2ðcIîþã/oëúÞŠÇ{/Éæ¼ÚŸ5î½uÑ$ =‘œÕKºaKÏ€KºeÊbÈN©®ò¸óG dúÔP߈ ª~`=qᤀÏ}ƒ¹eE·æ²†BÑ1¡ÎçÎ-ɳ2ºzÕ€œÕ0 E劥ÓäóW‚ |ã._°u{ô¹·_ö¿MŸš™{öi²ÿù•F…Ÿür«\ûÓW5›JQ§ÀfMËÈ»ß:Q>uÉ 7ÐYÌzæÄÒ÷¿0oʇ¿°îÿ쨢xžd!–Éž>½0"—·,h«øÓBhï{áá‡øÂ9WnøHmw-ªÑTÚı¹ú À´€cL`Œ™'ÆÃÏÕF=Û‚/>œª&/:ÿÆ®gZ– â…^&ûe[*€Üî JÁSþ7ú7qN%Tu…ª†ÙGcü³T®OS9ePy@1ý\•¾Ï àQ x/ðîJ¯+1ƒ¯®íŒè3ýCö4D¬µê¿¼1¯[K›çH¸ß0r@5P|9§Xr3llÕˆT¬N«‚ ›:‹¬ß\Ø™NÜÖê¬9'ŠÜ4DÜpÞyí› 1‰’ü<0¥³+z"«å÷’ ðb°^c#÷ ÷{Ù°©ðÁŽ9…Oݱ+’õ› ƒi_7‘ˆ/i[Ÿïy´÷ð[îÝõæ;~Œ+u•‚K.˜j¿ù³-oZÝžoc#ÛÛé*Ï¿.'¦ 'Òz8ðVàX`J2®­çXÄ]}ÿ‘8VÐÔÈHWfcîz¨ç… ƒÅ燈‡S'eõ¤‚ Ls§¯6@Ôzxx€ºÙGè‘ïþDæåfal]ô?5z‘ûh\¶ª&¦ÇX}‰ÝA”r*TQœ`­AÕ3“'«­Í0º>ü0@¢#ù$žÿÜÜTñ¾1‘kw‘eTIt«.vijggÖ®Z[<þŒkZÎ?ojÕÏ~³µw͆Â?êÓ ,'µnzù•üY3ϘPz±}[PŠˆXU5ŽâÎ)ÖQŒ­ëììaÓÔI.¯‰¼TUÅ:œ…ÞŸ|µôÊüs™áœ*¢Gšêµ#$€%ßfû†é±NEDÓgÄ¥Ømïd=‰«(±ñßKLý8ß{ôùiäBÉxvÆ”Ð3J‘›äô~ò²˜÷ߘûÅ¿_Ó8ém§Ö;DŠ˜tQ(œ¢JÓ¹S¼îA{ܲo¿r\6c4-lô¯LЀµ©3E5tQ‰¹g5½göiï½Ë©ë–È.A¤ÞF‘C0ê’1Ù«¾DFÿ.¨¢N÷Ö¿ 'š<™¾GðY`‰Xl%QÈÏnMïðµ ÓK}¬b í¡SŠ;ÛÚ¹îÇÍó«üÕäžÉN<ûò¶œûÞçÛ€Ÿ~ø ëÞyÖí]Óî\±‹ÐgG)¦³Xrâ×ø1%'ù¢KÌÖT1eì–-Ûç t” %²žó}ÜpÁÀÊ(÷r €¶äfÕÝ”^kñÒ÷›ÓgvõmÉÍœú˜Ôä‹ ÊÛIªäE8wѺ[ÿxã1Zñ ZpÉy™|wDO¿%„)%#:)4ñ÷¿6_‡{¢ ê5œˆÇZ=(0ulLå–µ6Šl\Ì{ˆˆ³±ø™ª³=ãåq6&u ¤ q †QPÁžbnÔEê›xÏ9¥Ÿ§ëIô$!Q* Ð,žJïëÝÍ úÆ„œv÷SÜ<±Þ;¶¾Æ¸RÇpýô)&mÚŸ\¿e{éÿýÃŽ‹©žÇ3žÇÝ·®ØuÞEõLèìýOö!Â@O¿Óï‘§‰ÿíg:rõõ´Ýv߮΋ì™1‘Ò>9ðøÎß]6"«xFž|aPxbדÍÚÃŽ<,û›;n<ÖåQk¶³;Òë~ü w?Ò«Ûv–\&4îÈCsÁ¥N•ËÞ?|_lª²†bQ%]í{àã ÀTÖóU4V×yd€¬:«ª©sêeªgI=I¨u¢‚–†úCyJÓóÍ$¢¹h-Ø6¦ß#«Ãܦ>`G__éŠSÿzÕW€Ù$ âé8y§‘H9ˆ‘Á¼£if͆R£Óöf’E58}þœÌ?hnœ{ÌÕÖ:‚M†Ý½õ:cè’Èn(i¾þˇO¨Ê™B\Ôì3mƒzÞ«ÙÑ÷íÀZ°=ÛwE¹[ûß|çƒ='üö{GÙ8ÒÄ&"»ñòŠÁß“š–{KΪñ;v ꦌ(¿ÁK®Ê~Û«Oß½yhç–CÄóP§ 䯂#QŽUKÝ! ÈN˜†‹Š’Ø{úž(ëRŒ"Kim½Þ_8ð_úý›_õ®ºp–ù7…»vÆÞÌYuÞÆ½^ú£blv ¢:àâtÇ ÏâgNc•"â‹âÇý±ÁjY‘§I¼êñ-äÙRÜP1ÑÒ´€;nßÀsjì(8Çc3fÐÙÙ9î«s©>ÒØÈݯ¾J—8=vã1`ìÉóWrfá15\tîäYÙŒ¹6ŠUŒÀÔ†€…ÇÔpî['QWëYá¹wÉ?¯£PÒ‰µòPG·Ö->¹öÂEo™è\Á…»úb}÷ÇV³³/Þ^W-÷D‘ÞfaµçÑ?uj&l¨ó&µÜµë3S¾Ü~ñ¾ud<¼µèg7–öÓÂëŸZ¯8)º³¥B¿¥ ýÎï¯> œO²ÚÃq®%lˆ,ý¹USæ4FżãyãùžFÅ\2pšøY®(‡Uâ«¿ß°˜¹qco'€ê2i®´ø¶Ÿ"o;`dâÆéá¨Å¹©}XJ‘z€gŒ0sJˆï e.™k•ï6ò¹¥˜D {‘„+wvîG«$1µ6q¶·‘ÄøV§×æÇ믳 Áö•ô=ïh÷œ=9m-m_°€¥à|2ÆÛ±­è.ºê%yäÙAchÍfô8ö¼Å Õøçe®»îvöÅý5U²¢H¯Oû‘èè(Ò›—œYó÷?\¾ýˆ«>4󔣿WÅDêÉ~À$ ´^!Ñ;¾óмŒã=h|Šsvš³.ÐTqvNQÔ ²µy/“—p&u¸ÔƒìRý3 ’ÄvåâÂb¼×ÇnbΪ ƒÍúuóÇ—0eê?Ó›/"„‰ËØDxÒ<Ê«#Ò\TňŒ¸òÀÓÏ“r ç~¢M×vœ@\Sm¼ço:Ñ›RcÄ¥Ú¦ÓÝt˜=š YXeQJS^KKyl÷ ²#¶|o‰Ý}iû&¡lœWÂÛ#V¿g(Öå7íÔeßííÝqoàË#Q¬Ë·þ‰gå$Þwô¼œ‚¸h Ö»ëŽ8Ò;H|Vù1O2w|w°Ë;‰_ÝóhïÉG[c±êÉ8h*ƒI@Y°¬%8´þ¡¯‹ò / «Õùˆµ¯l}9Œ&A[°¥|gãÑv»û“4Žp6FÁS7~ˆ…2˜D5 hÆ‘SoŽÉ„oÅ+@.Hf}TÅþ(§uµn™Ó$rýºåËñ–.YÑ!£ºÓÞhD1V…Rì4Šu}6àO}öÜ¡‚›7{nUq×C=À†¶xô}Á‚=ïÊŸTã‰;¼¬'>ÞËõHˆÄqbI æEž~a­;JÃÀ¦0à¡R¤7ϳÔUç²ÝÃyí"‰KŒÝÔ@[û˯äålU“›¸·Ÿ\oÖu¦zluøÐ3Cåq.“ilæF%Ûqñ9o™ ”œĈÆó|°PJÒI¾½bI˜­û@q gXÄÔª‹ýÀÙ=8ȾDÚž¢k¯»q­2D’ÏR¯—“÷RÃNÔ%Kº &çTŒËÑ—Ï/å¡g†Î»ã{//i¤ÄüôŽR«ÛÃ:þùÓM¿ðͧNˆ·ƒšYW*9ãä22¤Ë(I3ÆOF×\­`A½ñäTt b¤çGB!ûË^Ú)s¨T·]EEQ‡$q|P›‚IAqêJί›‘³gž<ýó?½óW«W7Å"-ãÍœÞà_tè,sdU’Ïzž0gFF¬å]a Ç©RˆœêÃO¶ß%±´öÆ=*;?u’÷ÞúZ¿&¶Hm•7ß÷Tƒlh˜=-Ìy÷=yowoÔ#ân!ÉÏÊ3 Ör_ç‡~Ç—çÌÊ7­!Rˆyë}fO gWåäQÌ?y‚ݾ3êq¿~›‚«²¿NOïg­,îÿã=÷w¿ÿ]ïl(Nr’yà·Çå.ÿbû²s¯n?¸Ø’>þ„:sÁ—>9ó-_ýìÜ8ê‹üLhÀ©™:)ÐÙÓÃ#ºz¢k¥™owÉ;¿yÏÌ@ÌKˆÔ9k¨¨sR©@Çqö8ïܘÊùcź÷óåìu ~¦èÇý™Öëÿþ!»ù“¿3 5? g°dwãLêPQJÅH3 {KË:ïÂ+\¤O/|HNjõÙ‹øz?±,æíMg7¬¸îKó¨1BuÖàùÉz/݈~o¢¾ðxk?ÿãÚƒ{é”)<ÕÙ9®’ £ÓK®úÐô_þÓ§ç’µŠç%®¬lhIk±ä’(µU¬ò¹¯¬ç–Ý_;ö0þãÅWè\SÞŽÈËmþ¿ùæQo>~A5ˈ#¥ëHò»sÊ®¼ãŠYËOõÿÃÉGqãSkèfŒØÒåhÍe;©6|ðñߟP7sZÆÆÃ60¾ðØ3<ûÒ]Ýa(:+Ë[Öqèœlç]à‡†RÞ"òEGäT7v–ä¢Ï¬^¿©ð1?@N1~XgK…˜ÄÒÚC§Ù'g©à0#çÇáPûj§¼¦Ë—ãSèV-Ï;Ⲇ_Á™*êðÂp1:éØ ^mµ÷WrRëC$†s%˜ðÜ5Ÿ¾d朋ß;…#ædê*Oñ 8‡–œ¼º3âáU|ó‡›Ía³2|îSsÈwG¾ˆ ¬ºq(û‡û»Y9çÚ{ïðÃÌ’¨˜/¡.,ëL㊳qÄW‰R“7ujŽ'ÎvUr¾r®UÁÅ$¬S¯¸+~öç_4ÿðñ#~ö­k_÷æC?@°N*EܨÄEÖï’?Üõë[77êêýrLÛ¾”>\ Ì%‰Ù™½ <‰îÑüø°k€*Oð'€‘Ä邽´_î—%‰å=ü'‰Ú1êÔ< ø ð 6mky§FøøãœAÍ8ë¨y¹ióÉj<âXyµ«ÄKëó¶»/ΓT½@’hXöO–ÝÛ€Û|p.ŽP§æ€¹N…xRÕ¤tɹ°“’»ÀÐݲPñÔ«žlMÔk×Þþ½LUÆ>ô­æE§Úb)ëû©uTHG9“¢Ø86çÙ¹³r“y„sŸKòÇ÷°¾LÓiÜúà+þ†ÞÞøX£¦þ8Eàá… ²ëž{¹pÒš ù…k6ä&1êôíÍfe³ª®‰"z2i@]Œv¨à¶YË*_aJ’2¢f¼ ÈÝ€4âoJ>¹8B$¬±™0ƒF”÷ÊÃîÆÝF‡3mÎY㊃ÞÀú‡ýµ÷þÎ7…ÞUŸ¾8 pÇ›RŒ!¬¢º;gJnNl@u‚µñœÙ8œRÇsc'1%×ò8Eˆ[Òr¯Ê•^Á͜擕±Kn´ý• CüðÌ^ÊÉF'¹äÊe¼Êlk[¡“$<”†öÒIÚŠÒ¶ö¤‘¾ÅÖ¶Â`ûGð µ™ qÞÕÆ±fâ×= Ú ôq¾è|À¤]Õ|‰ò3#§Õ.‘싞w(¢..Š «4~õÿÕõ/x;·Gé˜(å£ã§Z9Ž£sò”¶m.ÂÀæSO˜´þž›>ÚP;)8Íõçccð’€`¥ˆSÆ‚ÊàkuR/Àlò¹òCö6ˆ>4•X¾||'äj ¹lctè#íË4Éõ<¸6}Xæš–_ãZ j‚¨e)…r2ßAöç@ú[\»–"w²û¸}V‘½¯êŒºtÚUe_VWòYÔÅ%ñ²u:¸ù]w÷/ý¡Îµ;€§gNdݤvˆíU CÐpHUömšßpÙÅoš;kFöT<áúò±1øj+}L•úÒ(g‡sVP£™ 0Z©»7TcD`©T3Z²£c~8pŽ44KHEÓ´9Ò·–¥Íå¾Ñ2zÿë¨g?`zM`õ5Iá¬q#±µ}Xoˆj\/S«;ž¿Ë­½ý‡ÒPÃ=+îùpש gι¿Âj–ñ8B‡ Ôf©2 çiou΃¯Î¦Aº”+íƒ3%ú”%Ž,@.É—’ªA‘sƒê _ÎNœ>S]™u£¨ñü(Ê…][n–‘(ݪr5ÍUنٟr5`¢ü_صùvàË$¡Ž±m–ûöî´o3Ô¹u"~z¶ùÑ`Ï5$ÕÊüù9Ôë"Õn5¦RüìÅ-¸¨„ÉÖºÞöÇíÚÛ¨ïyçüÛnÿýûÉÈ»Ž .'@²û¡1¥¬@[G´+Æ÷‰ jĨ‡³*‰Ž$ãr£J`á½LŒôô•¢4ozì` ËÔÑ,sSæþêˆó?;ÉÏ&‹¿l (àgkè{å9Ö´|ã.Þo°oœç@³Lœ³è¢éõ‡½É¢ÖØÜÆš–o¼?móú½ômRnê¡¿>â}Ÿàg«ã£ÎÖLä•{ζ§þç“^uýZ;ÔwßAöé '£ê:Äx¨K“°ËîÊ1Ê9 ƈúíºÿùa°à¨†[oÿï ŽEãí®á"%WB‰QIÊ?Ó4Zué¡1Îþî\,>M!!{IDATâlŠÑuÖ& µZÆZn‰Ÿ©ò¼%)årŠ86¾: ÐMX‚=9¤p Ôgê&ÔÎ>*ïeªJ~¶6ò²Õ± sÖűUÇÎڨƹI<P®  ž_ô‚lAŒoÅø1I ÞÄqÚ,÷mB¶nr}í죆½LUIüк(²jãHµ@ƒJÕ#÷üD¾*/ŠñÎu vvOµÜb j'EÛk Šƒ}O¯~ðŸ”GiO<ì…~Ue "cï=üBûYãèC{çJ©ˆsQ'”b^\;¨À&Jaùá•äX*ÐÑ»áù–µ7믫§ŽªCL¡nî±€”³R-Æ8œ«Ô©†j Ý™¡êõNmd Ý)‹we?Òx}{¥gý37¯ýÿ¾¿zê¡dꨞ~8 ’ÄéQÏ÷Õ–F÷ÿò•ø)g£OºDùµè¨Ð¡TŸX:ž}TU{¸Ëè‹­x’Á:¥\Y?žXÛÆx²ÇÜxÚ„Î92¡j©§<õâ€m,oƒñSu“FÕ^¾ãùy~Å_‘ìœbL˜›vüeß 1hš0_ÜÄ "ðTÇŠŸæ3šUí¥½´oîc;ž½{=pÞ„Æ“ŽœÞq6Å6Ž…ÄqXÞ¾§R©°{yï7œü¨Àê %”@‡Žq^’z¾o]¡/(îØºcÙµ§vi•?Ýö–¬ïyieçn´/γ×c탓UœS”8¶„õÆ>ñTŸ¿e{é•eM¬M7ÌoP˽쟻hÙ5[Ÿÿáo]ÉNÃZTUýHDã[±z œIY´L‹¼ €›_:ÿ¹hxû$ù޸‚{i?}XôsýâÃWÖ=â…¹?(ª&I“QÀx^Fμ8žÚEÜÒ”>u,zLí‚–¥RÙ_TþÃá³ké‰m&Ìœ`‹ùX•`wŸ¨:0¾u66Pê8óä¹yñzž‰5¸J–½NrpÜj¼ûF¯uVñ$¾éž]pß5—5–š[ÚÇîO°u¬l.’Ôûàe2ý¨‚‘ÔuФ€®l–Ãh&Çèæ\Ï1 G²Ï€ÛWC+/ÅrÙÀKê¬ĨâDĈñ"[¶+Ë›€3I Q«i¦{e3/²L_æH¹ã_ P>ÍÍÎ}ñ¦›üŒbìÔ‘¤ÌŽèNå ,i½P¨¯ K¤$Be8u<î² l¿ânÿ`Ó”3!RÜyËïÚ)Àïy¢L,%â¡©ÉÐÒ¢b¤¼ïÍÁêG匀é&Ìý®þÐãO1~’È~I¸»Ãx}W•¢¡ÞË¡þVèëÝ[¿=€®O$I4¢¢êT­ñ«ë¾;á°ÓëfMX×€x>®˜'ßÓIï†ç†û›åNšù6Ëôqšå`w{ÍäR(ÙŸˆ þFBÇÄ$G…U—”lƒbæ\âY–”­¨©ðI÷‘ŠsêÒýüÜî‡1B®6TœË_ð‘¶ê-ÛKk›š¸>Í>¸È5×T†P^ E$ ÿÖßùŸç÷´·Î1~&É š0ïMÔÌšOD’ì© ‰¢½åÙ¢‰¡,^Õ¸0 ^˜óv­yL»^|P =¸8Ò°z‚ÔÍ=Ö›¾p bL("îÈüséÅ_|ásÅÞÍ»˜±ð{t¶î÷¯‡%ó†"^ÄHÅÈx)µêQúK"¢¶ò:[ÀI&@£È:çœ'â4 ˆ»{£àîÇ{n;8lÈÊñW’ãõå%tÍ5šÖrWºʤÇÁPºZÊlW”Ñ´w-×ì§]M¶ùL õ 0@à=‹zǹ_ii±@!. uØRaVPUï¼ ÅÙiŒîaõg§ý¥°9FW½½üâÓ Ê.§£@ªt Œr¨¤ ×Jàƒï©ç{êù¾z>ÎËÔ/“Ámë*xA† S-ê‹zñÂÀ3ovöeÄ•ý¨8þ<¬ºœ~’Ce7ˆ̦cieº§íˆKe$"Q´Oç¼¹—¤àXĨ—É H 'ûJó5^8Ñx¾ *..%Q$dÿsþšéàBëÒ•eS¹8­»Ù­Êv|+­\<)Š­JVdݺ~wé?µù/¿2/\P³ë†¯5N›3+ šŒ¹sšäM6ÿYßH÷.hjJ2q¢¡§ÇÉ=I¢£¸doÁó ³føz(Lª´´”½Ý{*>MM‰¸}ì1á<\«1^²á›œŠˆ ªª¼èìw{ìØ!¬XYvi(Ðr „¡ŸlQeÄÅEê9Fay½&;–hµ\,ס¶¥rzÍŒyG†Õ"gc¿Ð· 6ž7èbû†©¦%5és$¾&U›Ÿ½P‰óÓZ+¡g¢O_órðøó÷-<6üÔ½÷mîØ8ü±ÃæU}—aƒ£lÙ­rüõ‘°LK4 ´Œ”[­™ìT1FG2ãRLGGŽŽÑkUICø}R‡RªP°ó­' @)õFÕˆª‹Uõí,¦")¡$™À ZZx´¶4«U|Ûßcjfi§ÿöª®U÷]Dæ;÷.úg]Ÿ”XÙE©m©áåênœõ–&l© â‡ô¶·ô91›ÓwýË(àû¦ éäÚɲu¢eqv YÎúê,šu›†ó¾£þã7}}ʲU>~ö6¶ß#Ò7héL’¤äÏ $Pšå&[sfkpêá%¬ÃÏÕ•néæ? ¨®0þ• 8ŒØÒ`ïD~ü£ã@GøbX×0‡à‰Ä..Nö‚êâEüª:‚êúÆ §Î bGEÛ,O?:9ñ?ü‡°vò‘~®FÔ9/T›4sßþaç…¹E;V­¸ue³üšfž'Ù£` pfõÌ#þö°w^VTÕ:ñ¯Ó‹¶Ójƒ˜uD…Õé8¼!¾¦ƒ²,4)uS]×4߬WCVâØC“ý d?é"e}ÔZ{užÿùkÖ½úí;g´ÿÂiÿBÆÿ¿x ÝúÇÕç_½æQ}š3å¤?‹Ù°L#šåýSŽ{Ûïgž~A¢‹A?H^2ý*³Ñ—Oþ‹òl¸ózò;7]Í”)?§«+˜ zâc‡ýÕÇVM>„”sÜjŒxÉþ8¥NÛ´YçÏc狲呖ñ¼k&-?üœ+ÍL˜ ^¢ª?Dm„ ³®ÐÝi^]K±¿ œÃÏÕR5u.53烪3^`¢Â kû¯k(õïìÇÈu8÷]lK ×DÅ™DP}za ó[º´ý×IC櫺½8¤ªÕÆ$J{älïέTFÔ#Ç_ýì¡Ó¿xÅ!Ë&Ô÷!nq¬ò/¶à$“5æk×o• Œôóõ‚©¬xž>帷ë;a(¿sKñÃQiꬿ{\Nc,"\T¤nîññ®— ó;7}Ô,<£O€LÌ6ÌyÛI®T¸ªfZã¬)',Êwwz¢&£j1žO¡§?[‹Ÿ\ì¨jêa. 9 bªjK…Є¡Þ¹Ùµßöï”ú»"DîĹ›x”ŒÖk §Ÿ^,\ØobÉÍÓæ×œOwa°XŒ«=Qñ½²]¨J·ZNÊLªí«=È»Ä娀'ùüýšìÏoíê™3‡c7mb#¢äu‘Ï2i–3kfñàÌSÞ+âiæCåeã—Œ©*"†Bïv¶<´œ8ßÿðI¿ŠÜ<ëÔóϯ™u„Ž*ðãµYÑ®–Û…«î£ûå'ñþÍ™3gŸqá©USçj Füª:¶>qÅÞíÌ<õ}TO;Œ ª^Å÷UDPkÅÆ%J;u×KÉögþ$.. Þ¨ý6£ßÜð†Ñk lŠj“iÉþ ùØÛÿú½³7L#ò±¥û…BìƒÃˆ&Áó\‰N›|=RÙy†R’ŒQ ôÒúáàêæõÙ{אָæ}}CÜǟ׳]V<ÿ†äÛ æ2êa?Ð{c’mÿn¾ #{}O®ÞÁî{h^U‘¤¤û«$äÿ_à F#å> ƒ~®vz®aVmP31¶8L±¯‹ü®WuÀzDþˆê/óˆW¾ž´Ñû!‹Wú Ô|ý}çL»úüwN ›_5Æ¡.&²Bd Î *‚‘8¼ZùÇI§Ôu¿òü)¨›Áèw·í‡¬Ï¼‚‹ ëH7*­yrÃÀöU'¡ne0¹H0AÁ3›ãÂà,à’-êÓk†€m&̶¹RáI’¯Pë?€÷ú³ÐëÎqQ]hDZ°@„rBÝ»ß|tÍ TÓ8'ÃŒ†€êœ‡1B)rô X6o+²zý0O®à©Ù²­Ô<ˆÇ/ôÞE·§_¢ü†yj9øï¬=Pús™ÜPÄ Ì]tivçšgëbúª(äM±—Èw’Tý¾Qc÷ÆRS¡®[RÖcs$ß[ö%à6ÏcMmµéžPë•rY‘T¢¾ÜüpÁ‚¹UÓuõ‚rs£ßŽùÆ’¼ÎãÏÝîx[å¼Þ>üEéÿSÍIÔ'‹øHIEND®B`‚pyftpdlib-release-2.0.1/docs/images/netplay.jpg000066400000000000000000000344111470572577600215270ustar00rootroot00000000000000ÿØÿàJFIFHHÿí ®Photoshop 3.08BIMíGÿ´Gÿ´8BIM x8BIM8BIMó 8BIM 8BIM' 8BIMõH/fflff/ff¡™š2Z5-8BIMøpÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿèÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿèÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿèÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿè8BIM8BIM8BIM@@8BIM8BIMu?ú Untitled-1ú?8BIM8BIM8BIM ëpP$ÀÏÿØÿàJFIFHHÿîAdobed€ÿÛ„            ÿÀp"ÿÝÿÄ?   3!1AQa"q2‘¡±B#$RÁb34r‚ÑC%’Sðáñcs5¢²ƒ&D“TdE£t6ÒUâeò³„ÃÓuãóF'”¤…´•ÄÔäô¥µÅÕåõVfv†–¦¶ÆÖæö7GWgw‡—§·Ç×ç÷5!1AQaq"2‘¡±B#ÁRÑð3$bár‚’CScs4ñ%¢²ƒ&5ÂÒD“T£dEU6teâò³„ÃÓuãóF”¤…´•ÄÔäô¥µÅÕåõVfv†–¦¶ÆÖæö'7GWgw‡—§·ÇÿÚ ?õ7±–1Ì{CØðZæ¸H èZàV›û;,ãà]ë·G?÷¸ „{^oÒú[?T¹öYé¿ÔþkôjïPê£%˜tVlºÆ—Ø%²až×mo»ôŽÞçzTìý7óµªUzQS=*Eo#ÑcýK¡ÇÎW¿wÑüÏÑÿÚzÒSzΡecK ­ÚM­hsž K}]­×¿ôVú,õiý¨¯2Ö¼4på§¹N½õƒö ïF—YfâÓê–ÜÂêmo5ÿ„ü_èÖÖ¹Ór:@ÅëœVß±õÙSbùkÛöKIM^£=ŸhÉf?£ÿvQ1"Ô>Óê7·Óý_íQþèoÿ„ô9LGºxw?úòõ²±ÁGHÝ_ÍÖø¿ôG“Ï¿¨ô»ú¨é™vœ•—^6>šYg£»í,w­[(uïf7²ÏM•×U›ë\ÿ]4 ê¼Õn[©,Ç0×PXêÛe^žók1êôöbþŠ– ¦š)­máý³ìgÕÝê~Ô«í;¾Ÿ©¶¿WÖÿéú¿OÿUª¿VþÛûcönß´ËþÓÆß³n>¯í?OõWnß³zÿ¦ûGýqV¥Ì˜œ‘œŽ(ÇÓ °ôðFsÇÅd÷=ïç?[ÿSlÂY„@˜˜##Œ?NPãõp6ìäž±~~agS³¬kk¯Ó{CÜÊ®vì›=G±Ïõ¶Wú+?Áû—Eè]Kª2ëÕr骧ìe­me¶?IéúØ´¹Í©ÞÏYŸ¡{ÿâ”OÒêÿjô=´cïþvwz~‡¥è~›«éz^Ÿé}Ew }»öµaõ>Íý¡êý§ÒÙ·ôŸíwÛ}]›>Ïú?²zßkÿ´*§!#@s1…‘û¿§õq¿s×ïñ{œäÓšYÉ<1ÉfèÈÇüKÌôÖz¦Ywõ[kõêʱµ×S48Åìoé¿s³ôÛý´ý>¬¹µáZ-³©ãæcúÙ¸¢º›µ–2¢ÊÙêßO®Û}Œ÷ƒ§ô•¿ÔV~§ïÿ›Ý;tz_gê{#éý;~—æ-ÿ©§Øúný‘û.›gtl£ùÍÞßóT‡Û&^ØžpÃÚ÷_ÝLx}|_ÍüŸ¬P<ÍéÝF¸¸¸8¿O‹ú²y¿ªõ¸uˆslag^½¯®Çú®kÛŒýû¬g±ÎÜ?á67ë[üâï¾°tÜ^£Ò®« 8º›k;lªÚÁuwÑdNÖÿÔ~‰ÿ£±pý?Ñÿ.zû=/Ûù^–ÿWÔõ¢½ž‡Ø¿M»o©ô?Á£?Ðô¬û7Ú=oMþŸÙþÝênØèôþÕú¯þÄþ¯þ›Ø¬aË’>ìN™!îË×xþJÏïdÇ“ûÞ–¶^"a°<"¸#'«ÓFPÈ}ù˜ù53"Û(¡Áø®†XËêv;ne•×½þ«o¯ùŸæýÓ㫽;.Ο]…ã&Ñ‘wRÆ’*£)™ »×û»ìÇ®ì¶?Ù»ýý§¿Õ¦çBûÙ:/íˆû7ìºþÏéúßÎ~ƒézá¶où͈wþÍßÔ} Ÿó[{6ý§vß·no©ûÒýgÒõ?Ÿßÿz·Ù¿íJ§ŸÛŒñÊŸÐý/üV~Õý›Õ¿gÇí/[oÙvóþßæý]ÛÿÁ¬¬oFGÚ½=ÿfÆþs[nvíß™¿ùßúï§é«B\Љ”cÇÌKÛ–hz#ÿ5)z½範˜pGŠ>XÇG×¥9ÌÿÙ8BIM!UAdobe PhotoshopAdobe Photoshop 6.08BIMÿîAdobed@ÿÛ„      ÿÀ?úÿÝ ÿÄ¢  s!1AQa"q2‘¡±B#ÁRÑá3bð$r‚ñ%C4S’¢²csÂ5D'“£³6TdtÃÒâ&ƒ „”EF¤´VÓU(òãóÄÔäôeu…•¥µÅÕåõfv†–¦¶ÆÖæö7GWgw‡—§·Ç×ç÷8HXhxˆ˜¨¸ÈØèø)9IYiy‰™©¹ÉÙéù*:JZjzŠšªºÊÚêúm!1AQa"q‘2¡±ðÁÑá#BRbrñ3$4C‚’S%¢c²ÂsÒ5âDƒT“ &6E'dtU7ò£³Ã()Óã󄔤´ÄÔäôeu…•¥µÅÕåõFVfv†–¦¶ÆÖæöGWgw‡—§·Ç×ç÷8HXhxˆ˜¨¸ÈØèø9IYiy‰™©¹ÉÙéù*:JZjzŠšªºÊÚêúÿÚ ?ûùŠ¡/ìmu;+­:ö!=ìM Ì$PŠŠ¶ÈÄØD¢$(¾=ógäæG“–[ïË0Iç/À,ÿ-üÁ%¿¯a±jiz•Ìr‚xˆn¥6W'Ä‘aÂ@®a8òOæìºEëjÞP¿Ðä‰5(MÕƒËÐ_é÷¿Xh#vûp¸? £z _TijÖõfµ·¹¶k1ÛHªÞ½»ýYÞ6é"U¥‰ê7ûjc‘Ù–èù<Ý£[\Ck¨5Æ™5ÃqˆÜÛÈ"4‰3¢´@7«ŠcKl•Y]Uу£€U¨ ô àJìUØ«±Wb®Å]Š»v*ìUØ«±Wb®Å]Š»v*ìUª Ö›øâ­â®Å]Jâ®Å]Š»v*ÿÿÐûùŠ»BÞÞÙéÖ³ßjPØÙ[){‹¹ÝcŽ5Ù˜€Ï-mñgïtŸÎ0ÛjZ%ïån©åI®môÏÍkùì´ýVh"˜­¹™ä–Òè)$O#!ˆcµ—÷6{½OòËAÒü°º½Ö—¬­î¬8ŸLÑ´û“>h 3Hm”ª*zŽI!ZöÊÙ ¼Õy­y¶G´òv·«~^yïC¼úÖ“§jQĺw˜ ·¯©m aIÌzãEKØìüË¥]½­³ÌÖ7÷a½>ö6¶Š} © ©þI;oÓ/BŠ]Š»v*ìUØ«±Wb®Å]Š»v*ìUØ«±Wb®Å]Š»ZîqUØ«±Wb®Å_ÿÑûùЍÜ\[ÚA-ÕÔñÛ[@¥ç¸•‚"(ܳ3'|GæÝÏ~yüÀÔ¯¼Á.›¯þO\·zºÒ\X®ˆ© °ÙȽšgø½dã±Z€)²DP¦¸ƒ{³›+i^Xq%î¡uk=äŠÚ7—$Þ\„Œ|^ âdrãzr¤{ÄåmR¶V,†ÈØ£Ÿ‚#Ä5?Ê °8«ó?–/üÃ}¥ÜC~ñÛi ,¶öJme7­²MõÄp_²‚zJ•W隥חb¹“̧mŠin¯¥MF7‰íR%ùb¬êyƒJiôûûmFÎí YÝQn­ýAö_…@bÚ öÛ~s~iÿÎLÎG~G~|þS~YyJÑ¿4®2eŸë~LÓ¡º¶åkÊijƷ]5›…v,âf„ñþéhKº õÎ2źðr ºV´4ÜWWâ®Å]Š»v*ìUØ«±Wb®Å]Š»v*ìUØ«±Wb®Å]Š»v*ìUÿÒûͬë^_ÒµkV™mtÝ*ÞK«Û’~Ìq©cAÜšP¤ì0¡ñüâó/še{¯6è”õy. è~C{xïµ#"‘$œøÜÜnLé¼Iâ¼xœ<]ÈáÞÞ‘7øù®a¶Ò¬¯µ$µKƒ®krÃl¶×eÝ n!ËÄ¿\*Á€jdY)ê¾\ó›isªz+K¢9]_ÃO9’O…y95øˆfžb¨ÉuûÍA­¼¹äù§Ô¬ôhÄwzÊÊYïgR}R×$2FŽç‘eø›ýÖ¼J¯›ç$|ëæÏ/Ûák7i¶Z¼îOÑ®n-ï"€]O4‰"¨ÃÓŒ‚ fÛ…dztYÆèd´j3xQ·ËGó wZî“knúíé·úÁ“L—Ìš¦œ×³8&(Þçë~œb*U^•#íòë›mcGOˆäÞ~÷¢S˜‰'wÕÞ_óF‚óè×Z_œ5ßÉo4jD<çÍwçÌ^VÕ!eJÇ5窆ÞT´M+ÂÅœ%g88KŸ¸Mó{òoòÛÍ#Sò’GùïùÇuÇF×¼ëfÉq"$C×k;íĬQ}Bâ8…9s—š‘šnÐíx鲌á,™d,Dmé‘øïrpéÎHñȈµžÿ úãòCó—Düìò“y—H·úœ¶“ mFÕ$õâY5‘Z)JÆYXɇuèMý¯†·‹c¹bC˜ý£f9±Rá&úíæöLÎkv*ìUØ«±Wb®Å]Š»v*ìUØ«±Wb®Å]Š»v*ìUØ«±Wb¯ÿÓúÇÿ9'æ8m4ï*yJkh® ó ÕÎ¥|·vÒÝX´::$±Cw@ŸNk¹mÒ¤_Ùo²JÏ’ô{?'"êW¶3Üù×P‰dµò ¸7  ,ЀÖK%V §ª@©? / ˆJçG˜|õaäýKòV¥oåßÌO2êÑØÊª·ip°’è*È„½jìn š~ÛíXöv‰‘4Iâäi°ø²£ÈsI5oËÏÎË?.Þêmózù—LºN¹ ÍÆ`HdUú°‰â@ ˜¤SJõèy_iöö‡ µy¡Œãˆ³ õ{òqï?*ôÙd ,µ¾þr£óGËÞoƒNóñŸþqëò·_?Uò¯¦èºÅ§×ÒVŽk{énå¶ôÔ·À’…§0ÁÎàçS>Õ½ Õbœjèo#ñ¾ÿ{ 6‡ÅÔ œ`I«•ˆß™£_s6üÅЭ¿4_O—Í¿žžakÝ.7‚Úþ×É:\3ú.KzLÂøòPİØ“N¹Ìèÿà·¥Ò“Á òý¯g—þ}¡o,éüKÍ#ü´¶òn©åï1èžsÕ?3¼¯¢]‰ÿ0tË­+r×OB§ëÚ]½¬“­èƒâkˆ¨$Xÿyn%OQÙ?ðQÓö¹–›a) ¬WËso=Û>Àk{#Ï:1sÅ^ý…[À?:?ç!—ÌH<µù©¡ò…ÅŒBëZ³î5'b¯oòí¥¥·üãÝÔbÏó#Î6šR뾋},ŽLm¥¸·CnÜ£î~Ðû\AŽYJ8É€âéßäËD¦Dõîób_üäæ³¦Xêú?ç,z¾•©Â·:~§oäëŠx\U]uJsÍuðIŃ!Ç“£(š óæúfŸþyµã“¢Œ…‚ Ü|“uÿœ‘üßÓ!^½¥yïS·ž)G–õ}= +øǯm¡¡2ÛLÑòôd’6Ÿ%–[t?ðIÑçÍd‰„OñtýÚ{CþšÝ> dÇ8äþv}ÛWÿ9ÿ9€|³ÿ8©®~{þKK γ§êvú5Ö•®Z´wZN dáwc©X¹ÄfFöe,Œ¬}"2ƒ`ò/›Lš"ˆæð/(ÿÎEþt럕úæÏüäÇ®jžSO1Íå†ò˜Úz]µ‡×  ¹[ä˜Äpç@Ôß®p³öïIaÒpž!>[]×è{ì&´è¿9pà0ãú·ª¾\<þ/Ô?ËO2ÞùÓòãòÿÎ:”Ûj>lòÞ•¬ßÛ[†Ç=ýœW$aË7Î@©&™ÝEâåùÏÏùÈ?<ÿÎ6þEKçïËÈì[ÌSj¶ú|3j}b’Uvfôê?7ÃlI|à¿ó‘œšGçó䟞ÒkWö^X}ü7}ä]6nÇëV{‹køæXËŽÇÅMé\à1{¥ž¬i¸M™ð]mwÃÞ÷ùà}¬Ç¤:“8pˆqÕïUÍîó~§ùWV›_òÇ—5Û˜VÚãZÒìïç·BJÆ÷0$¬ŠNô¨+éx Ÿb®Å_ÿÔö—üç—æõçäÏœ?&<Ëc¡Ëæ+›‹0ÛC§-Ú[B­ËO•e¸ ÍQãB´8†N£Q0â=îç°»?kê<TIè?j þqÍžmüÜò?š5¿6¶›¦&¶ú”´$QEjˆg»¸Ÿ›Ow3K ”ŽETü584ùŽXñt,ý ì¼}™©ü¼$db?Î;×ÀSÖ<óç=3Ê_Ÿß–¯æ¥J¸Ñf{-A£&$‘nd[ŽÉ‘Z&`¢¡wÎOÚl‡³IšÜÂDÈÑ M’ys…¥,‘Qªz¯çWç’¼½äËÛA¯Ù\^ë\,¬£Žd"G”í5@g~ŠªI'¶`ûYÛøuIèôrñ³e¨Ô=\1'rHØm°ýLô:câ ÏÓ½ž%çÊÍ PÿœlÕ|½ù—åû]Nm?EÔukëø¿Õnf3]ýMècçňڠöγ°û<ö~‡œ›0ˆßÌý®.¯0Í–SKñëòò6?Í#Üy‹Qÿœ…üÉòXkÚŽ‰gåýY/-"µ°dŽ’O} ©ÜRƒ¶ÙÀ{Oí'cv>´éóia)Б<7Äö]£í]nœdÇ©˜ˆØ9_¡ùaùó¬ùÏÞnü›ó6¡¨ù¾ïÉúþ¯¤~\þa¸hîxð(Ô\3%@a(cATjš‡í°Ã9ÃÚ QÉÂd9‚/‹}±ø»¯g½¯Ï(äÐê sJ1•Äïì-þpþT[y+óY56yJú8õmcSò·¥¥éÏryN‚8”(Tûec&¦œ‰Î§Åñ8bgÅ@Yÿ@ß½Þ{+¬”tY%<ÂÔy’.\…òäÚñ?Êi4­SþrSòÂãKq?Õ[Vµº™ QÂi÷A%B@<_üúf³Ûx=ƒ©òáßüøºŒzìšžÙÓj%µÄúyðž¿WØŸž^_¼ó/›#|“œüÅä];ÍÚæ³±¬yfs ï§i¥IuÎ5qÎ0(Æ€Fùå¾Åö–—M‹YªÔ⣊<$õLGn+®nëÚ¬úL´øpe8Œå-Á#”oz÷%²Î6ÉåŸ0yCR‡ó ó¿ó‹Ê2ÝÝÛùëËšv¿i£êVÖæØµÕ¬Ó_EÒâŠê[ìôß=Ùïjýí#1—O‡ Waê¾íº‡ìæ»SB#­˜”¼È'‡ºf÷?£›Ì%µË/4ÿÎG~nê•ÚP°Ò&òÕ“j÷vÁbÓïµ®~öîÂÜ"z18¥@ÙŸ“¨ Ã:_m2ö†ÃÑÿ(m”ä;Ü_Òïøut^Ìê4í]L´ŸÝ˜uÞü>OÑ¿ùÄHä_ùÉßÍQÄúP~Wùev«ë:ÃÓõçiÿ|þ7dOË,‡û—šÿ‚O´c.ücï/Óìô×…Jµ½Fó.‘¨èaÒíu½WíuM&ö%žÞâ9#pUˆÅ_Ì¿—?$4Ÿ1~~ÎMþZùó;Ï?”>Iü¨ód¶>Pòç•®äžÙ`žêíYY'ºˆ'AiƵ©®yç·¹ÙÝ÷®ökK¯ÖÆpÁ¨–8¶â«ä¹4½ó½ÿ8Éùƒuå½kÌžgüØü››NÒ/üÅçv#&¡ ^jïw\½7œ¼ ,Ø”å^¬»‚œÕvŸÚžËîÏÆ1e@@χ¡­„»Àùt]Ÿí6«°µ‡K«Èrã4x‰21¾¢÷®ñò{gçO审ùÃùm¯¯å÷™£°Ö<ß§ZMpË2þˆóE½˜õlöÄ}b!ð[]úRV#ðh=Žÿ‚û+/äuö1‚E›âÆ{—Üå{Qìî.ÑU¥¯‹¡Ê~cÍoäà°óG䯙å;k©,u¿-è#ɾp±š&ŽïIÕ`³ú¥Ä3ÂüHd?ïFƒœßµÏÙ½±<Ó™äñ`AôÎ\@Ä—“Òv/hãÕvdpƒ¼aáÈq•Q°û[òoþssò«òÇÈ^\ü°üÿÔòÃÏ—úm–…lâÎÿQÓõÍ>ƶ¶Õ¬g³¶˜*N#øá‡‰ê¦¢Œ~ŽìOhô]³¦œ~¨’¡.±>îþEñ.ÐìFƒ1Å’$×"7ÿ³£ä¿ùίùÉ*Î^i¾SÿœvÿœzyÊ=Nú=cÍ~}šÎöËNÓ €2|FêY¸‡äÄ ÏZ“‡¶½¡ÑöNšZŒÓ9A2="yû9¯göV}vaŠ"ú‘@ò£çû8ôOË_ðM¯«¬yŸÍZOø3ÈÚ-¤FK½_W¹²k[x`…I#‘݉ãՀϜ=™Åªí~ØÃYIo´#ÅfÏØ;ËíÝµÚØt]›Ö_Q‰Ø¶n°CƒpyÞÛÕþo]›7ó¦OÂöûOù–çK´Ðu9õ«!©éqÆ Îœa[_“HÖ&Ù™˜€+ßÃ,”D‡ € ô;‡Z ÃÇ5/ÈßÉ;©to6êÞU‡O½òÌé©Ùê0ÝÝGé´.“…+ê°(¯·Tx¿dhñqâŒ<^‘ÿ¯ŸVã©ÈyÈž›¼ïþrCór}/þqëó[R:x´½×DÞXòôê2ê6k,“È”J@îÜw¡â†£7š-9Ï’º ÏÁÃÍ>Ûà?ùÀÿ.¾¡ù õ•¨Eóf¶„×`âê}³äoø4k/h§ö¨~—Ó=–íàЈŸç„_þ\ùZmOóÌþaóQ³šÓ]ÖõkHIkxOé+©ï-˨YÿtêÓ,l(~’3èÎËÓj'ÙzXe'Ó†ž‘<"öï·šÅÛGGªžM<" æL¥^©nhqsœº—þa~eXÜiþ_üªòe©Üù®ÞÒÃRóN¨çÔ¸¼¹rV¯n+¸'м+Ðòƒ¡Ë¢Ã-^h˜Â1³Q³BÉÛŸ.Øfíì:­Tã<§ ô">í¹õ/cü…ÿœ;üäòoæ?’<÷æ­+NÑ´íòî=OFŽì\]ntû˜Ö~Jž3:©Z–¡¯@sƽ°ÿ‚wdk»;>O)JS"F5ŒâL{þMòÚ´3^§aŒ›õYXó凕4ò­Ž¡ù©j6#[ó¦§.•¥Y®Ÿdožx’F.àqU㹠͇üý™Åí^|ø¥–Xü( z@•Ù®¨íoj%£ŒL"%g«Ðü™ù'¢y¯ÌÚUßüäçåeß‘ô ¡½µü·ò޼ÒÁ«êPʲ[KªÝݽ»¼•¨µT)+PÈJ¯ô²Ÿð7ÑvcŸŠY²ÿ ¤àh ¬ÿ;˜èñݯíF§´ 1š„:ü^óÝäýQ³šÎâÒÚm>Xg±’5k9mÙZ&ŽŸ Œ¥T­:SlïÞuø‘ÿ? Y/?ç5¿çí#ø[Ö† @ÜóºŸÀç%íäÄ;XOLRv]“ÃÖb—tƒÙ/ô›Ý#Î_‘ÿ÷_š¾[IÔŽ¥Ví—î9àðí#“·|!ÊX§æÑeíV¶%uñùø5³]ÎvÿÎ [Çöî,áAôß] ÷¿ø LCÙýdLEã»'…¬Å.é= Í^GÒµ¯Ì?ùÇ*ù¯Hµ×¼±æ/ÌØãÕôkø„Ö÷/ëNŠèÛûç‰ÿÀC´NNÖψr8IÿK(×û§«ö·YFžÌKLç?ç4ë¨om? ¼Ž—07(ô{i@>\„õé}Íÿ8ñ}¢y+þr›óQ¼Éæ3B†çò·Ë¨ÞÃjdã«êü™V^Av©*è=Ÿ²Á§9o¯ézæWäWåW›u[é¼ûæyô‹ÍbÂÎÓUÒG™äÑá¼µ²y^××´[ˆ–QM!Fe4©¦y³þÞöïgi| œ\DíÔyú¨üÖ¿gë2ø™‡ª«ê¯±à—1èŸóŠ7–·Zšl|Ùÿ8ëªÜG»¢A«[j:¯”ï'+»´E¦¹µ™È2 RPÕ‡¿KŽY}²ŽlRÅÚQÆF†=DFüôðÃ$GÒzò÷cáí(vT«¸°—f¼u1=^Ñç¿ÉÝoT¹Óÿ}Ǜο/?ç&,3üêy7Ê_“¾tÔ<ßåëW¹ó€Òé6ÏgéJ¶ó«5Ý줭ƄïÄfß¶½ƒÉØúxjµ:ÜÃÔ%ûÉqXâÑ sŽýÞl°{\3K†8¥Ä9žÁ¥ùƒó[óCy'Fÿœvó­ïšŽÚÚé‡QòìQ‹!9¶Ydœê~šƒ7ÃJ–ïÄŒ>Ï{›·á,š-^„„dk ¢Eò”#{.§Úøà5/™äò\ kqäHšY’ÊK[¬ ±ÉWƒH"ãPwS<ϱý íÏÖk2i3áÂS¤OÌDl@JÞƒ´µ:=v ÔÂS1ŽÛ` Ñmÿ•ãù¹ÿS†¯ÿ¬“þ<þâßþR/ùmþïýêÿ'ìûgÔ¾4ÿÎþwõ_'áqúëüÞï{ÿÖ…ÿÏÁ­ïu̶Ÿ\ŸêÞX²ó^µoue~ÒAnÓñ·{y‘®xÅ"´|îX€hÜðâãÚÚ±Îf8ÌH«à®[︽l÷žÉ~PäÆ5<<¯«éâ¿=·ûÙŸüâ˜0Wþr7òŽO2èÚƒh²ù'Qòç•a·´˜[ã›h¤]DW—(ÙÒÝd˜|4B qΓI›,æ¢Dkð\ßi{3³ôú,²ÁšË,ÜDߢ¿Mþ—ëçe™,4ø2Ñ\ßÄ.¥¥R4Z@Yº/'UQ^¤Ó6›>ÿœ“óæ+Åcå(èšÄz¯¡{¬ù‚i¾¯,Þª5­¼w#áà¯Ä¸i¸¯bë²´úy O4¢:Hùþ§Q’`_0ÎEù£Ï^y°7Þbò–¡ä ;èô}3TV¶å{$Q½ôŽe! ´ôÔ…? b:îNm;/­AŸÿÁK°òj½²ÅšSÂ0ˆO6ráõ~ï$ã3·/O«¥½.—6XᨂFý ç7X&—ù™qs%ìv‰$¾N¾ó–72K$Lì ctŒHB^@MóꈑÄ/•‡W]Å+óLóù›ÊÏiæûŸ/èšä"—6v–F:J³*q#¾ùéØ¿|Db·ÞùΞR×÷@ÊCù»ßË›íùÅßÍOÍËí>˟ί#ùÃÍ>M²³I¿/?64½ RÕßê|A†Êñm-æ–TáC Ê­·Âûqlø3þ ÞÂv<µRÕö.»Hg)æ0ÇÕ{Ê© „CÈÌİÝÍɨÌec+Öǹèÿ™¿ó‹‡NÒ¿årè7¿¢~³'èOñ—µÁoõž¼ú¿é ,/>xïOlÒv²~ÓqÏù3$x¨qx:¬7Ã{qxyn¯•ík›U:ýäÄ~·ΟùöÅE4%V»·?ýã3¥ÿBðCÿTÍÿ]Qÿª­šóÉôüã‡æ€òÏœ¼¯¥þFy{ÍþÿœjüÕ¿uµ²‡Êúå®›äÛç,$¾Òµ+Ë8,N•,ŠÂ{a-`“ã€Ï} ÿG´pÒþS·0,cÑ›ŽãórpÈËŒtŸñ{óÂÈbMÇkèñ/ùÏÉcÒ?ç5¿ç<Å|}7M¼¶’êÖÉòý•'jˆl-„—S]„q±=†n½¿ÓÏQìþ·–)e(Â?LÆ1d€ËŒr76÷-sóÊÞnóŸäF¢ižg³½ÿ•§ ]zúß–5½ׄݳ(»Ôì­ æAøSŸ7;*“Ÿ;À[°uZ/hF\³Âcàä ørËp?ƒå*ï5C©sµyòÏJ$ ê Äç?naÑÿç:?ç¼Ãz’ϧi¶ñ=Õµ”O{|Áo®!°µYn¦;ì#‰Ï¡à…§ž£Ùíf8‰KÊQ„~3™Œb<ä@p°HÇ #½‹Tóÿ–<ãùÅÿ8µ§hZw™,®í?3ZîYuß-ë:$òæ´Œ±Üj–v±;ÕÅ#V.EHZO ÿ€wajt=µ›&Yá8$*±e—×áÅ9È ¹‘\·ÝËÖfË88/¨/Õ,ú¡×?ŸoÊ_0é^Fÿœîÿœ¯½ó ¾©¨Á¨Û´vëåý.÷Ì+}z'¬Ði0ÝÉ ¥dUÚµÛ<3þÝ™›_Ùúhâ–8‘”ŸÞdLj}'‘Ë(|…—3G’p‘àûƒè/̯ίùíZÊàÐmÿÄ_R_¨ÿŒ<…ª‹ß©ó~>Ÿ×ô±'¥Ï)ð×—zç†v²~×ø'ù7'îø·ðuX¸x¨sàË\U^uNF]Q¿\7ó­ç_òºçÚÿõaòWþsÿÞ37?èOþꙿëª?õU¯óQþ`ù43?çÞÞf’Û@ò¥ž¢ù³U¹†)ëOòv£cæ ]Qœ}Nm*{-4N·)/…jÛÀsû/°?à£ÕC>9d™¾j!8ÈuŒ¢rA²‹ ê!!F#äòOùÃÿ1i?—Ÿž_󔉿wÌ_˜w:Þ–_ÌZw•5ëBYÒâù¦:®Ÿo§´ÖW5jH²"©~\ ¡§Sÿ-'hv·³òdÇM˜xœpÉ› •Cè”æãΨ’Ôtyg.O¸>Çü¸óüã'šÿ?:µoÎ %ÙÙ+ù*ßËóSN¶Ñ¯ËFú¿Öþ©gæH­î[ÄK¤|íÈ‘·wÿ-}`s” ñ¦w8e(ÿ)J7åv:µk2Js¹ u}siÿ>íº²½µ™ÿçÖæŽí¢›Êð° D±²²mû@‚;õãÇ[Ý8›>ò¶®ÿÎ-yº)?(uOúÿùÃ?Ì ùdÑÊqù“Uò…Û»z‚Þ;)%iíQ…$†¯Z jÀ?à·ì·cv¹–Xj°iûCÞ3Ë ~$zG ‘Kù“>ã·-ž“6|q¨‰û‰z.ù“ÿ8À<5ï5è–Þf_Î8ü¼tŸ9hÖOó½—Oi –‹ûÓ ªÈUeeV IøiâyûÚ_ä\zlÇäüNÑóäê†|ŸÉü'‡¾sïåÍêß—ÞaòøWòçX¹¸Ö Õìÿ/í,ßFýªÉ)a³iåTŽÍýN2Bª¬…‘ƒ$òR|/¶»7^5Zœ#Ã1:‰?s_ÛH“{íä\¸jµ“Ã.[lymäóoFùe_ýcOWýéµû?Éý÷ÛöéïŸoþ\ÿ:?âÜ<Ç>ÿêK“Î\¾ß·õ¿ÿÙpyftpdlib-release-2.0.1/docs/images/openerp.jpg000066400000000000000000000076151470572577600215310ustar00rootroot00000000000000‰PNG  IHDR¾.¡ÌSìtEXtSoftwareAdobe ImageReadyqÉe<PLTEíííÝ:6àUR322¨¨¨óîîðÒÑóììÞEBÜ1-á]ZœœœïÎÍYYYŸŸŸ¤¤¤qqqÛ*&éèèµµµîÂÁÏÏÏ………¾¾¾’’’Þ>:¢¢¢áNJ•••]]]€€€888ꘖÜ/+âa^UUUEEEïËʼ¼¼òããâfcóèèðððóêêÔÔÔ¸¸¸²²²>>>°°°ÉÉÉQQQÞA> í¾½   éœšiiiÀÀÀ***ÚÚÚßIEÇÇÇ목áYVꢠòääèãifÛ,(ñÛÛ爆!!!lllôññxxxßKHàQNì°®|||è”’BBBºººãliæ‚€ÝÝÝÜ40îîîäpn¦¦¦ð××ꧥ춵uuuñÝÜæ„‚ÃÃÃåxvÞÞÞòçç™™™ê žzzz@@@åvtâ*&í»º555LLLIII%%%ì²±æ}ꨧ˜˜˜åzxïÇÇËËËÄÄÄè’çŠärpòààñØØé–”Ý62憄sssŽŽŽíº¹ôììbbbôêêNNNñßßðÔÔì·¶ì´²ã3/ãkh,,,———;;;â_\ÐÐÐooo¯¯¯ÿÿÿ‹‹‹ëë늊ŠüüüÒÒÒÓÓÓêêêâââæææØØØ÷÷÷­­­þþþŒŒŒÙÙÙÌÌÌúúúööö‚‚‚ôòòáááÖÖÖ‰‰‰ùùù×××ûûûnnnýýýøøøÍÍÍ®®®ÑÑÑäääòâᬬ¬Þ?<çççìµ´äolàààÜ30ëêêóééãol玌á[XÝ;8ãããîÅÄ狉úùùÓÒÒÙØØë¬ªïÈÇñÚÙØ××Ý74ë®­ðÐÏðÖÕèîÆÅë´²È[YîÃÂÁŽø÷÷¿<:뺹ôÈÇꥣöõõñÞÝÑÐÐñ®¬ÂcaÞ51÷öö£££ãmj000媩òááÅÅÅÕÌÌðÓÓäusÒÑÑàßßòååÂ=;æ~ÍÌÌå{yîÀ¿Ÿ­¬¬Ñ[XÛ)%fffÿÿÿHd¦ftRNSÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿS÷% IDATxÚì™yXT×À‘% 8A$( 2‰²‰Q6Gb¸+uEmÚ˜¤M5š(Ñ÷ffd`Ã. ÷Ä„,ÍfL[ÛÒ5iÒ´±­i{/·çÞ7oæ=fÀ¥ÿÄïó~óî»çÜsï½sÏ9ïáAîêæqÿþ=ü{øßü•¬Ë™üàô;±Æ-)´Š}mè1ø1+üÈ$×–Ù!h=rä°T^–¸¿3è˜ÔäÕÏÊd3·G^ÍÿóÙ™¾HuA…²&æÜwkÈqÁ eÊÄÊŠÜ:iV!c4ttc倻æbŸ®Q’T¨ËÂâæ—m¯¤ ®ŒŽðp‡?2§]xê­ìÞö.@ÈwÝ-À¿¹9|ix@ô’R…Õ÷EÒCÔ¶ç•Ï‚aÓ,÷ø'쪻œw¬°b©yyŒ+þ[é(ëâ¡ï3¡¬Ø›Ò§í˜£–‘¶A+䑤Êhrlûðø'RÝÊ£—‰KLp‡? NŒ¿3 ).:ŸÄ:„T%7Û9cDs¹ûì½5)“„HHHx"ñ`ø•^^ŸIZ™ºÄt7T,‘á¯ZÕ^É ¯÷¹6üÍÎ]9ëêzó„ÄèYK¼ƒÓúÿ"óËå¡p«ŒŽ¢ c†´!àI‡‚ØnO’áwHŽPþrþ!¸ùžRŸŸ „’}†Ã7l®X^eÍ ±§EËÁïÆ–š«'™ÐDO)¤,ü6^aøŸJ‡Î°¡0~«Ta ÅWKñGkÖoeø³a¨oªÐßû,=6O-ò+zÀ¡òü_~þÿþÚ~ò‹Of›ù¯üÕŸ$¼ø»ïÿ«r:”14~!ÅÏ•âë÷>wÁQJ»—ß@h"݇èE‰Þ5ÿÕKÛ¶}2ždÇnÛ6…Rÿf"ÚöÒn§!òt² 5ßvƒ/sžP œ;c(ßl óý5ü×ïdÊìúlÖK¡S´kñ…>èÙÃ飤w9B~Ídád–ÃèST·ïà`ü¥¹å¹¬•‡D¸Á?åhÝÌbÏ’`Yä±ö÷÷nlÓ°=z^‚ÿž/àËÓ”Ï|ÄBÏ´=ã§@{›ì1| c~Lü"B9pXP¦ÏÓ~ãý€>™¾5¹ä4Bž+ãK›¿üÔÂ-‰Ð¶$z… I6Õá-¹ûö ¶ã/‡Õ³VÉìN/ÔñÙärùnhéÚô¯ ¹ïBS¨tª/ò¤î#„‘÷ç“+*Ô·šl%×c…Çp{ø.ñ—#ÃâW\•eÝQÔ©åA~Üs”κW 3zÏ\"àŸ£B¸6ºz¯¡Q÷Û³†_oÁ£p]é¸à«—ŠM=ðòMñ+7Kª67ø;ÖËk ª¾”Ù^µ‡Å›ÅH±h]sx['“!Iøä ñ‚Ÿ¯BèÆÜؘSõuÁŸãü#Ö‚=ösƒ8º°°0z K¨åd…˜àû“ í-:jÎÆšÁ'¸>ò]+³;•î\2òu¤P ¿¶<……°›=›Éh?tîAûm4bÊ5¦`Çx>ã‚/ œ&“ü:ád 坸qÁ—F7õ>u• ßçu$DKxàF}{ÅÁt€ËöC(÷µ‰%â{!)Š©Ï\¿†ÐEâ‚«i+‘ÕO5®øÃâ—(;Ï4ˆ‘Å,¤“ì>û~%k3Y%£¡ÂH/x¢Ù¡ýþxð}šÏ¨ë÷öñ'ØÏ’réY¤Ù¿uXüRàÉ*•ÞüyIÚ iÈh›ÄÜÜ·ŽadžÔ 8•‚ååà]?»s|!eÉRíà7C^UäHƳ}‘"º#%¯S»¤yöË[s8ÚeÚƒBÉócW|ÍàuCǺÇ'L½"åöðiúQ$;á‡à¶GпëèC£(ÛÂgσôN…Yk§ÐðíáGÁ» û ÂŽ.”´è² ï!ðÓXÁãÕp{ød+xÏ.G-v )ü®‹ÛrRÝ`è)4ñ¢õÄ9öRzÆÙ?=[t:ÿ =ß Îô"ÀR /HŠj÷ïºêÙ»®¤æ!‘¬lhã«7ÃÿêfJš7%ÃÒ~ b=‹á-Ñù™¡y'­Â—F]º4×ñ*óOÄZŸ02ýÄQ¶;B;-Ý$Ðý—†»| KUQµÎBÕS&øÿ™ýì44ø¦ßyF–~?sTrúÑ¢³KkØ–ªüüü£’ÑçWe¦gî*µo–ë³×-®÷÷%ù›d_ˆÎ+£æ`×™gÿPò¯¼Hz¾?Á1#fÎ>¦²½ß@O{öí£fÜáW¶^ˆõ™wïGÂgTHµëîŇué½o<¾™3ÇA¼mà8Ž6˜…h‰’Y9a Bø…ц8ÌDz;¨sœ]H0jæ¨E3 ¼J¸w‰‰#µÔ*tè­å„°BO` ü¾ËÁP -òMLÆ4@г8×B?€©W¡×Rë4àÀש乓XÉ·#o4’:\GŒ´œÕÙ"v}¼Rc c±% pŸ¬ƒ~ ëT*°ÆH_WÁ€Fi€©u˜d`c ü`ÞÄaÒF­¶€ ù”gÊ MD\SÉgÀ$`Æp©q,‡Ár9ÜFêÚøaØÜÁ×àŽÜ ·VU-ÏôZZ=\@ Z¥Q¢zbÆ1U1 ¢'5¸F‹«¼qŽéê²á„ªFе8 k‰c˜©ÁÕ€_W ³ÊlìÍ”‰ Î Õö5»šÆi)~5Ìà#꫺™¸» ,0(VcžÓêøF«´âïÏ{è5uuDkµÖ1|ÈP¾¬:ÃkÂ`ývÒŽ{Dü:>¡F€H_Æð•|‚MÀ?Û8?O|Í©S‚ âý[ØÇåõVÀek6eð3E|·:Äþ­„&_4àÀl‚bÇce`-,¤…õõ|“–¹¾jÑj!â°õvŽñWà'ã±ákS~L oð-æ&lfø3¬mX°ÊlÆž–pµØ_Ï.[“7ÆÄ‰øi8É!ñûí€q¼Q/pâŸÀcÓ ÜqÜBôúÆFÀ·XéÌ {ÎÍöö›ÇãƒH,7ôð-nŒPãý ¸ÃÝ F‚4x„à4¸H‚«[1àëÛÛI µA8‹7- ƒø™ý8£ZXÓZÕNþá‚›”-Ä$ˆEüWp†© IÖ‹œø–˜Na¢‹¢‡$G=¬ƒÎt¶0Œ#â`Wá¦ZgÅšºÜ#Ø£¶ ÀÆð±&Áv^ƒõç‰?îô¶ ø6+¬ 64Xó(7¶烨¯ÃšzúÿPùÅ7E` }ôöó×/?›¾ý¸o®"ÆÃA”E?ýeüO”±ô˜­–a†ŒŽÈÿ®dvêÙOŽå™Òð‘;LRÓÌDz…O2£È*¼n9ÑM•2Î_UƒHâ×à„é^/¤½­i\cÎæéVSŽ¬Ò´Ö¿òÇ7ÿ@£ã£' ÑÄ1 p€M£' ÑÄ1 p€M£' ÑÄ1 p€M£' 2Ç9p‚ÿ°ÁÒ×G8¸wïÞׯ_¹¹¹ÿýÿÿû÷o qñu@Q/qüúÃðï?3(Y“ÈÏ? LŒ¤’ÑüûÇðÑ,ˆDqâøõGQR°=È,Ð@‘ ih9ÊLåןkÎÝ+_wòÉ˃ªùþû¯‚¤À"ß/?Ûvoºûâ#–bàçŸBo£2wƒ#w^¤,:š5ýù»-ÞÞUK(Ù¿çrÑâC ÌHSKÿsr°,Ir2òÒþþ6£ùû¯¨ w¯q¸±²(/Ê<Ë“÷_Þ}ѱãÂ矸xx==<äåå§OŸ”zõêÐR2¦¾©ˆ²8ûýWIR`o±‚0ï‡o?'ì½qèöóÏ?~ós±Ù«J¦Ûi“ˆ¾Œ°ï¤m÷”i°4~Ù˜™î<|óòówQ¾@CÅžMgP¢™T)póq†™(ór°î½ñôë¯?LL H‚GUŒ¹j÷®‹Ïß|FŠ¿ÿx›)CR0Á#õï? 1î-yžÀ2˜WŸ»ýÙ{ °ŠŸ™‚¨Š¸‰ò‹Oßg¼ÆÁËïããÑLJJJ4 €(JLÌL“£l€)ãÞ›OSw]º÷$ nsl>uwå™»«Ò\´¥'FÚøMÞÁðuâX3ÁÚ­þjbFpŒ'¯€ ÷ ¨˜×±¦¶¿`[ %¤ÑTÏÌŒ\6°23ýüøuã…Å®z~zòw^ü T‰\xüýo$/ª#%ÌýÀ 6Mÿì… ßA’•zëúSÐÄgaÎsÔ–7_~Е‚Ô) âßÿ:?`ʸúì}øŒ]WŸ¼ƒ†33++³¹‚h¥·áÏß™™P|M°Àø Lš-DAnæC/9`†ˆ˜½”28XAˆBž¾ö$bÎ>`øúêË»ëÉ2 /abbgg- –½?ÿˆ q(‹k³Ы¸Ö¼[¸ÿ%ŒT%4åD¸9Ø~ü†&$ q EÀß@†–œÐdQ~NväÔÉ̼ääí?ÿþY)‹©I ¢·ÿþ‹4U†û›Ï>ÿÀŠº†cþ±›Þ}ÉwÖ–.Püúk®&i«*1÷èËOß!óYÛSGÈ.^süêýW Äš”µgÿý{äÆSß);{v]ä#rq 8Y&>ú‹µå Vöÿ?¥ËˆÌ’ã8ûèËÙ›.><}ó9(A .ö“מì¼ú8À@!ÌXiç©;ÿ@Îýñû¯©²Øô(›#w_NÞw¥Ì]ßWO^’Ÿ X¼ÿªtõñ ^£˜.0¼M”JÝô-•Ä!-`Fœuøúô½W~ÃÒÇßÿÿØÙÖf¸ p±Ç/ØLµ‰Vjú²Â,LLw^}F[׎‹ˆF" ÓÕ§ïO?x 40@_áê½Wˆ âß?~>NwmPÛbã…‡ ?3¡fk`Ù°àØ­:£+õ©;.€ª$&F`!LjÓ^«ð0@%fnðª—w_¢uŒ ÞÜ¿?ÿ^¿ú¨.!@T°SÁÿÈ.+ú“D3¢¹Ÿx@d–ÀDËÄÅn$' d¯¿ð½Ê€FÆÍ—iMIAV^.ˆ×€)Ø¥–´ñªgªÓl5ß}ùqôÎ f&Mémù^ ü ¿‘Š™_+|¶äx˜)ˆ,?}§eã`²àšnÕe lêCôT›X(‰sð¶\Ï)‘ÖÊ¢|GF `ÕÞh^èª4 æo¦ßß~n½ôȶ-¸9Ù^øõ×F]JI„˜X7]zÀÀŽžè9ÙXg¹,/ œu@…ǯ?ê2¡FŠë/<¼|ÿRÓ›‰‰éë·_Ï?~²³´YF 0`,áe3¨#‹3œÅÃ?Pñ*-þae`–û ’ûV b„²ÿ`Y²‹€"9q@lú #uq> c¾ýŒ³¡ÀÈpýù l—ñ°ÿ…¹ò/8•³ø‰û¯¬:7š·¯wìÝbÚ¼X‹JOCD9ÿû“ž<0jÍóö 3÷Ô®>–>gŸeû†§¾æ8h»K¯Ÿ¿á~ýbós²¥.<¨V»Òµ«IÓšÞÝ—€‚‰–jL<ày˜¬­ %q¤tÃè­+¤·]yôØÏÄð3Ó“'oå%0ÍË<†ï¿2´€âS÷_e@ÍØ,À,ûù;0)Ùñ–jGª+ü½M•µäEùyØA©äçì#"`Iâ–ÍxâRË`UñÜ* €ÈL F##0Ãú'°‘ˆ«Übdüøý(˜˜X0Ú§¼öœ¸íøµ'_üùýû/°JÎ[y À^1«0² +Kž“Pqê¢Ã—¯? q°303ݸýØŸŠû( › ´X¼MÝ5g÷¥×¾ýøغ ,€¥—€×oøRJFÆkOÞ;#Àzʘ ^øû_H;læŠ3÷@í$éØ[’yÎ:²ò"ÉÖêÀïákAƒch€eê¾Ë];/Û^Àî 0•‹À@´¡È;ÆN“XœüÁÒÌ¢B“Õ42t鉃D+™qWx_~€Ò t´ˆ¸ØAËK?}ááP•üùX–þgçá6è€ ÀÏ?i¨J˜Z2B@RUIüÍ—@ÝÊ"|À’¹ÄfÅ—_¿ƒVž['@cY˜)XÌ0AÂ0Öÿv©€ÌcEVH“ð÷`cXzmÜwí –È†úŠùƒ7Û¯<6UïÀÃÎÚ»ç2°õ€e¼(ðŸ±|ÅQ»Î M[ε˙{¯ÎÜyÉÄÁÊÃÆ"ÌËlrùèÊEšª‹“Îp+`KYÐç®..@ÆšµkdKs3{úÌðÖE Q\|¼€¨ñ LÀQ__okg÷àþý)S¦ÀS… €ÈLÀxýýó÷Í€‰ÃBQlÏùØÕýùk©(¤o¿üøåÓwq^‚&ÿûØœl h~ñéûÂã· p),î¼þˆ3iöӋן¶]ye¦d¨¸óÔ]q1AOm É‹OÜ&0wÈÆ¼ãÂC`²FóÌC׿ùI`!1¸7lE|úþóÓן÷Ÿ¾_søúQ·³bí¼tdµ\!0elظáÂ… ¯ß¼õbìííEED€‰ÀßßÈUPPؽgD ŠŠŠòòókjk!*©çÀd* 2¨²øþsßͧÀ|m¡Ú¿û°Ý€^¨þþ+ Ìd¤dîzþ÷Fœmà†Îë/?4%æ½öÍÌŸ¿6Ÿýùé–à(d¢`,hõ¹ûÀĬMXy95¤e>°Ÿ|éñ»€Ùã÷ß²u']4d–ºƒ³Ø&4`Ï ÞOºˆ˜@#†ßŒ.<è µ6Ì9aÚÁMÿ—/]F3<ú (Iüüüp) 8xð`m]ÙÞLj‚A06ÖEÇokP qîPKPUÿÜCƒ Ь,Sg°¹Š(PÍ’“·17¹ ·®}B)A`=òòó÷»OÞ±³‚FKŸ¾ûÔìX«€ç¯!#HpÄÀ@é¨< ó¡[Ïî¼þ$#Èí¦-f ´v¿¿~þ}Þ°2ï»ú´jÕ±¯ßcU l­ó p»èÉaï˜üþ# L,̯?ÿ€4ÛÿAÚàƒÉ!Ë &Æg/?V®?dfÚk-ÍpÕUåæµ.¹9Yõ”ÅWd¸¤Ùje W÷î+f,2E€Aöû(oƒ‘±ÎÇØÂæ§_Ÿ¾2ƒÛÿ>_pì&Pq‹¿©(°ùòí'h¢þç- dfÆwo¿ì¶=ÁV˜(ˆchõ¹{Äîlcf„ wb•V‘¼<[s<'':©I°³° ºüˆT–™n ôïʳwo¾µàÍ/YYÙ‹à–&¹@A¬¶ÅÏ=K´‡IDrµò÷ï?Äá>ì,KŽÜâfo 0–ÌÀæÕ¹GÀlð˜ SP_~þ.Zu| °þÖÇCÝ&ò"sS¶_}òôýWq~®$K5_}y`&nßvQ¤31öî¾ä£'o©$~¬"`ÁÑ›Gî¾ºŽ”£†d¸‰òÄ}Wö€ÈÌLKOݦoˆ›Ü|váᬛ—ðô¼jP«¹Ÿ¿ÿ£?ÇAˆ€]÷+Oß>ûð (¢..h¥,.ÊËl\wRQRÐÙÙyÿþý¿ÁÝZÿ€€’’`¤··È…ô>€‚XmLJJjkkRPTÜ´i°­ª¯¯OfP €"«Íñï4˜ÀSe“¶_Øãi¦ƒ¶£º°uQrëåÇ·žÍ>xíÌíxZjñ–jIÖpî…Çocæí{üê"ã23øô=hꎾë…–SdíÞ~5åê{`) zÌ+ž’€‰éÔ½W—Ÿ¾Ó•òÖœ»ÿ˜¹1f€=a`»˜ö¿ *܉äõ—ïÀîÏÇï?e¸–¾~÷%gùQ}yUI`Ljà*ݽY‡¯×o8ýæÍg YdC€Mн{÷“ 0¦á\äv26Eßø’’Â:„ÃßÏßÿáƒ8ÝG  ²##¢Ú±Èrù᛬øx9yØ™@²ÿ?|ûõØÊbιÀÀ™¯“tє֓þýçß±{/·_~ôXð jä~?+óýWŸ§ìÔ“±U‘Pç&;¿ÿòÜÃ7@Ý A¶¿Cgíaef|üî <103~ûùh‘0ªØ1‹fÆßÿþ9ônæç us6ÖÔÌÍΚµø+ÐkØ—§€“dÑê5ϼ{óÙ4Ò”…~ÿºã°§*ÉÏ¥,ʧ"ÆlðûÕÀNÖ•§ïŸ¾¯d·Æ€Ñ<‹¦§¯ì­› à>\˜ dÐ 6°[«o`À@ @d‡>ünúöëx` @~&`80 Ýña6|²lë²1ðDë¥û¯.Ý}‰˜:`b„ÎÅÃGÏÿý;xõ1H+3rG÷G ¨ãðW¿æÝ§ï>€&Ap-jül9S3#ž^îǯ?>~í²‡*’¬ÿþü¦ :t¾Æ–±Dw“ 9Y »¹Ý L@%ÅÅvöö‚ÀN,°ur–Pàèbü£ïX@‘œ8ð-OÂ;‡„ @ƒ`¬Ìÿ™˜÷ ˆ]nˆK DÏyÄ8˜1n¤-ÅèC‘2pÀi¶cŠÿG´^äå)ãâ… @6°Ì˜;w®¼‚†£ÿb´ù€˜Õ{& û [[:à€cœW>ÏÂ3Bf¹ûûúðô‰^ ˆäÄAùÒ# Ç€õú¶+ÎP’Ùe å rAØÿ?Œ¡•l“]–MçŒGTL±”° Ž&:çüöʼn0O:`¨Ä×èìãOED¯Ë:çèi¥ìÓø=´»XÑ‘ª=¸Ú£§‡Uå¢isÍX[ê”Tû¦.¥W¸«²cÄšëé©Lý/pœˆÌÄa¡,)ÂÉAÆ™`@×Z«H<ä%x&˜†¤°oi¡%KÌ™`Üœj’‚:òÏãåTå#¬ÉÙ‚\@ÚZUò…?ñg‚mæã"Ê¢?‘Æ?(ä=X@‘|`Üû÷ žçèi‚Á`;MPPPˆT-Drâàáá&ÕŽQ0À—/_IÕ@$W+?~ü ¬h @$'޵àµI£`$€"óÚQ0ä‡ÔÐhLœ €ÈYÏA wŒ‚Ah´ä8@&ŽQ€Ðhâ8@&ŽQ€OÓxÍÄ›ïIEND®B`‚pyftpdlib-release-2.0.1/docs/images/peerscape.gif000066400000000000000000000063411470572577600220100ustar00rootroot00000000000000‰PNG  IHDR00Wù‡sBIT|dˆ ˜IDAThµš{ÖåuÇ?ç<¿÷²wØ]X„r3îâ­Ž÷hÔš4^iª3½9N˜‰hGí6Ñ6m”j*i1mƉ©u´µ6µÕjŒˆÛâ!¨(ˆxApAXvYÞ}ß÷÷<§<¿wo"»=³Ïù=çœç9×ï³bï¾pjØøÄ…$@ R”Ѥ ªô’|­«h¾PªŠûѦùúòÅ2¹Æ}4åK46í™6Àÿ#%l|âÂô™¿¾%Q¡¢4älˆAD03¡b€G Ä 3µ©‚¹ü®ÊŠù¯ÙÜ/®ÊŸwí}Òºøƒ 7 $ A‰å@ â€L9à ¨(Š‚P‰ëˆè!A¡][ΧgÓù•—~¾¼ü·K6ê’ËHN^þ ´¶î”:I1ÁÔ0 #Ø$[3LL¤¦~üV$cxS@q ‰„©ìzãlyòû÷ToŸñBuãž11Dõ0Q!ÁïÁ@¢’‘ÈNZ¬vúÙmXí0U!@vŠa¹qÎáŠÒØøX¼Ì©Ê£HÌ1ÜÔÎ7?7™ºh“•ª6wŸw¸²4û³ŠÃ„œ>±À„jþóx=i¢ xÙ½uÎáÊRÌíÓZO3AÄÊ-øž·ç}§mÛ0ÓAİRÏÔÕ¤äsÌ@&êüA0L²cÝ©¶cÝÜÏâ³¾mmløÅU±ýƒ#ÉçJR,C¼öLú¸É€„Äš«\µâ€<ý›¦”W,þoúzš€hýa·Þ‰%ù2›8±æ: ÷A#ZpÀ[‚b„í¯]T¹å¨7Ý WÜk‹¶8«¸tûº£+w~éJ-ï]D¢8Kð¯÷sÔi×ßµjrò¹\Y Å´·ãWTWʤZ—ä*EÇ`WǤ—o8{þƒ"RI\®n°ì çj¢F).B°¬‰ó¼3ŠÉþ]ÇZ÷WxïñÄÁHÔ!š‹m†Uͳ±Ò>µg wj¿yr»›&³+í#ñPABÀT_ÝÆ}¯¾{ð» RT0îfYwj’ì±Óÿøö|çןóÕþz¿úîoÊ{ÝWH¢Ej!‡á³v8ÁQáÙÊêÌ£¼ ýu³¼:4pqÔÊçoõì?ö½^›œ¯+ Ÿ¤dÍ‘‚ˆ€Èo_¹"wÙOn‡ŸÔ–~åï8~vÚ³áÜ B.k¯1ñˆA¯5Ð]ù˜­”“"`gXˆ­}0aJc®gö$Ù£JÑ „ØY™òµÜW=²ðË/Ž]]_}D-àP<ÏH}LÝÎó³Ò) '°«e6ûŠÍ ÇS˦A„`%Ó[Ö(¹†’bÔ†ÁC5 Ú,ñ5œilŸÍpaÔ8€›ÊA XÀ±€ 8ÏÎãg¥Sñjlo›Ooó,$&†…Ìç̲ïâäxÖœöîh@¡X2âõ‹È!Ÿm2‚`â£_çª~yÁ(cÍòþÕYŠ*.6_àð<\>‰ëú¾F%×ÀÇm]ôÛÐPFIE4Žª–u EEΜÛÚ Ö¿iJõ–ù=’$Ô ‹2|¦µ\âÉÑB! mǬÏ-¼ø!ø¸#¬ì MK³D&J98~]ù÷”¿Èú¤“ýõí”òu¨I mKC3É ¤Ð’ϽùÑÍ_éHhœëò‹ mE‡Q¥Ö5´Vެ&,C:¡£)¿ã£L‡ÚJ"º h¯¥¸C£€"HÞwí¼.3Ù(3yW¦ñ‰4ã³Y©Î*ôKka«u°G’àÄ2a)jƒY,bNR;ÐÑi™wÌTÿÞÚš"’–nn~?© ´\vM>«Ž!Û(ÞŽs$æQó¼tòW¹ËY¥'Rt˜ „P¡  ®PEÓ<Þ â=’áHBôýaD ¦ê0jdA²¨¸X–NM˜;¥þµÚrÔMÚk• "¨%Ñ÷Œ·d©&(Uw§ñz)/¸ù †³›ÜªþÞÂtËú´ñ®Õoýéý¯ìøv>'“\–îÇS$Õ C bT}Ê‚öÉÛG`¹†ɪq„W<Ñ'K’ã[îzÔs¨§„X âK|ïÂo_zÜŒ§3¶àæi·ýûy}û©ÑÆ×!F-›òª)Ó[ `uõ†!¢Ù@Püл­‘Kr·ò°;“zAbËl!pB{óëc…ž7§ãé4D”#¬ìÃT‹hCÇüÉMï2@ò-{A*?–Ã04¤Üè–ñ¿tá¬kvÈ|Ú;ª¾}¬Èwwö-H†Í}ª®&˜Ã̘ܬ=¦µ®–„¢ÚбSj0­^˜f` Üœ¿Šug§‚fE%æ|£X̱òÙ-ËzÌk›Þ³fó¥/~4pa⢪Œó$"p!íÍoˆÈPD%Õúæ^ †ËK†‹g¬ÆÉIܦߠ2^cÓŠ„8€Ç)<ùö¶«çüÅcgüν«ŸïÙWžzÍ£ήË[k8g1dAŒeà2žÓgO]óüˆ¥ ×:ï3)…êÈŠIŒþ{’Ki  Ä*+"¤>Åá>Î%¡RNCÑD§ˆÒµjóž.$P(Á4ë_Æ×áÖ(¨€Ùþ³ç¶=;rÄS€äœMÚÞ—Z%3‡Š±Ñf³JLP )ûK[/_<í/_\vNWï­—ÌZ³üÔE_>zÊÊrJAÁ¹¸‡ Y~óî€J2eE¯.WØ}Qç´ #—† ÅâÕ,-?}׺O¶œ,i_£¥åâ?–/ÚWEOÏi ˜ÐRÈ¿öÐåK–_¼púó÷eß-™6­ÇÌþìþuï?uÝ£¿¹Û‡t¶ ¤âcV3ÅËøñ5h¯w=½c~>ŒÎ:óu঑‹+—>žôôÔ0ƒ›¾8ÿï.^8ýù1{Õã×üÛ«óÿéå~ä4v.À&@y±˜4¦O*~¸y¬aû°o_³©à,Vãsçu¬>ÿ%]Óÿ§RI3©Qù‰€9âKÌÜð)¤ïà8Œ$˜yL 㸎–mcŸÑز]²˜(¤3ªÞsü´¦·Ç.Ô€…m…7,>­àúû›ÆßWIÆ]³H‚O£Z¶]9¨pâÑy£7Îê5[÷œp0þõ;?>ŽÜDá{£)„ÀÌI Ÿz¦=¨rÒœgŽm«{)]žuoYffÉx·õõµýø¹w¾Ut¹O¹ý¸[!Œb!éY0sò{cW>‹üÎY î)—+ðʇ{—νíñ§6}²ÿ·Fò¼¸mçüÅw<ýÄæÝçãK:´}­ŽÙ!à’=ó â`?½-ÿf»Hÿ§9?‡ÌL¾ýËõß½÷¥­ßsX³™PLtë¢öæõ¹þ]ýíw–¥¾:C5‰øÆPß CsŠŒêˆ€}–þ˜‡‰‘úÀÒ®)·?på>–õ€î0j¯˜ãïüþSoîüÁ3W6$:©âmÎ+÷ͱ !…$A4>¡*)ÞòÙ„;r,¬]vLQ4 xK²A*>¬/œ6¯cÍÐïáì[/èüù_Y¼lVkýêÔ[Ū \ödŠÆw`Ë =Y i/d]à£lÔâÐ_•$öO $IØuYçÔÖ # ¯G6|¸äîî-×¼ôáž“úíÓ4ú¹Æß™pd“ £×®vÚ2ŒþÉgä\1‰è„©:'5=¶ö»|m ¨‘™Õý¦gïQõ—;­XM+¹J Pövbš¦TB ’ÚÀº*Á©I-¨br€8 Ä_@!BJß7Nœñ]Sš7H‡ÿo±ôÙ" >IEND®B`‚pyftpdlib-release-2.0.1/docs/images/putio.png000066400000000000000000000361661470572577600212300ustar00rootroot00000000000000‰PNG  IHDR––<qâ IDATxœíyœE¹÷¿ÕçœÙg2ÙB!;aM„°ˆ^YA¯@p!AA‘õ‚¯ p¯÷uas¹(Ê…{AÁW A½ˆİ…5Y ÙȾL’É,§ûyÿèîÓUÝÕ眙œ á~|æÓSÝÕOÕ¯ªú©çyªºê4TŽœ æU £¬R¼N‰óžÀ°ñ;±{6þF6%£b¤g楜WŠœ20âÕU(=h)œø¡Òò0Hs°Ð° ^Y’èXFZ]Ê”b 0án Tïg>²šì#)XWMªÎ¸ØQ™cQªð¼D{ªÑ©2vïêYg﮺‚çE¸ä!§ïì¶nfÓ]-µÛ•lÝ¯ÄÆUû—EÚI¡T05‚÷´¨ÂyC.C}6J£ba”’DúxƒYµS™ùÙ?qÏ×b¿îP}fo¶$‰Ó‡&L:íÝÝ6 M+ï'ð-àæ0¾ð”Ê "TâaK,Ì9ŠÞÕ¹ŽÅ:•$›éK3‡qŒròÓ-Àm‚üÜé;gc™I?4Ú«kÓÊڀˮDhŽkPßÈq2ѵÁcž‡µm¥UW›Ç¦Í.¬sY¿1Ïæ­--.;[…ŽN¡½ÝÏ¡ºJ‘«RÔ×)šúôÎ0 _†Á³ô뛡¶¦ÇGœ[•âgÀª¾sZz¬»´W Ö¦‡dQjp£ˆoòD©‚‡÷§|E¥p“Xˆ‹ à¹ðÁº<˹,ZÜÉ»‹:Xü^'k×çÙµK)W$©®Öa@ÿ £FT1vdÆUqÈÕ Ý'K¦Â>R¬øw÷ª¾Ïå+šyHŸÇÒ':÷4°7¯8ôp[ÉiÂ1¡ BG9¾YŒÅ¯^“çŹíÌÛÎó:X¿Áe7ä§Ë4p@†I‡ÔðñÉ5|ü¨Z†ï›£Rr&ð"p™Óïù×*“ceho,¶­<¸¡-ï}?ëd.G‘µ •E˜âaFeQ,ZÒÉ“³ÚxúÙ],]–Çu¥l^T±'œ˜u²¦ ¦$ÄH'¥`äþ9NþT§žXÇAãªqv¿ÕóÀת~/ìØíÜ*@ñ™÷E¸Z×r¬¿ÞÙ™é dLáù•'T¾yܰÁå±ÇÛùŸ¿¶±dé^g¬4rÿgžVÏYŸ«gŸÁþš€PPKRLîEX‚âëN¿çôHa»@¥^éì® KïíZ5¾Jàû¨ìÕ ²í®Ë®¼‡RpdNv*C¨DàõyLpÏÌn§³s7J[AŠ´XyB’ËÂñÇÕòµ³9戅á'–CLø®u¼Ô•Þ•ö¬ôY÷²e¡;‚U  ¬´­7Là>ŽTa{GÞ@6“!>†® OÏîàžßµ2AÞð™ ~>éqúC×~<.~¯Œba<Ÿ8)Ææ¸ôkMœvbÙ¬VéRjZêçQLUýç.§´`tKx,é é‹ùXå‚Kg;÷6,uRF©ûj²N¿Hp¢2´væéô âs1³èyðÌìn¿«•ÅKÜòªÿ¥Q#²\qQŸ=©ŽLF»a›±µŸ•bªêÿò_·c,ýEcœºÂ“H³qÙHG„+€ d«39ÇÑ´‘¢S;;½àÚ/a.0‹o¼™çç·µ2ï-_ÃëÚ ì®º&)­"g»”¦é>FP3¥ xiå°åpðøß½¢ÇQm}M‰g™¬Í ü›BnV^M{Fa¤k.ÛýT¥¢4æR‰ÓîaÉ#¶å½ÑY¹Ý󼯉€ÈõÙL¡QÿØÞ ^À#›6 ¿¼½ÇþÖÌ3Å_–”{ž6§^jþ|w1ÊyAdÃ×®œzB ßÿv/†ÎÐUø‚KÕ€W»²z¢ÛTÌjÛ4SW$¹@[ÞÕ„âANד‚e”¢6ã:å´æ=:<ðDñèŸ;¹íŽ6ZZì½9¼»ß/¦}Âû=…wâÓòÑñãaC½âêK9ïËõd3±|ã£Dø à,5à5}J"Í Ùpã3žy9‚U4 ˆQ€­ïê'ðWA&ÍäzžÏ þ©œ V§+,Yårãt0÷—®Ï7… ®k7[ë§ï.FZÚ8OZšÒñG–ãç×53bXòe:)(Aø pŠøúFJ N±‘"¤ aEæS5ÕÖ÷F˜)Â0§ !ï¯P”¢!—)ôÌÿ™™ç{?i£e{!•ÖkUA¤iŸ¡o’éSIb”VIŒHci܆V‹ZÄÌ'4÷é~ ×P§¸þ_ùâçj£â„X+aù@²ÌNTßXÜIS Ýõ¿‹ –m­s12kÛòу<ñždta* U¤¹òž‡?)ªÈx7þ¼éèÓ/¥4I’Ê™ß]êYŒ®ÕyÊi5üøß©¯S†z Ë–­‹”âj༵÷Ó(MÐ*¾‚´´íý±Äóžd‚ˆ‡ö_cùÒå ä=aÝá_¿ÛÉ› Q|2Ô16m¾|É6ùkˆ D ¡"c"“yXÖN¨!]ÅÏmBæé¼6Õ­¥­Û—ròž÷ksC1¬´ˆ‹ç™&Q€GþârÃOóäóqMצC]žÖ°ë"©±*‡IÇ?y?­®&u#“QüàÊ:Î?»¦(ªž ¸øºüvWž½m.Ó OâÔ­õÒyϽZsýZz¾Fò\<Ï ü •ÓÎsÝ„ËYü<ü0|ð¢i¬$vmÞŸGùJ!o;ÞÌ•À°ùsºe–G¯«™g¼<åa¸®ð½›Z¹åÎ]¾yßR΃Ð õ@Α«±S\NJ:ûÝõ± Ú²lÄgþŒàòcúRÁ‰'‚ˆÇ}ºÜt«¾ØNï‘áµ>y™|b÷MÌÞmöæ4)º_)Œ¸æ3y‚”–¸d¼-}×0¾}Q W\Pc©{¢* xJñY5øÇì €â­¨ÆêmYºÿHù"Ž­Wˆæ[)ýÉã§·…“žfÏóÏ£^Å…Ý×{±y?~/©¥¢P4MS9Œ¸V25“ââ(^=}tt ã§w´qÇïÚüûñCç"¿“5cG’NÅ,Ze4Ö–¥û× <+È®“Ý3 æ«ü^õ·§]®ùA'®ëóÅ)ÞëÒzuqŸ¤R´'0Ì:†ÂYêõQWÉq?ûn-_úçäÎ$³0€¯±çЍO8û¼ÛÝýŒÝ×X[–w@näˆ 5¡ém7¯Ìsù÷:ñ¼¸?¡Ëvü<> Šû\fúx^ú¨ÎŒ3Gy•Ç(~Dü¡9ÓGyr".>R,Ã󄫸‹§ŸÏW%‹p„Rr£¬íÉH<,JÝÖX[–ìw‚ÀßD {W\a¯X-L»°“-[u³×µÞØyž#©}ì>IÏb$1ËÓŠÅÚª+M Š?Ý]ÇØ2‰1©…<àÓjŸEÝZËÕ-ÁÚ¼xßf”z]„áaœæëFN;°sœsQ'‹—õ¼Yù•¦ý÷uxìÿÕÑÜTúÑ+ÅrÃÔ>K¶v§Ë¦póâ!ÈDdx0ó©Å*~ð“| TiIÆ›f!Úxmy¥£JaØLZùxé´ûï­ô¸ì{mÁÛ⇈ Gø‘¬Ùe9éº%r´ˆ\œÓ!¦H…³ë<âò·Yº¨Y3´@è# âšÎ4]åœwºŽÇ+¿î%JRŒ§æ¸üê·”¬àY^€ÈÑ],(Š.צ…ƒ²/ ê{­þ½ÂÜ•VEK…©y´·wµHÿ =A¹,̸«šÃ'”~üo(8R ]Vö®Ÿr–HDñ"ç r¨¯&=D\/xÙìB{‡ðè~ï QPÝ!#®¡¬)baü<í^´e>Ò:¶¸Jc”¢ò5r£|*ŽÑ™‡Ë¿ßAë®ÒZK!‡"r~WÐØyêêÐMï hFäZsò3&ñ<ÿ} çr×ï<Þ] é>‹yÏ>Ô6RÄB[Þ¶{ñ¡wZ\¥1J‘͇,Îßõµ_¥1–¾/üÇíáÜPñ¹VVo¶fd¡´w@’kéWXô"ÏÄ‹üªÅË„ßüþ#ÀÂÔK6E%0îzÀåµùá8mõIAKâú‰È5åæ[–µéþC‚Ô‰(2T”¡0ýÉ>ÅùßR¼:/šhL›E¯ãQWWêžÎ“–w¿X¸'1ºK=qÈxÅÿü&ëoŽ-FB+Š1jßk‚˜Ôí_ñE[¶58žˆ\‰H]áõŒøþDx„fñ±'…×ÞÄxÏf;7ß×™Ô•{:OZÞiqÅò©FZÙãùtõè Œ7ßî›á–cë¹ÒŽžÜ¥STcmZÐwÀb ¿Î i*‚PvµÁ™_˲v½Yë´·úÅ4E¼‘lqiiÒxãe±iœJa”Ò&i¼6*G›V£˜óP†¦F‹0¯[Eåì·j E6UØF…új@.¤.”&Ñí°DçÓqX»>,B$váÛvûÛ{mä¡Äà7F%…8yÆý4ÞxYB^[üîbDuÄZW3ïd} úú£ò·Àí÷z„j#\õ@òºN)¹ »µ+,M.ª±6.èÓ€ðž@ŸB1$^tض]qúWslmIn»¦ˆÄ® Ò®Kågó-Ò´SOa¤¥ïŠ6)7}%1êëàù‡úÆøˆ´XÅf`µßšpãkb˘mÍ2…saZ(T!‚HÒ >0Ãak‹*PÃsÝGÐ{[xõºP#¤¥µùI~Û¹Îâô†^'Ãì’ñtº´¦¥§ žîcìl…;î‹´T4¿X&)Ä÷™¦e·|^ŠÕ…o÷q^á`?ci,­×îh…Ï}¥Š-[Þ¡ õ]®6²‘¹¥*­÷¥¯H°iªb¾W%1Òü7³¾(w\ · ðÒ¡w/0¦¿Ä¼Vð&¨‰j¿òXvîØÖØ8>¨LFäà°PáÈÀ˜ßá/O8lݦ¯› +^'w›tuMQñ*Ioßé?z£T£§d/W±|zcÇNÅï 8Ä?ó:ˆ;‘#426UØöˆçòu}Ñ}ajAS“®+LŸaþòIØ[ô׿+poñÙ^ïè|öôÅ000ôt=‹a¶‡½tìxmm¤ÇõÆo†öŽh*IiÏ<ú9äëId e_!‹z×Õe9#—ñųP(¥ÙZ^zM±bµJ…íÝ™ýÁ¥WX6ÏÞ`¶ÆŒ ‡yjNUèŧUÂ<ôò%ñz#Ÿ‡GŸt@"áÒ},íïdy¯obãbÒÇ9‘fó³ÞhŠŒ‚™Ïh¿jìtއú!ÁoC¦óê÷ÐÒµk¼žÞ?låÑËì‡áyå0Ì:‡ç–-¿$OØz½mzcÆLUxþ†ej°fDŽ#F ÁÊ{Þ©y×£ÓõÐÃ|ÞÃuýcíxóÈé²½"!&˜i<¯~-N/]tßÔ2q,Rò³õêr7¬–¯s”_é «ÉŸ°¿ªÙï.U¼·2¸¯ûVáĉÈ)ÄȬ- OäS‚'þáz¾0å=Nñg^tÈ»úÓ0· ›=ݤ¨Çé׿¹-.žGÄk–#J#†-ÿžÄHÆIá::ÌB¥µAOc¸Ìœ™CÝ Æâ>%Ëš Y2.D€È„ÈYóMŸßì,ü}®>w¥">"ŸÌ$¥ñÅîh¦o“Êd¾f~fºd¾Fu$FN•Űˆã÷ô¶ ÛÍ„WF™Ãô{cÖ‹ºö ç¶bóY çj.í™,¢ ›˜Í§ðê[CâÍÞ«ÇGqf/²ñÄ{Z|ÎÆÌÏž^O`Ó&’rÞ3J™š ~i÷$fËÌ{Oa¼ò–Ck+ÔÕÆdÁ$G“…ˆÊ1Sy˜jpñ{þ+œxaºJi&(?-AJU “œL[\¨z#™}yP¬­zcç.xýÓ‰·Œ QÈ1z:Ccmj÷& þ÷CøÓlæûÿý ]CÀ°áE‹£çAŒ²½ôµ§1{h2¯âñÅøº/¿­>ö:Ú4NJI÷ ÆÜyÇnîüJè9a’~]’…/Õdó®êºþ ×<×èzÑñö»¶ºHìÜ_ÎyXÈòÕYy¬]ר»ƒ‘tŒË¯c¹Uß“¯¾I>âäq¨·¤¡ ¨²„K†‚ÿÝeÑÁµBˆÀâ%«ƒ»g(9:Kžë×Ý)çžÀøhÑüEž'ч¢lUš¡À 0L¡Œ7;¶o=-rÇÅú Ýùå#Ý\úªÔÕÕ1hÐ ªªªhmmeݺµ´··§”Ü–' 4˜ñãÇãyóæÍcË–-^ˆ¥›é††F;ì0ªªªX¼x1+V¬Hä>dÈšššºQWŸÖ¬YCKKùß?à€øÄ'>A>ŸçÉ'ŸdÍš5Æý¨vª©©aàÀÔÖÖÒÖÖÆÚµkikëÚÏ[¥alØ¢X· †ô/®êDOR°›@Á^àc­\•ÁuwO ÔÔÔ0eʦL™ÂÁDmmmAånß¾_|‘ßÿþ÷Ìœù$žWìÇãÿüÏŸã¿ø55þ…-[¶röÙgóÆoD\Ú0`Ĉxè¡ýâÖÝnž"o2>Ñiò©Dºöövοüåmì³Ï>(¥X²d çŸÿ òù¼Vøò—¿ÄÈ‘É_ÊVJ±páBî¸ã¿xóÍ7éÓ§7guS¦LI˜È!C†pá…póÍ7Ê¢cÄ·›…i7lö…"ðŠ LªD@Ñ…ä³~ÖÒ UXõPzZvøw|Ý\Åš™ÐyñG-gŸýåDCˆ·ß~7ÜpCAë½ÿþ ^~ùe–.]ÆÕW_•xè_ýêWÁR±fs¨Í9¦°¬qwtïq­_¿ž›nú™öàüúÜpÃõœþ׸ßüæ7™3ç9C/£ _o«_|‰£>†qãÆáº.ï¼óù¼[àóM”²JD„™3grá…±kW[cΜç˜7ïMn¸áz­ ~™§M›Æ/~q ®ëás5ó÷ûBuø‹Þº wÊ—¡ %ØêES(Láá¨èk†"ÐÙ í¦¦²}–D·Ñ|pA[éêùõ×_ç‡?ü!Ⴑ(áÖ[oeÁ‚Fá ƒ*8æqÍ7éþU2Ni~PX×mÛ¶ZMÐŽ;b黾™´££yóæ1þ|\7ŸH?hÐ Æ—(ûúõë¹ì²ËikkK`üæ7¿á¥—^JÔ{РAL˜0!¥ŒF E¾`»¢£ÃK,¡1&‚¥É «'¨”¿*^)Áu¡³3Z9éWŽ‚pèë­Cp(÷Gî¾ûn\7_ÐV‘`@>Ÿç‘GI4 ã8Aæûfšx¹âëîãü$øõ‡×’T †mý¹^>SÑ«W/éA7nlAëèøÃƒlÛ¶-ãÁJ< qš6?-äéèô° HFûBVáæT¤ª@ÅGçë†Ãk¿Il»=¢‘—¢_¿¾‰á°RŠyóÞ,¤±‹/1ò~ýúkXXyÒË•ާ­eÒCÝœÚðlë l¸ÉrDé<ð@æÌù; ¼Í»ï¾ÃŸþô' `äÓ·o¿”¶|£(ÆâÅ‹­éú÷ïW´¬¶{»ÚˆV’*S¸Â¥ˆTîÒÈz¡¿Ñ±Ï }‘(.Þy#m&‰‘`x:úñá ´žFD‚™xs‡Š~_Dhnn.ðDa„QWWkJ˜Îõ¿ÃK—ÔrÅGµqM“l³üðæ›ƈ#pÇq˜4i×\sMß>JUE1r¹œµ¬žg«#±¸ä:ºB¢…bÔ3Ka—ŽˆƒH0 ÔB]Ý&B¯h\}Ú@o˜ƒ:È@Czñçjâ °ví…tmmmìØ±#Á3xð`† ÛOk”¨\ÍÍÍŒ?>¡îóù<[¶lIŠbS fœÎgšã¤Ëå1nܸD=J{X† <‡zHQŒx[†|k×~`ÔÕ,¯QÛÄ!búY1SmXè…ÿ*'–Môë”æÒVÝT¤©O°›Ž¯~õ+8ŽcMÓÜÜÌYgUH†¼ýöÍ+Þ}wa"oÇqøö·¿mŽì²Ë¨««KÄ/_¾œ;vÄâ)Ô/nÓ̯Î[Ê–6­fž ,Àó¼ß”)Shll´bÔÕÕqÎ9çXóãy±g7ÿÉzÌw|ðdžwšB¥T^&ìž(<ÌÃÉB6J|Ò˜aR éZÂWõW£T´lVD¨¯¯å—¿¼~ýúiùøá‹/¾ÈÖ­[ ø Ìžý¬‘oÈ?eÊ™\wÝu444P[[ËUW}›‹/¾Èª}fÍz:Ñ£C Sç3‰e–Îh1IDATØ>Æ ãÊ+¯àÆoäŒ3N'“Éf_O¯—'®ˆgÆ ÆËõ0~РAÜrË-…w!¾?™ú † –ÀXºt)‹/6êgjB³áýê*ˆo!K¨<àùóXJµ¢ +þä¨+Ñ‹çðp2Í ù|8‚†lÑ|OÚ.¤Ë/¿œÃŸÈLgíÚuŒ;–óÎ;—#FXÜwÞ©áú=ôßúÖ·ŒYùÿß8Ÿ©SÏfåÊ•ì³Ï>ÆlsÈ£”Âu]î¿ÿ~ãAÆëÿH¸­N:R‘Gɽ÷ÞKCC"ÂyçËé§ŸÁ¹çžL|R¨OqX[[ËèÑ£Só~øaÞ}wa£b÷±Æëé£s“çºë®§¾¾Þà9þøOñ¹Ï}–?þq†–.µxÁ½È9Ÿ1cçŸÿu>øàD9FŽÉ~ðý"yù™¼ýöÛüþ÷÷Ê*‡dÇ1Ûº¦Ê#—ó]¢P& JZ; ð±q Ó$¦Õ)âklldܸ±VÞ‰'iùé“Íéy†íÙÙÙÉå—“–––’~šíزe+]t1ù|§–·‰aJJô<ëj¡*§¸L¶P)ODµ T^P›%ð­\yQtŠ¢ÃS´{ŠNOÑÔ¬û:¥mX5}«´¸´ûK–,á‹_üëÖ­7Ð|ÛïŸoݺiÓÎaíÚµ‰¼mþWȳrå*¦M;'Xqš$#®’y›mÒÑÑQX%¯gK˶X/«É .dÚ´sؼy³µýÒâÖ­[ÇÙgŸÍ’%K ßɆ¾Í^ éÁ!@!¾¾¥òVuvåY¿Ó….ìÌCkvåm.´¹~Ø»·mEg|1:O´<™Ïç¹ï¾û8õÔÓX¾|y„dÉW)X¼x1§žzÏ=÷\Ñ|ÃÆ~â‰'8í´ÓX¶lY"Ï4 ðîÅ©½½Õ«WkÚMÑÞÞÎÃ?œÀnkkã|ÄÁÿÂ:N .$™Ey+¥xùåW8å”Ï0{öì†xžÇcý•Oúdm2Õ¨#>‚ÔÏMBa ñ-Üz‚N˜ØRÞ›;'·Fü5˱)UìÛ/üò|Ü™ SéaJÙ{»¾ìXÄ_UðÞ{Ëyæ™gxà KWl §c„q«W¯æ _ø"Çÿ)Î>{*G5™¦¦&ÇÁó<6oÞÌìÙçÞ{ïå…^HÔ¡Œ3å˜cŽáôÓO'›ÍÒÒÒµ×^Ì1E#9ßǪªªâŒ3Π¦¦†÷ß_Áw¾óoÁh¬Ð:ˆÀUW]Íí·ÿª0‚{ùå—ùñbéˆQ™ßÿ}¾ô¥/3yòdÎ:ë,Ž9æhH6›¥££“Õ«W1gΦO€7ß|ÓZ/³¾IŒh0û ô4YH!Ūª±íþ®Á0î•çªî„äž >ð×'jøÏÛëã‰Â†tÅß⪫®2âV¯^ÍÔ©ÓØ¸qc!Îó‰|«ä(DçÇ4ØP¦úްÌ4{YÜÔúª«þÊb„¾Môàâu®´{‚.qüÈ7¤Ç1>24ÖG®P<ÔF‡wk‰ —JsÚ ò@=.J­| µ¹]ØÞ yO¡´Âu|ž\UÜôé~—hç:OR€Ì «Ä}Ó÷Š „-.ÍÇ óWZƒWÃöÀMŒø¶+}@`v.¥ñ÷H]KnÓVÚÖ!wmjóÚ\‘àYøÚ*¾ŸÿŸNsÉæl½6nþôûa˜4yºV±i‹´|Ó¦<’J1nM󰻿fPCiù«Xº¸©SV ]ûT#›Uœ?­È †èhC©è‡ñ-×XÆõ?³yÀô°ˆz'-H<ÐÜŽø¤ëÅÉe».WC˜×ñ‰Íð(æäÚãBÍZY IÁGsQûDéÄÀ05 ê ŒÓN‚ý÷+.TøÓÓûŽ}:#F‰©†$ƒº)£”ç(+Tàk%;êÔNªkƒâ~ŒI¦c™ô_ÒâL2{ží0ýtŒxyz#9ñDGÜŸJ–®Ò5ÕŠ‹ÎË©û!(ODÝ”,‘I%?Œ“qÔ»ÙŒóP•ãP• GQå(²d”¨o‚cN5—ÓØGvök7“…;EÒ—æÑqz£˜€Ï»<ÚŒi_p<0ES)ãü!A%wØÆ¨¤`ú‰ÍžRܨ”Ê+ý–CÖQä2U¿—dÅä“\ú ÖM¢î˜›ZÁ>¯’~¯˜Y(O2ч…QiªÆàAŠó¦f@YÉ<ò n4fe±vÁJÄ>°~¾rÔt',ÿpÈ(W|s˜quÕŠSÏÑ÷ŸéGï6öžñÚât­¢3è-kÆÓ&F¥iw1”R|û_²ÔÕFóS‰¢Ñ*âé æ—•/eh-€Çç .ð¶'Ôø.¢_©–N¼¼ ì÷CÿaÞsÝùüÜ?hOÓ?}Üág7䢾#P˜~4µaÂCÆh?¦Q„2A%{³sË´¯54:&”âÏ!­w6Þ|^Ñ^Ñ]ÞamË=ß[1öêݬ¸åÇUÔÕw؃ãæ}Æ,2åEÛ°Zx•“J ~$г†¢ =xµ£T0Ò@¨mNûšËý?Ï€¨bYv‘Tç¶ë½ cï ½¢Š~ýʰ,Â*?¢Lë¦Æ‚Rxïov¶MûZ゚’´U˜ÈÓ2è3vµ*V%4†îõú®j½cï ÏŸ–弩U”¡©@© ‡Ž^ö ej+b«,Ê:Î:=ïœÏûL«‚&Ž ³RpÂY«–dX³Ì>OÔu*W›ìí.¡øöåÕÑ–ùâô˜þ@å¤Ëö‰G¯¡Ã•K=Q-h“rúL¼ þg³Š3/ñ¨kühöêÿÔØ?¾¾–šZÿ– P´‘` ¨K‡Ž±š¢Ô­¡Ûç?±n…ˆ|+¼V`Œ{• âôê g\"d2=!\¶<+³'0ö e¸î»µ Û7ÍMIò•Ma.KÔ·ö½8ù%ö2¨;‚åTgœ{”R3¾jSøó"‘üû/ª‡…“¿ª»r•¢î™Ø®ÍýTÊŒøôÍK«9zr6}V]?„(uO,‹’£¤lX-'‘xg|r u¡R¬°>'Í4¢à°ã'œéG{'¨¿Á×ï•z_V,}Z>ñ4•Â(ã-–_9yÃ8û‹U|aJu¹Sõ+D© ‡^XŒ§¨uwÓøòñëÖƒšŠ¢#ÔN¡ )ý^¼j³ŠÏPu’ýÕ‰ù–޳ť¥IãµÝ‹§©F±°o±üÊÉ; ã3'çø—‹k)¼²)~tjêðÑ 7&s3ä%MvÒ6¬ú“èéU5ïM=~ÝW…ëJ §0Lõ+– ^Z+Ÿ;GqÈqñ¥´éô «¥yÒ0N<>Çw®®G9e ‚ºføèwßaÐÎã2â¥ð>׫ß(¦ÅR&SÕm(u®?í  ~ j³þ™_Ði_ó§%æ¿&­¯Šæ†¢î® 3ŒÖK©Xzпoñšúz%³—W#ùe/ª_´©ƒ’ZÊLW.Ɖ'TóÝïÔ“IÿN{¡ÖAê{€ÿLa+º(4NФֲ OÁ·Š……{¿}j`ÂLŽüíaPå8ä2~oð:\vÚóðäï…Wg j&Ba&ߌ“ T žpÝv=8­ñ$ÂÑ_2‡‚SIŒà*jdÉ|¯uò‘àž²¶M)ŒÓN­áš«êÉ–*æ'Žõv)»·b×E•‘Ònx–sÙ1®A}Dx΃Ñþ EmÖ)ü†© hÏ ;]pEðžÿ“ðÜ#уó)zèÉx“ǦáLÍGìž?žô´»ƒaB~$4zSs›qÅ1”‚©g×rÑ…uÁ·ÝRÈ„X¤àã#FÍ×?R®LØ¡±ÊUsqÍÆpÏSƒ‡‹ðw!¡ ™rŽbý.W"æ ÌN˜yàæ%è¡f#êZ#êźv1±8Oüžß •ÅÐyLmâ×ÇÔFáfÒ¤ŠšOOÇÈdàÿ|³Ó?_c>±¸<êñÂ¥øø£ÞZVòÉÇž5I­U¸.¥±ÒTbš`y€s÷SƒÇW9γ9Gõ >ëDÎQ´¹Â¶ÏªP°‚‡ºr‘ðç_y´n k_J;ØÏÃÆ¶k“x«Æó®F”_$4¶{ºÿhÃS1¡KçéÕËáÚ41iRU²:邵øÄ¨QoΧ<­¤Ÿ m‚e».ž^?8{ßÃfŠÐÇqücK‡°Ëõ´o!þM¶l†Gå²z±gôtóáDþPØ:º¦[N÷MÂ<|ž¤îÑ«,F¨]laÄoúXIÒënú†Ƙ19®»®CgR™•6'Ž=ïµàº«–«hšÐ–ʬ˜æÒt糇êÀÌ\Fõi÷`G§Gg°ÔF? ™EÈçáït™ûXøû[vTŽfÑMˆÙU#=îW#TÝY/?_£¤©üŽgœQÇÅ—4P•S6W2©µüä›G~ã5zˆÊ¬rÈšÏs÷Ÿ°«SfŠrmjëÄóüºy¢ —vh³åo{üõ×y¶oŠ| SStÏÿ «­ûS~ž•ÃÐý#N¶pÚ½ø(¹ÿ W]Õ‹ÉGW§›=HȪR¬äÓcF½ñ¦½TVêŠEóqºyWÁ ô§ÙázÝÖ‘)ã ‘4Z¨¹üo&ú÷·mž(ÏÛÏšÚk÷zºlf÷´I’_Ý%…Ý&ü6LÇQœôéZ.ý—&z59¥«ff·ä”1£_ïúr….R\°º¢½º¤éþß3Ãä=ñ„£E"§ÝDEëå=Q…ë¶z=¹\7™â÷ ¾1vÔ+­]Oœ ²ž{%MaYt׬aŽ ®áG"*:í@§lÖE.“)8÷ÒÏý¥ƒ·Ÿê¤½5Ù-˳ iÔUó׳T[çðùÓëùÒÙ45ÙG| ÁÔž¨ò‚ü›Rêæq£^®„]6íqÁ éÎYÃ?é ÷‰È¼ç •˜ÈÂQYÇÿѯNª2ŠNOhsag‹Ç›3;Y<;OûŽ®k›¦H×X×na”C §ýsSÎj¤oßSv³ÂçŒ5wV×K°ûd¬JKÒ=5|P§+¿öD}F×\¡YT*ƒ+ª tMU¶´{x"tzÁ;Ç]ðÞ .‹ŸídÇ:O{•Ô,{F{ Ù'Çç>ßÀ)§ÖÓØîsѱ°ÄŇ„¨Ç@¾~àè¹æ—@÷ …Ó åRÉ-b]Íãö§öwò®\à ?hý,”ÂÃ!Œ"3JQqØÖááŠ÷„O| 'àzŠuïº,>Ïoåq»óપGUÃ)§52qRÿÒXŸ6H›B°ÛšÀ5À8ê¥RÊ¡èÛvóYh¦0N·>1|´+êvWäS.Ь“!/àzþ”D]6C»+ìrW„NWèôt-L´Š¢½UØ0ßeõkyÖ-Ì“o/W«”7)º»ZªºÚáàC«9î“õóñ:zõªÀ®qÅ,àâ £^´y“®±*¡ºB ¼[žØßéôdZÆÉü¥tzþ/Ù€o7·{ä=èp%0‘ÑĪ'Ú o¥p2¾­[è²~Ažõ ó´æ²»”®2l¤ ’åÐÃk™ø±Z›XCcSÅšz=p‚{'Œz¡.LEä`¯ÑX:Ýúä¨~®ðý <¡ª:ãà8Ê7ƒž/ížàj¤υ/½'Ôd2Ôdr­B~µÇû‹;Y±´“µ+ólÛì’Ïë> EÎuJÆg²Š>}2ì7¼ŠFU1fl5cÇWÓ@¶ÒãÉÿ­àÚƒF=o[Fü¡RW}¬=J7=1j¬‹º±WUæs;:ÅÙ•÷p=ÁÃßÞ/øó\¢k.@áÿÌ’ ÞI Ššl†ÚŒÃ¨^ê²á2\è•ËRã:¬[›gÝÚN6¬wÙ²9OË6íÛ]Úv ¦8äª55ІF‡¦^z÷ÉÐ@–ƒ² œ£¡ÁÁé¹.ëáÿ¶ú¿<ê¹’¿SõaQO V¹j5•ï·ÏH[Þ›´©Íý~Þ““=ÁqÅßÞ¯ “§i,¥ ¬róD¡”CmΡM†á P¡1S k¬&ÓƒRPAò€Ç\ ¼rðÈ9¥ø?TÚ«5–N×?> DMr…«òž|¾SȆ¾•¾B"Aæœp¦¿‚µ1—a\sŽšl Åšk²ô®‰ßZ#݆×ñ—½¶óRa#-ÎóJ˜â&à•CFþ½¼û©;‚Õ“N~Yyç/£F¸ÂÅçŠø‹ ½ðå¶‚¬¢0± ¾€íß”ch½?Ù(â¿Ìݧ±†LÚÒƒ*Ëm/G Jg¸sÃ퇎œm[Ýo«rÚ®«Ï®ÛÝÇJÛtQ¬`Ý)¤ÎKœÿßþ2ºÎ>ï ç ê“®à€"ç@Æq@<ü%Ñ“ø&/ö©©¢±ºü{ˆ<à„»Q2ã°‘³»òn¯œgRîsÛmŒÝ µeÞS-µ"WþyŒ£PCó_pgfuDF9Žïs)ömȰ_Sá«ë*ÇaP}MωãZ¨¸šó€¹+ÅV>ò™*Øž#Û<–U;h<ú½øâ¯b|Ű”0Ú™ÅËà|ûÏã¨Î8Cu²‡:¥ÊQŸ×;×§>—ñßÿ)è_WMu6xgñ‘ºô§”ße¹V¾™{ø«øùšÃG>Öi9­ JµÕÁP”&Âr+/¤mQ>1~Ë–ŸÎãÜ>û°ªuÙ 9Ç9N਺lvRŸÚêw\bÒó´4i‚…ËP¼¼ÌF˜?qÔ¬¼¥Ýi+biº+Xň›ÂR…(ÇÇ*f{Ó žÛxõëRTLPxuÉñ"Í‚š€b<Â(#†CQôI)1ò€Í«P²QK€Å  ˜²uâÈYñºéõ³å¯W°¶|>t ¥ÝHSuq*GeÚ„0­ÀiiéÒòHÓp¶|u~à•%'„×U@ hꀬA ´)¥Z­ 6 t(`ÒÈ'¡¼¶,ejŠÕ¹Üçõabà`>”RäÄB=J:í=‘g1¬=a«OvwÊ´×`üñÆÖxæ‡IEND®B`‚pyftpdlib-release-2.0.1/docs/images/pyfilesystem.svg000066400000000000000000000101411470572577600226210ustar00rootroot00000000000000Artboard 1pyftpdlib-release-2.0.1/docs/images/rackspace-cloud-hosting.jpg000066400000000000000000000310121470572577600245560ustar00rootroot00000000000000ÿØÿàJFIFHHÿþCreated with GIMPÿÛCÿÛCÿÀ8X"ÿÄ  ÿÄ7!1 "QA#aq‘2$BRÑ¡±ðÿÄÿÄ5!1AQaq"2‘±ð¡ÁÑáñ#3CÿÚ ?÷ñÑÑÑÒ”u±UÈÿ®õTOßå<"ù_„jy•øöUODO?÷'Xfç}˜ãÊ—Ýjm`­QX,>]-…‘-G;í*ëãI °%QZ¯ˆX&|,_«:2tФ:žï4Wg6³ Z´èQ Z÷Š5ÎŽÀÈ@DGLURiÀ# È}m¡J 0’‘â¹Â â )J)ÈÉÜW]:çn¶˜¿Ô&³$ˆq’êÒ•J”ïÂÃYÉ. HŸ=éo÷ùU_>ÏòŸÇ[ºâ…Èö{'Ÿ•wŸ*×/”_ ЬUjøTTOW9<"*ª¯ž¹zÀï÷uÛöÇù®ÀcŠ::::šš::::R¸Ü®öñá|*xóý•WûxþúëD_SÊùð©ãÏ”EÿOþ¿·ÏÏ\r½õrùðÔjª'²üy_>Z«ðŸ…W/”DDøU_(;¥á=G#'Sk '{4Ú§vc^,NŸ-Ÿ{®K¢Ž„ÄUE–Ö¹ìg•=Sv¡J’‡Þf;¯5°óî6…)$ <ùNB[*8â^†ø®¶eÒßL(ræ±EÅå3 —Ö”.S¡"˜Žþ‹ Ž.’N9ÓϯÏÊüüÿå?ŽŽ¸àz=®TóåÕÞÉê¾}QWÃUUíO.ø÷Fª¢#‘Š×8ë ±ÜŒn9?Ÿ:ìFàI $ÆÝ6®~£>\å ça®7z‚ÊúÈãˆ`¢|l2ÞЗúWÕW#ÿï4É“ÑÞb€tœÙÚ£‹?RS¿(«çÇÂ|/„ò¾SçÇÏ„øÿDêŽ?QNJ²Ûò¨\m\\ëšãAa”Á`{Ù { qXqæËôÕ#™j©  5‘(„“yN¦’ÇÙâ_®ät`~ó…|'å÷ò??é9KLN»Lg¹S:(íïþŸIZÙŸ õÀD÷*B0þ^Õ!iF¬²r¼Â ‘í—m‹Éøã“9ñ “-–g#°ŠÄjO+¥Ò‹@oõXp±¯|®¯û†VT«#žBH"Ö8caƒ‹#k “q¢¼© ªt³XÞYƒY^3QílÖVä@ -j5QÂes¡“4=]Lsj%’¨IÇÎUrtl‡î#]OÇVu•9 ­ãߦf¦ÕÈØSGÈ÷±eݬðÅ#ØöÕ€€Óײi ž¦(g«û¹Ý ÓMwRCMœ¬<˜D¬¡2ÀÆ‚|FÆ"|"u›g';üC®{mJ–ݶ÷g‡LDzHز+íå¾îI"•_,tÞ}˜¿µËáUSâüûk¾'KÁ[lcÝ)ÉV×É3Þé$iêEžY®t²ÎµË4’=Î|‘^åW*ªùû4DlSþ×=Z©#QdET_Ur9ËøOtk—÷"*zyíÛ4FCƒ¸º€Èä€Á1´¤<¬VJ)¶b2ÔÑdj¢*H9fÍ ˆ¨‹õï?>z:ø§O!ËöùUÑÏåüŠêùk–x7yK‘åÓó‚‘=YœƒPšºGN8⯺m‰u…Ôi\I1ÅRŽ,{yì Z–<£æ&¡ï?¶sôASgs›o!Û5V›OÆ[,®W}jXÕ5îM Æ| òžE(`ˆ)÷ ÕU£1„ý˜âŽDÏø<ÿ ÷ÃÚÝ^¨­é*±œ§uI±E8ÙP= §C ™²G,#Û<R7±ìša!†Fº7¹:Ë­ 袌Ѕ=°%ŒQåF1À•`—f‰ÿHªò#i@• 6xI‰¥1èBýN©°áÛ˜ýG?/Ûç¸Ö–îtå^Úøê¢“ÌCe-E·†ð¿.+ü|}€•ìœJùÁÃÕSÞ8ZÈ4µõ¶2„%d3 ð`2™0°uœwݯo77¹î3§2ӎמּ޿'×`oøì[ªÇUôn³¦g)΀z꘎ŽÀ©ÞÀ+Dœ¥hïÉù3‘»jàÝ“7Üs‹Èr’˜ü6OÖ{œðE(Á‹YJ ¦ŠÈ‹‘®šQÀd2H(QDŒHGI{Éîw¶Ž[íëu™Ïl ¶ÙAýçõðLMf êÂã8 Ûx@Õá"°bã87XÛ§3ÅjžCÐ}«5â^àx³œ"¶^:Ô-‘ô22 üýu†wME:É$MmŽ~ì ,àŠB"˜VX,3ÖL`“‹ êôs][*¯ê%‹óçÇþ4NDUr¢*rw„_Ýû¼¢*§ÏáUÈÕôñÓgþÅG·—‘bËQG¼ž…¹©µÌ«ƒúü´-%¥¥KìÙHPþ¼ȱ:eG8qQíVŒ:1å=¾oŽ;æ©ÛìîEÏes]Ÿéìn­Êÿ3íEo'Ã,†6²RH<‚eJЀ²ílI¤ 4è`’Fp7á;c‘Øsëܱ¿}¦œŽSå\ eHÙò.‚,ÞvƲ´bÎ4û¤B×ÕÔÖ e¥©Ó/¼ëpÓJ8Ãy)4y qZWƒa vÆxcØD%€DÔÚÄ1Å:ÄuañE`qEÒ6úL“67§£eÞ(ç®)æúãlxÃgW¦J©Åw©µWôÒ:Y¡bÝgmÂú¢ äÈÆ(ê¸C2aˆˆBdxó$iwaÛï q'ðÿ'ÍiÁÛÜf^·=¤Æl°ºÚß{ðGh×÷ãÙƒNu9‚jîYc +bÂÌûù')ždl’:œiÈ?ÉQÝjø—C‰Õ’|µ­ÕÛd穞ÒBðVŨD}› ™+C¸Dž!×é5#töŠWÃÈÿÅ<]¢n_6Aå¬_’?oõ¬…°†¶: óã«|î³h¯N&Íí²’2帵2H†T1¤I_ß_o'XÔ€~‡I•ü¡ÆÏé¶x-¾Cq!)$°¬Kª!Á …ÄUójD$xÝ3_l™`ˆy³IÈÿöê>’´KZ¬ßêuO­°‚"@,ú»{vRýÔRƱ¿è]WfÆz>7 q=º«ÏÈøÿ)`µœ{©hµ•t¦À\QJ±©P½¢ØŠ²Fï¶>¤´†Ò¨È‘ÐÅ:XK+uÀÂvé’{rÉ=óùËÄwíöØVlÉñ±ÈåEUG'ª£ÑèåGøk½ßšöª"9¨Ö¹<+öµMÐwµÀtº \ÅeÖŸwqBZyâuû€éÊG§˜L¶£«.®YSÒuXÆ*gFƒJÉšÆÉÉñö)¯³ÝvÃ7º $*Â<ý¾p©¦t„>a2:{üp‹4²ùq)%e rÈŽ‘Ò=SωÒ7|ƒw+ÙÏ6~/£Øä³ñÐd³åøï){£¯§±4³„˜ø÷=zf´×ÎËAÞ© F¤ÂI¬‚1"`Ë>]:oߟBNO]ë“Üþ¡ô©‰;âŽu†âN3×Çu=Ìúœšû<þІy_4#¶Ë?¡¯¬¶„MŠl‚ÏX\ÂS¬ŒV¶X·¶­¥¬±··³¢²°"l,lìK€ +Á',òË.XD0áb”QeKÐŽ×JT±Ž‹#k·—ø›“;ðá=G^Jyš>5䜯&ÌÆ§,ûJúš¦ÞeE=4ôÔKrï¾ ÒBC!°% K:6£†_ï6îö°vëc4ìÉó&[ÛmÇwŒÛü¯Ñ·^FXò#tjÊËÓ^„¬aée[\àd‰QÒ$pŒ@qŽØçëè7'ÐîžCÐ}«»Ÿ¿ŽÜclÆ ¢ÙZf%к«ãE3 ;Ò7qg÷c}H§Dœì£OO¬×N‘*µù^mŸªÕe®kïóW0Ê‹ºƒa,žÿF¼rzÇ'ùÈñ¤c²CZ»ª°-k§|R ¤cfGÛÆMËx\ÑÈc‰ÏX%dS²F"ùÉ}ÝpOÛ•Ôm£›B*Å÷´´`Øß™^ŽDóŒ•bNY)áîp¶FR1ÑÉôÞɘÄÊ»‹Ý•Å< ÉûªfCŽW xUcŽ{ ãÒÉ$^­gÛÂkÄ–hZž© $ªöþçS7é¡ÃY^â®ù“•ù69v4œ¬¬ÀPæî!àÚî,2õ›–«TÙ*iSü?®Æ×ÒVŸõª 55v—5ö–DçÏ öÚoOÙß²]u6¢zcv‹tØÐZ‰oSH™6lŒ-);”6‚£ñá'Ý)?%×:³T5©lš#EDµAv&ä»­à¸`Û!Eq-•x ©ºI'OÄsš·N*îË‚ùž÷ü-ŽÚAþ-p“6^ì ›Æ‰ï.jêሎ0acˆ¶n|Ûi) yí#2 rÕ/8Ø5PLÕdŒîœ‰åÉî=URG¢+ü§#UŠŸ»Ç»ìÏÙwàyþ³ÐfÉM(™j£Kiy\•€¥×Ýßd*VOU¡º¥-õ2sŽ}=ö4™sUwúF]T?6ß#ÿT|Í2¯‰Ü/¯•G¯ø{ˆjùøG9¾íGzþÖÊÅkZÖªú#ýõúÌÙ µ[—£¦­(¸„ÙZŸŽ¯ joÝQAJ@PÆr|?´êw û1oU3mMÙ>Ð-âIµ¸µFy–ÒàfZ8‡oê89#&jâí“GU¦ýA³V”§mQdgp–õv¢ÎÒE´¨³ˆ‚«¬FNˆ2ŽP¤Féc(Ya*9¦dÍ{˜ÞÄ»VânCí3-½ä< ; 1‰¤Ô:êÍR©³6×Ö£b«òŠ“2,÷Ùd£qöM[»Él-TPàìj5¥ýB²˜w”ò¥ÈÎôô¿pú ¨|¿N)$cZçõb+½œ×+Õ?Ì_>¶ ZZ.öŒÆžrëâB´¢ ç. eÈò›fr[ÇðÒÒV´+e€œÂÍ|âè÷´9úÛØ”Ý`l fáy“p·®Ì™-®"¤ZÒçè$!õ”¸–c,#%|Dï^—cñáQ<þºùÿO ðŸ㣭YòŽðžÙ||xÿôÿ_÷èëàé$¤$¤ïî’ í’6Ü`ùgzýp8p8ycoÏZÕ~<ª¯†£~>?ºªüyïåjÃnù?’- IK Y²+#“A`ø!E{•}‰Y -óâ8ãcá­j' ÷üþÏ?÷£“ñå<"xU_íùs? øþýTÏ8q«óü—¬lƒøæÈ­-tÎödsvD‡’ñÕ¨O´>ch¬ý°Æ#^Ö£^jÚÂTG]ôçôóèFûT/á?/¸¨‡´ÜÖ÷“(ØâQ3º ©l£4fAŸp†1î™=bxwÔ…G;Õ ‹é*I ó¼I(ænä¹Ck°6lv¾ÿ%‹­:Aó‚gÍ2’X*«cµ¼”iÄ0‡Ø9P˜ê S@w ‚ME¿¸®–Xd_dÑ7=Ê­TTÒzþæµR$\³Zˆß¦ÿW1®ðž|~ô÷sU>áêïÇ[’O¦Þ•9|ÍÚ>^Ãîxëe7ßo+²7ÕÚÇC¡¬"½õ²J`#²ã²®<±›#™C Êƒµð4•0Ü»ÝÉLó/íc|~¨ÔO« ‘­I'ýì‘Qæ¹ê¯öUsxè™ð<ƒ…ÕŒ“6:íMHv­k%‘‘çïçLÅÔóA3<…y-—ˆ"–A寈†ÆÕoºu‡a$ÙòeÎG‰€fŽi­ç”:@«ÎgÊ*WÕ[ê pîJJøBñ G˜OêV’ăHYä"¶ܶþ)Qoo¼$¼§Ê¹ê"@I³TNf‡pB©tÕÓ"²¾Gµ¯šô§²­‚5~áâÈu„åÕJ×_¤>}>UÎTr¢«—Ï•þêŸ ð«ýѬk—Ë£jFæ'Q põ 䟮“úɳýöŸK0è9—öªŽjIô}æû*Àc{„¨¬Šia d|ÒÈE‘¶G1""~åWãù_Êÿºÿ~±QÉ>¿ãø­ÒÝñHï$5¿Üܰ=þ8Ÿ•ÛõQ¾XÖ¾6û1Wñû½Z¯oŸOO(¾ùò‰ûZ¯DòäFªùðäWz¯Ê¹«ëî«ð­ð‰åSÇ]aÜùwuúR詊ÑÔ XUWäU…5Ý`g¢!ÂWÚÉŽSQ­BÇx¢%‰3á:î<'ðŸÏá?>|ùÿ”Eÿ~«SU«Ø`x˽.ã‹ç ¬öKK±®ãyø‹Wµ*ºª˜Î=†@íê2×ö©öî]3ÇŠÒªSÀ›Cgõe "k&Aþnü;“áã»uä,G–Þl¶\-.ð=I€uP}åÎùɬ?¢Ô×VÀCžAè g%c’Då ¬#Y‚Ão‚»s‹ÉìëÆ™ä ³;O¢ r$OY'€[€Ì‚)žßÚùcc^äøs•:Çj8O†¨*íh踓Œiio ŒkÊŠŒV¶®äheŠx‡µ¯ª,`Šh!š8Œ†hÙ,1HÖ£ãb£'½0;~~ô¬~Ë#ð\|\¿n&çˆÝ’y)þkÂeþCúcŠ$Ž‘ÁJ['‰‹ôé "«Õ rí¹,Fo³Ü)Í::N'æ>¤®ÀjrûëŠü×õ1òáAQK©ÎÚÜÏY[¤¨ÐT5‹ ªœ„|³Jh‘MC5Mµ‹í P CÁqà 0FÈ¢†˜ØâŠ(ãF²8ã­dq±¨Ö1­kQˆ‰…ëøÃyíǼy†ÛýŠ=¡‹òT_³k×ËÚ/õšó~Ý¿/H}Ëò¾zR£|/qÜgʇyǼqpFÕÙª®o¶9‘­qýišÁ†Ï?b¤WÍ¢$ey°„*’!áÃ`àímU€±ªÁÅÙYý@8š³uZËÊ ßo‡íŨ!Ñ-M­.òÈJÈnóÆ ’äz9Îr=ò¹È®rùò«æ@¦¢¤ÎW Ož§ª¢¨/ ]5x•uÁ®W¬"‚ 0 þì{)1Õ¼M¦tmX%Y®w3qwgTºcE)çmU%ñÔóÚ’Áä•+^r‰§6•åNð¸+ Ç÷:lÿ'avº'ÕË?)ÓÔ鯴ºBb–*¡ê¨H²·ûIìž<'“( °Kõ¿SÐiq”Mß÷41Ký¹î¢š #cáš)ud±Ë‘Y$r1ïlŒ{U¯kÜ×"£•˜ á>Ë]7K™âN1Îhã’iE‚ÊÔ]±ó+’g²Ö¾¤sšùQÎI“¢½är¯•ó© ç<‡íƒûtù÷¬Wñ—ØRáÃ|M¿âþÆâúE&«”ÇáíÌÕ°G*0ê]îÌޏ7Íî­ŠÒ‹G}OX$l-(wI qJ¸¯füÑÛvO·~:ÌÁ¹ãž>¿ÍgD ³Ms£‰‚íO·«¹*¬¹¾ÿAóCløäÛãï´X¢° áÂxOÂxO ñãá?Ûãýº‹ô¼»;i/¶?Åڻɽ>µÖ—òw¶Òý6ú3êXÚTcýˆÆ{L¾­OTðŸfI9óüþh06‡ñHuŸ4a¹{¿¾ÝÃã˸5”\§0×®¨Y Í¡¸ËNM•-%ãö7¬¯”³Ë¬ º¤Žâ¾(Iy0LÈæ®ïsÚ:©xŸñÔ«^Þ÷dßßçê#ú÷6HSÁÊM_œ§\È6­k,ÃÏH8q¾”[1Œå½ÀÔi5Ahç°"†Òļ_'géÆiª54UØh\>†“7‰Š¤šJ{ë¬êlå.aéìÆ$/U¢|"5<#]ý“Çïòž¨ž?+ýütµsh]´sí‹n9s…°»]CDø”ÚˆÁÓ¼H[$q6¢©ÀÞÎí‘í9Nx°ûÊèãkžõë×XoðbÛgéëÔI2¬÷ æ©Qm©dÇÀKíekZO Ï p2 Iæ:ÇG^.Z‚É«ô­Ò-·PZ#Kµ~šâʶ] Ë)uÖ¤x÷¡m¸œ¡h<;0qJŸþª¾÷Üoþ1ënÁ°+ŽwFS&ÜìýY:- i³ŠÍ2(3ðMmMo§-R¢Ô#×9o>J«aPýãò\ú­ºä€ìjñU¼ŸÁœŒûA*W…¯Ïbéï4ÕB5ÒO`N‚3 ¬sÞ]xqoöp“èï…{[íï·ˆì?èÇâøøÛaÚ½Å5HîÑ[ÿ^ -4æ}Ö‚ÄIW–=y¶39SNTCÆAË'sË]¾ð§:^0qV‘¡¥tòÓO¬ÏWYO1k”ê[9 }…WÞ6šz8í.6BÙ¾³Yê…§QéÛ%êä¨v«Šì7[S–©,91•Í :P§$xÜ)h(­ Qm 's†Y¨4V®ÔzzÎÜÛå¬j»=í›ÔyJНé^,bˆA¶ðñ@JŠPò‰Q;’sš¥ÏÔ¿c’î§¶5î»·k‚yŽø¯s²Îê­ÄÍj* ¬ª:JêݹuJœõÜU´z<ÅXGkCa¨®&¨bˆ›µ¿Õ¿·1ìÃŒq{²ïÓ˜xcŒ2ü[ [#Ȇ`óàåètÔú‘+¤ÌU…©¦;®L¯±ÍXËfWÚ  Ú;²ã>âîijŽxÇŸÅa£.ÈÖåèˆz‡q;ç¶•Cz>'¾ÂYòÕÍ_¹÷{åötUYíMžÆî¯ÈÑÛ?F|ÓýgÂRÒI#žŽz¦j Ðs¾U|“µ„‡½~¢ÉåÍM`ê=4åž-Žùl¹¿ Ípve­ë{Ì"C‘d©%Èr‚‚B¸° q²œŒcв¸h}fÍö~¡ÓW«4ú†× þ5Æ;FD¨é ­Ü>øRxˆ 8qð¨õªŒýðûNAç.RînÀ2EÈç¸ÛSÃâ]Q«ÙrF×µÑ Lùdd— 7Õ6ùò;é}Må8£=ˆ—c´qo8ØÇêY³¼æJ{‘hj,¹ƒ‹´²Šä2»ÈÚ\Ö·;ÈUL‘“],˜Ü…œ€¶1χ#~yÕÑß^YŸ³õŸ“Éæ±Ú¬–;;ŸÉe¨о9—©¯  ¦ ô€ ŠzÈG¯¯ *²Å‚˜Š¾‘µ[Ô3ͽ«vëÜë§æ~#Æo§U\ZV² ýiHJÑ´’{ K‰–I¤«ŽÁ’wºYtŠçõÉg^F“uÔ.Ýíªzרá7l\VJ‡2[DQ×v[¡t÷RÏe786= Μ»´Þ Ò—)3Ûzlw {×%©Ë}´’¦ÚuJ*hàÆvó«ú ~ ¼5ÜUe8Nä½.wËûë}I´¶Ô#XXݲ²¼ ªëô0Ò߬tÎU¶žz¨` « ØÂR#÷µîG%¡»ýûÛgÄžÂ>4àŽÕµ:u/ªñrð-^pË4bC#›uÍÍ’ÈöÈ8 Äu±8 §’ aôóì¬,û3böÕÅL©ŒèìåúÔQMjYcÁ(°Km?Ö¼µû1 & ølO* à”ˆ tL2v«?›ÁãqØ\ÿæótõ8L¶Z§ŸÈ í¥¬ÉÑÕES òÉV0ÐVF§ÒhlH<:?·{\Ú 7¥ÓÖË‹QôÄé '>ۯʶ€B ÓÅÅîðŽ,€1F=–ê™×Žê»Å¶TÍilǶ²ëlÛ܈Aa¥’§k dñ«'rjƒ{ýT»ráÎÑó|qË6š ŽA⨵TuyúÜÅÅâm³‘ß\Ýc=c]uud‹œ>¯;nÍņJ(´uöeA!ƒvRú[r\œ™úŒÒigût/E‘æíQ1‡,’‹:€µŸíä—Õ_±Ò}'z±íÍŠv²fÌÆßÉ_¦ïb¥hK7l|Y%ƒet®®þˆDY7*ú£…—1ã&«ŸÕÍž¶jW×Kìô|Y½JX>Ñûmâþ@›•8ó†ð¸íñ?×.¢Ž˜zû6¦–ežêa•ƒ²_á²Ãµ¬O_ c’Ïêí'ÙªcY­7F$jfÂ^\©1Ýe /„%´$øaÅ(à•¨¨ŽI'(¾Îõěރ¸_ï–ytLž6aÊû+} ‹úD>·ÕÿÓà HáG»¸$’YX×Ê/¢yøò¾|¢ÿøÿ…^Ž´…QZªˆäO><9Íwž<+\äð©áSå>ÿ‰Û |´¶—±tÒ5ʪÙ]ZÚf±­s|*BÛ)=íÜ»ÓÛ££«¥GaæØT`vŸè}+1¤í Â¥I7|qb»Ã¥¦ÃRG‚F£Wü‚/´Ò_>ûHÇKY- búø‰QÎ{O‹Ãd¸þ‘™üu u KdtÏŽ(™ÆJaåHéL²:dcRsìH$ɽZ’Læ26´èꤓŸ?Ïà})Ø~¡ô¬µÖùõj7Ï…_‰åQ¨«ãóá­kSøDDü"u¯GGQSGGGGJQÑÑÑÒ”tttt¥)GGGGJQÑÑÑÒ•ŸÇXãù«“Чk÷4™ÓrµWß{`É¢²)Åœ a̬›Ú3}iÀ”ˆÜö«&Tc#frÅòŠŠž®TÿÔ¨©ð­Ts‘=üµSÙSÙþÍöUjôtt¦oÏÀ>•¿£££¥(èèèéJ::::RŽŽŽŽ”­)çÇG«|ùõO?Ï„ò¿ïüôttî:ˆî;yõj<'ðŸðká?„èèê0ÀØúR¶ú3å}Zª©áUQU<ªø_Â*¯„ü'ŸŽµVµ~þ:::ž\¼‡Èr.ª00;òëßöJÑÔOÔñüxþ?¿ûÿ¯Zú·øO+ýü'Ÿùèèê Rq7à÷åS“’s¹æzŸSZz7ÿkWçÏÊ"ü¢¢¢üÿtTEOáSÊtz·ÿj~U ùUUUÿåUU•UUùèèè€98Û'¿­+_Vÿ ÿ Ñêß>|'Ÿå~?ŸÏãÏúttt@AŽ|ùRµèèèêi_ÿÙpyftpdlib-release-2.0.1/docs/images/smartfile.png000066400000000000000000000242701470572577600220470ustar00rootroot00000000000000‰PNG  IHDR |såØsBIT|dˆ IDATxœíwxÕ¹ÿ¿S¶W­ÊJZuK²dÙÆ¸cŒ;`l:$ô„š˜Àå—äÞärSÈsS¹!!7¿› !¡\ˆbÅØT7l\ä&[–,«íª®VÛËÌ9÷c°vgVZY’}>Ïã?¼óÎ9g43ß9ç=ïyÀ`0iÀuãÞh&åW«íyÚr^ä²€*Å#¤­«%ÒÒïŽ 2ë†2Æ& €¨álŵ¦e3–eßZVgšeÍÕäL¢ád"Sâïøû;Þè_wd§ÿy_oü0(ÈXµ›qæa¢qŽÃñÐT/¾Áù`íöEZ¯Qs!”z=±®Mëùãžw¼GC²{´ÛÊcÝÆØÁ œ~ú¢¬û¾øïå¿)­5ODNõóÀqg´Š–šy¶ÅŽ|íùí‡ÃõÑÜ=šíeŒ˜hœ£pÄE_t>|ùšâï-¢i$e9Ë å•3-—5í l Xãl‡‰Æ¹‰°âK¿]~sÁÊáˆæ,½r¦åò#;ý2á8»a¢qŽÁ œaÉMù,½±à+™Œ,¢¹ò|ëeÍ{›Ã~©3“e3ÆL4Î!D-o]rcþÃËn.¸7Ó‚÷s˜í[ùTóŠöCáú€7Ñšé:csQË[1ïÇ‹oÌÜ$‡&«h²qagcx_À›86šu1Î÷ã–›zÚ¢[G~5Œñ³šÕ_)úóWçÞ(мb¯2èK7>ëùïoôÿZŠ“¾!‹ä qU/»|MñO˦šëÔ4ÃÛë{ê{M×t·F7¥{ Œñžœ¥ˆÎrñí…¿¹è:çm‚ Áèitþù{M_hÜîšÈ4””øû‡ëßxÞ`ª &ªx!u¯Cg Õsl«íì xÙPe¢ÃDã,DÔòÖe·übÑuÎÛ²`¸›ÃG_øyëÝ]-‘wP5u™F›wÞ‡ÜâÓ´TѤÇqF‹h.j^Òy$|Ðß—8šÆå0ÆL4Î2´z>kéÍùÿµèzç]¢–•ìÛ…¿ühÛwsxcºu‚x[Cø}"Q[qy†¨áRÖg¶kì¥u¦ÅL8&2L4Î"D gYzKÁÏ^ç¼SÓÓs4|ì¥_¶~ÅÓyw¸u™Æ;ÛˆLí¥u時zýŠÙ®±•Ô¶ïô3ᘈ0Ñ8KàΰòîÂÇ^ã¼M•`´„Ûþú³c·uµDÞiÝD¦ñöC¡÷d‰:ʧ›g)ͬ˜³4öI3,·Õ¼L8&L4ÎxžÓ¯ZãúË…W;oTãôìm‹xžzèèÕ}Ñm™j%ÚB1â˜t¾e¶R<‡Ñ*ZjæÛ®jÚØô1çèD‚‰ÆGÔp–•÷>~áÕÎÕÄNt6…þù»ÍWúºã»3ÝJ!·7„7H š]Zgž¡´Ô^«çu“çØ.okíó÷%š3ÝÆèÀDc£ÕóŽ‹owýzÁÕy·‚rc ;Þýןµ|Y©‡Á‹œ-¿Òr±«ÆrKn™i¹ áuáÁD'($¥:(i?z7%¦Ò)¦™¢&¹3–ã8Î`M3ÌËÝM‘þžø¥òc ŠVÏ;V|©à ®Ê»5Õ‹y)A¤7Ÿèxøðvÿ³©Ëòæ__ô‡9WþǤَ¥Óí‹ÊgدÑèø‚ž–Ð6"ÓˆR]”@r oNĨ±´Î“Çs\^¹išÙ¡­ëhð¿C¤Ôâ” ÑÙÞ󤿹J>£U´TœgYÞÖÜçïK4)_c,Pµèˆ1>àÎpÕ¿?7ïòœëÔ¬%9™–½ J6F»¦NÔ ÆT6•s—,»»â%AÃç©©W–hdóËÝß~í±ÎG¥Qô‰8òu¹_þQ勇fªšòg&ŽÏºâÞ¢§æ\–s%Ï«[ž~2’DSðEüR %4¡dWRg¿â+¯¨J!o[×óà?ëx$!1%{³]c^x]Þý`Ï縄ݔ €Î(䮼ÛõÛ9«s®Ž`Çqz%9A:[ë}›Ëâ9®dªuþ’ÛKŸÓ…5õS ²ýõ¾¾ù§Ž_ÆÂrTÉ>»@7]¹”½ÆØÀ|ã½Ip^vë·s/˹.ÆéÞ>XßÛýHÁŒö¶Fö:+L‹ÌYÚ”½Žã8G¡¡Âš«›îi l•âÄ«ÔJ yš#[#!Y,›jš#j’G®v6GŽØì{ŽR(ö|g&ã­žÏ^õÕ¢Çf_š}ÕHxö8ð"(äTv‰¨ÜÓu$¸;·Ì8_I8 «ÐPavèj=-Rœ (ÙSÉs4²-”5åÓ-sEñóÓ±„Pºë­þÿm=z[©<Æ™‡‰Æ8EÔpÖk¾^úü¬‹³W« WÂds›w¶¼‰%ÛXHîp |”WfZ`v( ‡£ÐPa/ÐÏj?àߨf:–$ÜM‘MAoU3­ NžŽ%„ÒæÝ=¯ÿ¡ó…¼Œ1‚‰Æ8DÔp–+î+~fÎʜ˹aú0NGktF‹Xvp³ïï”BÑÈžöýþ ªÍ+Lvm¶’½Ý©/Éræµîöý“T²§²»)ü¾çhÄ£3 Åö\mN÷±ˆ{Çýk×ý¶ý+‰a»µSXº¿q†ÎÀç¬^SôÄì•Û%I…$ù½ç»þ´áiÏ×€ÔÔOÛc*W}½êõÜRS•’-%”v4ø?ÞðøÑk1¢zß^à4¼À ”P"ËTbJoXOca0 …WÜWòĬ‹³/WJ¡7xžã]“MS©LmmCïÊ/§œ ÞÖ=¾·ó+ÍKÕ8GmyzWN±qAgƒÿC)NúÕ´‹R"S‰ÈP™9Œ1v0Ñ'ÌBÁê{‹Ÿ±Ì±*>Œdˆ"'–Ö™gÇ#Dãn o£DyvBŠ“¾ÎCm9ÅÆy–l]¾’½-O_dËÓŸç9ü(:A1câÂDc ˆœñÚo”<ÞÇ¥j"=ca9Ú},z¤a«oóî Þ·:‡"Î œNkàõJ¹,‘ʧYæKq¢ï8ÞªF8âÙã>ø(Ëe˜eÍÕ¹”ìíùúR{~¦ûP`s&„ƒã µ9u3æëmùúù¢†7ÆÂr¯š¶32 óiŒjçÛî¼å¡ŠÇ•¦U ¡¤­!tøÝçº~Ö´Ûÿšœ 'OqjrŠt3ç®Ê¹îªÜkõ¦Ôáà’ø[Ovþ|ëºÞŸ™*\€É®™¶ô®ò§ «-3ÔØ·îõm~ï/­_Š…¤agèDÎ:eIîwf¬,øšÁ"Z •£û6ö<³ëuÏ7ˆÄfYÎ$¬§1˜·*÷{åÓ,)÷!2%;Þìåù½ª·=ºœþ’“°_î<²3°ÎÝn+›j¾È`S lj‡,KÛÁÐ;PáãHDIOëÞÁ Uææ,m®’½Ý©/q–›–´ìø'‘©_Éþtx³Î\]ð˹W»ÐèøOWÞ "/:+L3qbîn½•n¹ŒáÃDcPw¡ý®¢É¦IÉŽ™’-ÿè}îÕß¶ÝI”V—Ò~w¬¾e_°¾z¶õ2UÂ1Õ<‡ã9gËÞà›PሔdàèǯÖX/3gis”ì-Ùºü‚*Ë%Çêß’Ê`'àÎ6÷×ïf¬Ì¿}¨ãÏñÙÅÆ©{ßî~*gƒ#‡‰Æ8 Û¥›\5ËzÑP¾Y¦dóË=O­ÿ³û›r_ê€7q´e_pOõë*%áàŽ/©1Í ޶†Ð{TE†."S[½ocn‰i%GW dovhórJ t lIDI¯’=/pÖy׺~_·,÷†TKê 'Ö¿Ùõ'J TÆÈ L4Æý±–ªÙÖUV‡æ” *I"òk»ÿ¸ñYσ k;N'àM4·í+›j^l²‰ÖT¶‚È euæÙÈjk} vV¥£Á¿Éž¯?ßæÔ+Ù[sõ®œã}máC‘€Ô$½½E,›w]ÑcSå\+©ý<¿äß»¾û!¨Z12q@"N|Í»ÛÍYb‰¨åL±ˆìMxÞ|Âýóm¯ö>,Å©o¸eûûMî¦psq­iÙ®±§²åŽ/®1/ň®óHx!ˆ+¶=Fz=íö|ý 5ÂaÉÑOµ]¦Õ e‰( ™Êƒ¨åm–]eqõŽ…7•ü¢dªíB^àSƪ™È{Þì~²«)øšR½ŒÌÁfOÆÑ"Vhô¼18 uHñÌ…RM6®ü·Êÿà,U~±ãQﯞÇ>x¡û»RB9$ ±jÉeÏ×Ùæªm“$i 3ÒÈAA䵎B}±Ö(ÔœKd"7mx{Ósm·ª "cdÖÓOPȉ鋆dO¦kùûM‡Ã‡&Ͱ\l´|>ßÉ"'ךgÈ:¶/ø6Ttý¥8ñ¶ï÷¿ë¬0/5giò”bE€ãª&»ÖnÍÑå™Úl!ÅRù“¡„Ò¦íooYÛ~G"¦ìad&çþþDSÓ.ÿ¶ªYÖÕF«hNe+ˆœP:Õ4Ko*Žì ¼ U!çdàèǯ8'™–YU8G‡ËÁ÷{_ÙöbÇíjªŒÌÃDcb¢±æêæØœúót&±8–)…âÖ”ÚŽì l­<ß²ÒdÓXRÙò<ÇM6NÕèø¢–½Á·UΪÛ÷ûßɯ4¯0;”ã8Òeÿ;=/íxŽ&cC’±‚‰ÆÃhÓTÍÿBÑæ\åz°ú‚ì›+ç:¾P<ÕvE_{¸9â—se@Ø/µ·5„ö•Ô˜Zš¬T¶<ÏñÅ“MÓ)`o?ÞLdª¸¬^Šo ?ÞS2Ív™¨ý, k$BÉáMý¯ôrÇÝL0Æ&^à ÝZ²¶j^ö¥:£hµ¼V£ô–]Aéy¶Õ=GCGB¾Ä¨Ð xGÛ…÷תãKjM3yËnomVrô&š-9Úé¹¥¦”‘®j%"5nñ¾¾íÅöÛ™`Œ=L4&¶<ý¢‹n)}h('£V/‹¦X—õ´„›B¾x#Ô GKç‘pcqi¡%+õt¬ rBÑdÓy‚Èe·¾¯8K!ÇÂR¸zžãNÅ³É ‘·z_ÿ襎{ØŠÙñ „kŠõÚŠ™Y—$;®5¦’:ëŠÞÖpk°?~@M™þþDSëþÐî²i¦åæ,-•­ rBñdÓ ^äò›ë¯AA˜äM¸ê¬×˜lÚ”‚” B(9øAßß·ÿ Æx‚ma0HÄdEg§Ñ®Í^ñ•ŠÇœ“Ì׫-·ëX䃧pôºÞ¶ˆGÉVÔòâ‚«ónÎué/R²GäÁh@êQÛŽÓÙ¿±çÅí/wÞÆ$ã &ˆž£¡ñ°¤(‹ÆvñW+˯4ß ¶l¯'öñS?h¾Ös4ܪd«ÕóúªÙVÅ¥ñŽã†74ÙÿNÏK¯s¯‘âdØÑ°ŒÑ‰Æ"’Žly¡ã?¥Q ï6Ú4ŽK¿6éù•æÀ©†öuĶ=ÿ“–›;C)÷QåyŽS“ŽPkì«Æ©¦î™Èû6ö¼¸ýïw3ÁŸ0ŸÆÄ‚öwF¶Ç£„ÏŸdž£A)jy]ñTÛ²¾¶ð± 7~*´BƒR{Gcø`a•q¾-gè,äñ(‰½û¼ç÷ƒ½‰#©ÊÊ)1^4íâ¼»”6~>,©áþW>~¥“õ0Æ1L4&¤·5´%–hA•ež áµ©Œ5zÁP4źÜ뎸ý}±ýP9«Òv0Tïª6.°åœš/C–ˆ¼÷=ï†mëz’{ñgœuyÁOsKMµj.JJÄÁ÷z_øøîû±ôWô2ÎL4&&r_[x[4(É…5օ»”Ì'±¤÷X¨Eí¬JÈ'µ5ï l5giª²œÚ|"ƒD‚RhÛº¾¿­ÿ³û~¥•·®Zëç¯.x ÕÖ‹' „’ïô¾ðñ«îQ³Kcla«\'6â¤9Y_[üå²_ªÙ¶1Lø7ü±å÷¡ÀSéT¢3𕋘”ZÔìgbËÓÍYõÿªÖYr”3—ƒ‚Ö¯ïzîãuž5²DT­¨eŒ-¬§1±!>Ot§¿7æ+™f[¡äœµ‚®l†ýâÞÖ'ЯW[‰,Qo4$·™*¥„Á"V¬øê¤W² ôÅJ+])¡tÏ]Oïüg×ט`L˜hL|ˆÏÝH„\5–‹1uCÐðÚÒéöå}m¡ŽO†*Ëx•U ¿`é]åkóÊLÕJÛIÊ‘ömìyaçkž52Ë&>¡`¢qv@ú;"ÛÉH~¥ù¥Eb¢†×–Lµ.ðD{ý=±½È€p8\†…Kï,&¯ÌT­ÔÃâ$¾oCϳ»^óÜ/Kê’ü0ÆL4ÎHgdG4 Åó+Íó…C+è\µ–‹ú;#î@_lD=‡Ë°pÑm¥O¨ÙïUJľ=ÿ»ûuÏ7¥aÉ€' L4Î.HGdGЛðÕZ+Åqht‚¾hŠué€'Ú;Ø«Ç0öQu-¹£ì/jC–ˆ´ïížgw¿îù:Œ‰ ³2àŽì ôÇü%Ól+9G5:A_2Ͷ¬¿=ÜãïíJ§"{¾þ‚‹¿Z±Öá2–ª±ßóz×S»_ïz@–ˆ¢C•1~a¢qvB|žèÎw´»t†}¥’p^[v~Ö%!oœ x¢; œ¡‹ÏŸd¾zÅšŠç¬9:§’ƒJv½æyr÷]÷™†Ó¾Ƹ‚‰ÆÙ ñuEwû<ÑW­u±¨M9*œX<ͶØYnº$”ÂáÁD‘OuRòŸ„‡ã 0Xç#§äb˜³ò4óF&"! ¸÷c°çu$bˆês÷!»dvJ)AóöB–ºÒj8-J§=“£<¥YhÀƒ¶½ß¥rzå/˜P>ãGÐYTl]H)º›×£¿ã¯Ãª 4ºTÌú­nØeœL<@Ç.ú×CŠuAéþÙó¯EAõjp|ÊÃ&ämEÛþÿ%¾¤6–ìÅ(ªûRÆÚöu¢mï£ Ä›ÔÆš»®šÛÀ Ÿ¥Ìôu†ûÐÏ¡ö™ç…|TÌzZãg»ÞI‘mA–<Ãnÿ ²‹o…sÒò—sO:þäÔ}/´Æ:Lšó\“/Qá¥çÑ ‡·<„¾ÖÇAi\Õyù•_FnÙœ”6„ûÛà>üpZm2gÍGÉyÿ ÁªhÛ¾ÿ‡ òðDÃh»å³¾¡ÚÞäX:"Ñ0Ú£xêW‡}þP”ÍB¾NùèÇèk},¥mVÁpÕÜžÑúO&Ðw îÆç‘ˆ&¼ŠÛહ3cu:á>ü<â‘䢑]t\µwò›ÑzžÆGA‰ºç]«/GaÍÐêO}&Û¼ŠÐÀºô~U÷ ¿rшË9AnI:þä3eÖ™ê0cåË(¬^™‘ ôf;¦­ø\µß8MFÊžç᪹oLë¹¥é9&‡Ýû\¬¹C#„`°k² “‹Š5w>4z'â‘ÌQ:^‚¯ë/CÓèþ£} ìΪa•Ý{ìô{$éñó.y¹eW¥,£þ­  »‡UÿÙÆð¥d&†Hc·ëVaÍÕļÏà´pÕÞ ^T/ŒÇÎMã[–|6' ¢qÛ÷1Ø}4©Éá‚Árþ0êNÒCH)v [f÷/ÉeÌž¤óB%ëbRôµ¿ "'Ÿ–ì9Q'èɘ²ª`/¸ýíÏœò»Á<¹¥Ã RKÿ¦‰º ØœÉ{R´¡mðum‚ÍY1¤ ÏóÈ+¿ ƒ=¯"(ZÆÙÅèi¨âºû!jË1àÙ€h`?âÑnP´Ö­õ£ÕÆÔˆ¢Õ·£¿c-pRäivÉa´9ÏHl¹3¡7å'=>àÙ "÷Âß»D¾¼0ôßÝæœAㄜyñ Æÿ—k¼·qêEÔUŽÊ¹÷¸!Ÿ¡Cpo×ýbáýãþ‘xdG„£pLö ¼5(¨¼öŒÕos®†¨ÕyŒR oç›oçû R4i|‰ÙQ ‹c |Ý™ñÿåïí;³œ‘QÇEƒÈ!ŠS§'0Ù `² ¯|)(ý‚^7ü½;àó|ˆ¾öõHÄôÌu±µœ“nÃÑߨò—Ã’]}Fêæ83œ‹“‡ýùŽwñCºaͺm¢VsÎ¥ðuoÌ`û”nL¿ö£S·ÑšQ[JÕûÛd©SÿÎècʪA^ù½ªã4äD¾®—@äOg°Ž‹F ï¢¡>-C8à8–l,Ù.¸j®>^^;zZÖÁÓø¢Á8côâ)_À±=?‘zPd°×f´O†¿· §_Çià(Z £½(cuôæ,,¹}¿j{J)ömøWô´üj[¥®£‰ÝY»ó—ióÑË]ô½~â¿Ç_¬HpüÝ»`´df±š%»fÇ×P:ý´ìþ#Ú÷Dޏ\_×~èͅЛŸ;&hMpM¾ƒ½ÛasNòüXØ‹ð`7² jSÔ’Þ—/»xD­!éñÁÞ ¤ÿÓÿ‡}›@¤{’ŠšÞT “m6B¾Miµ#“/ü&ªçÝ7ä1NÐAµ«k¢ÂqMòûx&Û1Þà¸SžÕO†#4Š–]?Ä€g_+â j¨˜u/\µÿLLïv|Ýͯ yŒçy¸jïĤ™ÿ Q7´ÏÀsähßÿÅËR!cIDATÌdž‡kUÊÝßñÞ)ÿ÷uïE<Å2oÁ[þÂL5 Zˆ:ËÿÔF"æV8¶:˜OãdÆ¿ê¤9äÛŠ}®AãÖGèo†œ¡Ø AÔÂUs tÆ‘û(¢óÐÓ ÒаÙQŒì’ÙC¾ÄR"†î¦gUçöPƒFW {Áä¤Ç£!¾m§üB4Ø‘ôžç‘[r%ÆË¬¥!ß>`ÔDƒ1ž!„œžäÔ.r<ÒŒ¶}߆ûðïaÏ¿¶¼K‘[v!´Æ\ˆZ³jGéé­…09* ~ë?!Ø ¯»9%³TŸC)Å€ûc„|» Ï`Uvñ¥)×ìÄÃ>p¼zó©k_ü}Ç`ÏŸ‘ô<{ADí$Hñ¦Œµu¸H‰<Ïu3Ò†Þö‘ˆ‡UŸèÛ5Š-RÇhO}ÇÐqði¢ÎgFä(Âþ'ÿ4ô¸ZŠ· ¯­}mÏáèN#ô–é°çÏ…-ïRä”΃Fg/hT_ /j ÑÔ”.”ÄÑuä8ŠÎOKÄ:þü½ÿåÚFçË*IgÿÉLŸÆqѵ.mÓÀqC¹ ‘ò~BAi¡õ ¼ Ž×CÐd¡xÊ÷Q>ëž3’³(ñ¡óÐZTÌZ£lK)´îýo9ùlˆÖ`«ö>pÜ™HA8î»ãg” 0 5Xs«`Í^v§ÑB–ºÑyè¥!EƒR wÃ_AIfSÅñ¼Ù%©³y;ö¡þ­9Š‹¥Šê~„š ‡Î:€rË®EëÞá‹F2ü½ëÑ{ìÖ$ß§%·l¬¹—ÀßûfÆëgLXN¬ríõšˆÜ¯l4 ¼/ ì¿Fë©‹ÆÂ¾ô[›ñúLY“aÊJÝ:ØûŽª°ëÎw$ °\ˆöY ò@:ÍTŒöƒ¿BnÙ¢¤½ QcDéôÿÀ¾L4Î$•s€ð ºÜ¶ÑÀt4|TåªU‡kª¼ "§ññïEÇÁï ïD}Ð[rÒ.H áA‚ý£“¥'jÆ¡¿sÖÔS~ú"yêéX²—¥RÈR>÷6¨évÇ£ öÀhK¾JÖš3 zsƒÃTÝÝ`ÿfô¶}€Âê¡{Ç{: `v,GЛ¹åú§Õ2JåN\rKg˜©Ê–ž¦Ÿ@Š© ƒÅâºám»‹\ð™hDü»ÐsìM”L»uX…¥‚Oãß 1|ÄмOÃÛ9:ÅŸŠˆœ²KRZÈñü½[U•&ÅáëÞ £-ùMÔ™l°d/CxpwZ-U¥Qtøä/‚Ö0toƒã”÷mx“ê¯ãÌÁó“]þøèøì ¥q´ÖÿÞŽÔõt!„ÀÛñ:ÒH†2¶$¿~­¡F1¹¯»²Ô®®&Á`—r˜¹³r%0JÓŸþMèkMÞ‹à8¹åKa´±ìñÉw$æ…aïÆ/¢¤î;(šzÃgä J@¥Ú<‡öý?…W—žžÈqHRòU¨”Œ|¥%%RÊ:Re³9ë j­)ÏïïH' Š¢¯m#$é)­´ú"ˆšBHñ–Ï“¥PêëRúW( ¡mÿ¯‘Sº¼&¹0Tß„¦>Dº3)²”úž‚Ð>øCý ˆ<º½"B>]Dá>œ¥2ˆ”P¼© !I?ôDެìÓø$¼ahQ4“`Êšq˜II¤xAïv9½\—ë…0X†Þ?…RŠ`ÿ&$bê¾âɵE°æ&£NÄüŸd)úüË!j aÉ^ŽzÖ‰þ ÅÓ¹nöük“&)o#0ÄFJü̦MxŸ uK¦Ï¹^ «E¸{\8Zÿ§§Í/Zâ­Aä¡ ±¡´>jÂ]í8¼u¼÷Ö…‚þü¢E¡ôfAî™ç÷‚ˆ\i¢v eÏ…ºÌ™ªÐ¿*Æí{¢544X•Nç×Ðü¿³œp€ÚÚ&O.¡´$’¢\JJryü©íìÿ(2¢wÅâ &•„)) "xž"wèêIÐÑ#r"JtÐÑ×è—NØå_¨ò:j´'¾Çw94ünBX·dú<œ“Aæ¦,"L™RÊ‚ùS¸hJ¥¥yˆŒõÉÚë—Ñu꺺Nã³ ®]UN{ZCÛÉ(ûxí¢ùL…s2¡1-Qú`ÂdÝÒ™ßÑè{ñ?4LƒE ëX¹bÅE¹çu€?`󕛯ä_l¥ ïü>ɹä„|¼öNDB’aÖíHìÉѺf¶NÖÌšå›[_´A ù¾ˆŒXR[Ê­_]ÉâK§ ú³5#áp€Ò’¡*ú‰Gß>|bžØqp÷Úe3× | ­£å$IźqÓ1´çaX>ì`>v°ÃÛÕ`4NÓ‘ƒt7}@´;¤ꋘ{ÅÕ—×Nˆôûï&•Êd#­Oçœ3ÊïpwÚâK"—;9q`+ Çè ‚.$TTƒ,£åX3]ÇvÑ¢­%•u,øÂíUL x:åròTŒ­Ûö3„þ­'vÚ}Á~óþ¡ž›–͸[#ms¸xŠÅ8Y&ë%ëÅs=švïÇq4P\1“å×Ý̸ GƒN»ôN“[XJýÅ3ˆëc¤œ¡ BôëÙÚ7§9U‡×[íÓïhî`Áž# gd³¦eR^}튄ç§zîåYÁ;ŽG4æQPZNÝ´zêÅywøPÓȽÙ“Íž‘íŰlÚ„§µ|Kk­·ïMÑ=_ ((Éaw›M{2‡)õ•g½s]Óƒ¾œJ¦ÌZ¼ŗSS7‘³¡46·%Fc›z_6[Y×Ѳ¿­'2»ª¤‘­.S&) ­q=Áó¥3Nv´eBL±ìŠE”M*Àó4IÇ °¬†²šLª¬!œ;þª ðwO=Æ?¼þÍ2@« °Oõ4:õÁxºNè7.ŸQ)ZÚî¸.ŸYu¾1:ƒIM4æ’HkÒv%Õ3—PZYCNnbœ7Ø<¼õe¾»ácž+ôúØ;é¯ó±,2¡ìôDçN.þ#üÂ<“é“ÇðYBnȤ0×Dò¦S1c9999ð½GâDoyË”\›CÎ*¡q{Ô"_t¥«÷Gë_P9­aŸ@MK—¦¥/@ç î˜Å©˜Mo¦7nÑ·é[¸: ¼À¤Â ?¾} ³ë‹'d£µë$b ùKƒA“âkˆ¢{S?-Xx©G²u"dùuw.2—"º^!u'½ä"mX´Å,Þ|9£ðûÈË g®Š0óòrG~k­ù÷§·òäkü䎉¨,*¡»u÷´ÂÊŠœ@áU!Rm^(ÕçÝ|ùœ.¿æõ˜ê?ærɼÔÕVQYVLqa>`0ˆiqµI"é‘tIG‘H+Ò.„ýŠ9ùüׇÑÚÙ?!ð×.^ÊÞ–&¢»’® ñ¦)ä- гeð¦ÐÜ›³Ã{dá]·®áÚÕ+莺ÄSp Gs*J;CxžFkð4(¥H(´†ÊŠR¶¿×ŽÒ0‘ð¶ÕŸãŸ_|†ØÞ$“//'ááéÌ‚šnÓ÷[CHZ î-eÐ"—æ„C\÷Ù´œJÓóH: øL!`|£.[ú3÷AßнmH{”—“v<ºzãŠ@qn_iX…ö4ïõ±ºêÚ3޵µ†±¶h1yY  µUVZÈ`Ra™‚Ï|¶àî·G]VlÀ6Ž"ô¤MEYfì·u)££ñ8?ñY®þÑ_r¤½mŒùÛW_@ÿ¾(e³w¯v™‰ˆ¶¯!;7 bê·†Éøí3‘ð‘ò ?z®4Ô×Vé$íºüüÅg™õÍ[ùÁÆGØ~ð#îü×ÄSê,óEµ‰JhZzš©V¼3s3ãÐ0d)d›š^ÏóŠmS°MA“Ù§j Jß Jë¡ß‚Ö¥ehh´L¦Ôd<¸óð!îyþ§ioÃ*4)½&—D“î]Gxêí7Y×°rÄ~ÌI’’ ©Ó©>‚Vf2kO#Ffå•g%€¦/žHa€Ed˜ÌÇ ä- Â>KÐÚdPùåë;PZšá㪛(Í)ã•âÍÄö¤x`ósÜxåg’Nšoox…&PcQ_:•ÍmOàÆ<ÜÁ¡…X2ØÇ%  zcñ–!XÃP ‘!°Cž'ãù!Ü„9s(ݤ4ߺïNŽöl¼ån[{;w/¸—çob[ÇKøk-ö55óúÞÝømºþ;Ú1CÂü/Φ$PJÒKfœöpÚ½¡h˜pÇ' ½±xà C@ƒ–¡"DkÔ‘ ‰ mògUÍZk¾yß<õêãˆ)]âk«oå;³ÈÃG`ã±õëm’M·ýËÏèDk¯Ôdå-W°fî—¹wßpú\t’­n†ŒÉY#`ôÄI ‘o{^#°Ì¯¨¼ïW÷Œ€/¾.ÌU—­¢—ÔI‡xsŠd{/ê¡Ø™¦ÿõZéW.I¼ãŽÙ錟FµlNõdªÈ `>KnXµ–Ío¿È ¿}lðWؾì¤{\ÄTJãœTĤ1âB© R«ò(çݰqÛÞØxmÇÀö‰‚îï<Õ{aȇDDø·¿þ5°øé{%N¢%EªËA;cÏ”†Åë×ô½œ`ðË‹*xôú5,R“(!€Žºãçg%à)u²­£ë°-›_ýÍFþü†¿¢ï¥$Éc‰ã"*}†ˆJeîµ:ólÍôZ$}æû€+Þ¤lvÆB0/<Õ91ó3 ‰?¹ñ»åTñ£õA<7FÞR?á70‚ÚÉÔQyÀ¥@8™HDcqzúNS\xŽïqC¢µ¦£³›Æ¦M-í46G8ÖÒÆÉ®Ì0¼(üy¢éwiÝÜJ`²Eh¾J©B½Ì}›¦ÆSg÷­ŒÒ‰HáT¼?å46E(^t6d*MËñ›Û8ÚáhsM­íÄâÉŸ8T(.Ÿœ¢2”¤"œdR¨’Ý]A~¹¿•Èæf¡Ahº "Dw¥¨Ñ™&EËÓãÑ™;¼‘ÀÇSC °0òKªKç,½æáúš ã³+—’vZ#'8ÚÜF¤£ 5ª†/Ê1©ž”O]U1åBÛ¶“k 2söâLá§\´¥<ĤßÉçÉ›xïô ¢¤Gú)W!µÀ+yÕ2Ì߸zð¹M;Û²¯~Y\Œ„kÚ¢U+K+êî0 #`ª4–£*4@E “\¯“êÆ– Ë"˜[J´;sêQ=y* h10B4uÁ®}]gm`$ÍIbÇm%ÏO“Ðßn|·é‚2ÇÇ LrN „ýþü’%³êo¯¹_2´3¦ƒ‚°¢<ߥ äô9؆‹hí¥ å•“°kع·ÓÑÔYí´¦ÃÛ(JýÕñBeBKÔš†Y9*éÌö<5_Ðs ÓœaŠ1M ª äÇtŸR²I yô‰w¾Ãx.PþW_êïX´È>mLõ4ó]åÍ5Åše §†Ô†‘9ZSÚñ´ÞfYzC_ž~iË–£©ót{AòiýÙCÖ.™Q‹)ÓS»ž{ÿPϧdçw_þÄd*“â뢾IEND®B`‚pyftpdlib-release-2.0.1/docs/index.rst000066400000000000000000000011211470572577600177350ustar00rootroot00000000000000.. pyftpdlib documentation master file, created by sphinx-quickstart on Sat Apr 12 12:12:58 2014. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. ==================================== Welcome to pyftpdlib's documentation ==================================== If you're in a hurry just read the `Tutorial `__. .. toctree:: :maxdepth: 2 install tutorial api faqs benchmarks rfc-compliance adoptions Indices and tables ================== * :ref:`genindex` * :ref:`search` pyftpdlib-release-2.0.1/docs/install.rst000066400000000000000000000015361470572577600203060ustar00rootroot00000000000000Install ======= By using pip: .. code-block:: sh $ pip3 install pyftpdlib From sources: .. code-block:: sh $ git clone git@github.com:giampaolo/pyftpdlib.git $ cd pyftpdlib $ python3 setup.py install You might want to run tests to make sure pyftpdlib works: .. code-block:: sh $ make test $ make test-contrib Additional dependencies ----------------------- `PyOpenSSL`_, to support `FTPS`_: .. code-block:: sh $ pip3 install PyOpenSSL `pywin32`_ if you want to use `WindowsAuthorizer`_ on Windows: .. code-block:: sh $ pip3 install pypiwin32 .. _`FTPS`: https://pyftpdlib.readthedocs.io/en/latest/tutorial.html#ftps-ftp-over-tls-ssl-server .. _`PyOpenSSL`: https://pypi.org/project/pyOpenSSL .. _`WindowsAuthorizer`: api.html#pyftpdlib.authorizers.UnixAuthorizer .. _`pywin32`: https://pypi.org/project/pywin32/ pyftpdlib-release-2.0.1/docs/make.bat000066400000000000000000000150631470572577600175130ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pyftpdlib.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pyftpdlib.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end pyftpdlib-release-2.0.1/docs/requirements.txt000066400000000000000000000000301470572577600213560ustar00rootroot00000000000000sphinx sphinx_rtd_theme pyftpdlib-release-2.0.1/docs/rfc-compliance.rst000066400000000000000000001514671470572577600215330ustar00rootroot00000000000000============== RFC compliance ============== .. contents:: Table of Contents Introduction ============ This page lists the standard Internet RFCs that define the FTP protocol. pyftpdlib conforms to the FTP protocol standard as defined in `RFC-959`_ and `RFC-1123`_, implementing all the fundamental commands and features described in them. It also implements some more (relatively) recent features such as OPTS and FEAT commands (`RFC-2398`_), EPRT and EPSV commands to implement IPv6 support (`RFC-2428`_) and MDTM, MLSD, MLST and SIZE commands defined in `RFC-3659`_ that standardize directory listing. TLS/SSL support (FTPS) as defined in `RFC-4217`_ is also implemented. Some features like ACCT or SMNT commands are deliberately not implemented. RFC-959 - File Transfer Protocol ================================ The base specification of the current File Transfer Protocol. - `RFC-959`_ - Issued: October 1985 - Status: STANDARD - Obsoletes: `RFC-765`_ - Updated by: `RFC-1123`_, `RFC-2228`_, `RFC-2640`_, `RFC-2773`_ +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | *Command* | *Implemented* | *Milestone* | *Description* | *Notes* | +===========+===============+=============+==================================================+================================================================================================================================================================================================================+ | ABOR | YES | 0.1.0 | Abort data transfer. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ACCT | NO | --- | Specify account information. | It will never be implemented (useless). | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ALLO | YES | 0.1.0 | Ask for server to allocate enough storage space. | Treated as a NOOP (no operation). | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | APPE | YES | 0.1.0 | Append data to an existing file. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | CDUP | YES | 0.1.0 | Go to parent directory. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | CWD | YES | 0.1.0 | Change current working directory. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | DELE | YES | 0.1.0 | Delete file. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | HELP | YES | 0.1.0 | Show help. | Accept also arguments. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | LIST | YES | 0.1.0 | List files. | Accept also bad arguments like "-ls", "-la", ... | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | MKD | YES | 0.1.0 | Create directory. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | MODE | YES | 0.1.0 | Set data transfer mode. | "STREAM" mode is supported, "Block" and "Compressed" aren't. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | NLST | YES | 0.1.0 | List files in a compact form. | Globbing of wildcards is not supported (for example, ``NLST *.txt`` will not work) | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | NOOP | YES | 0.1.0 | NOOP (no operation), just do nothing. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | PASS | YES | 0.1.0 | Set user password. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | PASV | YES | 0.1.0 | Set server in passive connection mode. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | PORT | YES | 0.1.0 | Set server in active connection mode. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | PWD | YES | 0.1.0 | Get current working directory. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | QUIT | YES | 0.1.0 | Quit session. | If file transfer is in progress, the connection will remain open until it is finished. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | REIN | YES | 0.1.0 | Reinitialize user's current session. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | REST | YES | 0.1.0 | Restart file position. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | RETR | YES | 0.1.0 | Retrieve a file (client's download). | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | RMD | YES | 0.1.0 | Remove directory. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | RNFR | YES | 0.1.0 | File renaming (source) | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | RNTO | YES | 0.1.0 | File renaming (destination) | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | SITE | YES | 0.5.1 | Site specific server services. | No SITE commands aside from "SITE HELP" are implemented by default. The user willing to add support for a specific SITE command has to define a new ``ftp_SITE_CMD`` method in the ``FTPHandler`` subclass. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | SMNT | NO | --- | Mount file-system structure. | Will never be implemented (too much system-dependent and almost never used). | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | STAT | YES | 0.1.0 | Server's status information / File LIST | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | STOR | YES | 0.1.0 | Store a file (client's upload). | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | STOU | YES | 0.1.0 | Store a file with a unique name. | | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | STRU | YES | 0.1.0 | Set file structure. | Supports only File type structure by doing a NOOP (no operation). Other structure types (Record and Page) are not implemented. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | SYST | YES | 0.1.0 | Get system type. | Always return "UNIX Type: L8" because of the LIST output provided. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | TYPE | YES | 0.1.0 | Set current type (Binary/ASCII). | Accept only Binary and ASII TYPEs. Other TYPEs such as EBCDIC are obsoleted, system-dependent and thus not implemented. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | USER | YES | 0.1.0 | Set user. | 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. | +-----------+---------------+-------------+--------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ RFC-1123 - Requirements for Internet Hosts ========================================== Extends and clarifies some aspects of `RFC-959`_. Introduces new response codes 554 and 555. - `RFC-1123`_ - Issued: October 1989 - Status: STANDARD +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | *Feature* | *Implemented* | *Milestone* | *Description* | *Notes* | +======================================+===============+=============+====================================================================================================================================================================================================================================================+=======================================================================================+ | TYPE L 8 as synonym of TYPE I | YES | 0.2.0 | TYPE L 8 command should be treated as synonym of TYPE I ("IMAGE" or binary type). | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | PASV is per-transfer | YES | 0.1.0 | PASV must be used for a unique transfer. | If PASV is issued twice data-channel is restarted. | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | Implied type for LIST and NLST | YES | 0.1.0 | The data returned by a LIST or NLST command SHOULD use an implied TYPE AN. | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | STOU format output | YES | 0.2.0 | Defined the exact format output which STOU response must respect ("125/150 FILE filename"). | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | Avoid 250 response type on STOU | YES | 0.2.0 | The 250 positive response indicated in `RFC-959`_ has been declared incorrect in `RFC-1123`_ which requires 125/150 instead. | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | Handle "Experimental" directory cmds | YES | 0.1.0 | The server should support XCUP, XCWD, XMKD, XPWD and XRMD obsoleted commands and treat them as synonyms for CDUP, CWD, MKD, LIST and RMD commands. | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | Idle timeout | YES | 0.5.0 | A Server-FTP process SHOULD have a configurable idle timeout of 5 minutes, which will terminate the process and close the control connection if the server is inactive (i.e., no command or data transfer in progress) for a long period of time. | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | Concurrency of data and control | YES | 0.1.0 | Server-FTP should be able to process STAT or ABOR while a data transfer is in progress | Feature granted natively for ALL commands since we're in an asynchronous environment. | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ | 554 response on wrong REST | YES | 0.2.0 | Return a 554 reply may for a command that follows a REST command. The reply indicates that the existing file at the Server-FTP cannot be repositioned as specified in the REST. | | +--------------------------------------+---------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------+ RFC-2228 - FTP Security Extensions ================================== Specifies several security extensions to the base FTP protocol defined in `RFC-959`_. New commands: AUTH, ADAT, PROT, PBSZ, CCC, MIC, CONF, and ENC. New response codes: 232, 234, 235, 334, 335, 336, 431, 533, 534, 535, 536, 537, 631, 632, and 633. - `RFC-2228`_ - Issued: October 1997 - Status: PROPOSED STANDARD - Updates: `RFC-959`_ +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | *Command* | *Implemented* | *Milestone* | *Description* | *Notes* | +===========+===============+=============+====================================+====================================================================================================================================================================================================================================+ | AUTH | YES | 1.5.2 | Secure Control Connection | | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | CCC | NO | --- | Unsecure Control Connection | | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | CONF | NO | --- | Confidentiality Protected Command. | Somewhat obsoleted by `RFC-4217`_. | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | EENC | NO | --- | Privacy Protected Command. | Somewhat obsoleted by `RFC-4217`_. | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | MIC | NO | --- | Integrity Protected Command. | Somewhat obsoleted by `RFC-4217`_. | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | PBSZ | YES | 1.5.2 | Protection Buffer Size. | As per `RFC-4217`_ recommendation, basically a no-op command. | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | PROT | YES | 1.5.2 | Data Channel Protection Level. | As per `RFC-4217`_ guide recommendation, only supports "P" and "C" protection levels. | +-----------+---------------+-------------+------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ RFC-2389 - Feature negotiation mechanism for the File Transfer Protocol ======================================================================= Introduces the new FEAT and OPTS commands. - `RFC-2389`_ - Issued: August 1998 - Status: PROPOSED STANDARD +-----------+---------------+-------------+-----------------------------------------------------------------------------------------+---------------------------------------------------------+ | *Command* | *Implemented* | *Milestone* | *Description* | *Notes* | +===========+===============+=============+=========================================================================================+=========================================================+ | FEAT | YES | 0.3.0 | List new supported commands subsequent `RFC-959`_ | | +-----------+---------------+-------------+-----------------------------------------------------------------------------------------+---------------------------------------------------------+ | OPTS | YES | 0.3.0 | Set options for certain commands. | MLST is the only command which could be used with OPTS. | +-----------+---------------+-------------+-----------------------------------------------------------------------------------------+---------------------------------------------------------+ RFC-2428 - FTP Extensions for IPv6 and NATs =========================================== Introduces the new commands EPRT and EPSV extending FTP to enable its use over various network protocols, and the new response codes 522 and 229. - `RFC-2428`_ - Issued: September 1998 - Status: PROPOSED STANDARD +-----------+---------------+-------------+-----------------------------------------------+---------+ | *Command* | *Implemented* | *Milestone* | *Description* | *Notes* | +===========+===============+=============+===============================================+=========+ | EPRT | YES | 0.4.0 | Set active data connection over IPv4 or IPv6 | | +-----------+---------------+-------------+-----------------------------------------------+---------+ | EPSV | YES | 0.4.0 | Set passive data connection over IPv4 or IPv6 | | +-----------+---------------+-------------+-----------------------------------------------+---------+ RFC-2577 - FTP Security Considerations ====================================== Provides several configuration and implementation suggestions to mitigate some security concerns, including limiting failed password attempts and third-party "proxy FTP" transfers, which can be used in "bounce attacks". - `RFC-2577`_ - Issued: May 1999 - Status: INFORMATIONAL +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | *Feature* | *Implemented* | *Milestone* | *Description* | *Notes* | +============================================+===============+=============+===========================================================================================================================================+===============+ | FTP bounce protection | YES | 0.2.0 | Reject PORT if IP address specified in it does not match client IP address. Drop the incoming (PASV) data connection for the same reason. | Configurable. | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | Restrict PASV/PORT to non privileged ports | YES | 0.2.0 | Reject connections to privileged ports. | Configurable. | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | Brute force protection (1) | YES | 0.1.0 | Disconnect client after a certain number (3 or 5) of wrong authentications. | Configurable. | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | Brute force protection (2) | YES | 0.5.0 | Impose a 5 second delay before replying to an invalid "PASS" command to diminish the efficiency of a brute force attack. | | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | Per-source-IP limit | YES | 0.2.0 | Limit the total number of per-ip control connections to avoid parallel brute-force attack attempts. | Configurable. | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | Do not reject wrong usernames | YES | 0.1.0 | Always return 331 to the USER command to prevent client from determining valid usernames on the server. | | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ | Port stealing protection | YES | 0.1.1 | Use random-assigned local ports for data connections. | | +--------------------------------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------+ RFC-2640 - Internationalization of the File Transfer Protocol ============================================================= Extends the FTP protocol to support multiple character sets, in addition to the original 7-bit ASCII. Introduces the new LANG command. - `RFC-2640`_ - Issued: July 1999 - Status: PROPOSED STANDARD - Updates: `RFC-959`_ +----------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------+---------+ | *Feature* | *Implemented* | *Milestone* | *Description* | *Notes* | +======================+===============+=============+===============================================================================================================================+=========+ | LANG command | NO | --- | Set current response's language. | | +----------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------+---------+ | Support for UNICODE | YES | 1.0.0 | For support of global compatibility it is rencommended that clients and servers use UTF-8 encoding when exchanging pathnames. | | +----------------------+---------------+-------------+-------------------------------------------------------------------------------------------------------------------------------+---------+ RFC-3659 - Extensions to FTP ============================ Four new commands are added: "SIZE", "MDTM", "MLST", and "MLSD". The existing command "REST" is modified. - `RFC-3659`_ - Issued: March 2007 - Status: PROPOSED STANDARD - Updates: `RFC-959`_ +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | *Feature* | *Implemented* | *Milestone* | *Description* | *Notes* | +====================================+===============+=============+==========================================================================================================================================+=====================================================================================================================+ | MDTM command | YES | 0.1.0 | Get file's last modification time | | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | MLSD command | YES | 0.3.0 | Get directory list in a standardized form. | | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | MLST command | YES | 0.3.0 | Get file information in a standardized form. | | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | SIZE command | YES | 0.1.0 | Get file size. | In case of ASCII TYPE it does not perform the ASCII conversion to avoid DoS conditions (see FAQs for more details). | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | TVSF mechanism | YES | 0.1.0 | Provide a file system naming conventions modeled loosely upon those of the Unix file system supporting relative and absolute path names. | | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | Minimum required set of MLST facts | YES | 0.3.0 | If conceivably possible, support at least the type, perm, size, unique, and modify MLSX command facts. | | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ | GMT should be used for timestamps | YES | 0.6.0 | All times reported by MDTM, LIST, MLSD and MLST commands must be in GMT times | Possibility to change time display between GMT and local time provided as "use_gmt_times" attribute | +------------------------------------+---------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+ RFC-4217 - Securing FTP with TLS ================================ Provides a description on how to implement TLS as a security mechanism to secure FTP clients and/or servers. - `RFC-4217`_ - Issued: October 2005 - Status: STANDARD - Updates: `RFC-959`_, `RFC-2246`_, `RFC-2228`_ +-----------+---------------+-------------+------------------------------------+---------------------------------------------+ | *Command* | *Implemented* | *Milestone* | *Description* | *Notes* | +===========+===============+=============+====================================+=============================================+ | AUTH | YES | --- | Secure control connection | | +-----------+---------------+-------------+------------------------------------+---------------------------------------------+ | CCC | NO | --- | Unsecure control connection | | +-----------+---------------+-------------+------------------------------------+---------------------------------------------+ | PBSZ | YES | --- | Protection Buffer Size. | Implemented as as a no-op as recommended. | +-----------+---------------+-------------+------------------------------------+---------------------------------------------+ | PROT | YES | --- | Data Channel Protection Level. | Support only "P" and "C" protection levels. | +-----------+---------------+-------------+------------------------------------+---------------------------------------------+ RFC-8996 - Deprecate TLS 1.0 and 1.1 ==================================== - `RFC-8996`_ - Issued: March 2021 - Status: STANDARD - Implemented by pyftpdlib: NO (not by default). Unofficial commands =================== These are commands not officialy included in any RFC but many FTP servers implement them. +------------+---------------+-------------+-------------------+---------+ | *Command* | *Implemented* | *Milestone* | *Description* | *Notes* | +============+===============+=============+===================+=========+ | SITE CHMOD | YES | 0.7.0 | Change file mode. | | +------------+---------------+-------------+-------------------+---------+ .. _`RFC-1123`: https://datatracker.ietf.org/doc/html/rfc1123 .. _`RFC-2228`: https://datatracker.ietf.org/doc/html/rfc2228 .. _`RFC-2246`: https://datatracker.ietf.org/doc/html/rfc2246 .. _`RFC-2389`: https://datatracker.ietf.org/doc/html/rfc2389 .. _`RFC-2398`: https://datatracker.ietf.org/doc/html/rfc2389 .. _`RFC-2428`: https://datatracker.ietf.org/doc/html/rfc2428 .. _`RFC-2577`: https://datatracker.ietf.org/doc/html/rfc2577 .. _`RFC-2640`: https://datatracker.ietf.org/doc/html/rfc2640 .. _`RFC-2773`: https://datatracker.ietf.org/doc/html/rfc2773 .. _`RFC-3659`: https://datatracker.ietf.org/doc/html/rfc3659 .. _`RFC-4217`: https://datatracker.ietf.org/doc/html/rfc4217 .. _`RFC-765`: https://datatracker.ietf.org/doc/html/rfc765 .. _`RFC-8996`: https://datatracker.ietf.org/doc/html/rfc8996 .. _`RFC-959`: https://datatracker.ietf.org/doc/html/rfc959 pyftpdlib-release-2.0.1/docs/tutorial.rst000066400000000000000000000473711470572577600205120ustar00rootroot00000000000000======== Tutorial ======== .. contents:: Table of Contents Below is a set of example scripts showing some of the possible customizations that can be done with pyftpdlib. Some of them are included in `demo `__ directory. A base FTP server ================= This is probably the best starting point to understand how things work. We use the base `DummyAuthorizer`_ for adding a bunch of virtual users, we set a limit for `incoming connections`_ and a range of `passive ports`_. See `demo/basic_ftpd.py`_. .. code-block:: python import os from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer # Instantiate a dummy authorizer for managing 'virtual' users authorizer = DummyAuthorizer() # Define a new user having full r/w permissions and a read-only # anonymous user authorizer.add_user('user', '12345', '.', perm='elradfmwMT') authorizer.add_anonymous(os.getcwd()) # Instantiate FTP handler class handler = FTPHandler handler.authorizer = authorizer # Define a customized banner (string returned when client connects) handler.banner = "pyftpdlib based FTP server ready." # Specify a masquerade address and the range of ports to use for # passive connections. Decomment in case you're behind a NAT. #handler.masquerade_address = '151.25.42.11' #handler.passive_ports = range(60000, 65535) # Instantiate FTP server class and listen on all interfaces, port 2121 address = ('', 2121) server = FTPServer(address, handler) # set a limit for connections server.max_cons = 256 server.max_cons_per_ip = 5 # start ftp server server.serve_forever() Logging management ================== pyftpdlib uses the stdlib `logging`_ module to handle logs. If you don't configure logging pyftpdlib will do it for you. In order to configure logging you should do it *before* calling `FTPServer.serve_forever`_. Example which logs to a file: .. code-block:: python import logging from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import DummyAuthorizer authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.', perm='elradfmwMT') handler = FTPHandler handler.authorizer = authorizer logging.basicConfig(filename='/var/log/pyftpd.log', level=logging.INFO) server = FTPServer(('', 2121), handler) server.serve_forever() DEBUG logging ^^^^^^^^^^^^^ You may want to enable DEBUG logging to observe commands and responses exchanged by client and server. DEBUG logging will also log internal errors which may occur on socket related calls such as ``send()`` and ``recv()``. To enable DEBUG logging from code use: .. code-block:: python logging.basicConfig(level=logging.DEBUG) To enable DEBUG logging from command line use: .. code-block:: bash python3 -m pyftpdlib -D DEBUG logs look like this: :: [I 2017-11-07 12:03:44] >>> starting FTP server on 0.0.0.0:2121, pid=22991 <<< [I 2017-11-07 12:03:44] concurrency model: async [I 2017-11-07 12:03:44] masquerade (NAT) address: None [I 2017-11-07 12:03:44] passive ports: None [D 2017-11-07 12:03:44] poller: 'pyftpdlib.ioloop.Epoll' [D 2017-11-07 12:03:44] authorizer: 'pyftpdlib.authorizers.DummyAuthorizer' [D 2017-11-07 12:03:44] use sendfile(2): True [D 2017-11-07 12:03:44] handler: 'pyftpdlib.handlers.FTPHandler' [D 2017-11-07 12:03:44] max connections: 512 [D 2017-11-07 12:03:44] max connections per ip: unlimited [D 2017-11-07 12:03:44] timeout: 300 [D 2017-11-07 12:03:44] banner: 'pyftpdlib 1.5.4 ready.' [D 2017-11-07 12:03:44] max login attempts: 3 [I 2017-11-07 12:03:44] 127.0.0.1:37303-[] FTP session opened (connect) [D 2017-11-07 12:03:44] 127.0.0.1:37303-[] -> 220 pyftpdlib 1.0.0 ready. [D 2017-11-07 12:03:44] 127.0.0.1:37303-[] <- USER user [D 2017-11-07 12:03:44] 127.0.0.1:37303-[] -> 331 Username ok, send password. [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] <- PASS ****** [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] -> 230 Login successful. [I 2017-11-07 12:03:44] 127.0.0.1:37303-[user] USER 'user' logged in. [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] <- TYPE I [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] -> 200 Type set to: Binary. [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] <- PASV [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] -> 227 Entering passive mode (127,0,0,1,233,208). [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] <- RETR tmp-pyftpdlib [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] -> 125 Data connection already open. Transfer starting. [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] -> 226 Transfer complete. [I 2017-11-07 12:03:44] 127.0.0.1:37303-[user] RETR /home/giampaolo/IMG29312.JPG completed=1 bytes=1205012 seconds=0.003 [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] <- QUIT [D 2017-11-07 12:03:44] 127.0.0.1:37303-[user] -> 221 Goodbye. [I 2017-11-07 12:03:44] 127.0.0.1:37303-[user] FTP session closed (disconnect). Changing log line prefix ^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: python handler = FTPHandler handler.log_prefix = 'XXX [%(username)s]@%(remote_ip)s' server = FTPServer(('localhost', 2121), handler) server.serve_forever() Logs will now look like this: :: [I 13-02-01 19:12:26] XXX []@127.0.0.1 FTP session opened (connect) [I 13-02-01 19:12:26] XXX [user]@127.0.0.1 USER 'user' logged in. Storing passwords as hash digests ================================= By using the default `DummyAuthorizer`_ you typically store passwords in clear-text. A FTP server using the default dummy authorizer would typically require a configuration file for authenticating users and their passwords, but storing clear-text passwords is undesirable. You may want to store passwords as hash digests into a file or wherever you find it convenient. The example below shows how to store passwords as one-way hashes by using md5 algorithm. See `demo/md5_ftpd.py`_. .. code-block:: python import os import hashlib from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import DummyAuthorizer, AuthenticationFailed class DummyMD5Authorizer(DummyAuthorizer): def validate_authentication(self, username, password, handler): hash_ = hashlib.md5(password.encode('latin1')).hexdigest() try: if self.user_table[username]['pwd'] != hash_: raise KeyError except KeyError: raise AuthenticationFailed def main(): # get a hash digest from a clear-text password password = '12345' hash_ = hashlib.md5(password.encode('latin1')).hexdigest() authorizer = DummyMD5Authorizer() authorizer.add_user('user', hash_, os.getcwd(), perm='elradfmwMT') authorizer.add_anonymous(os.getcwd()) handler = FTPHandler handler.authorizer = authorizer server = FTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() Unix FTP server =============== If you're on UNIX you may want to configure your FTP server to include support for "real" users existing on the system, and navigate the real filesystem. The example below uses `UnixAuthorizer`_ and `UnixFilesystem`_ classes to do so. See `demo/unix_ftpd.py`_. .. code-block:: python from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import UnixAuthorizer from pyftpdlib.filesystems import UnixFilesystem def main(): authorizer = UnixAuthorizer(rejected_users=["root"], require_valid_shell=True) handler = FTPHandler handler.authorizer = authorizer handler.abstracted_fs = UnixFilesystem server = FTPServer(('', 21), handler) server.serve_forever() if __name__ == "__main__": main() Windows FTP server ================== Same as above, but for Windows. This code requires `pywin32`_ extension to be installed. See `demo/win_ftpd.py`_. .. code-block:: python from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import WindowsAuthorizer def main(): authorizer = WindowsAuthorizer() # Use Guest user with empty password to handle anonymous sessions. # Guest user must be enabled first, empty password set and profile # directory specified. #authorizer = WindowsAuthorizer(anonymous_user="Guest", anonymous_password="") handler = FTPHandler handler.authorizer = authorizer server = FTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() .. _changing-the-concurrency-model: Changing the concurrency model ============================== By nature pyftpdlib is asynchronous. That means that it uses a single process/thread to handle multiple client connections and file transfers. This is why it is so fast, lightweight and scalable (see `benchmarks`_). The async model has one big drawback though: the code cannot contain instructions that block for a long period of time, otherwise the whole FTP server will hang. As such, the user should avoid calls such as ``time.sleep(3)``, heavy DB queries, etc. at all costs. There are cases where the async model is not appropriate, e.g. if you're dealing with a particularly slow disk or a network filesystem. If the calls that interact with the filesystem are slow (e.g., ``open(file, 'r').read(8192)`` takes 2 seconds to complete) then you are stuck. In such cases you can change the concurrency model from async to multi processes or multi threads. In practice this means that every time a client connects, a separate thread or process is spawned, and internally it will run its own IO loop. Multiple threads ^^^^^^^^^^^^^^^^ .. code-block:: python from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import ThreadedFTPServer # <- from pyftpdlib.authorizers import DummyAuthorizer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.') handler = FTPHandler handler.authorizer = authorizer server = ThreadedFTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() Multiple processes ^^^^^^^^^^^^^^^^^^ .. code-block:: python from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import MultiprocessFTPServer # <- from pyftpdlib.authorizers import DummyAuthorizer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.') handler = FTPHandler handler.authorizer = authorizer server = MultiprocessFTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() It must be noted that the multi-thread approach should NOT be used with `UnixAuthorizer`_ or `WindowsAuthorizer`_ . Reason: every time the FTP server accesses the filesystem (e.g. for creating or renaming a file) the authorizer will temporarily impersonate the currently logged on user by changing effective user or group ID of the current process. .. _pre-fork-model: Pre fork model ^^^^^^^^^^^^^^ There is also a third option (UNIX only): the pre-fork model. Pre-fork means that a certain number of worker processes are ``spawn()``-ed before starting the server. Each worker process will keep using a 1-thread, async concurrency model, handling multiple concurrent connections, but the workload is split. This way the delay introduced by a blocking function call is amortized and divided by the number of workers, and thus also the disk I/O latency is minimized. Every time a new connection comes in, the parent process will automatically delegate the connection to one of the worker processes, so from the app standpoint this is completely transparent. As a general rule, it is always a good idea to use this model in production. The optimal value depends on many factors including (but not limited to) the number of CPU cores, the number of hard disk drives that store data, and load pattern. When one is in doubt, setting it to the number of available CPU cores would be a good start. .. code-block:: python import os from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import DummyAuthorizer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.') handler = FTPHandler handler.authorizer = authorizer server = FTPServer(('', 2121), handler) server.serve_forever(worker_processes=os.cpu_count()) # <- if __name__ == "__main__": main() .. _ftps-server: FTPS (FTP over TLS/SSL) server ============================== pyftpdlib implements FTP over TLS, also known as FTPS, as defined in `RFC-4217`_. This requires installing `PyOpenSSL`_ third party module. `TLS_FTPHandler`_ class requires a ``certfile`` and a ``keyfile``. You can generate self-signed SSL certificates like this (see also `Apache FAQs`_): .. code-block:: sh $ openssl req -x509 -newkey rsa:2048 -keyout ftpd.key -out ftpd.crt -nodes $ ls ftpd.crt ftpd.key If you don't care about having your personal self-signed certificates you can use the one in the demo directory which include both and is available `here `__ (not recommended). See `demo/tls_ftpd.py`_. .. code-block:: python """ An RFC-4217 asynchronous FTPS server supporting both SSL and TLS. Requires PyOpenSSL module (https://pypi.org/project/pyOpenSSL). """ from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import TLS_FTPHandler def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', '.', perm='elradfmwMT') authorizer.add_anonymous('.') handler = TLS_FTPHandler handler.certfile = '/path/to/ftpd.crt' # <-- handler.keyfile = '/path/to/ftpd.key' # <-- handler.authorizer = authorizer # optionally require SSL for both control and data channel #handler.tls_control_required = True #handler.tls_data_required = True server = FTPServer(('', 21), handler) server.serve_forever() if __name__ == '__main__': main() Event callbacks =============== Here's an example which shows how to use callback methods via `FTPHandler`_ subclassing: .. code-block:: python from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import DummyAuthorizer class MyHandler(FTPHandler): def on_connect(self): print("%s:%s connected" % (self.remote_ip, self.remote_port)) def on_disconnect(self): # do something when client disconnects pass def on_login(self, username): # do something when user login pass def on_logout(self, username): # do something when user logs out pass def on_file_sent(self, file): # do something when a file has been sent pass def on_file_received(self, file): # do something when a file has been received pass def on_incomplete_file_sent(self, file): # do something when a file is partially sent pass def on_incomplete_file_received(self, file): # remove partially uploaded files import os os.remove(file) def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', homedir='.', perm='elradfmwMT') authorizer.add_anonymous(homedir='.') handler = MyHandler handler.authorizer = authorizer server = FTPServer(('', 2121), handler) server.serve_forever() if __name__ == "__main__": main() Throttle bandwidth ================== If desired, you can limit the transfer speed for downloads and uploads by using the `ThrottledDTPHandler`_ class. The basic idea behind ``ThrottledDTPHandler`` is to wrap sending and receiving in a data counter, and temporary "sleep" the data channel so that you burst to no more than X Kb/sec on average. .. code-block:: python import os from pyftpdlib.handlers import FTPHandler, ThrottledDTPHandler from pyftpdlib.servers import FTPServer from pyftpdlib.authorizers import DummyAuthorizer def main(): authorizer = DummyAuthorizer() authorizer.add_user('user', '12345', os.getcwd(), perm='elradfmwMT') authorizer.add_anonymous(os.getcwd()) dtp_handler = ThrottledDTPHandler dtp_handler.read_limit = 30720 # 30 Kb/sec (30 * 1024) dtp_handler.write_limit = 30720 # 30 Kb/sec (30 * 1024) ftp_handler = FTPHandler ftp_handler.authorizer = authorizer # have the ftp handler use the alternative dtp handler class ftp_handler.dtp_handler = dtp_handler server = FTPServer(('', 2121), ftp_handler) server.serve_forever() if __name__ == '__main__': main() Command line usage ================== Pyftpdlib can also be run as a simple stand-alone server from command line. This is useful when you want to quickly share a directory. Here's some examples. Anonymous server, listening on port 2121, sharing the current directory: .. code-block:: sh $ python3 -m pyftpdlib [I 13-04-09 17:55:18] >>> starting FTP server on 0.0.0.0:2121, pid=6412 <<< [I 13-04-09 17:55:18] poller: [I 13-04-09 17:55:18] masquerade (NAT) address: None [I 13-04-09 17:55:18] passive ports: None [I 13-04-09 17:55:18] use sendfile(2): True Anonymous server with write permission: .. code-block:: sh $ python3 -m pyftpdlib -w Specify a user with write permissions: .. code-block:: sh $ python3 -m pyftpdlib -u bob -P mypassword Set a different address/port and home directory: .. code-block:: sh $ python3 -m pyftpdlib -i localhost -p 2121 -d /home/bob See ``python3 -m pyftpdlib -h`` for a complete list of options. .. _`Apache FAQs`: https://httpd.apache.org/docs/2.4/ssl/ssl_faq.html#selfcert .. _`benchmarks`: benchmarks.html .. _`demo/basic_ftpd.py`: https://github.com/giampaolo/pyftpdlib/blob/master/demo/basic_ftpd.py .. _`demo/md5_ftpd.py`: https://github.com/giampaolo/pyftpdlib/blob/master/demo/md5_ftpd.py .. _`demo/tls_ftpd.py`: https://github.com/giampaolo/pyftpdlib/blob/master/demo/tls_ftpd.py .. _`demo/unix_ftpd.py`: https://github.com/giampaolo/pyftpdlib/blob/master/demo/unix_ftpd.py .. _`demo/win_ftpd.py`: https://github.com/giampaolo/pyftpdlib/blob/master/demo/win_ftpd.py .. _`DummyAuthorizer`: api.html#pyftpdlib.authorizers.DummyAuthorizer .. _`FTPHandler`: api.html#pyftpdlib.handlers.FTPHandler .. _`FTPServer.serve_forever`: api.html#pyftpdlib.servers.FTPServer.serve_forever .. _`incoming connections`: api.html#pyftpdlib.servers.FTPServer.max_cons .. _`logging`: https://docs.python.org/3/library/logging.html .. _`passive ports`: api.html#pyftpdlib.handlers.FTPHandler.passive_ports .. _`PyOpenSSL`: https://pypi.org/project/pyOpenSSL .. _`pywin32`: https://pypi.org/project/pywin32/ .. _`RFC-4217`: https://www.ietf.org/rfc/rfc4217.txt .. _`ThrottledDTPHandler`: api.html#pyftpdlib.handlers.ThrottledDTPHandler .. _`TLS_FTPHandler`: api.html#pyftpdlib.handlers.TLS_FTPHandler .. _`UnixAuthorizer`: api.html#pyftpdlib.authorizers.UnixAuthorizer .. _`UnixFilesystem`: api.html#pyftpdlib.filesystems.UnixFilesystem .. _`WindowsAuthorizer`: api.html#pyftpdlib.authorizers.WindowsAuthorizer pyftpdlib-release-2.0.1/make.bat000066400000000000000000000006711470572577600165620ustar00rootroot00000000000000@echo off rem ========================================================================== rem Shortcuts for various tasks, emulating UNIX "make" on Windows. rem To use a specific Python version run: rem set PYTHON=C:\Python34\python.exe & make.bat test rem ========================================================================== if "%PYTHON%" == "" ( set PYTHON=python ) "%PYTHON%" scripts\internal\winmake.py %1 %2 %3 %4 %5 %6 pyftpdlib-release-2.0.1/pyftpdlib/000077500000000000000000000000001470572577600171465ustar00rootroot00000000000000pyftpdlib-release-2.0.1/pyftpdlib/__init__.py000066400000000000000000000054361470572577600212670ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ 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="elradfmwMT") >>> 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: [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). """ __ver__ = '2.0.1' __author__ = "Giampaolo Rodola' " __web__ = 'https://github.com/giampaolo/pyftpdlib/' pyftpdlib-release-2.0.1/pyftpdlib/__main__.py000066400000000000000000000107231470572577600212430ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ Start a stand alone anonymous FTP server from the command line as in: $ python3 -m pyftpdlib """ import argparse import logging import os import sys from . import __ver__ from .authorizers import DummyAuthorizer from .handlers import FTPHandler from .log import config_logging from .servers import FTPServer def main(args=None): """Start a stand alone anonymous FTP server.""" usage = "python3 -m pyftpdlib [options]" parser = argparse.ArgumentParser( usage=usage, description=main.__doc__, ) parser.add_argument( '-i', '--interface', default=None, metavar="ADDRESS", help="specify the interface to run on (default all interfaces)", ) parser.add_argument( '-p', '--port', type=int, default=2121, metavar="PORT", help="specify port number to run on (default 2121)", ) parser.add_argument( '-w', '--write', action="store_true", default=False, help="grants write access for logged in user (default read-only)", ) parser.add_argument( '-d', '--directory', default=os.getcwd(), metavar="FOLDER", help="specify the directory to share (default current directory)", ) parser.add_argument( '-n', '--nat-address', default=None, metavar="ADDRESS", help="the NAT address to use for passive connections", ) parser.add_argument( '-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_argument( '-D', '--debug', action='store_true', help="enable DEBUG logging level" ) parser.add_argument( '-v', '--version', action='store_true', help="print pyftpdlib version and exit", ) parser.add_argument( '-V', '--verbose', action='store_true', help="activate a more verbose logging", ) parser.add_argument( '-u', '--username', type=str, default=None, help=( "specify username to login with (anonymous login " "will be disabled and password required " "if supplied)" ), ) parser.add_argument( '-P', '--password', type=str, default=None, help=( "specify a password to login with (username required to be useful)" ), ) options = parser.parse_args(args=args) if options.version: sys.exit(f"pyftpdlib {__ver__}") if options.debug: config_logging(level=logging.DEBUG) 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 = "elradfmwMT" if options.write else "elr" if options.username: if not options.password: parser.error( "if username (-u) is supplied, password ('-P') is required" ) authorizer.add_user( options.username, options.password, options.directory, perm=perm ) else: 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) # On Windows specify a timeout for the underlying select() so # that the server can be interrupted with CTRL + C. try: ftpd.serve_forever(timeout=2 if os.name == 'nt' else None) finally: ftpd.close_all() if args: # only used in unit tests return ftpd if __name__ == '__main__': main() pyftpdlib-release-2.0.1/pyftpdlib/authorizers.py000066400000000000000000001035101470572577600220770ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """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 __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: """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 = "adfmwMT" 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) - "T" = update file last modified time (MFMT 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(f'user {username!r} already exists') if not os.path.isdir(homedir): raise ValueError(f'no such directory: {homedir!r}') 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(f'no such directory: {directory!r}') 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: "elradfmwMT". """ 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']: 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.""" try: return self.user_table[username]['msg_quit'] except KeyError: return "Goodbye." def _check_permissions(self, username, perm): warned = 0 for p in perm: if p not in self.read_perms + self.write_perms: raise ValueError(f'no such permission {p!r}') if ( username == 'anonymous' and p in self.write_perms and not warned ): warnings.warn( "write permissions assigned to anonymous user.", RuntimeWarning, stacklevel=2, ) 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: """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(f'unknown user {user}') if self.anonymous_user is not None: if not self.has_user(self.anonymous_user): raise AuthorizerError(f'no such user {self.anonymous_user}') home = self.get_home_dir(self.anonymous_user) if not os.path.isdir(home): raise AuthorizerError( f'no valid home set for user {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(f'{username} is not an allowed user') if self.rejected_users and username in self.rejected_users: raise AuthorizerError(f'{username} is not an allowed user') if username == "anonymous" and password: raise AuthorizerError("can't assign password to anonymous user") if not self.has_user(username): raise AuthorizerError(f'no such user {username}') 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 os.getcwd(), 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 return bool(self.rejected_users and username in self.rejected_users) # =================================================================== # --- UNIX # =================================================================== try: with warnings.catch_warnings(): warnings.simplefilter("ignore") import crypt import pwd import spwd except ImportError: pass else: __all__ += ['BaseUnixAuthorizer', 'UnixAuthorizer'] # the uid/gid the server runs under PROCESS_UID = os.getuid() PROCESS_GID = os.getgid() class BaseUnixAuthorizer: """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 # noqa except KeyError: raise AuthorizerError(f'no such user {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: return pwd.getpwnam(username).pw_dir except KeyError: raise AuthorizerError(self.msg_no_such_user) @staticmethod def _get_system_users(): """Return all users defined on the UNIX system.""" 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 "elradfmwMT" 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.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="elradfmwMT", 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 "elradfmwMT" 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( f"user {username} has not a valid shell" ) 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. """ try: file = open('/etc/shells') except FileNotFoundError: return True else: with file: 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 # =================================================================== # --- Windows # =================================================================== # Note: requires pywin32 extension try: import pywintypes import win32api import win32con import win32net import win32security except ImportError: pass else: # pragma: no cover import winreg __all__ += ['BaseWindowsAuthorizer', 'WindowsAuthorizer'] class BaseWindowsAuthorizer: """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 as err: raise AuthorizerError(err) path = r"SOFTWARE\Microsoft\Windows NT" path += r"\CurrentVersion\ProfileList" + "\\" + sid try: key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) except OSError: raise AuthorizerError( f"No profile directory defined for user {username}" ) value = winreg.QueryValueEx(key, "ProfileImagePath")[0] home = win32api.ExpandEnvironmentStrings(value) 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 "elradfmwMT" 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.authorizers import WindowsAuthorizer >>> # accept all except Administrator >>> auth = WindowsAuthorizer(rejected_users=["Administrator"]) >>> >>> # accept some users only >>> auth = WindowsAuthorizer(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="elradfmwMT", 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 "elradfmwMT" 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) return home pyftpdlib-release-2.0.1/pyftpdlib/filesystems.py000066400000000000000000000531351470572577600220760ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import os import stat import tempfile import time try: import grp import pwd except ImportError: pwd = grp = None __all__ = ['AbstractedFS', 'FilesystemError'] _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', } def _memoize(fun): """A simple memoize decorator for functions supporting (hashable) positional arguments. """ def wrapper(*args, **kwargs): key = (args, frozenset(sorted(kwargs.items()))) try: return cache[key] except KeyError: ret = cache[key] = fun(*args, **kwargs) return ret cache = {} return wrapper # =================================================================== # --- 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: """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. """ # 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 = '/' 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): self._root = path @cwd.setter def cwd(self, path): self._cwd = path # --- Pathname / conversion utilities @staticmethod def _isabs(path, _windows=os.name == "nt"): # Windows + Python 3.13: isabs() changed so that a path # starting with "/" is no longer considered absolute. # https://github.com/python/cpython/issues/44626 # https://github.com/python/cpython/pull/113829/ if _windows and path.startswith("/"): return True return os.path.isabs(path) 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. """ if self._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 self._isabs(p): p = "/" 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. """ # 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 "/". """ if self._isabs(fspath): p = os.path.normpath(fspath) else: p = os.path.normpath(os.path.join(self.root, fspath)) if not self.validpath(p): return '/' 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. """ root = self.realpath(self.root) path = self.realpath(path) if not root.endswith(os.sep): root += os.sep if not path.endswith(os.sep): path += os.sep return path[0 : len(root)] == root # --- Wrapper methods around open() and tempfile.mkstemp def open(self, filename, mode): """Open a file returning its handler.""" 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 = 'b' not 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. If this method is overridden it is vital that `cwd` attribute gets set. """ # note: process cwd will be reset by the caller os.chdir(path) self.cwd = self.fs2ftp(path) def mkdir(self, path): """Create the specified directory.""" os.mkdir(path) def listdir(self, path): """List the content of a directory.""" return os.listdir(path) def listdirinfo(self, path): """List the content of a directory.""" return os.listdir(path) def rmdir(self, path): """Remove the specified directory.""" os.rmdir(path) def remove(self, path): """Remove the specified file.""" os.remove(path) def rename(self, src, dst): """Rename the specified src file to the dst filename.""" os.rename(src, dst) def chmod(self, path, mode): """Change file/directory mode.""" if not hasattr(os, 'chmod'): raise NotImplementedError os.chmod(path, mode) def stat(self, path): """Perform a stat() system call on the given path.""" return os.stat(path) def utime(self, path, timeval): """Perform a utime() call on the given path.""" # utime expects a int/float (atime, mtime) in seconds # thus, setting both access and modify time to timeval return os.utime(path, (timeval, timeval)) if hasattr(os, 'lstat'): def lstat(self, path): """Like stat but does not follow symbolic links.""" return os.lstat(path) else: lstat = stat if hasattr(os, 'readlink'): def readlink(self, path): """Return a string representing the path to which a symbolic link points. """ return os.readlink(path) # --- Wrapper methods around os.path.* calls def isfile(self, path): """Return True if path is a file.""" return os.path.isfile(path) def islink(self, path): """Return True if path is a symbolic link.""" return os.path.islink(path) def isdir(self, path): """Return True if path is a directory.""" return os.path.isdir(path) def getsize(self, path): """Return the size of the specified file in bytes.""" return os.path.getsize(path) def getmtime(self, path): """Return the last modified time as a number of seconds since the epoch.""" 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). """ 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. """ return os.path.lexists(path) if pwd is not None: 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 else: def get_user_by_uid(self, uid): return "owner" if grp is not None: def get_group_by_gid(self, gid): """Return the group name 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 else: def get_group_by_gid(self, gid): return "group" # --- Listing utilities 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 """ @_memoize def get_user_by_uid(uid): return self.get_user_by_uid(uid) @_memoize def get_group_by_gid(gid): return self.get_group_by_gid(gid) 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: file = os.path.join(basedir, basename) try: st = self.lstat(file) except (OSError, FilesystemError): if ignore_err: continue raise perms = stat.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 = get_user_by_uid(st.st_uid) gname = 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: # https://github.com/giampaolo/pyftpdlib/issues/187 fmtstr = '%d %Y' if now - st.st_mtime > SIX_MONTHS else '%d %H:%M' try: mtimestr = "%s %s" % ( # noqa: UP031 _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" % ( # noqa: UP031 _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( self.cmd_channel.encoding, 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=8012; music.mp3 type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks type=file;size=211;perm=r;modify=20071103093626;unique=192; module.py """ 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 = {} 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'] = f"{st.st_dev:x}g{st.st_ino:x}" # facts can be in any order but we sort them by name factstring = "".join( [f"{x}={retfacts[x]};" for x in sorted(retfacts.keys())] ) line = f"{factstring} {basename}\r\n" yield line.encode( self.cmd_channel.encoding, self.cmd_channel.unicode_errors ) # =================================================================== # --- platform specific implementation # =================================================================== if os.name == 'posix': __all__ += ['UnixFilesystem'] class UnixFilesystem(AbstractedFS): """Represents the real UNIX filesystem. Differently from AbstractedFS the client will login into /home/ 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 pyftpdlib-release-2.0.1/pyftpdlib/handlers.py000066400000000000000000004305141470572577600213270ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import asynchat import contextlib import errno import glob import logging import os import random import socket import sys import time import traceback from datetime import datetime try: import grp import pwd except ImportError: pwd = grp = None try: from OpenSSL import SSL # requires "pip install pyopenssl" except ImportError: SSL = None from . import __ver__ from .authorizers import AuthenticationFailed from .authorizers import AuthorizerError from .authorizers import DummyAuthorizer from .filesystems import AbstractedFS from .filesystems import FilesystemError from .ioloop import _ERRNOS_DISCONNECTED from .ioloop import _ERRNOS_RETRY from .ioloop import Acceptor from .ioloop import AsyncChat from .ioloop import Connector from .ioloop import RetryError from .ioloop import timer from .log import debug from .log import logger CR_BYTE = ord('\r') 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 bytes (noop; allocate storage).', ), 'APPE': dict( perm='a', auth=True, arg=True, help='Syntax: APPE 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 [ dir-name] (change working directory).', ), 'DELE': dict( perm='d', auth=True, arg=True, help='Syntax: DELE file-name (delete file).', ), 'EPRT': dict( perm=None, auth=True, arg=True, help='Syntax: EPRT |proto|ip|port| (extended active mode).', ), 'EPSV': dict( perm=None, auth=True, arg=None, help='Syntax: EPSV [ 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 [ cmd] (show help).', ), 'LIST': dict( perm='l', auth=True, arg=None, help='Syntax: LIST [ path] (list files).', ), 'MDTM': dict( perm='l', auth=True, arg=True, help='Syntax: MDTM [ path] (file last modification time).', ), 'MFMT': dict( perm='T', auth=True, arg=True, help=( 'Syntax: MFMT timeval path (file update last ' 'modification time).' ), ), 'MLSD': dict( perm='l', auth=True, arg=None, help='Syntax: MLSD [ path] (list directory).', ), 'MLST': dict( perm='l', auth=True, arg=None, help='Syntax: MLST [ path] (show information about path).', ), 'MODE': dict( perm=None, auth=True, arg=True, help='Syntax: MODE mode (noop; set data transfer mode).', ), 'MKD': dict( perm='m', auth=True, arg=True, help='Syntax: MKD path (create directory).', ), 'NLST': dict( perm='l', auth=True, arg=None, help='Syntax: NLST [ 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 cmd [ option] (set option for command).', ), 'PASS': dict( perm=None, auth=False, arg=None, help='Syntax: PASS [ 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 h,h,h,h,p,p (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 offset (set file offset).', ), 'RETR': dict( perm='r', auth=True, arg=True, help='Syntax: RETR file-name (retrieve a file).', ), 'RMD': dict( perm='d', auth=True, arg=True, help='Syntax: RMD dir-name (remove directory).', ), 'RNFR': dict( perm='f', auth=True, arg=True, help='Syntax: RNFR file-name (rename (source name)).', ), 'RNTO': dict( perm='f', auth=True, arg=True, help='Syntax: RNTO file-name (rename (destination name)).', ), 'SITE': dict( perm=None, auth=False, arg=True, help='Syntax: SITE site-command (execute SITE command).', ), 'SITE HELP': dict( perm=None, auth=False, arg=None, help='Syntax: SITE HELP [ cmd] (show SITE command help).', ), 'SITE CHMOD': dict( perm='M', auth=True, arg=True, help='Syntax: SITE CHMOD mode path (change file mode).', ), 'SIZE': dict( perm='l', auth=True, arg=True, help='Syntax: SIZE file-name (get file size).', ), 'STAT': dict( perm='l', auth=False, arg=None, help='Syntax: STAT [ path name] (server stats [list files]).', ), 'STOR': dict( perm='w', auth=True, arg=True, help='Syntax: STOR file-name (store a file).', ), 'STOU': dict( perm='w', auth=True, arg=None, help='Syntax: STOU [ name] (store a file with a unique name).', ), 'STRU': dict( perm=None, auth=True, arg=True, help='Syntax: STRU 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 [A | I] (set transfer type).', ), 'USER': dict( perm=None, auth=False, arg=True, help='Syntax: USER 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 [ dir-name] (obsolete; change directory).', ), 'XMKD': dict( perm='m', auth=True, arg=True, help='Syntax: XMKD 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 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 _is_ssl_sock(sock): return SSL is not None and isinstance(sock, SSL.Connection) 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: # https://bugs.python.org/issue6926 try: if not socket.has_ipv6: return False with contextlib.closing(socket.socket(socket.AF_INET6)) as sock: return not sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) except (OSError, AttributeError): return False SUPPORTS_HYBRID_IPV6 = _support_hybrid_ipv6() class _FileReadWriteError(OSError): """Exception raised when reading or writing a file during a transfer.""" class _GiveUpOnSendfile(Exception): """Exception raised in case use of sendfile() fails on first try, in which case send() will be used. """ # --- 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: whether use extended passive mode response type. """ self.cmd_channel = cmd_channel self.log = cmd_channel.log self.log_exception = cmd_channel.log_exception 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.socket.family != socket.AF_INET: # dual stack IPv4/IPv6 support af = self.bind_af_unspecified((local_ip, 0)) self.socket.close() self.del_channel() else: af = self.cmd_channel.socket.family 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 PermissionError: self.cmd_channel.log( f"ignoring EPERM when bind()ing port {port}", logfun=logger.debug, ) except OSError as err: if err.errno == 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: # https://en.wikipedia.org/wiki/IPv6#IPv4-mapped_addresses # https://datatracker.ietf.org/doc/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: resp = '227 Entering passive mode (%s,%d,%d).' % ( ip.replace('.', ','), port // 256, port % 256, ) self.cmd_channel.respond(resp) else: self.cmd_channel.respond( f'229 Entering extended passive mode (|||{int(port)}|).' ) if self.timeout: self.call_later(self.timeout, self.handle_timeout) # --- 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 OSError: pass msg = ( '425 Rejected data connection from foreign address ' f'{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 ' f'{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=logger.info ) self.close() def handle_error(self): """Called to handle any uncaught exceptions.""" try: raise # noqa: PLE0704 except Exception: logger.error(traceback.format_exc()) try: self.close() except Exception: logger.critical(traceback.format_exc()) def close(self): debug("call: close()", inst=self) Acceptor.close(self) 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 attempting 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._idler = None if self.timeout: self._idler = self.ioloop.call_later( self.timeout, self.handle_timeout, _errback=self.handle_error ) if ip.count('.') == 3: self._cmd = "PORT" self._normalized_addr = f"{ip}:{port}" else: self._cmd = "EPRT" self._normalized_addr = f"[{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, OSError): self.handle_close() def readable(self): return False 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() # test_active_conn_error tests this condition err = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) if err != 0: raise OSError(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 # noqa: PLE0704 except (socket.gaierror, OSError): pass except Exception: self.log_exception(self) try: self.handle_close() except Exception: logger.critical(traceback.format_exc()) def close(self): debug("call: close()", inst=self) if not self._closed: 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._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 OSError as err: # if we get an exception here we want the dispatcher # instance to set socket attribute before closing, see: # https://github.com/giampaolo/pyftpdlib/issues/188 AsyncChat.__init__( self, socket.socket(), ioloop=cmd_channel.ioloop ) # https://github.com/giampaolo/pyftpdlib/issues/143 self.close() if err.errno == 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): return '<%s(%s)>' % ( # noqa: UP031 self.__class__.__name__, self.cmd_channel.get_repr_info(as_str=True), # noqa: UP031 ) __str__ = __repr__ def use_sendfile(self): if not self.cmd_channel.use_sendfile: # as per server config return False if self.file_obj is None or not hasattr(self.file_obj, "fileno"): # directory listing or unusual file obj return False try: # io.IOBase default implementation raises io.UnsupportedOperation # UnsupportedOperation inherits ValueError # also may raise ValueError if stream is closed # https://docs.python.org/3/library/io.html#io.IOBase self.file_obj.fileno() except (OSError, ValueError): return False if self.cmd_channel._current_type != 'i': # noqa: SIM103 # text file transfer (need to transform file content on the fly) return False return True def push(self, data): self._initialized = True self.modify_ioloop_events(self.ioloop.WRITE) self._wanted_io_events = self.ioloop.WRITE AsyncChat.push(self, data) def push_with_producer(self, producer): self._initialized = True self.modify_ioloop_events(self.ioloop.WRITE) self._wanted_io_events = self.ioloop.WRITE if self.use_sendfile(): self._offset = producer.file.tell() self._filefd = self.file_obj.fileno() try: self.initiate_sendfile() except _GiveUpOnSendfile: pass else: self.initiate_send = self.initiate_sendfile return debug("starting transfer using send()", self) 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 = os.sendfile( self._fileno, self._filefd, self._offset, self.ac_out_buffer_size, ) except OSError as err: if err.errno in _ERRNOS_RETRY or err.errno == errno.EBUSY: return elif err.errno in _ERRNOS_DISCONNECTED: self.handle_close() elif self.tot_bytes_sent == 0: logger.warning( "sendfile() failed; falling back on using plain send" ) raise _GiveUpOnSendfile 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', bytes(os.linesep, "ascii")) 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.modify_ioloop_events(self.ioloop.READ) self._wanted_io_events = 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): # pragma: no cover """Overridden as a fix around https://bugs.python.org/issue1740572 (when the producer is consumed, close() was called instead of handle_close()). """ while True: 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 += 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 RetryError: pass except OSError: 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 as err: 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 transfer 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 # noqa: PLE0704 # an error could occur in case we fail reading / writing # from / to file (e.g. file system gets full) except _FileReadWriteError as err: error = _strerror(err.errno) 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 = (f"426 {error}; transfer aborted.", 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 = ( ( f"426 Transfer aborted; {int(tot_bytes)} bytes" " transmitted." ), logger.debug, ) finally: self.close() def close(self): """Close the data channel, first attempting to close any remaining file handles.""" debug("call: close()", inst=self) if not self._closed: # RFC-959 says we must close the connection before replying AsyncChat.close(self) # Close file object before responding successfully to client if self.file_obj is not None and not self.file_obj.closed: self.file_obj.close() if self._resp: self.cmd_channel.respond(self._resp[0], logfun=self._resp[1]) 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) elif 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() class ThrottledDTPHandler(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().__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 __repr__(self): return DTPHandler.__repr__(self) def use_sendfile(self): return False def recv(self, buffer_size): chunk = super().recv(buffer_size) if self.read_limit: self._throttle_bandwidth(len(chunk), self.read_limit) return chunk def send(self, data): num_sent = super().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().close() # --- producers class FileProducer: """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 self._prev_chunk_endswith_cr = False if type == 'a' and os.linesep != '\r\n': self._data_wrapper = self._posix_ascii_data_wrapper else: self._data_wrapper = None def _posix_ascii_data_wrapper(self, chunk): """The data wrapper used for sending data in ASCII mode on systems using a single line terminator, handling those cases where CRLF ('\r\n') gets delivered in two chunks. """ chunk = bytearray(chunk) pos = 0 if self._prev_chunk_endswith_cr and chunk.startswith(b'\n'): pos += 1 while True: pos = chunk.find(b'\n', pos) if pos == -1: break if chunk[pos - 1] != CR_BYTE: chunk.insert(pos, CR_BYTE) pos += 1 pos += 1 self._prev_chunk_endswith_cr = chunk.endswith(b'\r') return chunk def more(self): """Attempt a chunk of data of size self.buffer_size.""" try: data = self.file.read(self.buffer_size) except OSError as err: raise _FileReadWriteError(err) else: if self._data_wrapper is not None: data = self._data_wrapper(data) return data class BufferedIteratorProducer: """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 _ in range(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). Linux only. - (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) encoding: the encoding used for client / server communication. Defaults to 'utf-8'. - (str) unicode_errors: the error handler passed to ''.encode() and ''.decode(): https://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 = f"pyftpdlib {__ver__} ready." 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 = hasattr(os, "sendfile") # added in python 3.3 tcp_no_delay = hasattr(socket, "TCP_NODELAY") encoding = "utf8" unicode_errors = 'replace' log_prefix = '%(remote_ip)s:%(remote_port)s-[%(username)s]' auth_failed_timeout = 3 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 = "" self.started = time.time() # private session attributes self._last_response = "" self._current_type = 'a' self._restart_position = 0 self._quit_pending = False 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._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 OSError as err: # if we get an exception here we want the dispatcher # instance to set socket attribute before closing, see: # https://github.com/giampaolo/pyftpdlib/issues/188 AsyncChat.__init__(self, socket.socket(), ioloop=ioloop) self.close() debug(f"call: FTPHandler.__init__, err {err!r}", self) if err.errno == errno.EINVAL: # https://github.com/giampaolo/pyftpdlib/issues/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 OSError as err: debug( f"call: FTPHandler.__init__, err on getpeername() {err!r}", self, ) # 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.errno in (errno.ENOTCONN, errno.EINVAL): self.close() else: self.handle_error() return else: self.log("FTP session opened (connect)") # try to handle urgent data inline try: self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_OOBINLINE, 1) except OSError as err: debug( f"call: FTPHandler.__init__, err on SO_OOBINLINE {err!r}", self ) # 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 OSError as err: debug( f"call: FTPHandler.__init__, err on TCP_NODELAY {err!r}", self, ) # 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 get_repr_info(self, as_str=False, extra_info=None): if extra_info is None: extra_info = {} info = {} info['id'] = id(self) info['addr'] = f"{self.remote_ip}:{self.remote_port}" if _is_ssl_sock(self.socket): info['ssl'] = True if self.username: info['user'] = self.username # If threads are involved sometimes "self" may be None (?!?). dc = getattr(self, 'data_channel', None) if dc is not None: if _is_ssl_sock(dc.socket): info['ssl-data'] = True if dc.file_obj: if self.data_channel.receive: info['sending-file'] = dc.file_obj if dc.use_sendfile(): info['use-sendfile(2)'] = True else: info['receiving-file'] = dc.file_obj info['bytes-trans'] = dc.get_transmitted_bytes() info.update(extra_info) if as_str: return ', '.join([f'{k}={v!r}' for (k, v) in info.items()]) return info def __repr__(self): return f'<{self.__class__.__name__}({self.get_repr_info(True)})>' __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(f"220 {self.banner!s}") else: self.push(f'220-{self.banner!s}\r\n') 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 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: # https://github.com/giampaolo/pyftpdlib/issues/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(self.encoding, 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" f" {sys.getfilesystemencoding()})" ) def pre_process_command(self, line, cmd, arg): kwargs = {} if cmd == "SITE" and arg: cmd = f"SITE {arg.split(' ')[0].upper()}" arg = line[len(cmd) + 1 :] if cmd != 'PASS': self.logline(f"<- {line}") else: self.logline(f"<- {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 cmd not in self.proto_cmds: if cmd[-4:] in ('ABOR', 'STAT', 'QUIT'): cmd = cmd[-4:] else: msg = f'Command "{cmd}" not understood.' self.respond('500 ' + msg) if cmd: self.log_cmd(cmd, arg, 500, msg) return if not arg and self.proto_cmds[cmd]['arg'] is True: # NOQA 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'] is False: # NOQA 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('') 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 '/') elif cmd in ('CDUP', 'XCUP'): arg = self.fs.ftp2fs('..') 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) elif cmd == 'MFMT': if ' ' not in arg: msg = "Syntax error: command needs two arguments." self.respond("501 " + msg) self.log_cmd(cmd, "", 501, msg) return else: timeval, arg = arg.split(' ', 1) arg = self.fs.ftp2fs(arg) kwargs = dict(timeval=timeval) 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 = f"{line!r} points to a path which is outside " msg += "the user's root directory" self.respond(f"550 {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.""" debug("call: close()", inst=self) if not self._closed: 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 successfully 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 successfully 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 Exception: # 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, data): asynchat.async_chat.push(self, data.encode(self.encoding)) 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(f'-> {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 Exception: # 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(f"{prefix} {msg}") def logline(self, msg, logfun=logger.debug): """Log a line including additional identifying session data. By default this is disabled unless logging level == DEBUG. """ if self._log_debug: prefix = self.log_prefix % self.__dict__ logfun(f"{prefix} {msg}") def logerror(self, msg): """Log an error including additional identifying session data.""" prefix = self.log_prefix % self.__dict__ logger.error(f"{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", "MFMT", ] 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 = f"{cmd.strip()} {arg.strip()} {respcode}" if str(respcode)[0] in ('4', '5'): line += f' {respstr!r}' 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 transfer. - (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' % ( # noqa 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: # https://en.wikipedia.org/wiki/IPv6#IPv4-mapped_addresses # https://datatracker.ietf.org/doc/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 = ( f"501 Rejected data connection to foreign address {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 = f'501 PORT against the privileged port "{port}" refused.' 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: # protoipport # ...where is an arbitrary delimiter character (usually "|") and # 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.socket.family == 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.socket.family == socket.AF_INET: self.respond('522 Network protocol not supported (use 1).') else: self._make_eport(ip, port) elif self.socket.family == 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 satisfying 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.socket.family != socket.AF_INET: self.respond('522 Network protocol not supported (use 2).') else: self._make_epasv(extmode=True) # IPv6 elif line == "2": if self.socket.family == 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.' ) elif self.socket.family == 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(f"221 {msg_quit}") else: self.push(f"221-{msg_quit}\r\n") 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: isdir = self.fs.isdir(path) if isdir: listing = self.run_as_current_user(self.fs.listdir, path) # RFC 959 recommends the listing to be sorted. listing.sort() iterator = self.fs.format_list(path, listing) else: basedir, filename = os.path.split(path) self.fs.lstat(path) # raise exc in case of problems iterator = self.fs.format_list(basedir, [filename]) except (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {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 = list(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) as err: self.respond(f'550 {_strerror(err)}.') else: data = '' if listing: # RFC 959 recommends the listing to be sorted. listing.sort() data = '\r\n'.join(listing) + '\r\n' data = data.encode(self.encoding, 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) as err: self.respond(f'550 {_strerror(err)}.') else: data = data.decode(self.encoding, self.unicode_errors) # since TVFS is supported (see RFC-3659 chapter 6), a fully # qualified pathname should be returned data = data.split(' ')[0] + f' {line}\r\n' # response is expected on the command channel self.push(f'250-Listing "{line}":\r\n') # 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) as err: why = _strerror(err) self.respond(f'550 {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 (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {why}.') return try: 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: fsize = self.fs.getsize(file) if rest_pos > fsize: raise ValueError fd.seek(rest_pos) ok = 1 except ValueError: why = f"REST position ({rest_pos}) > file size ({fsize})" except (OSError, FilesystemError) as err: why = _strerror(err) if not ok: fd.close() self.respond(f'554 {why}') return producer = FileProducer(fd, self._current_type) self.push_dtp_data(producer, isproducer=True, file=fd, cmd="RETR") return file except Exception: fd.close() raise 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) cmd = 'APPE' if 'a' in mode else '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 (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {why}.') return try: 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: fsize = self.fs.getsize(file) if rest_pos > fsize: raise ValueError fd.seek(rest_pos) ok = 1 except ValueError: why = f"REST position ({rest_pos}) > file size ({fsize})" except (OSError, FilesystemError) as err: why = _strerror(err) if not ok: fd.close() self.respond(f'554 {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 except Exception: fd.close() raise 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 += '.' 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 (OSError, FilesystemError) as err: # 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(f"450 {why}.") return try: 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(f"125 FILE: {filename}") self.data_channel.file_obj = fd self.data_channel.enable_receiving(self._current_type, "STOU") else: self.respond(f"150 FILE: {filename}") self._in_dtp_queue = (fd, "STOU") return filename except Exception: fd.close() raise 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(f"350 Restarting at position {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=logger.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(f'331 {msg}, send password.', logfun=logger.info) self.username = line def handle_auth_failed(self, msg, password): def callback(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(f"USER '{username}' failed login.") self.on_login_failed(username, password) self.del_channel() 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.ioloop.call_later( self.auth_failed_timeout, callback, self.username, password, msg, _errback=self.handle_error, ) self.username = "" def handle_auth_success(self, home, password, msg_login): if len(msg_login) <= 75: self.respond(f'230 {msg_login}') else: self.push(f"230-{msg_login}\r\n") self.respond("230 ") self.log(f"USER '{self.username}' logged in.") self.authenticated = True self.password = password self.attempted_logins = 0 self.fs = self.abstracted_fs(home, self) self.on_login(self.username) 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) as err: self.handle_auth_failed(str(err), line) else: self.handle_auth_success(home, line, msg_login) 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 self.respond( '257 "%s" is the current directory.' # noqa: UP031 % cwd.replace('"', '""') # noqa ) 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 = os.getcwd() try: self.run_as_current_user(self.fs.chdir, path) except (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {why}.') else: cwd = self.fs.cwd self.respond(f'250 "{cwd}" is the current directory.') if os.getcwd() != 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(f"550 {why}.") return if not self.fs.isfile(self.fs.realpath(path)): why = f"{line} is not retrievable" self.respond(f"550 {why}.") return try: size = self.run_as_current_user(self.fs.getsize, path) except (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {why}.') else: self.respond(f"213 {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(f"550 {line} is not retrievable") return timefunc = time.gmtime if self.use_gmt_times else 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) as err: 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(f'550 {why}.') else: self.respond(f"213 {lmt}") return path def ftp_MFMT(self, path, timeval): """Sets the last modification time of file to timeval 3307 style timestamp (YYYYMMDDHHMMSS) as defined in RFC-3659. On success return the modified time and file path, else None. """ # Note: the MFMT command is not a formal RFC command # but stated in the following MEMO: # https://tools.ietf.org/html/draft-somers-ftp-mfxx-04 # this is implemented to assist with file synchronization line = self.fs.fs2ftp(path) if len(timeval) != len("YYYYMMDDHHMMSS"): why = "Invalid time format; expected: YYYYMMDDHHMMSS" self.respond(f'550 {why}.') return if not self.fs.isfile(self.fs.realpath(path)): self.respond(f"550 {line} is not retrievable") return timefunc = time.gmtime if self.use_gmt_times else time.localtime try: # convert timeval string to epoch seconds epoch = datetime.utcfromtimestamp(0) timeval_datetime_obj = datetime.strptime(timeval, '%Y%m%d%H%M%S') timeval_secs = (timeval_datetime_obj - epoch).total_seconds() except ValueError: why = "Invalid time format; expected: YYYYMMDDHHMMSS" self.respond(f'550 {why}.') return try: # Modify Time self.run_as_current_user(self.fs.utime, path, timeval_secs) # Fetch Time 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) as err: 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(f'550 {why}.') else: self.respond(f"213 Modify={lmt}; {line}.") return (lmt, 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) as err: why = _strerror(err) self.respond(f'550 {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('"', '""') # noqa ) 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(f"550 {msg}") return try: self.run_as_current_user(self.fs.rmdir, path) except (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {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) as err: why = _strerror(err) self.respond(f'550 {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) as err: why = _strerror(err) self.respond(f'550 {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(f'504 Unsupported type "{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] # noqa ) if self.authenticated: s.append(f'Logged in as: {self.username}') elif not self.username: s.append("Waiting for username.") else: s.append("Waiting for password.") type = 'ASCII' if self._current_type == 'a' else 'Binary' s.append(f"TYPE: {type}; STRUcture: File; MODE: Stream") 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.extend(( 'Data connection open:', f'Total bytes sent: {bytes_sent}', f'Total bytes received: {bytes_recv}', f'Transfer elapsed time: {elapsed_time} secs', )) else: s.append('Data connection closed.') self.push('211-FTP server status:\r\n') self.push(''.join([f' {item}\r\n' for item in s])) self.respond('211 End of status.') # return directory LISTing over the command channel else: line = self.fs.fs2ftp(path) try: isdir = self.fs.isdir(path) if isdir: listing = self.run_as_current_user(self.fs.listdir, path) if isinstance(listing, list): # RFC 959 recommends the listing to be sorted. listing.sort() iterator = self.fs.format_list(path, listing) else: basedir, filename = os.path.split(path) self.fs.lstat(path) # raise exc in case of problems iterator = self.fs.format_list(basedir, [filename]) except (OSError, FilesystemError) as err: why = _strerror(err) self.respond(f'550 {why}.') else: self.push(f'213-Status of "{line}":\r\n') 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 = {'TVFS'} if self.encoding.lower() in ("utf8", "utf-8"): features.add('UTF8') features.update([ feat for feat in ('EPRT', 'EPSV', 'MDTM', 'MFMT', '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([f" {x}\r\n" 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(f'Unsupported command "{cmd}"') except ValueError as err: self.respond(f'501 {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 did nothing'.") 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(f"214 {self.proto_cmds[line]['help']}") else: self.respond("501 Unrecognized command.") else: # provide a compact list of recognized commands def formatted_help(): cmds = [] keys = sorted( [x for x in self.proto_cmds if not x.startswith('SITE ')] ) 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) as err: why = _strerror(err) self.respond(f'550 {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(f"214 {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(f' {cmd[5:]}\r\n') 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 # =================================================================== if SSL is not None: class SSLConnection: """An AsyncChat subclass supporting TLS/SSL.""" _ssl_accepting = False _ssl_established = False _ssl_closing = False _ssl_requested = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._error = False self._ssl_want_read = False self._ssl_want_write = False def readable(self): return ( self._ssl_accepting or self._ssl_want_read or super().readable() ) def writable(self): return self._ssl_want_write or super().writable() def secure_connection(self, ssl_context): """Secure the connection switching from plain-text to SSL/TLS. """ debug("securing SSL connection", self) self._ssl_requested = True try: self.socket = SSL.Connection(ssl_context, self.socket) except OSError as err: # may happen in case the client connects/disconnects # very quickly debug( "call: secure_connection(); can't secure SSL connection " f"{err!r}; closing", self, ) self.close() except ValueError: # may happen in case the client connects/disconnects # very quickly if self.socket.fileno() == -1: debug( "ValueError and fd == -1 on secure_connection()", self ) return raise else: self.socket.set_accept_state() self._ssl_accepting = True @contextlib.contextmanager def _handle_ssl_want_rw(self): prev_row_pending = self._ssl_want_read or self._ssl_want_write try: yield except SSL.WantReadError: # we should never get here; it's just for extra safety self._ssl_want_read = True except SSL.WantWriteError: # we should never get here; it's just for extra safety self._ssl_want_write = True if self._ssl_want_read: self.modify_ioloop_events( self._wanted_io_events | self.ioloop.READ, logdebug=True ) elif self._ssl_want_write: self.modify_ioloop_events( self._wanted_io_events | self.ioloop.WRITE, logdebug=True ) elif prev_row_pending: self.modify_ioloop_events(self._wanted_io_events) def _do_ssl_handshake(self): self._ssl_accepting = True self._ssl_want_read = False self._ssl_want_write = False try: self.socket.do_handshake() except SSL.WantReadError: self._ssl_want_read = True debug("call: _do_ssl_handshake, err: ssl-want-read", inst=self) except SSL.WantWriteError: self._ssl_want_write = True debug( "call: _do_ssl_handshake, err: ssl-want-write", inst=self ) except SSL.SysCallError as err: debug(f"call: _do_ssl_handshake, err: {err!r}", inst=self) retval, desc = err.args if (retval == -1 and desc == 'Unexpected EOF') or retval > 0: # Happens when the other side closes the socket before # completing the SSL handshake, e.g.: # client.sock.sendall(b"PORT ...\r\n") # client.getresp() # sock, _ = sock.accept() # sock.close() self.log("Unexpected SSL EOF.") self.close() else: raise except SSL.Error as err: debug(f"call: _do_ssl_handshake, err: {err!r}", inst=self) self.handle_failed_ssl_handshake() else: debug("SSL connection established", self) self._ssl_accepting = False self._ssl_established = True self.handle_ssl_established() def handle_ssl_established(self): """Called when SSL handshake has completed.""" def handle_ssl_shutdown(self): """Called when SSL shutdown() has completed.""" super().close() def handle_failed_ssl_handshake(self): raise NotImplementedError("must be implemented in subclass") def handle_read_event(self): if not self._ssl_requested: super().handle_read_event() else: with self._handle_ssl_want_rw(): self._ssl_want_read = False if self._ssl_accepting: self._do_ssl_handshake() elif self._ssl_closing: self._do_ssl_shutdown() else: super().handle_read_event() def handle_write_event(self): if not self._ssl_requested: super().handle_write_event() else: with self._handle_ssl_want_rw(): self._ssl_want_write = False if self._ssl_accepting: self._do_ssl_handshake() elif self._ssl_closing: self._do_ssl_shutdown() else: super().handle_write_event() def handle_error(self): self._error = True try: raise # noqa: PLE0704 except Exception: 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().close() except Exception: logger.critical(traceback.format_exc()) def send(self, data): if not isinstance(data, bytes): data = bytes(data) try: return super().send(data) except SSL.WantReadError: debug("call: send(), err: ssl-want-read", inst=self) self._ssl_want_read = True return 0 except SSL.WantWriteError: debug("call: send(), err: ssl-want-write", inst=self) self._ssl_want_write = True return 0 except SSL.ZeroReturnError: debug( "call: send() -> shutdown(), err: zero-return", inst=self ) super().handle_close() return 0 except SSL.SysCallError as err: debug(f"call: send(), err: {err!r}", inst=self) errnum, errstr = err.args if errnum == errno.EWOULDBLOCK: return 0 elif ( errnum in _ERRNOS_DISCONNECTED or errstr == 'Unexpected EOF' ): super().handle_close() return 0 else: raise def recv(self, buffer_size): try: return super().recv(buffer_size) except SSL.WantReadError: debug("call: recv(), err: ssl-want-read", inst=self) self._ssl_want_read = True raise RetryError except SSL.WantWriteError: debug("call: recv(), err: ssl-want-write", inst=self) self._ssl_want_write = True raise RetryError except SSL.ZeroReturnError: debug( "call: recv() -> shutdown(), err: zero-return", inst=self ) super().handle_close() return b'' except SSL.SysCallError as err: debug(f"call: recv(), err: {err!r}", inst=self) errnum, errstr = err.args if ( errnum in _ERRNOS_DISCONNECTED or errstr == 'Unexpected EOF' ): super().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 if os.name == 'posix': # 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 as err: debug( f"call: _do_ssl_shutdown() -> os.write, err: {err!r}", inst=self, ) if err.errno in { errno.EINTR, errno.EWOULDBLOCK, errno.ENOBUFS, }: return elif err.errno in _ERRNOS_DISCONNECTED: return super().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: self._ssl_want_read = True debug("call: _do_ssl_shutdown, err: ssl-want-read", inst=self) except SSL.WantWriteError: self._ssl_want_write = True debug("call: _do_ssl_shutdown, err: ssl-want-write", inst=self) except SSL.ZeroReturnError: debug( "call: _do_ssl_shutdown() -> shutdown(), err: zero-return", inst=self, ) super().close() except SSL.SysCallError as err: debug( f"call: _do_ssl_shutdown() -> shutdown(), err: {err!r}", inst=self, ) errnum, errstr = err.args if ( errnum in _ERRNOS_DISCONNECTED or errstr == 'Unexpected EOF' ): super().close() else: raise except SSL.Error as err: debug( f"call: _do_ssl_shutdown() -> shutdown(), err: {err!r}", inst=self, ) # see: # https://github.com/giampaolo/pyftpdlib/issues/171 # https://bugs.launchpad.net/pyopenssl/+bug/785985 if err.args and not getattr(err, "errno", None): pass else: raise except OSError as err: debug( f"call: _do_ssl_shutdown() -> shutdown(), err: {err!r}", inst=self, ) if err.errno in _ERRNOS_DISCONNECTED: super().close() else: raise else: if done: debug( "call: _do_ssl_shutdown(), shutdown completed", inst=self, ) self._ssl_established = False self._ssl_closing = False self.handle_ssl_shutdown() else: debug( "call: _do_ssl_shutdown(), shutdown not completed yet", inst=self, ) 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().close() class TLS_DTPHandler(SSLConnection, DTPHandler): """A DTPHandler subclass supporting TLS/SSL.""" def __init__(self, sock, cmd_channel): super().__init__(sock, cmd_channel) if self.cmd_channel._prot: self.secure_connection(self.cmd_channel.ssl_context) def __repr__(self): return DTPHandler.__repr__(self) def use_sendfile(self): if isinstance(self.socket, SSL.Connection): return False else: return super().use_sendfile() 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) ssl_protocol: the desired SSL protocol version to use. This defaults to TLS_SERVER_METHOD, which includes TLSv1, TLSv1.1, TLSv1.2 and TLSv1.3. The actual protocol version used will be negotiated to the highest version mutually supported by the client and the server. - (int) ssl_options: specific OpenSSL options. These default to: SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3 | SSL.OP_NO_COMPRESSION ...which are all considered insecure features. Can be set to None in order to improve compatibility with older (insecure) FTP clients. - (instance) ssl_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 # Includes: SSLv3, TLSv1, TLSv1.1, TLSv1.2, TLSv1.3 ssl_protocol = SSL.TLS_SERVER_METHOD # - SSLv2 is easily broken and is considered harmful and dangerous # - SSLv3 has several problems and is now dangerous # - Disable compression to prevent CRIME attacks for OpenSSL 1.0+ # (see https://github.com/shazow/urllib3/pull/309) ssl_options = SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3 if hasattr(SSL, "OP_NO_COMPRESSION"): ssl_options |= SSL.OP_NO_COMPRESSION ssl_context = None # overridden attributes dtp_handler = TLS_DTPHandler proto_cmds = FTPHandler.proto_cmds.copy() proto_cmds.update({ 'AUTH': dict( perm=None, auth=False, arg=True, help=( 'Syntax: AUTH TLS|SSL (set up secure control ' 'channel).' ), ), 'PBSZ': dict( perm=None, auth=False, arg=True, help='Syntax: PBSZ 0 (negotiate TLS buffer).', ), 'PROT': dict( perm=None, auth=False, arg=True, help=( 'Syntax: PROT [C|P] (set up un/secure data channel).' ), ), }) def __init__(self, conn, server, ioloop=None): super().__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() def __repr__(self): return FTPHandler.__repr__(self) @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) cls.ssl_context.use_certificate_chain_file(cls.certfile) if not cls.keyfile: cls.keyfile = cls.certfile cls.ssl_context.use_privatekey_file(cls.keyfile) if cls.ssl_options: cls.ssl_context.set_options(cls.ssl_options) 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) def close(self): SSLConnection.close(self) FTPHandler.close(self) # --- 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(f'234 AUTH {arg} successful.') 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(f'521 PROT {arg} unsupported (use C or P).') else: self.respond("502 Unrecognized PROT type (use C or P).") pyftpdlib-release-2.0.1/pyftpdlib/ioloop.py000066400000000000000000001100601470572577600210170ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ 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', 2121) IOLoop.instance().loop() """ import errno import heapq import os import select import socket import sys import threading import time import traceback import warnings from .log import config_logging from .log import debug from .log import is_logging_configured from .log import logger with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) import asynchat import asyncore timer = getattr(time, 'monotonic', time.time) _read = asyncore.read _write = asyncore.write # These errnos indicate that a connection has been abruptly terminated. _ERRNOS_DISCONNECTED = { errno.ECONNRESET, errno.ENOTCONN, errno.ESHUTDOWN, errno.ECONNABORTED, errno.EPIPE, errno.EBADF, errno.ETIMEDOUT, } if hasattr(errno, "WSAECONNRESET"): _ERRNOS_DISCONNECTED.add(errno.WSAECONNRESET) if hasattr(errno, "WSAECONNABORTED"): _ERRNOS_DISCONNECTED.add(errno.WSAECONNABORTED) # These errnos indicate that a non-blocking operation must be retried # at a later time. _ERRNOS_RETRY = {errno.EAGAIN, errno.EWOULDBLOCK} if hasattr(errno, "WSAEWOULDBLOCK"): _ERRNOS_RETRY.add(errno.WSAEWOULDBLOCK) class RetryError(Exception): pass # =================================================================== # --- scheduler # =================================================================== class _Scheduler: """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 ): debug(f"re-heapifying {self._cancellations} cancelled tasks") 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) def close(self): for x in self._tasks: try: if not x.cancelled: x.cancel() except Exception: logger.error(traceback.format_exc()) del self._tasks[:] self._cancellations = 0 class _CallLater: """Container object which instance is returned by ioloop.call_later().""" __slots__ = ( '_args', '_delay', '_errback', '_kwargs', '_repush', '_sched', '_target', 'cancelled', 'timeout', ) def __init__(self, seconds, target, *args, **kwargs): assert callable(target), f"{target} is not callable" assert ( sys.maxsize >= seconds >= 0 ), f"{seconds} is not greater than or equal to 0 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' % ( # noqa: UP031 self._args or '[]', self._kwargs or '{}', self.cancelled, self._delay, ) return f'<{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: self._target(*self._args, **self._kwargs) except Exception as _: exc = _ 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.""" if not self.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: """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() def __enter__(self): return self def __exit__(self, *args): self.close() def __repr__(self): status = [self.__class__.__module__ + "." + self.__class__.__name__] status.append( f"(fds={len(self.socket_map)}, tasks={len(self.sched._tasks)})" ) return '<%s at %#x>' % (' '.join(status), id(self)) # noqa: UP031 __str__ = __repr__ @classmethod def instance(cls): """Return a global IOLoop instance.""" if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance = cls() return cls._instance @classmethod def factory(cls): """Constructs a new IOLoop instance.""" return cls() 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 is_logging_configured(): # 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 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.""" debug("closing IOLoop", self) 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 as err: if err.errno != errno.EBADF: logger.error(traceback.format_exc()) except Exception: logger.error(traceback.format_exc()) self.socket_map.clear() # free scheduled functions self.sched.close() # =================================================================== # --- 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: debug("call: unregister(); fd was no longer in socket_map", self) for ls in (self._r, self._w): try: ls.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) else: debug("call: modify(); fd was no longer in socket_map", self) def poll(self, timeout): try: r, w, _ = select.select(self._r, self._w, [], timeout) except InterruptedError: return 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() (UNIX), epoll() (Linux) and /dev/poll (Solaris) implementations which share almost 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): try: self._poller.register(fd, events) except FileExistsError: debug("call: register(); poller raised EEXIST; ignored", self) self.socket_map[fd] = instance def unregister(self, fd): try: del self.socket_map[fd] except KeyError: debug("call: unregister(); fd was no longer in socket_map", self) else: try: self._poller.unregister(fd) except OSError as err: if err.errno in (errno.ENOENT, errno.EBADF): debug( f"call: unregister(); poller returned {err!r};" " ignoring it", self, ) else: raise def modify(self, fd, events): try: self._poller.modify(fd, events) except FileNotFoundError: if fd in self.socket_map: # XXX - see: # https://github.com/giampaolo/pyftpdlib/issues/329 instance = self.socket_map[fd] self.register(fd, instance, events) else: raise def poll(self, timeout): if timeout is None: timeout = -1 # -1 waits indefinitely try: events = self._poller.poll(timeout) except InterruptedError: return # 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 and inst.readable(): _read(inst) if event & self.WRITE and 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 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) # =================================================================== # --- /dev/poll - Solaris (introduced in python 3.3) # =================================================================== if hasattr(select, 'devpoll'): # pragma: no cover class DevPoll(_BasePollEpoll): """/dev/poll based poller (introduced in python 3.3).""" READ = select.POLLIN WRITE = select.POLLOUT _ERROR = select.POLLERR | select.POLLHUP | select.POLLNVAL _poller = select.devpoll # introduced in python 3.4 if hasattr(select.devpoll, 'fileno'): def fileno(self): """Return devpoll() fd.""" return self._poller.fileno() def modify(self, fd, events): inst = self.socket_map[fd] self.unregister(fd) self.register(fd, inst, events) def poll(self, timeout): # /dev/poll timeout is expressed in milliseconds if timeout is not None: timeout = int(timeout * 1000) _BasePollEpoll.poll(self, timeout) # introduced in python 3.4 if hasattr(select.devpoll, 'close'): def close(self): _IOLoop.close(self) self._poller.close() # =================================================================== # --- 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'): # pragma: no cover 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._kqueue.fileno() def close(self): _IOLoop.close(self) self._kqueue.close() def register(self, fd, instance, events): self.socket_map[fd] = instance try: self._control(fd, events, select.KQ_EV_ADD) except FileExistsError: debug("call: register(); poller raised EEXIST; ignored", self) 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 as err: if err.errno in (errno.ENOENT, errno.EBADF): debug( f"call: unregister(); poller returned {err!r};" " ignoring it", self, ) else: 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 InterruptedError: return for kevent in kevents: inst = self.socket_map.get(kevent.ident) if inst is None: continue if kevent.filter == _READ and 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() elif inst.writable(): _write(inst) if kevent.flags & _ERROR: inst.handle_close() # =================================================================== # --- choose the better poller for this platform # =================================================================== if hasattr(select, 'epoll'): # epoll() - Linux IOLoop = Epoll elif hasattr(select, 'kqueue'): # kqueue() - BSD / OSX IOLoop = Kqueue elif hasattr(select, 'devpoll'): # /dev/poll - Solaris IOLoop = DevPoll 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 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=None, ioloop=None): self.ioloop = ioloop or IOLoop.instance() self._wanted_io_events = self.ioloop.READ self._current_io_events = self.ioloop.READ self._closed = False self._closing = False self._fileno = sock.fileno() if sock else None self._tasks = [] asynchat.async_chat.__init__(self, sock) # --- IO loop related methods def add_channel(self, map=None, events=None): assert self._fileno, repr(self._fileno) events = events if events is not None else self.ioloop.READ self.ioloop.register(self._fileno, self, events) self._wanted_io_events = events self._current_io_events = events def del_channel(self, map=None): if self._fileno is not None: self.ioloop.unregister(self._fileno) def modify_ioloop_events(self, events, logdebug=False): if not self._closed: assert self._fileno, repr(self._fileno) if self._fileno not in self.ioloop.socket_map: debug( "call: modify_ioloop_events(), fd was no longer in " "socket_map, had to register() it again", inst=self, ) self.add_channel(events=events) elif events != self._current_io_events: if logdebug: if events == self.ioloop.READ: ev = "R" elif events == self.ioloop.WRITE: ev = "W" elif events == self.ioloop.READ | self.ioloop.WRITE: ev = "RW" else: ev = events debug( f"call: IOLoop.modify(); setting {ev!r} IO events", self, ) self.ioloop.modify(self._fileno, events) self._current_io_events = events else: debug( "call: modify_ioloop_events(), handler had already been " "close()d, skipping modify()", inst=self, ) # --- utils def call_later(self, seconds, target, *args, **kwargs): """Same as self.ioloop.call_later but also cancel()s the scheduled function on close(). """ if '_errback' not in kwargs and hasattr(self, 'handle_error'): kwargs['_errback'] = self.handle_error callback = self.ioloop.call_later(seconds, target, *args, **kwargs) self._tasks.append(callback) return callback # --- overridden asynchat methods def connect(self, addr): self.modify_ioloop_events(self.ioloop.WRITE) asynchat.async_chat.connect(self, addr) 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: # https://en.wikipedia.org/wiki/IPv6\ # IPv4-mapped_addresses # https://datatracker.ietf.org/doc/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 OSError as _: err = _ 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 OSError(err) return af # send() and recv() overridden as a fix around various bugs: # - https://bugs.python.org/issue1736101 # - https://github.com/giampaolo/pyftpdlib/issues/104 # - https://github.com/giampaolo/pyftpdlib/issues/109 def send(self, data): try: return self.socket.send(data) except OSError as err: debug(f"call: send(), err: {err}", inst=self) if err.errno in _ERRNOS_RETRY: return 0 elif err.errno in _ERRNOS_DISCONNECTED: self.handle_close() return 0 else: raise def recv(self, buffer_size): try: data = self.socket.recv(buffer_size) except OSError as err: debug(f"call: recv(), err: {err}", inst=self) if err.errno in _ERRNOS_DISCONNECTED: self.handle_close() return b'' elif err.errno in _ERRNOS_RETRY: raise RetryError else: raise else: 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 def handle_read(self): try: asynchat.async_chat.handle_read(self) except RetryError: # This can be raised by (the overridden) recv(). pass 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 interested in reading if not self.producer_fifo: wanted = self.ioloop.READ else: # In FTPHandler, we also want to listen for user input # hence the READ. DTPHandler has its own initiate_send() # which will either READ or WRITE. wanted = self.ioloop.READ | self.ioloop.WRITE if self._wanted_io_events != wanted: self.ioloop.modify(self._fileno, wanted) self._wanted_io_events = wanted else: debug( "call: initiate_send(); called with no connection", inst=self ) 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) def close(self): if not self._closed: self._closed = True try: asynchat.async_chat.close(self) finally: for fun in self._tasks: try: fun.cancel() except Exception: logger.error(traceback.format_exc()) self._tasks = [] self._closed = True self._closing = False self.connected = False class Connector(AsyncChat): """Same as base AsyncChat and supposed to be used for clients. """ def add_channel(self, map=None, events=None): AsyncChat.add_channel(self, map=map, events=self.ioloop.WRITE) class Acceptor(AsyncChat): """Same as base AsyncChat and supposed to be used to accept new connections. """ def add_channel(self, map=None, events=None): AsyncChat.add_channel(self, map=map, events=self.ioloop.READ) 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 not 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 OSError as _: err = _ 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 OSError(err) return af def listen(self, num): AsyncChat.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: # https://github.com/giampaolo/pyftpdlib/issues/91 debug("call: handle_accept(); accept() returned None", self) return except OSError as err: # ECONNABORTED might be thrown on *BSD, see: # https://github.com/giampaolo/pyftpdlib/issues/105 if err.errno != errno.ECONNABORTED: raise else: debug( "call: handle_accept(); accept() returned ECONNABORTED", self, ) 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 pyftpdlib-release-2.0.1/pyftpdlib/log.py000066400000000000000000000143311470572577600203030ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ Logging support for pyftpdlib, inspired from Tornado's (https://www.tornadoweb.org/). This is not supposed to be imported/used directly. Instead you should use logging.basicConfig before serve_forever(). """ import logging import re import sys import time try: import curses except ImportError: curses = None # 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: # noqa pass return color # configurable options LEVEL = logging.INFO PREFIX = '[%(levelname)1.1s %(asctime)s]' PREFIX_MPROC = '[%(levelname)1.1s %(asctime)s %(process)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. """ PREFIX = PREFIX 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 # str() below are harmless in python2 but will do the # right conversion in python 3. fg_color = ( curses.tigetstr("setaf") or curses.tigetstr("setf") or "" ) self._colors = { # blues logging.DEBUG: str(curses.tparm(fg_color, 4), "ascii"), # green logging.INFO: str(curses.tparm(fg_color, 2), "ascii"), # yellow logging.WARNING: str(curses.tparm(fg_color, 3), "ascii"), # red logging.ERROR: str(curses.tparm(fg_color, 1), "ascii"), } self._normal = str(curses.tigetstr("sgr0"), "ascii") def format(self, record): try: record.message = record.getMessage() except Exception as err: record.message = f"Bad message ({err!r}): {record.__dict__!r}" record.asctime = time.strftime( TIME_FORMAT, self.converter(record.created) ) prefix = self.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 = str(record.message) except UnicodeDecodeError: message = repr(record.message) formatted = prefix + " " + message if record.exc_info and 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 debug(s, inst=None): s = "[debug] " + s if inst is not None: s += f" ({inst!r})" logger.debug(s) def is_logging_configured(): if logging.getLogger('pyftpdlib').handlers: return True return bool(logging.root.handlers) # TODO: write tests def config_logging(level=LEVEL, prefix=PREFIX, other_loggers=None): # Speedup logging by preventing certain internal log record info to # be unnecessarily fetched. This results in about 28% speedup. See: # * https://docs.python.org/3/howto/logging.html#optimization # * https://docs.python.org/3/library/logging.html#logrecord-attributes # * https://stackoverflow.com/a/38924153/376587 key_names = set( re.findall( r'(?. # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """Process utils.""" import os import random import sys import time from binascii import hexlify try: import multiprocessing except ImportError: multiprocessing = None from .log import logger _task_id = None def cpu_count(): """Returns the number of processors on this machine.""" if multiprocessing is None: return 1 try: return multiprocessing.cpu_count() except NotImplementedError: pass try: return os.sysconf("SC_NPROCESSORS_CONF") except (AttributeError, ValueError): pass return 1 def _reseed_random(): # If os.urandom is available, this method does the same thing as # random.seed. If os.urandom is not available, we mix in the pid in # addition to a timestamp. try: seed = int(hexlify(os.urandom(16)), 16) except NotImplementedError: seed = int(time.time() * 1000) ^ os.getpid() random.seed(seed) def fork_processes(number, max_restarts=100): """Starts multiple worker processes. If *number* is None or <= 0, we detect the number of cores available on this machine and fork that number of child processes. If *number* is given and > 0, we fork that specific number of sub-processes. Since we use processes and not threads, there is no shared memory between any server code. In each child process, *fork_processes* returns its *task id*, a number between 0 and *number*. Processes that exit abnormally (due to a signal or non-zero exit status) are restarted with the same id (up to *max_restarts* times). In the parent process, *fork_processes* returns None if all child processes have exited normally, but will otherwise only exit by throwing an exception. """ assert _task_id is None if number is None or number <= 0: number = cpu_count() logger.info("starting %d pre-fork processes", number) children = {} def start_child(i): pid = os.fork() if pid == 0: # child process _reseed_random() global _task_id _task_id = i return i else: children[pid] = i return None for i in range(number): id = start_child(i) if id is not None: return id num_restarts = 0 while children: try: pid, status = os.wait() except InterruptedError: continue if pid not in children: continue id = children.pop(pid) if os.WIFSIGNALED(status): logger.warning( "child %d (pid %d) killed by signal %d, restarting", id, pid, os.WTERMSIG(status), ) elif os.WEXITSTATUS(status) != 0: logger.warning( "child %d (pid %d) exited with status %d, restarting", id, pid, os.WEXITSTATUS(status), ) else: logger.info("child %d (pid %d) exited normally", id, pid) continue num_restarts += 1 if num_restarts > max_restarts: raise RuntimeError("Too many child restarts, giving up") new_id = start_child(id) if new_id is not None: return new_id # All child processes exited cleanly, so exit the master process # instead of just returning to right after the call to # fork_processes (which will probably just start up another IOLoop # unless the caller checks the return value). sys.exit(0) pyftpdlib-release-2.0.1/pyftpdlib/servers.py000066400000000000000000000523471470572577600212240ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ 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://github.com/giampaolo/pyftpdlib/issues/197 https://github.com/giampaolo/pyftpdlib/issues/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 errno import os import select import signal import sys import threading import time import traceback from .ioloop import Acceptor from .log import PREFIX from .log import PREFIX_MPROC from .log import config_logging from .log import debug from .log import is_logging_configured from .log import logger from .prefork import fork_processes __all__ = ['FTPServer', 'ThreadedFTPServer'] _BSD = 'bsd' in sys.platform # =================================================================== # --- base class # =================================================================== class FTPServer(Acceptor): """Creates a socket listening on
, dispatching the requests to a (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=100): """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 callable(getattr(address_or_socket, 'listen', None)): sock = address_or_socket sock.setblocking(0) self.set_socket(sock) else: self.bind_af_unspecified(address_or_socket) self.listen(backlog) def __enter__(self): return self def __exit__(self, *args): self.close_all() @property def address(self): """The address this server is listening on as a (ip, port) tuple.""" 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, prefork=False): def get_fqname(obj): try: return obj.__module__ + "." + obj.__class__.__name__ except AttributeError: try: return obj.__module__ + "." + obj.__name__ except AttributeError: return str(obj) if not is_logging_configured(): # If we get to this point it means the user hasn't # configured any logger. We want logging to be on # by default (stderr). config_logging(prefix=PREFIX_MPROC if prefork else PREFIX) if self.handler.passive_ports: pasv_ports = "%s->%s" % ( # noqa: UP031 self.handler.passive_ports[0], self.handler.passive_ports[-1], ) else: pasv_ports = None model = 'prefork + ' if prefork else '' if 'ThreadedFTPServer' in __all__ and issubclass( self.__class__, ThreadedFTPServer ): model += 'multi-thread' elif 'MultiprocessFTPServer' in __all__ and issubclass( self.__class__, MultiprocessFTPServer ): model += 'multi-process' elif issubclass(self.__class__, FTPServer): model += 'async' else: model += 'unknown (custom class)' logger.info("concurrency model: " + model) logger.info( "masquerade (NAT) address: %s", self.handler.masquerade_address ) logger.info("passive ports: %s", pasv_ports) logger.debug("poller: %r", get_fqname(self.ioloop)) logger.debug("authorizer: %r", get_fqname(self.handler.authorizer)) if os.name == 'posix': logger.debug("use sendfile(2): %s", self.handler.use_sendfile) logger.debug("handler: %r", get_fqname(self.handler)) logger.debug("max connections: %s", self.max_cons or "unlimited") logger.debug( "max connections per ip: %s", self.max_cons_per_ip or "unlimited" ) logger.debug("timeout: %s", self.handler.timeout or "unlimited") logger.debug("banner: %r", self.handler.banner) logger.debug("max login attempts: %r", self.handler.max_login_attempts) if getattr(self.handler, 'certfile', None): logger.debug("SSL certfile: %r", self.handler.certfile) if getattr(self.handler, 'keyfile', None): logger.debug("SSL keyfile: %r", self.handler.keyfile) def serve_forever( self, timeout=None, blocking=True, handle_exit=True, worker_processes=1 ): """Start serving. - (float) timeout: the timeout passed to the underlying IO loop expressed in seconds. - (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. - (int) worker_processes: pre-fork a certain number of child processes before starting. Each child process will keep using a 1-thread, async concurrency model, handling multiple concurrent connections. If the number is None or <= 0 the number of usable cores available on this machine is detected and used. It is a good idea to use this option in case the app risks blocking for too long on a single function call (e.g. hard-disk is slow, long DB query on auth etc.). By splitting the work load over multiple processes the delay introduced by a blocking function call is amortized and divided by the number of worker processes. """ log = handle_exit and blocking if worker_processes != 1 and os.name == 'posix': if not blocking: raise ValueError( "'worker_processes' and 'blocking' are mutually exclusive" ) if log: self._log_start(prefork=True) fork_processes(worker_processes) elif log: self._log_start() proto = "FTP+SSL" if hasattr(self.handler, 'ssl_protocol') else "FTP" logger.info( ">>> starting %s server on %s:%s, pid=%i <<<" % (proto, self.address[0], self.address[1], os.getpid()) ) if handle_exit: try: self.ioloop.loop(timeout, blocking) except (KeyboardInterrupt, SystemExit): logger.info("received interrupt signal") if blocking: if log: logger.info( ">>> shutting down FTP server, %s socket(s), pid=%i " "<<<", self._map_len(), os.getpid(), ) 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 Exception: 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: # - https://github.com/giampaolo/pyftpdlib/issues/143 # - https://github.com/giampaolo/pyftpdlib/issues/166 # - https://groups.google.com/forum/#!topic/pyftpdlib/h7pPybzAx14 logger.error(traceback.format_exc()) if handler is not None: handler.close() elif 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 # noqa: PLE0704 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 # How often thread/process finished tasks should be cleaned up. refresh_interval = 5 _lock = None _exit = None def __init__(self, address_or_socket, handler, ioloop=None, backlog=100): FTPServer.__init__( self, address_or_socket, handler, ioloop=ioloop, backlog=backlog ) self._active_tasks = [] self._active_tasks_idler = self.ioloop.call_every( self.refresh_interval, self._refresh_tasks, _errback=self.handle_error, ) def _start_task(self, *args, **kwargs): raise NotImplementedError('must be implemented in subclass') def _map_len(self): if len(self._active_tasks) >= self.max_cons: # Since refresh()ing is a potentially expensive operation # (O(N)) do it only if we're exceeding max connections # limit. Other than in here, tasks are refreshed every 10 # seconds anyway. self._refresh_tasks() return len(self._active_tasks) def _refresh_tasks(self): """join() terminated tasks and update internal _tasks list. This gets called every X secs. """ if self._active_tasks: logger.debug( f"refreshing tasks ({len(self._active_tasks)} join()" " potentials)" ) with self._lock: new = [] for t in self._active_tasks: if not t.is_alive(): self._join_task(t) else: new.append(t) self._active_tasks = new def _loop(self, handler): """Serve handler's IO loop in a separate thread or process.""" with self.ioloop.factory() as ioloop: handler.ioloop = ioloop try: handler.add_channel() except OSError as err: if err.errno == errno.EBADF: # we might get here in case the other end quickly # disconnected (see test_quick_connect()) debug( "call: %s._loop(); add_channel() returned EBADF", self ) 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 empty 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://github.com/giampaolo/pyftpdlib/issues/245 if not ioloop.socket_map: # get rid of cancel()led calls ioloop.sched.reheapify() 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 OSError as err: # on Windows we can get WSAENOTSOCK if the client # rapidly connect and disconnects if os.name == 'nt' and err.winerror == 10038: for fd in list(ioloop.socket_map.keys()): try: select.select([fd], [], [], 0) except OSError: 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 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,), name='ftpd' ) t.name = repr(addr) t.start() # it is a different process so free resources here if hasattr(t, 'pid'): handler.close() with self._lock: # add the new task self._active_tasks.append(t) def _log_start(self): FTPServer._log_start(self) def serve_forever(self, timeout=1.0, blocking=True, handle_exit=True): self._exit.clear() if handle_exit: log = handle_exit and blocking 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 _terminate_task(self, t): if hasattr(t, 'terminate'): logger.debug(f"terminate()ing task {t!r}") 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 ProcessLookupError: pass def _join_task(self, t): logger.debug(f"join()ing task {t!r}") t.join(self.join_timeout) if t.is_alive(): logger.warning( "task %r remained alive after %r secs", t, self.join_timeout ) def close_all(self): self._active_tasks_idler.cancel() # this must be set after getting active tasks as it causes # thread objects to get out of the list too soon self._exit.set() with self._lock: for t in self._active_tasks: self._terminate_task(t) for t in self._active_tasks: self._join_task(t) del self._active_tasks[:] FTPServer.close_all(self) 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() def _start_task(self, *args, **kwargs): return threading.Thread(*args, **kwargs) if os.name == 'posix': try: import multiprocessing multiprocessing.Lock() except Exception: # noqa # see https://github.com/giampaolo/pyftpdlib/issues/496 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) pyftpdlib-release-2.0.1/pyftpdlib/test/000077500000000000000000000000001470572577600201255ustar00rootroot00000000000000pyftpdlib-release-2.0.1/pyftpdlib/test/README000066400000000000000000000002211470572577600210000ustar00rootroot00000000000000RUNNNING TESTS ============== In order to run these tests cd into project root directory then run "make test" (this is also valid for Windows). pyftpdlib-release-2.0.1/pyftpdlib/test/__init__.py000066400000000000000000000345611470572577600222470ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import contextlib import functools import logging import multiprocessing import os import shutil import socket import stat import sys import tempfile import threading import time import unittest import warnings import psutil import pyftpdlib.servers from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.ioloop import IOLoop from pyftpdlib.servers import FTPServer HERE = os.path.realpath(os.path.abspath(os.path.dirname(__file__))) ROOT_DIR = os.path.realpath(os.path.join(HERE, '..', '..')) PYPY = '__pypy__' in sys.builtin_module_names OSX = sys.platform.startswith("darwin") POSIX = os.name == 'posix' BSD = "bsd" in sys.platform WINDOWS = os.name == 'nt' GITHUB_ACTIONS = 'GITHUB_ACTIONS' in os.environ or 'CIBUILDWHEEL' in os.environ CI_TESTING = GITHUB_ACTIONS COVERAGE = 'COVERAGE_RUN' in os.environ PYTEST_PARALLEL = "PYTEST_XDIST_WORKER" in os.environ # `make test-parallel` # Attempt to use IP rather than hostname (test suite will run a lot faster) try: HOST = socket.gethostbyname('localhost') except OSError: HOST = 'localhost' USER = 'user' PASSWD = '12345' HOME = os.getcwd() # Use PID to disambiguate file name for parallel testing. TESTFN_PREFIX = f'pyftpd-tmp-{os.getpid()}-' GLOBAL_TIMEOUT = 2 BUFSIZE = 1024 INTERRUPTED_TRANSF_SIZE = 32768 NO_RETRIES = 5 VERBOSITY = 1 if os.getenv('SILENT') else 2 if CI_TESTING: GLOBAL_TIMEOUT *= 3 NO_RETRIES *= 3 SUPPORTS_IPV4 = None # set later SUPPORTS_IPV6 = None # set later SUPPORTS_MULTIPROCESSING = hasattr(pyftpdlib.servers, 'MultiprocessFTPServer') if BSD or (OSX and GITHUB_ACTIONS): SUPPORTS_MULTIPROCESSING = False # XXX: it's broken!! class PyftpdlibTestCase(unittest.TestCase): """All test classes inherit from this one.""" def __str__(self): # Print a full path representation of the single unit tests # being run. fqmod = self.__class__.__module__ if not fqmod.startswith('pyftpdlib.'): fqmod = 'pyftpdlib.test.' + fqmod return f"{fqmod}.{self.__class__.__name__}.{self._testMethodName}" def get_testfn(self, suffix="", dir=None): fname = get_testfn(suffix=suffix, dir=dir) self.addCleanup(safe_rmpath, fname) return fname def close_client(session): """Closes a ftplib.FTP client session.""" try: if session.sock is not None: try: resp = session.quit() except Exception: # noqa pass else: # ...just to make sure the server isn't replying to some # pending command. assert resp.startswith('221'), resp finally: session.close() def try_address(host, port=0, family=socket.AF_INET): """Try to bind a socket on the given host:port and return True if that has been possible.""" # Note: if IPv6 fails on Linux do: # $ sudo sh -c 'echo 0 > /proc/sys/net/ipv6/conf/all/disable_ipv6' try: with contextlib.closing(socket.socket(family)) as sock: sock.bind((host, port)) except (OSError, socket.gaierror): return False else: return True SUPPORTS_IPV4 = try_address('127.0.0.1') SUPPORTS_IPV6 = socket.has_ipv6 and try_address('::1', family=socket.AF_INET6) def get_testfn(suffix="", dir=None): """Return an absolute pathname of a file or dir that did not exist at the time this call is made. Also schedule it for safe deletion at interpreter exit. It's technically racy but probably not really due to the time variant. """ if dir is None: dir = os.getcwd() while True: name = tempfile.mktemp(prefix=TESTFN_PREFIX, suffix=suffix, dir=dir) if not os.path.exists(name): # also include dirs return os.path.basename(name) def safe_rmpath(path): """Convenience function for removing temporary test files or dirs.""" def retry_fun(fun): # On Windows it could happen that the file or directory has # open handles or references preventing the delete operation # to succeed immediately, so we retry for a while. See: # https://bugs.python.org/issue33240 stop_at = time.time() + GLOBAL_TIMEOUT while time.time() < stop_at: try: return fun() except FileNotFoundError: pass except OSError as _: err = _ warnings.warn(f"ignoring {err!s}", UserWarning, stacklevel=2) time.sleep(0.01) raise err try: st = os.stat(path) if stat.S_ISDIR(st.st_mode): fun = functools.partial(shutil.rmtree, path) else: fun = functools.partial(os.remove, path) if POSIX: fun() else: retry_fun(fun) except FileNotFoundError: pass def touch(name): """Create a file and return its name.""" with open(name, 'w') as f: return f.name def disable_log_warning(fun): """Temporarily set FTP server's logging level to ERROR.""" @functools.wraps(fun) def wrapper(self, *args, **kwargs): logger = logging.getLogger('pyftpdlib') level = logger.getEffectiveLevel() logger.setLevel(logging.ERROR) try: return fun(self, *args, **kwargs) finally: logger.setLevel(level) return wrapper class retry: """A retry decorator.""" def __init__( self, exception=Exception, timeout=None, retries=None, interval=0.001, logfun=None, ): if timeout and retries: raise ValueError("timeout and retries args are mutually exclusive") self.exception = exception self.timeout = timeout self.retries = retries self.interval = interval self.logfun = logfun def __iter__(self): if self.timeout: stop_at = time.time() + self.timeout while time.time() < stop_at: yield elif self.retries: for _ in range(self.retries): yield else: while True: yield def sleep(self): if self.interval is not None: time.sleep(self.interval) def __call__(self, fun): @functools.wraps(fun) def wrapper(cls, *args, **kwargs): exc = None for _ in self: try: return fun(cls, *args, **kwargs) except self.exception as _: exc = _ if self.logfun is not None: self.logfun(exc) self.sleep() if isinstance(cls, unittest.TestCase): cls.tearDown() cls.setUp() continue raise exc # noqa: PLE0704 # This way the user of the decorated function can change config # parameters. wrapper.decorator = self return wrapper def retry_on_failure(fun): """Decorator which runs a test function and retries N times before actually failing. """ @functools.wraps(fun) def wrapper(self, *args, **kwargs): for x in range(NO_RETRIES): try: return fun(self, *args, **kwargs) except AssertionError as exc: if x + 1 >= NO_RETRIES: raise msg = f"{exc!r}, retrying" print(msg, file=sys.stderr) # NOQA if PYTEST_PARALLEL: warnings.warn(msg, ResourceWarning, stacklevel=2) self.tearDown() self.setUp() return wrapper def call_until(fun, expr, timeout=GLOBAL_TIMEOUT): """Keep calling function for timeout secs and exit if eval() expression is True. """ stop_at = time.time() + timeout while time.time() < stop_at: ret = fun() if eval(expr): # noqa return ret time.sleep(0.001) raise RuntimeError(f'timed out (ret={ret!r})') def get_server_handler(): """Return the first FTPHandler instance running in the IOLoop.""" ioloop = IOLoop.instance() for fd in ioloop.socket_map: instance = ioloop.socket_map[fd] if isinstance(instance, FTPHandler): return instance raise RuntimeError("can't find any FTPHandler instance") # commented out as per bug https://bugs.python.org/issue10354 # tempfile.template = 'tmp-pyftpdlib' def setup_server(handler, server_class, addr=None): addr = (HOST, 0) if addr is None else addr authorizer = DummyAuthorizer() # full perms authorizer.add_user(USER, PASSWD, HOME, perm='elradfmwMT') authorizer.add_anonymous(HOME) handler.authorizer = authorizer handler.auth_failed_timeout = 0.001 # lower buffer sizes = more "loops" while transferring data # = less false positives handler.dtp_handler.ac_in_buffer_size = 4096 handler.dtp_handler.ac_out_buffer_size = 4096 server = server_class(addr, handler) return server def assert_free_resources(parent_pid=None): # check orphaned threads ts = threading.enumerate() assert len(ts) == 1, ts # check orphaned process children this_proc = psutil.Process(parent_pid or os.getpid()) children = this_proc.children() if children: warnings.warn( f"some children didn't terminate (pid={os.getpid()!r})" f" {str(children)!r}", UserWarning, stacklevel=2, ) for child in children: try: child.kill() child.wait(GLOBAL_TIMEOUT) except psutil.NoSuchProcess: pass # check unclosed connections if POSIX: cons = [ x for x in this_proc.net_connections('tcp') if x.status != psutil.CONN_CLOSE_WAIT ] if cons: warnings.warn( f"some connections didn't close (pid={os.getpid()!r})" f" {str(cons)!r}", UserWarning, stacklevel=2, ) def reset_server_opts(): # Since all pyftpdlib configurable "options" are class attributes # we reset them at module.class level. import pyftpdlib.handlers # noqa: PLC0415 import pyftpdlib.servers # noqa: PLC0415 # Control handlers. tls_handler = getattr( pyftpdlib.handlers, "TLS_FTPHandler", pyftpdlib.handlers.FTPHandler ) for klass in (pyftpdlib.handlers.FTPHandler, tls_handler): klass.auth_failed_timeout = 0.001 klass.authorizer = DummyAuthorizer() klass.banner = "pyftpdlib ready." klass.masquerade_address = None klass.masquerade_address_map = {} klass.max_login_attempts = 3 klass.passive_ports = None klass.permit_foreign_addresses = False klass.permit_privileged_ports = False klass.tcp_no_delay = hasattr(socket, 'TCP_NODELAY') klass.timeout = 300 klass.unicode_errors = "replace" klass.use_gmt_times = True klass.use_sendfile = hasattr(os, "sendfile") klass.ac_in_buffer_size = 4096 klass.ac_out_buffer_size = 4096 klass.encoding = "utf8" if klass.__name__ == 'TLS_FTPHandler': klass.tls_control_required = False klass.tls_data_required = False # Data handlers. tls_handler = getattr( pyftpdlib.handlers, "TLS_DTPHandler", pyftpdlib.handlers.DTPHandler ) for klass in (pyftpdlib.handlers.DTPHandler, tls_handler): klass.timeout = 300 klass.ac_in_buffer_size = 4096 klass.ac_out_buffer_size = 4096 pyftpdlib.handlers.ThrottledDTPHandler.read_limit = 0 pyftpdlib.handlers.ThrottledDTPHandler.write_limit = 0 pyftpdlib.handlers.ThrottledDTPHandler.auto_sized_buffers = True # Acceptors. ls = [pyftpdlib.servers.FTPServer, pyftpdlib.servers.ThreadedFTPServer] if POSIX: ls.append(pyftpdlib.servers.MultiprocessFTPServer) for klass in ls: klass.max_cons = 0 klass.max_cons_per_ip = 0 class FtpdThreadWrapper(threading.Thread): """A threaded FTP server used for running tests. This is basically a modified version of the FTPServer class which wraps the polling loop into a thread. The instance returned can be start()ed and stop()ped. """ handler = FTPHandler server_class = FTPServer poll_interval = 0.001 if CI_TESTING else 0.000001 # Makes the thread stop on interpreter exit. daemon = True def __init__(self, addr=None): self.parent_pid = os.getpid() super().__init__(name='test-ftpd') self.server = setup_server(self.handler, self.server_class, addr=addr) self.host, self.port = self.server.socket.getsockname()[:2] self.lock = threading.Lock() self._stop_flag = False self._event_stop = threading.Event() def run(self): try: while not self._stop_flag: with self.lock: self.server.serve_forever( timeout=self.poll_interval, blocking=False ) finally: self._event_stop.set() def stop(self): self._stop_flag = True # signal the main loop to exit self._event_stop.wait() self.server.close_all() self.join() reset_server_opts() assert_free_resources(self.parent_pid) if POSIX: class FtpdMultiprocWrapper(multiprocessing.Process): """Same as above but using a sub process instead.""" handler = FTPHandler server_class = FTPServer def __init__(self, addr=None): super().__init__() self.server = setup_server( self.handler, self.server_class, addr=addr ) self.host, self.port = self.server.socket.getsockname()[:2] self._started = False def run(self): assert not self._started self._started = True self.name = f"{self.__class__.__name__}({self.pid})" self.server.serve_forever() def stop(self): self.server.close_all() self.terminate() self.join() reset_server_opts() assert_free_resources() else: # Windows FtpdMultiprocWrapper = FtpdThreadWrapper pyftpdlib-release-2.0.1/pyftpdlib/test/conftest.py000066400000000000000000000054151470572577600223310ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ pytest config file (file name has special meaning), executed before running tests. In here we tell pytest to execute setup/teardown functions before/after each unit-test. We do so to make sure no orphaned resources are left behind. In unittest terms, this is equivalent to implicitly defining setUp(), tearDown(), setUpClass(), tearDownClass() methods for each test class. """ import atexit import os import threading import warnings import psutil import pytest from pyftpdlib.ioloop import IOLoop from . import POSIX from . import ROOT_DIR from . import TESTFN_PREFIX from . import safe_rmpath # set it to True to raise an exception instead of warning FAIL = False this_proc = psutil.Process() def collect_resources(): res = {} res["threads"] = set(threading.enumerate()) if POSIX: res["num_fds"] = this_proc.num_fds() # res["cons"] = set(this_proc.net_connections(kind="all")) # res["files"] = set(this_proc.open_files()) return res def warn(msg): if FAIL: raise RuntimeError(msg) warnings.warn(msg, ResourceWarning, stacklevel=3) def assert_closed_resources(setup_ctx, request): if request.session.testsfailed: return # no need to warn if test already failed before = setup_ctx.copy() after = collect_resources() for key, value in before.items(): if key.startswith("_"): continue msg = ( f"{setup_ctx['_origin']!r} left some unclosed {key!r} resources" " behind: " ) extra = after[key] - before[key] if extra: if isinstance(value, set): msg += repr(extra) warn(msg) elif extra > 0: # unused, here just in case we extend it later msg += f"before={before[key]!r}, after={after[key]!r}" warn(msg) def assert_closed_ioloop(): inst = IOLoop.instance() if inst.socket_map: warn(f"unclosed ioloop socket map {inst.socket_map}") if inst.sched._tasks: warn(f"unclosed ioloop tasks {inst.sched._tasks}") # --- def setup_method(origin): ctx = collect_resources() ctx["_origin"] = origin return ctx def teardown_method(setup_ctx, request): assert_closed_resources(setup_ctx, request) assert_closed_ioloop() @pytest.fixture(autouse=True, scope="function") def for_each_test_method(request): ctx = setup_method(request.node.nodeid) request.addfinalizer(lambda: teardown_method(ctx, request)) @atexit.register def on_exit(): for name in os.listdir(ROOT_DIR): if name.startswith(TESTFN_PREFIX): safe_rmpath(os.path.join(ROOT_DIR, name)) pyftpdlib-release-2.0.1/pyftpdlib/test/keycert.pem000066400000000000000000000056051470572577600223040ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDZwgTzjduhKarf kaGsAuBSHALVpsUH/S5iujPu+Cp/9UrBJaVjGxqLGI3Tnquv8yrQJE8KkkOHNWY5 teZKuKleysTP1veKGXavSaCzw4d0Jj/xioVod2eNHRXBR9WafbO1RZxiUtMZVyIp yeMF74Ue+xrRNsFH1R9g535oGe4Lq2oLDVqLiELzp9VtlAL6Wko1b45r+hoOZgoz 68Gv6wqyzZZCZR61NXJ0riuia7rAcMnJn3pkOqfcdH/wbMzlb+eJM32aICDNQSL/ FYDXdGF01rd6k8LWL++X97PLwhbWax0wSFwM+Je9CHjDM4jL8de3FH/Ux84x5wtl MB2HOKKxAgMBAAECggEABo9gGM+VkJWxw8nJBhE8HXJ3P2UmcGbXMTriJPktm+yR fkoaYRGdoMJyRgV0r03zDzwuHr0TSGJ6cZNM8cP9uOZWPDCsr74JTknjNnyAeyZK gjClyOTipg9R6ssK4KbtdVuN1NnL6ZUvaUo0taa76u1Y/HkwJMWDNtHNLr5WkTFg PDCJCdMutGE0lu0Rt0gc2Izwe5g/zLoUBwLkwhtY26qAeLz5pIGJMM8JF42gYbvS QFvMfWTUXnzUkOLey/7com853fEZxSx1f+1T+KDfOzME79uK5+gd7NenoEBNCnKF F2vPsNHOk3BmhlJf521NRNX3y7WUc/w2KWdk/MS25QKBgQDqMgF3O0OUaVQIdQHC ItaIe/NuFRSZ1OnkpuBG26xWy2XZzD4HqLYGR0m2QsMOhZwEbsTHg3a+SCUCvKfM QDBVBoOAYJrrRmd9rvMFZoksm7Ei1qeMdX0iRM2DOFNO+lAEi7zEDWzG1oy7v20b YFwYPwRCh7nbRQ6eHDQWYruPvwKBgQDuCDsOW8ed9PdPnK8Kufk3PmsXwW26A7vB 0+UV/l9AwrT0cf0dr4x4tcUHIEon+BIwvJI8eEIFEfjo50Eh0Ew9q/tfDlivayin S1wF4ntIB32l5s91Kc3scxLKHDzdi9uIxD04DS7Lro7YzIIQsWdpLm1Bnytnos65 7FUObQJpjwKBgG6NhoWbU06G3iVT3q2fNnidUo+foebwTC0k3XB1mIgsYfsLYCjL aonSMyi3oU6Eod6xz3CDTZWLhvUgy3Eux+ILPh5m/BqeVJJO+OeOvKhzIo5YmCVE /PolUoJkH2eD4CwVLtm5oKTIeQzT05R9y1uiu8cQPRsWIU1f8PK0TugPAoGAB9hd meuMeLhKLmWLn17hx+BWx0GozCizV4AUXNU1bnz8WdIn9YKDrrbO950o1IhokRKl /zg3dNNS0NpOWz7yRFYWwttGMQHnJRxmvArq5UTZ703cKJBoKRLh26dymhqx8aAG JILKuAvYyWx0HPi738uX7kHAvHmxNo+DfiY5niECgYBGt4rjQBBEN+gBlis0nFvR KkbY2MN21Bf2qe8r7x+NIrBrpRol3HMkdsTLyP8vuij4qspMcgdBj9YRbdRlwnhC oK4JOGaI0kWBQo8XouXFTgRp9awS0U7prdkaOu1uTwggKSaenAxhqj8zWI9yedAV 2uqDMbuV1S3mMZlgXfYSpA== -----END PRIVATE KEY----- -----BEGIN CERTIFICATE----- MIIDazCCAlOgAwIBAgIUESOrlenrlOmk3SphhWJeEVX5prowDQYJKoZIhvcNAQEL BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMDgxMTM2MDNaFw0yNDAz MDkxMTM2MDNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQDZwgTzjduhKarfkaGsAuBSHALVpsUH/S5iujPu+Cp/ 9UrBJaVjGxqLGI3Tnquv8yrQJE8KkkOHNWY5teZKuKleysTP1veKGXavSaCzw4d0 Jj/xioVod2eNHRXBR9WafbO1RZxiUtMZVyIpyeMF74Ue+xrRNsFH1R9g535oGe4L q2oLDVqLiELzp9VtlAL6Wko1b45r+hoOZgoz68Gv6wqyzZZCZR61NXJ0riuia7rA cMnJn3pkOqfcdH/wbMzlb+eJM32aICDNQSL/FYDXdGF01rd6k8LWL++X97PLwhbW ax0wSFwM+Je9CHjDM4jL8de3FH/Ux84x5wtlMB2HOKKxAgMBAAGjUzBRMB0GA1Ud DgQWBBQSbuaoS2+bNEg4+g7oso21c5sBHzAfBgNVHSMEGDAWgBQSbuaoS2+bNEg4 +g7oso21c5sBHzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBi 9ZZQ4ZdVvew/iWrWv3TxC7Sgt/hYLHtPjrs2PZVtG2Z+W+tol/Y1ysgzvSATSAbY GbxzHwi6yM7DXn4kZC/LNWXgQyrSf/XhOj/FkBR2bwp4iHfjP3589rU5yb1jrUwO v2WqVPWV3+b7RAeCR0aUBBdLXkQF0ET1UIsV968GT7HDdb4UCMnoif4ms1XuVZw/ t+YFMxqsPgCBkH2lUSFKcMBwA9qOU9K9GtxjFNhVBF1lzDjBhBL99+TZDLLrtKsp 2d1C4Cm5e590LuaUBijUEQH0ZK3e9UtbkNYtA7JYlmq+oQH7eS0fh+eOIOa3hDfm xHGDmUjpQuWawJuuUXZF -----END CERTIFICATE----- pyftpdlib-release-2.0.1/pyftpdlib/test/test_authorizers.py000066400000000000000000000551331470572577600241240ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import os import random import string import sys import warnings import pytest from pyftpdlib.authorizers import AuthenticationFailed from pyftpdlib.authorizers import AuthorizerError from pyftpdlib.authorizers import DummyAuthorizer from . import HOME from . import PASSWD from . import POSIX from . import USER from . import WINDOWS from . import PyftpdlibTestCase from . import touch if POSIX: import pwd try: from pyftpdlib.authorizers import UnixAuthorizer except ImportError: UnixAuthorizer = None else: UnixAuthorizer = None if WINDOWS: from pywintypes import error as Win32ExtError from pyftpdlib.authorizers import WindowsAuthorizer else: WindowsAuthorizer = None class TestDummyAuthorizer(PyftpdlibTestCase): """Tests for DummyAuthorizer class.""" # temporarily change warnings to exceptions for the purposes of testing def setUp(self): super().setUp() self.tempdir = os.path.abspath(self.get_testfn()) self.subtempdir = os.path.join(self.tempdir, self.get_testfn()) self.tempfile = os.path.join(self.tempdir, self.get_testfn()) self.subtempfile = os.path.join(self.subtempdir, self.get_testfn()) os.mkdir(self.tempdir) os.mkdir(self.subtempdir) touch(self.tempfile) touch(self.subtempfile) warnings.filterwarnings("error") def tearDown(self): os.remove(self.tempfile) os.remove(self.subtempfile) os.rmdir(self.subtempdir) os.rmdir(self.tempdir) warnings.resetwarnings() super().tearDown() def test_common_methods(self): auth = DummyAuthorizer() # create user auth.add_user(USER, PASSWD, HOME) auth.add_anonymous(HOME) # check credentials auth.validate_authentication(USER, PASSWD, None) with pytest.raises(AuthenticationFailed): auth.validate_authentication(USER, 'wrongpwd', None) auth.validate_authentication('anonymous', 'foo', None) auth.validate_authentication('anonymous', '', None) # empty passwd # remove them auth.remove_user(USER) auth.remove_user('anonymous') # raise exc if user does not exists with pytest.raises(KeyError): auth.remove_user(USER) # raise exc if path does not exist with pytest.raises(ValueError, match='no such directory'): auth.add_user(USER, PASSWD, '?:\\') with pytest.raises(ValueError, match='no such directory'): auth.add_anonymous('?:\\') # raise exc if user already exists auth.add_user(USER, PASSWD, HOME) auth.add_anonymous(HOME) with pytest.raises(ValueError, match=f'user {USER!r} already exists'): auth.add_user(USER, PASSWD, HOME) with pytest.raises( ValueError, match="user 'anonymous' already exists" ): auth.add_anonymous(HOME) auth.remove_user(USER) auth.remove_user('anonymous') # raise on wrong permission with pytest.raises(ValueError, match="no such permission"): auth.add_user(USER, PASSWD, HOME, perm='?') with pytest.raises(ValueError, match="no such permission"): auth.add_anonymous(HOME, perm='?') # expect warning on write permissions assigned to anonymous user for x in "adfmw": with pytest.raises( RuntimeWarning, match="write permissions assigned to anonymous user.", ): auth.add_anonymous(HOME, perm=x) def test_override_perm_interface(self): auth = DummyAuthorizer() auth.add_user(USER, PASSWD, HOME, perm='elr') # raise exc if user does not exists with pytest.raises(KeyError): auth.override_perm(USER + 'w', HOME, 'elr') # raise exc if path does not exist or it's not a directory with pytest.raises(ValueError, match='no such directory'): auth.override_perm(USER, '?:\\', 'elr') with pytest.raises(ValueError, match='no such directory'): auth.override_perm(USER, self.tempfile, 'elr') # raise on wrong permission with pytest.raises(ValueError, match="no such permission"): auth.override_perm(USER, HOME, perm='?') # expect warning on write permissions assigned to anonymous user auth.add_anonymous(HOME) for p in "adfmw": with pytest.raises( RuntimeWarning, match="write permissions assigned to anonymous user.", ): auth.override_perm('anonymous', HOME, p) # raise on attempt to override home directory permissions with pytest.raises( ValueError, match="can't override home directory permissions" ): auth.override_perm(USER, HOME, perm='w') # raise on attempt to override a path escaping home directory if os.path.dirname(HOME) != HOME: with pytest.raises( ValueError, match="path escapes user home directory" ): auth.override_perm(USER, os.path.dirname(HOME), perm='w') # try to re-set an overridden permission auth.override_perm(USER, self.tempdir, perm='w') auth.override_perm(USER, self.tempdir, perm='wr') def test_override_perm_recursive_paths(self): auth = DummyAuthorizer() auth.add_user(USER, PASSWD, HOME, perm='elr') assert not auth.has_perm(USER, 'w', self.tempdir) auth.override_perm(USER, self.tempdir, perm='w', recursive=True) assert not auth.has_perm(USER, 'w', HOME) assert auth.has_perm(USER, 'w', self.tempdir) assert auth.has_perm(USER, 'w', self.tempfile) assert auth.has_perm(USER, 'w', self.subtempdir) assert auth.has_perm(USER, 'w', self.subtempfile) assert not auth.has_perm(USER, 'w', HOME + '@') assert not auth.has_perm(USER, 'w', self.tempdir + '@') path = os.path.join( self.tempdir + '@', os.path.basename(self.tempfile) ) assert not auth.has_perm(USER, 'w', path) # test case-sensitiveness if (os.name in ('nt', 'ce')) or (sys.platform == 'cygwin'): assert auth.has_perm(USER, 'w', self.tempdir.upper()) def test_override_perm_not_recursive_paths(self): auth = DummyAuthorizer() auth.add_user(USER, PASSWD, HOME, perm='elr') assert not auth.has_perm(USER, 'w', self.tempdir) auth.override_perm(USER, self.tempdir, perm='w') assert not auth.has_perm(USER, 'w', HOME) assert auth.has_perm(USER, 'w', self.tempdir) assert auth.has_perm(USER, 'w', self.tempfile) assert not auth.has_perm(USER, 'w', self.subtempdir) assert not auth.has_perm(USER, 'w', self.subtempfile) assert not auth.has_perm(USER, 'w', HOME + '@') assert not auth.has_perm(USER, 'w', self.tempdir + '@') path = os.path.join( self.tempdir + '@', os.path.basename(self.tempfile) ) assert not auth.has_perm(USER, 'w', path) # test case-sensitiveness if (os.name in ('nt', 'ce')) or (sys.platform == 'cygwin'): assert auth.has_perm(USER, 'w', self.tempdir.upper()) class _SharedAuthorizerTests: """Tests valid for both UnixAuthorizer and WindowsAuthorizer for those parts which share the same API. """ authorizer_class = None # --- utils def get_users(self): return self.authorizer_class._get_system_users() @staticmethod def get_current_user(): if POSIX: return pwd.getpwuid(os.getuid()).pw_name else: return os.environ['USERNAME'] @staticmethod def get_current_user_homedir(): if POSIX: return pwd.getpwuid(os.getuid()).pw_dir else: return os.environ['USERPROFILE'] def get_nonexistent_user(self): # return a user which does not exist on the system users = self.get_users() letters = string.ascii_lowercase while True: user = ''.join([random.choice(letters) for i in range(10)]) if user not in users: return user def assertRaisesWithMsg(self, excClass, msg, callableObj, *args, **kwargs): try: callableObj(*args, **kwargs) except excClass as err: if str(err) == msg: return raise self.failureException(f"{err!s} != {msg}") else: if hasattr(excClass, '__name__'): excName = excClass.__name__ else: excName = str(excClass) raise self.failureException(f"{excName} not raised") # --- /utils def test_get_home_dir(self): auth = self.authorizer_class() home = auth.get_home_dir(self.get_current_user()) nonexistent_user = self.get_nonexistent_user() assert os.path.isdir(home) if auth.has_user('nobody'): home = auth.get_home_dir('nobody') with pytest.raises(AuthorizerError): auth.get_home_dir(nonexistent_user) def test_has_user(self): auth = self.authorizer_class() current_user = self.get_current_user() nonexistent_user = self.get_nonexistent_user() assert auth.has_user(current_user) assert not auth.has_user(nonexistent_user) auth = self.authorizer_class(rejected_users=[current_user]) assert not auth.has_user(current_user) def test_validate_authentication(self): # can't test for actual success in case of valid authentication # here as we don't have the user password if self.authorizer_class.__name__ == 'UnixAuthorizer': auth = self.authorizer_class(require_valid_shell=False) else: auth = self.authorizer_class() current_user = self.get_current_user() nonexistent_user = self.get_nonexistent_user() with pytest.raises(AuthenticationFailed): auth.validate_authentication( current_user, 'wrongpasswd', None, ) with pytest.raises(AuthenticationFailed): auth.validate_authentication( nonexistent_user, 'bar', None, ) def test_impersonate_user(self): auth = self.authorizer_class() nonexistent_user = self.get_nonexistent_user() try: if self.authorizer_class.__name__ == 'UnixAuthorizer': auth.impersonate_user(self.get_current_user(), '') with pytest.raises(AuthorizerError): auth.impersonate_user( nonexistent_user, 'pwd', ) else: with pytest.raises(Win32ExtError): auth.impersonate_user( nonexistent_user, 'pwd', ) with pytest.raises(Win32ExtError): auth.impersonate_user( self.get_current_user(), '', ) finally: auth.terminate_impersonation('') def test_terminate_impersonation(self): auth = self.authorizer_class() auth.terminate_impersonation('') auth.terminate_impersonation('') def test_get_perms(self): auth = self.authorizer_class(global_perm='elr') assert 'r' in auth.get_perms(self.get_current_user()) assert 'w' not in auth.get_perms(self.get_current_user()) def test_has_perm(self): auth = self.authorizer_class(global_perm='elr') assert auth.has_perm(self.get_current_user(), 'r') assert not auth.has_perm(self.get_current_user(), 'w') def test_messages(self): auth = self.authorizer_class(msg_login="login", msg_quit="quit") assert auth.get_msg_login, "login" assert auth.get_msg_quit, "quit" def test_error_options(self): wrong_user = self.get_nonexistent_user() self.assertRaisesWithMsg( AuthorizerError, "rejected_users and allowed_users options are mutually exclusive", self.authorizer_class, allowed_users=['foo'], rejected_users=['bar'], ) self.assertRaisesWithMsg( AuthorizerError, 'invalid username "anonymous"', self.authorizer_class, allowed_users=['anonymous'], ) self.assertRaisesWithMsg( AuthorizerError, 'invalid username "anonymous"', self.authorizer_class, rejected_users=['anonymous'], ) self.assertRaisesWithMsg( AuthorizerError, f'unknown user {wrong_user}', self.authorizer_class, allowed_users=[wrong_user], ) self.assertRaisesWithMsg( AuthorizerError, f'unknown user {wrong_user}', self.authorizer_class, rejected_users=[wrong_user], ) def test_override_user_password(self): auth = self.authorizer_class() user = self.get_current_user() auth.override_user(user, password='foo') auth.validate_authentication(user, 'foo', None) with pytest.raises(AuthenticationFailed): auth.validate_authentication( user, 'bar', None, ) # make sure other settings keep using default values assert auth.get_home_dir(user) == self.get_current_user_homedir() assert auth.get_perms(user) == "elradfmwMT" assert auth.get_msg_login(user) == "Login successful." assert auth.get_msg_quit(user) == "Goodbye." def test_override_user_homedir(self): auth = self.authorizer_class() user = self.get_current_user() dir = os.path.dirname(os.getcwd()) auth.override_user(user, homedir=dir) assert auth.get_home_dir(user) == dir # make sure other settings keep using default values # self.assertEqual(auth.get_home_dir(user), # self.get_current_user_homedir()) assert auth.get_perms(user) == "elradfmwMT" assert auth.get_msg_login(user) == "Login successful." assert auth.get_msg_quit(user) == "Goodbye." def test_override_user_perm(self): auth = self.authorizer_class() user = self.get_current_user() auth.override_user(user, perm="elr") assert auth.get_perms(user) == "elr" # make sure other settings keep using default values assert auth.get_home_dir(user) == self.get_current_user_homedir() # self.assertEqual(auth.get_perms(user), "elradfmwMT") assert auth.get_msg_login(user) == "Login successful." assert auth.get_msg_quit(user) == "Goodbye." def test_override_user_msg_login_quit(self): auth = self.authorizer_class() user = self.get_current_user() auth.override_user(user, msg_login="foo", msg_quit="bar") assert auth.get_msg_login(user) == "foo" assert auth.get_msg_quit(user) == "bar" # make sure other settings keep using default values assert auth.get_home_dir(user) == self.get_current_user_homedir() assert auth.get_perms(user) == "elradfmwMT" # self.assertEqual(auth.get_msg_login(user), "Login successful.") # self.assertEqual(auth.get_msg_quit(user), "Goodbye.") def test_override_user_errors(self): if self.authorizer_class.__name__ == 'UnixAuthorizer': auth = self.authorizer_class(require_valid_shell=False) else: auth = self.authorizer_class() this_user = self.get_current_user() for x in self.get_users(): if x != this_user: another_user = x break nonexistent_user = self.get_nonexistent_user() self.assertRaisesWithMsg( AuthorizerError, "at least one keyword argument must be specified", auth.override_user, this_user, ) self.assertRaisesWithMsg( AuthorizerError, f'no such user {nonexistent_user}', auth.override_user, nonexistent_user, perm='r', ) if self.authorizer_class.__name__ == 'UnixAuthorizer': auth = self.authorizer_class( allowed_users=[this_user], require_valid_shell=False ) else: auth = self.authorizer_class(allowed_users=[this_user]) auth.override_user(this_user, perm='r') self.assertRaisesWithMsg( AuthorizerError, f'{another_user} is not an allowed user', auth.override_user, another_user, perm='r', ) if self.authorizer_class.__name__ == 'UnixAuthorizer': auth = self.authorizer_class( rejected_users=[this_user], require_valid_shell=False ) else: auth = self.authorizer_class(rejected_users=[this_user]) auth.override_user(another_user, perm='r') self.assertRaisesWithMsg( AuthorizerError, f'{this_user} is not an allowed user', auth.override_user, this_user, perm='r', ) self.assertRaisesWithMsg( AuthorizerError, "can't assign password to anonymous user", auth.override_user, "anonymous", password='foo', ) # ===================================================================== # --- UNIX authorizer # ===================================================================== @pytest.mark.skipif(not POSIX, reason="UNIX only") @pytest.mark.skipif( UnixAuthorizer is None, reason="UnixAuthorizer class not available" ) class TestUnixAuthorizer(_SharedAuthorizerTests, PyftpdlibTestCase): """Unix authorizer specific tests.""" authorizer_class = UnixAuthorizer def setUp(self): super().setUp() try: UnixAuthorizer() except AuthorizerError: # not root self.skipTest("need root access") def test_get_perms_anonymous(self): auth = UnixAuthorizer( global_perm='elr', anonymous_user=self.get_current_user() ) assert 'e' in auth.get_perms('anonymous') assert 'w' not in auth.get_perms('anonymous') warnings.filterwarnings("ignore") auth.override_user('anonymous', perm='w') warnings.resetwarnings() assert 'w' in auth.get_perms('anonymous') def test_has_perm_anonymous(self): auth = UnixAuthorizer( global_perm='elr', anonymous_user=self.get_current_user() ) assert auth.has_perm(self.get_current_user(), 'r') assert not auth.has_perm(self.get_current_user(), 'w') assert auth.has_perm('anonymous', 'e') assert not auth.has_perm('anonymous', 'w') warnings.filterwarnings("ignore") auth.override_user('anonymous', perm='w') warnings.resetwarnings() assert auth.has_perm('anonymous', 'w') def test_validate_authentication(self): # we can only test for invalid credentials auth = UnixAuthorizer(require_valid_shell=False) with pytest.raises(AuthenticationFailed): auth.validate_authentication( '?!foo', '?!foo', None, ) auth = UnixAuthorizer(require_valid_shell=True) with pytest.raises(AuthenticationFailed): auth.validate_authentication( '?!foo', '?!foo', None, ) def test_validate_authentication_anonymous(self): current_user = self.get_current_user() auth = UnixAuthorizer( anonymous_user=current_user, require_valid_shell=False ) with pytest.raises(AuthenticationFailed): auth.validate_authentication( 'foo', 'passwd', None, ) with pytest.raises(AuthenticationFailed): auth.validate_authentication( current_user, 'passwd', None, ) auth.validate_authentication('anonymous', 'passwd', None) def test_require_valid_shell(self): def get_fake_shell_user(): for user in self.get_users(): shell = pwd.getpwnam(user).pw_shell # On linux fake shell is usually /bin/false, on # freebsd /usr/sbin/nologin; in case of other # UNIX variants test needs to be adjusted. if '/false' in shell or '/nologin' in shell: return user self.fail("no user found") user = get_fake_shell_user() self.assertRaisesWithMsg( AuthorizerError, f"user {user} has not a valid shell", UnixAuthorizer, allowed_users=[user], ) # commented as it first fails for invalid home # self.assertRaisesWithMsg( # ValueError, # "user %s has not a valid shell" % user, # UnixAuthorizer, anonymous_user=user) auth = UnixAuthorizer() assert auth._has_valid_shell(self.get_current_user()) assert not auth._has_valid_shell(user) self.assertRaisesWithMsg( AuthorizerError, f"User {user} doesn't have a valid shell.", auth.override_user, user, perm='r', ) def test_not_root(self): # UnixAuthorizer is supposed to work only as super user auth = self.authorizer_class() try: auth.impersonate_user('nobody', '') self.assertRaisesWithMsg( AuthorizerError, "super user privileges are required", UnixAuthorizer, ) finally: auth.terminate_impersonation('nobody') # ===================================================================== # --- Windows authorizer # ===================================================================== @pytest.mark.skipif(not WINDOWS, reason="Windows only") class TestWindowsAuthorizer(_SharedAuthorizerTests, PyftpdlibTestCase): """Windows authorizer specific tests.""" authorizer_class = WindowsAuthorizer def test_wrong_anonymous_credentials(self): user = self.get_current_user() with pytest.raises(Win32ExtError): self.authorizer_class( anonymous_user=user, anonymous_password='$|1wrongpasswd' ) pyftpdlib-release-2.0.1/pyftpdlib/test/test_cli.py000066400000000000000000000107041470572577600223070ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import io import os import warnings from unittest.mock import patch import pytest import pyftpdlib from pyftpdlib import __ver__ from pyftpdlib.__main__ import main from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.servers import FTPServer from . import PyftpdlibTestCase class TestCommandLineParser(PyftpdlibTestCase): """Test command line parser.""" def setUp(self): super().setUp() class DummyFTPServer(FTPServer): """An overridden version of FTPServer class which forces serve_forever() to return immediately. """ def serve_forever(self, *args, **kwargs): self.close_all() self.devnull = io.BytesIO() self.original_ftpserver_class = FTPServer self.clog = patch("pyftpdlib.__main__.config_logging") self.clog.start() pyftpdlib.__main__.FTPServer = DummyFTPServer def tearDown(self): self.clog.stop() pyftpdlib.servers.FTPServer = self.original_ftpserver_class super().tearDown() def test_interface_opt(self): # no param with pytest.raises(SystemExit): main(["-i"]) with pytest.raises(SystemExit): main(["--interface"]) main(["--interface", "127.0.0.1", "-p", "0"]) def test_port_opt(self): # no param with pytest.raises(SystemExit): main(["-p"]) # not an int with pytest.raises(SystemExit): main(["-p", "foo"]) main(["-p", "0"]) main(["--port", "0"]) def test_write_opt(self): with warnings.catch_warnings(): warnings.filterwarnings("error") with pytest.raises(RuntimeWarning): main(["-w", "-p", "0"]) with warnings.catch_warnings(): warnings.filterwarnings("ignore") ftpd = main(["-w", "-p", "0"]) perms = ftpd.handler.authorizer.get_perms("anonymous") assert ( perms == DummyAuthorizer.read_perms + DummyAuthorizer.write_perms ) # unexpected argument with warnings.catch_warnings(): with pytest.raises(SystemExit): main(["-w", "foo", "-p", "0"]) def test_directory_opt(self): dirname = self.get_testfn() os.mkdir(dirname) ftpd = main(["-d", dirname, "-p", "0"]) ftpd = main(["--directory", dirname, "-p", "0"]) assert ftpd.handler.authorizer.get_home_dir( "anonymous" ) == os.path.abspath(dirname) # without argument with pytest.raises(SystemExit): main(["-d"]) # no such directory with pytest.raises(ValueError, match="no such directory"): main(["-d", "?!?", "-p", "0"]) def test_nat_address_opt(self): ftpd = main(["-n", "127.0.0.1", "-p", "0"]) assert ftpd.handler.masquerade_address == "127.0.0.1" ftpd.close_all() ftpd = main(["--nat-address", "127.0.0.1", "-p", "0"]) ftpd.close_all() assert ftpd.handler.masquerade_address == "127.0.0.1" # without argument with pytest.raises(SystemExit): main(["-n", "-p", "0"]) def test_range_opt(self): ftpd = main(["-r", "60000-61000", "-p", "0"]) assert ftpd.handler.passive_ports == list(range(60000, 61000 + 1)) # without arg with pytest.raises(SystemExit): main(["-r"]) # wrong arg with pytest.raises(SystemExit): main(["-r", "yyy-zzz"]) def test_debug_opt(self): main(["-D", "-p", "0"]) main(["--debug", "-p", "0"]) # with arg with pytest.raises(SystemExit): main(["-D", "xxx"]) def test_version_opt(self): for opt in ("-v", "--version"): with pytest.raises(SystemExit) as cm: main([opt, "-p", "0"]) assert str(cm.value) == f"pyftpdlib {__ver__}" def test_verbose_opt(self): for opt in ("-V", "--verbose"): main([opt, "-p", "0"]) def test_username_and_password_opt(self): ftpd = main(["--username", "foo", "--password", "bar", "-p", "0"]) assert ftpd.handler.authorizer.has_user("foo") # no --password with pytest.raises(SystemExit): main(["--username", "foo"]) pyftpdlib-release-2.0.1/pyftpdlib/test/test_filesystems.py000066400000000000000000000161621470572577600241130ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import os import tempfile import pytest from pyftpdlib.filesystems import AbstractedFS from . import HOME from . import POSIX from . import PyftpdlibTestCase from . import safe_rmpath from . import touch if POSIX: from pyftpdlib.filesystems import UnixFilesystem class TestAbstractedFS(PyftpdlibTestCase): """Test for conversion utility methods of AbstractedFS class.""" def test_ftpnorm(self): # Tests for ftpnorm method. ae = self.assertEqual fs = AbstractedFS('/', None) fs._cwd = '/' ae(fs.ftpnorm(''), '/') ae(fs.ftpnorm('/'), '/') ae(fs.ftpnorm('.'), '/') ae(fs.ftpnorm('..'), '/') ae(fs.ftpnorm('a'), '/a') ae(fs.ftpnorm('/a'), '/a') ae(fs.ftpnorm('/a/'), '/a') ae(fs.ftpnorm('a/..'), '/') ae(fs.ftpnorm('a/b'), '/a/b') ae(fs.ftpnorm('a/b/..'), '/a') ae(fs.ftpnorm('a/b/../..'), '/') fs._cwd = '/sub' ae(fs.ftpnorm(''), '/sub') ae(fs.ftpnorm('/'), '/') ae(fs.ftpnorm('.'), '/sub') ae(fs.ftpnorm('..'), '/') ae(fs.ftpnorm('a'), '/sub/a') ae(fs.ftpnorm('a/'), '/sub/a') ae(fs.ftpnorm('a/..'), '/sub') ae(fs.ftpnorm('a/b'), '/sub/a/b') ae(fs.ftpnorm('a/b/'), '/sub/a/b') ae(fs.ftpnorm('a/b/..'), '/sub/a') ae(fs.ftpnorm('a/b/../..'), '/sub') ae(fs.ftpnorm('a/b/../../..'), '/') ae(fs.ftpnorm('//'), '/') # UNC paths must be collapsed def test_ftp2fs(self): # Tests for ftp2fs method. def join(x, y): return os.path.join(x, y.replace('/', os.sep)) ae = self.assertEqual fs = AbstractedFS('/', None) def goforit(root): fs._root = root fs._cwd = '/' ae(fs.ftp2fs(''), root) ae(fs.ftp2fs('/'), root) ae(fs.ftp2fs('.'), root) ae(fs.ftp2fs('..'), root) ae(fs.ftp2fs('a'), join(root, 'a')) ae(fs.ftp2fs('/a'), join(root, 'a')) ae(fs.ftp2fs('/a/'), join(root, 'a')) ae(fs.ftp2fs('a/..'), root) ae(fs.ftp2fs('a/b'), join(root, r'a/b')) ae(fs.ftp2fs('/a/b'), join(root, r'a/b')) ae(fs.ftp2fs('/a/b/..'), join(root, 'a')) ae(fs.ftp2fs('/a/b/../..'), root) fs._cwd = '/sub' ae(fs.ftp2fs(''), join(root, 'sub')) ae(fs.ftp2fs('/'), root) ae(fs.ftp2fs('.'), join(root, 'sub')) ae(fs.ftp2fs('..'), root) ae(fs.ftp2fs('a'), join(root, 'sub/a')) ae(fs.ftp2fs('a/'), join(root, 'sub/a')) ae(fs.ftp2fs('a/..'), join(root, 'sub')) ae(fs.ftp2fs('a/b'), join(root, 'sub/a/b')) ae(fs.ftp2fs('a/b/..'), join(root, 'sub/a')) ae(fs.ftp2fs('a/b/../..'), join(root, 'sub')) ae(fs.ftp2fs('a/b/../../..'), root) # UNC paths must be collapsed ae(fs.ftp2fs('//a'), join(root, 'a')) if os.sep == '\\': goforit(r'C:\dir') goforit('C:\\') # on DOS-derived filesystems (e.g. Windows) this is the same # as specifying the current drive directory (e.g. 'C:\\') goforit('\\') elif os.sep == '/': goforit('/home/user') goforit('/') else: # os.sep == ':'? Don't know... let's try it anyway goforit(os.getcwd()) def test_fs2ftp(self): # Tests for fs2ftp method. def join(x, y): return os.path.join(x, y.replace('/', os.sep)) ae = self.assertEqual fs = AbstractedFS('/', None) def goforit(root): fs._root = root ae(fs.fs2ftp(root), '/') ae(fs.fs2ftp(join(root, '/')), '/') ae(fs.fs2ftp(join(root, '.')), '/') # can't escape from root ae(fs.fs2ftp(join(root, '..')), '/') ae(fs.fs2ftp(join(root, 'a')), '/a') ae(fs.fs2ftp(join(root, 'a/')), '/a') ae(fs.fs2ftp(join(root, 'a/..')), '/') ae(fs.fs2ftp(join(root, 'a/b')), '/a/b') ae(fs.fs2ftp(join(root, 'a/b')), '/a/b') ae(fs.fs2ftp(join(root, 'a/b/..')), '/a') ae(fs.fs2ftp(join(root, '/a/b/../..')), '/') fs._cwd = '/sub' ae(fs.fs2ftp(join(root, 'a/')), '/a') if os.sep == '\\': goforit(r'C:\dir') goforit('C:\\') # on DOS-derived filesystems (e.g. Windows) this is the same # as specifying the current drive directory (e.g. 'C:\\') goforit('\\') fs._root = r'C:\dir' ae(fs.fs2ftp('C:\\'), '/') ae(fs.fs2ftp('D:\\'), '/') ae(fs.fs2ftp('D:\\dir'), '/') elif os.sep == '/': goforit('/') if os.path.realpath('/__home/user') != '/__home/user': self.fail('Test skipped (symlinks not allowed).') goforit('/__home/user') fs._root = '/__home/user' ae(fs.fs2ftp('/__home'), '/') ae(fs.fs2ftp('/'), '/') ae(fs.fs2ftp('/__home/userx'), '/') else: # os.sep == ':'? Don't know... let's try it anyway goforit(os.getcwd()) def test_validpath(self): # Tests for validpath method. fs = AbstractedFS('/', None) fs._root = HOME assert fs.validpath(HOME) assert fs.validpath(HOME + '/') assert not fs.validpath(HOME + 'bar') if hasattr(os, 'symlink'): def test_validpath_validlink(self): # Test validpath by issuing a symlink pointing to a path # inside the root directory. testfn = self.get_testfn() testfn2 = self.get_testfn() fs = AbstractedFS('/', None) fs._root = HOME touch(testfn) os.symlink(testfn, testfn2) assert fs.validpath(testfn) def test_validpath_external_symlink(self): # Test validpath by issuing a symlink pointing to a path # outside the root directory. fs = AbstractedFS('/', None) fs._root = HOME # tempfile should create our file in /tmp directory # which should be outside the user root. If it is # not we just skip the test. testfn = self.get_testfn() with tempfile.NamedTemporaryFile() as file: try: if os.path.dirname(file.name) == HOME: return os.symlink(file.name, testfn) assert not fs.validpath(testfn) finally: safe_rmpath(testfn) @pytest.mark.skipif(not POSIX, reason="UNIX only") class TestUnixFilesystem(PyftpdlibTestCase): def test_case(self): root = os.getcwd() fs = UnixFilesystem(root, None) assert fs.root == root assert fs.cwd == root cdup = os.path.dirname(root) assert fs.ftp2fs('..') == cdup assert fs.fs2ftp(root) == root pyftpdlib-release-2.0.1/pyftpdlib/test/test_functional.py000066400000000000000000003167241470572577600237150ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import contextlib import errno import ftplib import io import logging import os import random import re import select import socket import ssl import stat import struct import time from unittest.mock import patch import pytest from pyftpdlib.filesystems import AbstractedFS from pyftpdlib.handlers import SUPPORTS_HYBRID_IPV6 from pyftpdlib.handlers import DTPHandler from pyftpdlib.handlers import FTPHandler from pyftpdlib.handlers import ThrottledDTPHandler from pyftpdlib.ioloop import IOLoop from pyftpdlib.servers import FTPServer from . import BUFSIZE from . import CI_TESTING from . import GLOBAL_TIMEOUT from . import HOME from . import HOST from . import INTERRUPTED_TRANSF_SIZE from . import OSX from . import PASSWD from . import POSIX from . import SUPPORTS_IPV4 from . import SUPPORTS_IPV6 from . import USER from . import WINDOWS from . import FtpdThreadWrapper from . import PyftpdlibTestCase from . import close_client from . import disable_log_warning from . import get_server_handler from . import retry_on_failure from . import safe_rmpath from . import touch class TestFtpAuthentication(PyftpdlibTestCase): """Test: USER, PASS, REIN.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.testfn = self.get_testfn() self.file = open(self.testfn, 'w+b') self.dummyfile = io.BytesIO() def tearDown(self): close_client(self.client) self.server.stop() if not self.file.closed: self.file.close() if not self.dummyfile.closed: self.dummyfile.close() super().tearDown() def assert_auth_failed(self, user, passwd): with pytest.raises( ftplib.error_perm, match='530 Authentication failed' ): self.client.login(user, passwd) def test_auth_ok(self): self.client.login(user=USER, passwd=PASSWD) def test_anon_auth(self): self.client.login(user='anonymous', passwd='anon@') self.client.login(user='anonymous', passwd='') # supposed to be case sensitive self.assert_auth_failed('AnoNymouS', 'foo') # empty passwords should be allowed self.client.sendcmd('user anonymous') self.client.sendcmd('pass ') self.client.sendcmd('user anonymous') self.client.sendcmd('pass') def test_auth_failed(self): self.assert_auth_failed(USER, 'wrong') self.assert_auth_failed('wrong', PASSWD) self.assert_auth_failed('wrong', 'wrong') def test_wrong_cmds_order(self): with pytest.raises( ftplib.error_perm, match='503 Login with USER first' ): self.client.sendcmd('pass ' + PASSWD) self.client.login(user=USER, passwd=PASSWD) with pytest.raises( ftplib.error_perm, match="503 User already authenticated." ): self.client.sendcmd('pass ' + PASSWD) def test_max_auth(self): self.assert_auth_failed(USER, 'wrong') self.assert_auth_failed(USER, 'wrong') self.assert_auth_failed(USER, 'wrong') # If authentication fails for 3 times ftpd disconnects the # client. We can check if that happens by using self.client.sendcmd() # on the 'dead' socket object. If socket object is really # closed it should be raised a OSError exception (Windows) # or a EOFError exception (Linux). self.client.sock.settimeout(0.1) with pytest.raises((OSError, EOFError)): self.client.sendcmd('') def test_rein(self): self.client.login(user=USER, passwd=PASSWD) self.client.sendcmd('rein') # user not authenticated, error response expected with pytest.raises( ftplib.error_perm, match='530 Log in with USER and PASS first' ): self.client.sendcmd('pwd') # by logging-in again we should be able to execute a # file-system command self.client.login(user=USER, passwd=PASSWD) self.client.sendcmd('pwd') @retry_on_failure def test_rein_during_transfer(self): # Test REIN while already authenticated and a transfer is # in progress. self.client.login(user=USER, passwd=PASSWD) data = b'abcde12345' * 1000000 self.file.write(data) self.file.close() conn = self.client.transfercmd('retr ' + self.testfn) with contextlib.closing(conn): rein_sent = False bytes_recv = 0 while True: chunk = conn.recv(BUFSIZE) if not chunk: break bytes_recv += len(chunk) self.dummyfile.write(chunk) if bytes_recv > INTERRUPTED_TRANSF_SIZE and not rein_sent: rein_sent = True # flush account, error response expected self.client.sendcmd('rein') with pytest.raises( ftplib.error_perm, match='530 Log in with USER and PASS first', ): self.client.dir() # a 226 response is expected once transfer finishes assert self.client.voidresp()[:3] == '226' # account is still flushed, error response is still expected with pytest.raises( ftplib.error_perm, match='530 Log in with USER and PASS first' ): self.client.sendcmd('size ' + self.testfn) # by logging-in again we should be able to execute a # filesystem command self.client.login(user=USER, passwd=PASSWD) self.client.sendcmd('pwd') self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) def test_user(self): # Test USER while already authenticated and no transfer # is in progress. self.client.login(user=USER, passwd=PASSWD) self.client.sendcmd('user ' + USER) # authentication flushed with pytest.raises( ftplib.error_perm, match='530 Log in with USER and PASS first' ): self.client.sendcmd('pwd') self.client.sendcmd('pass ' + PASSWD) self.client.sendcmd('pwd') def test_user_during_transfer(self): # Test USER while already authenticated and a transfer is # in progress. self.client.login(user=USER, passwd=PASSWD) data = b'abcde12345' * 1000000 self.file.write(data) self.file.close() conn = self.client.transfercmd('retr ' + self.testfn) with contextlib.closing(conn): rein_sent = 0 bytes_recv = 0 while True: chunk = conn.recv(BUFSIZE) if not chunk: break bytes_recv += len(chunk) self.dummyfile.write(chunk) # stop transfer while it isn't finished yet if bytes_recv > INTERRUPTED_TRANSF_SIZE and not rein_sent: rein_sent = True # flush account, expect an error response self.client.sendcmd('user ' + USER) with pytest.raises( ftplib.error_perm, match='530 Log in with USER and PASS first', ): self.client.dir() # a 226 response is expected once transfer finishes assert self.client.voidresp()[:3] == '226' # account is still flushed, error response is still expected with pytest.raises( ftplib.error_perm, match='530 Log in with USER and PASS first' ): self.client.sendcmd('pwd') # by logging-in again we should be able to execute a # filesystem command self.client.sendcmd('pass ' + PASSWD) self.client.sendcmd('pwd') self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) class TestFtpDummyCmds(PyftpdlibTestCase): """Test: TYPE, STRU, MODE, NOOP, SYST, ALLO, HELP, SITE HELP.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def test_type(self): self.client.sendcmd('type a') self.client.sendcmd('type i') self.client.sendcmd('type l7') self.client.sendcmd('type l8') with pytest.raises(ftplib.error_perm, match="Unsupported type"): self.client.sendcmd('type ?!?') def test_stru(self): self.client.sendcmd('stru f') self.client.sendcmd('stru F') with pytest.raises(ftplib.error_perm, match="Unimplemented"): self.client.sendcmd('stru p') with pytest.raises(ftplib.error_perm, match="Unimplemented"): self.client.sendcmd('stru r') with pytest.raises(ftplib.error_perm, match="Unrecognized"): self.client.sendcmd('stru ?!?') def test_mode(self): self.client.sendcmd('mode s') self.client.sendcmd('mode S') with pytest.raises(ftplib.error_perm, match="Unimplemented"): self.client.sendcmd('mode b') with pytest.raises(ftplib.error_perm, match="Unimplemented"): self.client.sendcmd('mode c') with pytest.raises(ftplib.error_perm, match="Unrecognized"): self.client.sendcmd('mode ?!?') def test_noop(self): self.client.sendcmd('noop') def test_syst(self): self.client.sendcmd('syst') def test_allo(self): self.client.sendcmd('allo x') def test_quit(self): self.client.sendcmd('quit') def test_help(self): self.client.sendcmd('help') cmd = random.choice(list(FTPHandler.proto_cmds.keys())) self.client.sendcmd(f'help {cmd}') with pytest.raises(ftplib.error_perm, match="Unrecognized"): self.client.sendcmd('help ?!?') def test_site(self): with pytest.raises(ftplib.error_perm, match="needs an argument"): self.client.sendcmd('site') with pytest.raises(ftplib.error_perm, match="not understood"): self.client.sendcmd('site ?!?') with pytest.raises(ftplib.error_perm, match="not understood"): self.client.sendcmd('site foo bar') with pytest.raises(ftplib.error_perm, match="not understood"): self.client.sendcmd('sitefoo bar') def test_site_help(self): self.client.sendcmd('site help') self.client.sendcmd('site help help') with pytest.raises(ftplib.error_perm, match="Unrecognized SITE"): self.client.sendcmd('site help ?!?') def test_rest(self): # Test error conditions only; resumed data transfers are # tested later. self.client.sendcmd('type i') with pytest.raises(ftplib.error_perm, match="needs an argument"): self.client.sendcmd('rest') with pytest.raises(ftplib.error_perm, match="Invalid parameter"): self.client.sendcmd('rest str') with pytest.raises(ftplib.error_perm, match="Invalid parameter"): self.client.sendcmd('rest -1') with pytest.raises(ftplib.error_perm, match="Invalid parameter"): self.client.sendcmd('rest 10.1') # REST is not supposed to be allowed in ASCII mode self.client.sendcmd('type a') with pytest.raises( ftplib.error_perm, match='not allowed in ASCII mode' ): self.client.sendcmd('rest 10') def test_feat(self): resp = self.client.sendcmd('feat') assert 'UTF8' in resp assert 'TVFS' in resp def test_opts_feat(self): with pytest.raises(ftplib.error_perm, match="Invalid argument"): self.client.sendcmd('opts mlst bad_fact') with pytest.raises( ftplib.error_perm, match="Invalid number of arguments" ): self.client.sendcmd('opts mlst type ;') with pytest.raises(ftplib.error_perm, match="Unsupported command"): self.client.sendcmd('opts not_mlst') # utility function which used for extracting the MLST "facts" # string from the FEAT response def mlst(): resp = self.client.sendcmd('feat') return re.search(r'^\s*MLST\s+(\S+)$', resp, re.MULTILINE).group(1) # we rely on "type", "perm", "size", and "modify" facts which # are those available on all platforms assert 'type*;perm*;size*;modify*;' in mlst() assert self.client.sendcmd('opts mlst type;') == '200 MLST OPTS type;' assert self.client.sendcmd('opts mLSt TypE;') == '200 MLST OPTS type;' assert 'type*;perm;size;modify;' in mlst() assert self.client.sendcmd('opts mlst') == '200 MLST OPTS ' assert '*' not in mlst() assert self.client.sendcmd('opts mlst fish;cakes;') == '200 MLST OPTS ' assert '*' not in mlst() assert ( self.client.sendcmd('opts mlst fish;cakes;type;') == '200 MLST OPTS type;' ) assert 'type*;perm;size;modify;' in mlst() class TestFtpCmdsSemantic(PyftpdlibTestCase): server_class = FtpdThreadWrapper client_class = ftplib.FTP arg_cmds = [ 'allo', 'appe', 'dele', 'eprt', 'mdtm', 'mfmt', 'mkd', 'mode', 'opts', 'port', 'rest', 'retr', 'rmd', 'rnfr', 'rnto', 'site chmod', 'site', 'size', 'stor', 'stru', 'type', 'user', 'xmkd', 'xrmd', ] def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def test_arg_cmds(self): # Test commands requiring an argument. expected = "501 Syntax error: command needs an argument." for cmd in self.arg_cmds: self.client.putcmd(cmd) resp = self.client.getmultiline() assert resp == expected def test_no_arg_cmds(self): # Test commands accepting no arguments. expected = "501 Syntax error: command does not accept arguments." narg_cmds = [ 'abor', 'cdup', 'feat', 'noop', 'pasv', 'pwd', 'quit', 'rein', 'syst', 'xcup', 'xpwd', ] for cmd in narg_cmds: self.client.putcmd(cmd + ' arg') resp = self.client.getmultiline() assert resp == expected def test_auth_cmds(self): # Test those commands requiring client to be authenticated. expected = "530 Log in with USER and PASS first." self.client.sendcmd('rein') for cmd in self.server.handler.proto_cmds: cmd = cmd.lower() if cmd in ( 'feat', 'help', 'noop', 'user', 'pass', 'stat', 'syst', 'quit', 'site', 'site help', 'pbsz', 'auth', 'prot', 'ccc', ): continue if cmd in self.arg_cmds: cmd += ' arg' self.client.putcmd(cmd) resp = self.client.getmultiline() assert resp == expected def test_no_auth_cmds(self): # Test those commands that do not require client to be authenticated. self.client.sendcmd('rein') for cmd in ('feat', 'help', 'noop', 'stat', 'syst', 'site help'): self.client.sendcmd(cmd) # STAT provided with an argument is equal to LIST hence not allowed # if not authenticated with pytest.raises(ftplib.error_perm, match='530 Log in with USER'): self.client.sendcmd('stat /') self.client.sendcmd('quit') class TestFtpFsOperations(PyftpdlibTestCase): """Test: PWD, CWD, CDUP, SIZE, RNFR, RNTO, DELE, MKD, RMD, MDTM, STAT, MFMT. """ server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) self.tempfile = self.get_testfn() self.tempdir = self.get_testfn() touch(self.tempfile) os.mkdir(self.tempdir) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def test_cwd(self): self.client.cwd(self.tempdir) assert self.client.pwd() == '/' + self.tempdir with pytest.raises(ftplib.error_perm, match="No such file"): self.client.cwd('subtempdir') # cwd provided with no arguments is supposed to move us to the # root directory self.client.sendcmd('cwd') assert self.client.pwd() == '/' def test_pwd(self): assert self.client.pwd() == '/' self.client.cwd(self.tempdir) assert self.client.pwd() == '/' + self.tempdir def test_cdup(self): subfolder = self.get_testfn(dir=self.tempdir) os.mkdir(os.path.join(self.tempdir, subfolder)) assert self.client.pwd() == '/' self.client.cwd(self.tempdir) assert self.client.pwd() == f'/{self.tempdir}' self.client.cwd(subfolder) assert self.client.pwd() == f'/{self.tempdir}/{subfolder}' self.client.sendcmd('cdup') assert self.client.pwd() == f'/{self.tempdir}' self.client.sendcmd('cdup') assert self.client.pwd() == '/' # make sure we can't escape from root directory self.client.sendcmd('cdup') assert self.client.pwd() == '/' def test_mkd(self): tempdir = self.get_testfn() dirname = self.client.mkd(tempdir) # the 257 response is supposed to include the absolute dirname assert dirname == '/' + tempdir # make sure we can't create directories which already exist # (probably not really necessary); # let's use a try/except statement to avoid leaving behind # orphaned temporary directory in the event of a test failure. with pytest.raises(ftplib.error_perm, match="File exists"): self.client.mkd(tempdir) def test_rmd(self): self.client.rmd(self.tempdir) with pytest.raises(ftplib.error_perm, match="Not a directory"): self.client.rmd(self.tempfile) # make sure we can't remove the root directory with pytest.raises( ftplib.error_perm, match="Can't remove root directory" ): self.client.rmd('/') def test_dele(self): self.client.delete(self.tempfile) with pytest.raises(ftplib.error_perm): self.client.delete(self.tempdir) def test_rnfr_rnto(self): # rename file tempname = self.get_testfn() self.client.rename(self.tempfile, tempname) self.client.rename(tempname, self.tempfile) # rename dir tempname = self.get_testfn() self.client.rename(self.tempdir, tempname) self.client.rename(tempname, self.tempdir) # rnfr/rnto over non-existing paths bogus = self.get_testfn() with pytest.raises(ftplib.error_perm, match="No such file"): self.client.rename(bogus, '/x') with pytest.raises(ftplib.error_perm): self.client.rename(self.tempfile, '/') # rnto sent without first specifying the source with pytest.raises(ftplib.error_perm, match="use RNFR first"): self.client.sendcmd('rnto ' + self.tempfile) # make sure we can't rename root directory with pytest.raises( ftplib.error_perm, match="Can't rename home directory" ): self.client.rename('/', '/x') def test_mdtm(self): self.client.sendcmd('mdtm ' + self.tempfile) bogus = self.get_testfn() with pytest.raises(ftplib.error_perm, match="not retrievable"): self.client.sendcmd('mdtm ' + bogus) # make sure we can't use mdtm against directories with pytest.raises(ftplib.error_perm, match="not retrievable"): self.client.sendcmd('mdtm ' + self.tempdir) def test_mfmt(self): # making sure MFMT is able to modify the timestamp for the file test_timestamp = "20170921013410" self.client.sendcmd('mfmt ' + test_timestamp + ' ' + self.tempfile) resp_time = os.path.getmtime(self.tempfile) resp_time_str = time.strftime('%Y%m%d%H%M%S', time.gmtime(resp_time)) assert test_timestamp in resp_time_str def test_invalid_mfmt_timeval(self): # testing MFMT with invalid timeval argument test_timestamp_with_chars = "B017092101341A" test_timestamp_invalid_length = "20170921" with pytest.raises(ftplib.error_perm, match="Invalid time format"): self.client.sendcmd( 'mfmt ' + test_timestamp_with_chars + ' ' + self.tempfile ) with pytest.raises(ftplib.error_perm, match="Invalid time format"): self.client.sendcmd( 'mfmt ' + test_timestamp_invalid_length + ' ' + self.tempfile ) def test_missing_mfmt_timeval_arg(self): # testing missing timeval argument with pytest.raises(ftplib.error_perm, match="Syntax error"): self.client.sendcmd('mfmt ' + self.tempfile) def test_size(self): self.client.sendcmd('type a') with pytest.raises( ftplib.error_perm, match="SIZE not allowed in ASCII mode" ): self.client.size(self.tempfile) self.client.sendcmd('type i') self.client.size(self.tempfile) # make sure we can't use size against directories with pytest.raises(ftplib.error_perm, match="not retrievable"): self.client.sendcmd('size ' + self.tempdir) if not hasattr(os, 'chmod'): def test_site_chmod(self): with pytest.raises(ftplib.error_perm): self.client.sendcmd('site chmod 777 ' + self.tempfile) else: def test_site_chmod(self): # not enough args with pytest.raises(ftplib.error_perm, match="needs two arguments"): self.client.sendcmd('site chmod 777') # bad args with pytest.raises( ftplib.error_perm, match="Invalid SITE CHMOD format" ): self.client.sendcmd('site chmod -177 ' + self.tempfile) with pytest.raises( ftplib.error_perm, match="Invalid SITE CHMOD format" ): self.client.sendcmd('site chmod 778 ' + self.tempfile) with pytest.raises( ftplib.error_perm, match="Invalid SITE CHMOD format" ): self.client.sendcmd('site chmod foo ' + self.tempfile) def getmode(): mode = oct(stat.S_IMODE(os.stat(self.tempfile).st_mode)) mode = mode.replace('o', '') return mode # on Windows it is possible to set read-only flag only if WINDOWS: self.client.sendcmd('site chmod 777 ' + self.tempfile) assert getmode() == '0666' self.client.sendcmd('site chmod 444 ' + self.tempfile) assert getmode() == '0444' self.client.sendcmd('site chmod 666 ' + self.tempfile) assert getmode() == '0666' else: self.client.sendcmd('site chmod 777 ' + self.tempfile) assert getmode() == '0777' self.client.sendcmd('site chmod 755 ' + self.tempfile) assert getmode() == '0755' self.client.sendcmd('site chmod 555 ' + self.tempfile) assert getmode() == '0555' class CustomIO(io.RawIOBase): def __init__(self): super().__init__() self._bytesio = io.BytesIO() def seek(self, offset, whence=io.SEEK_SET): return self._bytesio.seek(offset, whence) def readinto(self, b): return self._bytesio.readinto(b) def write(self, b): return self._bytesio.write(b) class TestFtpStoreData(PyftpdlibTestCase): """Test STOR, STOU, APPE, REST, TYPE.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP use_sendfile = None use_custom_io = False def setUp(self): super().setUp() self.server = self.server_class() if self.use_sendfile is not None: self.server.handler.use_sendfile = self.use_sendfile self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) if self.use_custom_io: self.dummy_recvfile = CustomIO() self.dummy_sendfile = CustomIO() else: self.dummy_recvfile = io.BytesIO() self.dummy_sendfile = io.BytesIO() self.testfn = self.get_testfn() def tearDown(self): close_client(self.client) self.server.stop() self.dummy_recvfile.close() self.dummy_sendfile.close() if self.use_sendfile is not None: self.server.handler.use_sendfile = hasattr(os, "sendfile") super().tearDown() def test_stor(self): data = b'abcde12345' * 100000 self.dummy_sendfile.write(data) self.dummy_sendfile.seek(0) self.client.storbinary('stor ' + self.testfn, self.dummy_sendfile) self.client.retrbinary( 'retr ' + self.testfn, self.dummy_recvfile.write ) self.dummy_recvfile.seek(0) datafile = self.dummy_recvfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) def test_stor_active(self): # Like test_stor but using PORT self.client.set_pasv(False) self.test_stor() @retry_on_failure def test_stor_ascii(self): # Test STOR in ASCII mode def store(cmd, fp, blocksize=8192): # like storbinary() except it sends "type a" instead of # "type i" before starting the transfer self.client.voidcmd('type a') with contextlib.closing(self.client.transfercmd(cmd)) as conn: while True: buf = fp.read(blocksize) if not buf: break conn.sendall(buf) if isinstance(conn, ssl.SSLSocket): conn.unwrap() return self.client.voidresp() data = b'abcde12345\r\n' * 100000 self.dummy_sendfile.write(data) self.dummy_sendfile.seek(0) store('stor ' + self.testfn, self.dummy_sendfile) self.client.retrbinary( 'retr ' + self.testfn, self.dummy_recvfile.write ) expected = data.replace(b'\r\n', bytes(os.linesep, "ascii")) self.dummy_recvfile.seek(0) datafile = self.dummy_recvfile.read() assert len(expected) == len(datafile) assert hash(expected) == hash(datafile) @retry_on_failure def test_stor_ascii_2(self): # Test that no extra extra carriage returns are added to the # file in ASCII mode in case CRLF gets truncated in two chunks # (issue 116) def store(cmd, fp, blocksize=8192): # like storbinary() except it sends "type a" instead of # "type i" before starting the transfer self.client.voidcmd('type a') with contextlib.closing(self.client.transfercmd(cmd)) as conn: while True: buf = fp.read(blocksize) if not buf: break conn.sendall(buf) return self.client.voidresp() old_buffer = DTPHandler.ac_in_buffer_size try: # set a small buffer so that CRLF gets delivered in two # separate chunks: "CRLF", " f", "oo", " CR", "LF", " b", "ar" DTPHandler.ac_in_buffer_size = 2 data = b'\r\n foo \r\n bar' self.dummy_sendfile.write(data) self.dummy_sendfile.seek(0) store('stor ' + self.testfn, self.dummy_sendfile) expected = data.replace(b'\r\n', bytes(os.linesep, "ascii")) self.client.retrbinary( 'retr ' + self.testfn, self.dummy_recvfile.write ) self.client.quit() self.dummy_recvfile.seek(0) assert expected == self.dummy_recvfile.read() finally: DTPHandler.ac_in_buffer_size = old_buffer def test_stou(self): data = b'abcde12345' * 100000 self.dummy_sendfile.write(data) self.dummy_sendfile.seek(0) self.client.voidcmd('TYPE I') # filename comes in as "1xx FILE: " filename = self.client.sendcmd('stou').split('FILE: ')[1] try: with contextlib.closing(self.client.makeport()) as sock: conn, _ = sock.accept() with contextlib.closing(conn): conn.settimeout(GLOBAL_TIMEOUT) if hasattr(self.client_class, 'ssl_version'): conn = ssl.wrap_socket(conn) while True: buf = self.dummy_sendfile.read(8192) if not buf: break conn.sendall(buf) # transfer finished, a 226 response is expected assert self.client.voidresp()[:3] == '226' self.client.retrbinary( 'retr ' + filename, self.dummy_recvfile.write ) self.dummy_recvfile.seek(0) datafile = self.dummy_recvfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) finally: # We do not use os.remove() because file could still be # locked by ftpd thread. If DELE through FTP fails try # os.remove() as last resort. if os.path.exists(filename): try: self.client.delete(filename) except (ftplib.Error, EOFError, OSError): safe_rmpath(filename) def test_stou_rest(self): # Watch for STOU preceded by REST, which makes no sense. self.client.sendcmd('type i') self.client.sendcmd('rest 10') with pytest.raises(ftplib.error_temp, match="Can't STOU while REST"): self.client.sendcmd('stou') def test_stou_orphaned_file(self): # Check that no orphaned file gets left behind when STOU fails. # Even if STOU fails the file is first created and then erased. # Since we can't know the name of the file the best way that # we have to test this case is comparing the content of the # directory before and after STOU has been issued. # Assuming that testfn is supposed to be a "reserved" file # name we shouldn't get false positives. # login as a limited user in order to make STOU fail self.client.login('anonymous', '@nopasswd') before = os.listdir(HOME) with pytest.raises(ftplib.error_perm, match="Not enough privileges"): self.client.sendcmd('stou ' + self.testfn) after = os.listdir(HOME) if before != after: for file in after: assert not file.startswith(self.testfn) def test_appe(self): data1 = b'abcde12345' * 100000 self.dummy_sendfile.write(data1) self.dummy_sendfile.seek(0) self.client.storbinary('stor ' + self.testfn, self.dummy_sendfile) data2 = b'fghil67890' * 100000 self.dummy_sendfile.write(data2) self.dummy_sendfile.seek(len(data1)) self.client.storbinary('appe ' + self.testfn, self.dummy_sendfile) self.client.retrbinary( "retr " + self.testfn, self.dummy_recvfile.write ) self.dummy_recvfile.seek(0) datafile = self.dummy_recvfile.read() assert len(data1 + data2) == len(datafile) assert hash(data1 + data2) == hash(datafile) def test_appe_rest(self): # Watch for APPE preceded by REST, which makes no sense. self.client.sendcmd('type i') self.client.sendcmd('rest 10') with pytest.raises(ftplib.error_temp, match="Can't APPE while REST"): self.client.sendcmd('appe x') def test_rest_on_stor(self): # Test STOR preceded by REST. data = b'abcde12345' * 100000 self.dummy_sendfile.write(data) self.dummy_sendfile.seek(0) self.client.voidcmd('TYPE I') with contextlib.closing( self.client.transfercmd('stor ' + self.testfn) ) as conn: bytes_sent = 0 while True: chunk = self.dummy_sendfile.read(BUFSIZE) conn.sendall(chunk) bytes_sent += len(chunk) # stop transfer while it isn't finished yet if bytes_sent >= INTERRUPTED_TRANSF_SIZE or not chunk: break if isinstance(conn, ssl.SSLSocket): conn.unwrap() # transfer wasn't finished yet but server can't know this, # hence expect a 226 response assert self.client.voidresp()[:3] == '226' # resuming transfer by using a marker value greater than the # file size stored on the server should result in an error # on stor file_size = self.client.size(self.testfn) assert file_size == bytes_sent self.client.sendcmd(f'rest {file_size + 1}') with pytest.raises(ftplib.error_perm, match="> file size"): self.client.sendcmd('stor ' + self.testfn) self.client.sendcmd(f'rest {bytes_sent}') self.client.storbinary('stor ' + self.testfn, self.dummy_sendfile) self.client.retrbinary( 'retr ' + self.testfn, self.dummy_recvfile.write ) self.dummy_sendfile.seek(0) self.dummy_recvfile.seek(0) data_sendfile = self.dummy_sendfile.read() data_recvfile = self.dummy_recvfile.read() assert len(data_sendfile) == len(data_recvfile) assert len(data_sendfile) == len(data_recvfile) def test_failing_rest_on_stor(self): # Test REST -> STOR against a non existing file. self.client.sendcmd('type i') self.client.sendcmd('rest 10') with pytest.raises(ftplib.error_perm, match="No such file"): self.client.storbinary('stor ' + self.testfn, lambda x: x) # if the first STOR failed because of REST, the REST marker # is supposed to be resetted to 0 self.dummy_sendfile.write(b'x' * 4096) self.dummy_sendfile.seek(0) self.client.storbinary('stor ' + self.testfn, self.dummy_sendfile) def test_quit_during_transfer(self): # RFC-959 states that if QUIT is sent while a transfer is in # progress, the connection must remain open for result response # and the server will then close it. with contextlib.closing( self.client.transfercmd('stor ' + self.testfn) ) as conn: conn.sendall(b'abcde12345' * 50000) self.client.sendcmd('quit') conn.sendall(b'abcde12345' * 50000) # expect the response (transfer ok) assert self.client.voidresp()[:3] == '226' # Make sure client has been disconnected. # OSError (Windows) or EOFError (Linux) exception is supposed # to be raised in such a case. self.client.sock.settimeout(0.1) with pytest.raises((OSError, EOFError)): self.client.sendcmd('noop') def test_stor_empty_file(self): self.client.storbinary('stor ' + self.testfn, self.dummy_sendfile) self.client.quit() with open(self.testfn) as f: assert not f.read() @pytest.mark.skipif(not POSIX, reason="POSIX only") class TestFtpStoreDataNoSendfile(TestFtpStoreData): """Test STOR, STOU, APPE, REST, TYPE not using sendfile().""" use_sendfile = False class TestFtpStoreDataWithCustomIO(TestFtpStoreData): """Test STOR, STOU, APPE, REST, TYPE with custom IO objects().""" use_custom_io = True class TestFtpRetrieveData(PyftpdlibTestCase): """Test RETR, REST, TYPE.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP use_sendfile = None use_custom_io = False def retrieve_ascii(self, cmd, callback, blocksize=8192, rest=None): """Like retrbinary but uses TYPE A instead.""" self.client.voidcmd('type a') with contextlib.closing(self.client.transfercmd(cmd, rest)) as conn: conn.settimeout(GLOBAL_TIMEOUT) while True: data = conn.recv(blocksize) if not data: break callback(data) return self.client.voidresp() def setUp(self): super().setUp() self.server = self.server_class() if self.use_sendfile is not None: self.server.handler.use_sendfile = self.use_sendfile self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) self.testfn = self.get_testfn() if self.use_custom_io: self.dummyfile = CustomIO() else: self.dummyfile = io.BytesIO() def tearDown(self): close_client(self.client) self.server.stop() self.dummyfile.close() if self.use_sendfile is not None: self.server.handler.use_sendfile = hasattr(os, "sendfile") super().tearDown() def test_retr(self): data = b'abcde12345' * 100000 with open(self.testfn, 'wb') as f: f.write(data) self.client.retrbinary("retr " + self.testfn, self.dummyfile.write) self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) # attempt to retrieve a file which doesn't exist bogus = self.get_testfn() with pytest.raises(ftplib.error_perm, match="No such file"): self.client.retrbinary("retr " + bogus, lambda x: x) def test_retr_ascii(self): # Test RETR in ASCII mode. data = (b'abcde12345' + bytes(os.linesep, "ascii")) * 100000 with open(self.testfn, 'wb') as f: f.write(data) self.retrieve_ascii("retr " + self.testfn, self.dummyfile.write) expected = data.replace(bytes(os.linesep, "ascii"), b'\r\n') self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(expected) == len(datafile) assert hash(expected) == hash(datafile) def test_retr_ascii_already_crlf(self): # Test ASCII mode RETR for data with CRLF line endings. data = b'abcde12345\r\n' * 100000 with open(self.testfn, 'wb') as f: f.write(data) self.retrieve_ascii("retr " + self.testfn, self.dummyfile.write) self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) @retry_on_failure def test_restore_on_retr(self): data = b'abcde12345' * 1000000 with open(self.testfn, 'wb') as f: f.write(data) received_bytes = 0 self.client.voidcmd('TYPE I') with contextlib.closing( self.client.transfercmd('retr ' + self.testfn) ) as conn: conn.settimeout(GLOBAL_TIMEOUT) while True: chunk = conn.recv(BUFSIZE) if not chunk: break self.dummyfile.write(chunk) received_bytes += len(chunk) if received_bytes >= INTERRUPTED_TRANSF_SIZE: break # transfer wasn't finished yet so we expect a 426 response assert self.client.getline()[:3] == "426" # resuming transfer by using a marker value greater than the # file size stored on the server should result in an error # on retr (RFC-1123) file_size = self.client.size(self.testfn) self.client.sendcmd(f'rest {file_size + 1}') with pytest.raises( ftplib.error_perm, match="position .*? > file size" ): self.client.sendcmd('retr ' + self.testfn) # test resume self.client.sendcmd(f'rest {received_bytes}') self.client.retrbinary("retr " + self.testfn, self.dummyfile.write) self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) def test_retr_empty_file(self): touch(self.testfn) self.client.retrbinary("retr " + self.testfn, self.dummyfile.write) self.dummyfile.seek(0) assert self.dummyfile.read() == b"" @pytest.mark.skipif(not POSIX, reason="POSIX only") class TestFtpRetrieveDataNoSendfile(TestFtpRetrieveData): """Test RETR, REST, TYPE by not using sendfile().""" use_sendfile = False class TestFtpRetrieveDataCustomIO(TestFtpRetrieveData): """Test RETR, REST, TYPE using custom IO objects.""" use_custom_io = True class TestFtpListingCmds(PyftpdlibTestCase): """Test LIST, NLST, argumented STAT.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) self.testfn = self.get_testfn() touch(self.testfn) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def _test_listing_cmds(self, cmd): """Tests common to LIST NLST and MLSD commands.""" # assume that no argument has the same meaning of "/" l1 = l2 = [] self.client.retrlines(cmd, l1.append) self.client.retrlines(cmd + ' /', l2.append) assert l1 == l2 if cmd.lower() != 'mlsd': # if pathname is a file one line is expected x = [] self.client.retrlines(f'{cmd} ' + self.testfn, x.append) assert len(x) == 1 assert ''.join(x).endswith(self.testfn) # non-existent path, 550 response is expected bogus = self.get_testfn() with pytest.raises( ftplib.error_perm, match="No such (file|directory)" ): self.client.retrlines(f'{cmd} ' + bogus, lambda x: x) # for an empty directory we excpect that the data channel is # opened anyway and that no data is received x = [] tempdir = self.get_testfn() os.mkdir(tempdir) try: self.client.retrlines(f'{cmd} {tempdir}', x.append) assert x == [] finally: safe_rmpath(tempdir) def test_nlst(self): # common tests self._test_listing_cmds('nlst') def test_list(self): # common tests self._test_listing_cmds('list') # known incorrect pathname arguments (e.g. old clients) are # expected to be treated as if pathname would be == '/' l1 = l2 = l3 = l4 = l5 = [] self.client.retrlines('list /', l1.append) self.client.retrlines('list -a', l2.append) self.client.retrlines('list -l', l3.append) self.client.retrlines('list -al', l4.append) self.client.retrlines('list -la', l5.append) tot = (l1, l2, l3, l4, l5) for x in range(len(tot) - 1): assert tot[x] == tot[x + 1] def test_mlst(self): # utility function for extracting the line of interest def mlstline(cmd): return self.client.voidcmd(cmd).split('\n')[1] # the fact set must be preceded by a space assert mlstline('mlst').startswith(' ') # where TVFS is supported, a fully qualified pathname is expected assert mlstline('mlst ' + self.testfn).endswith('/' + self.testfn) assert mlstline('mlst').endswith('/') # assume that no argument has the same meaning of "/" assert mlstline('mlst') == mlstline('mlst /') # non-existent path bogus = self.get_testfn() with pytest.raises(ftplib.error_perm, match="No such file"): self.client.sendcmd('mlst ' + bogus) # test file/dir notations assert 'type=dir' in mlstline('mlst') assert 'type=file' in mlstline('mlst ' + self.testfn) # let's add some tests for OPTS command self.client.sendcmd('opts mlst type;') assert mlstline('mlst') == ' type=dir; /' # where no facts are present, two leading spaces before the # pathname are required (RFC-3659) self.client.sendcmd('opts mlst') assert mlstline('mlst') == ' /' def test_mlsd(self): # common tests self._test_listing_cmds('mlsd') dir = self.get_testfn() os.mkdir(dir) try: self.client.retrlines('mlsd ' + self.testfn, lambda x: x) except ftplib.error_perm as err: resp = str(err) # if path is a file a 501 response code is expected assert str(resp)[0:3] == "501" else: self.fail("Exception not raised") def test_mlsd_all_facts(self): feat = self.client.sendcmd('feat') # all the facts facts = re.search(r'^\s*MLST\s+(\S+)$', feat, re.MULTILINE).group(1) facts = facts.replace("*;", ";") self.client.sendcmd('opts mlst ' + facts) resp = self.client.sendcmd('mlst') local = facts[:-1].split(";") returned = resp.split("\n")[1].strip()[:-3] returned = [x.split("=")[0] for x in returned.split(";")] assert sorted(local) == sorted(returned) assert "type" in resp assert "size" in resp assert "perm" in resp assert "modify" in resp if POSIX: assert "unique" in resp assert "unix.mode" in resp assert "unix.uid" in resp assert "unix.gid" in resp elif WINDOWS: assert "create" in resp def test_stat(self): # Test STAT provided with argument which is equal to LIST self.client.sendcmd('stat /') self.client.sendcmd('stat ' + self.testfn) self.client.putcmd('stat *') resp = self.client.getmultiline() assert resp == '550 Globbing not supported.' bogus = self.get_testfn() with pytest.raises(ftplib.error_perm, match="No such file"): self.client.sendcmd('stat ' + bogus) def test_unforeseen_time_event(self): # Emulate a case where the file last modification time is prior # to year 1900. This most likely will never happen unless # someone specifically force the last modification time of a # file in some way. # To do so we temporarily override os.path.getmtime so that it # returns a negative value referring to a year prior to 1900. # It causes time.localtime/gmtime to raise a ValueError exception # which is supposed to be handled by server. _getmtime = AbstractedFS.getmtime try: AbstractedFS.getmtime = lambda x, y: -9000000000 self.client.sendcmd('stat /') # test AbstractedFS.format_list() self.client.sendcmd('mlst /') # test AbstractedFS.format_mlsx() # make sure client hasn't been disconnected self.client.sendcmd('noop') finally: AbstractedFS.getmtime = _getmtime class TestFtpAbort(PyftpdlibTestCase): """Test: ABOR.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def test_abor_no_data(self): # Case 1: ABOR while no data channel is opened: respond with 225. resp = self.client.sendcmd('ABOR') assert resp == '225 No transfer to abort.' self.client.retrlines('list', [].append) def test_abor_pasv(self): # Case 2: user sends a PASV, a data-channel socket is listening # but not connected, and ABOR is sent: close listening data # socket, respond with 225. self.client.makepasv() respcode = self.client.sendcmd('ABOR')[:3] assert respcode == '225' self.client.retrlines('list', [].append) def test_abor_port(self): # Case 3: data channel opened with PASV or PORT, but ABOR sent # before a data transfer has been started: close data channel, # respond with 225 self.client.set_pasv(0) with contextlib.closing(self.client.makeport()): respcode = self.client.sendcmd('ABOR')[:3] assert respcode == '225' self.client.retrlines('list', [].append) def test_abor_during_transfer(self): # Case 4: ABOR while a data transfer on DTP channel is in # progress: close data channel, respond with 426, respond # with 226. data = b'abcde12345' * 1000000 testfn = self.get_testfn() with open(testfn, 'w+b') as f: f.write(data) self.client.voidcmd('TYPE I') with contextlib.closing( self.client.transfercmd('retr ' + testfn) ) as conn: bytes_recv = 0 while bytes_recv < 65536: chunk = conn.recv(BUFSIZE) bytes_recv += len(chunk) # stop transfer while it isn't finished yet self.client.putcmd('ABOR') # transfer isn't finished yet so ftpd should respond with 426 assert self.client.getline()[:3] == "426" # transfer successfully aborted, so should now respond # with a 226 assert self.client.voidresp()[:3] == '226' @pytest.mark.skipif( not hasattr(socket, 'MSG_OOB'), reason="MSG_OOB not available" ) @pytest.mark.skipif(OSX, reason="does not work on OSX") def test_oob_abor(self): # Send ABOR by following the RFC-959 directives of sending # Telnet IP/Synch sequence as OOB data. # On some systems like FreeBSD this happened to be a problem # due to a different SO_OOBINLINE behavior. # On some platforms (e.g. Python CE) the test may fail # although the MSG_OOB constant is defined. self.client.sock.sendall(bytes(chr(244), "latin-1"), socket.MSG_OOB) self.client.sock.sendall(bytes(chr(255), "latin-1"), socket.MSG_OOB) self.client.sock.sendall(b'abor\r\n') assert self.client.getresp()[:3] == '225' class TestThrottleBandwidth(PyftpdlibTestCase): """Test ThrottledDTPHandler class.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() class CustomDTPHandler(ThrottledDTPHandler): # overridden so that the "awake" callback is executed # immediately; this way we won't introduce any slowdown # and still test the code of interest def _throttle_bandwidth(self, *args, **kwargs): ThrottledDTPHandler._throttle_bandwidth(self, *args, **kwargs) if ( self._throttler is not None and not self._throttler.cancelled ): self._throttler.call() self._throttler = None self.server = self.server_class() self.server.handler.dtp_handler = CustomDTPHandler self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) self.dummyfile = io.BytesIO() self.testfn = self.get_testfn() def tearDown(self): close_client(self.client) self.server.handler.dtp_handler.read_limit = 0 self.server.handler.dtp_handler.write_limit = 0 self.server.handler.dtp_handler = DTPHandler self.server.stop() if not self.dummyfile.closed: self.dummyfile.close() super().tearDown() def test_throttle_send(self): # This test doesn't test the actual speed accuracy, just # awakes all that code which implements the throttling. # with self.server.lock: self.server.handler.dtp_handler.write_limit = 32768 data = b'abcde12345' * 100000 with open(self.testfn, 'wb') as file: file.write(data) self.client.retrbinary("retr " + self.testfn, self.dummyfile.write) self.dummyfile.seek(0) datafile = self.dummyfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) def test_throttle_recv(self): # This test doesn't test the actual speed accuracy, just # awakes all that code which implements the throttling. # with self.server.lock: self.server.handler.dtp_handler.read_limit = 32768 data = b'abcde12345' * 100000 self.dummyfile.write(data) self.dummyfile.seek(0) self.client.storbinary("stor " + self.testfn, self.dummyfile) self.client.quit() # needed to fix occasional failures with open(self.testfn, 'rb') as file: file_data = file.read() assert len(data) == len(file_data) assert hash(data) == hash(file_data) class TestTimeouts(PyftpdlibTestCase): """Test idle-timeout capabilities of control and data channels. Some tests may fail on slow machines. """ server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = None self.client = None self.testfn = self.get_testfn() def _setUp( self, idle_timeout=300, data_timeout=300, pasv_timeout=30, port_timeout=30, ): self.server = self.server_class() self.server.handler.timeout = idle_timeout self.server.handler.dtp_handler.timeout = data_timeout self.server.handler.passive_dtp.timeout = pasv_timeout self.server.handler.active_dtp.timeout = port_timeout self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) def tearDown(self): if self.client is not None and self.server is not None: close_client(self.client) self.server.handler.timeout = 300 self.server.handler.dtp_handler.timeout = 300 self.server.handler.passive_dtp.timeout = 30 self.server.handler.active_dtp.timeout = 30 self.server.stop() super().tearDown() # Note: moved later. # def test_idle_timeout(self): # # Test control channel timeout. The client which does not send # # any command within the time specified in FTPHandler.timeout is # # supposed to be kicked off. # self._setUp(idle_timeout=0.1) # # fail if no msg is received within 1 second # self.client.sock.settimeout(1) # data = self.client.sock.recv(BUFSIZE) # self.assertEqual(data, b"421 Control connection timed out.\r\n") # # ensure client has been kicked off # self.assertRaises((OSError, EOFError), self.client.sendcmd, # 'noop') def test_data_timeout(self): # Test data channel timeout. The client which does not send # or receive any data within the time specified in # DTPHandler.timeout is supposed to be kicked off. self._setUp(data_timeout=0.5 if CI_TESTING else 0.1) addr = self.client.makepasv() with contextlib.closing(socket.socket()) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect(addr) # fail if no msg is received within 1 second self.client.sock.settimeout(1) data = self.client.sock.recv(BUFSIZE) assert data == b"421 Data connection timed out.\r\n" # ensure client has been kicked off with pytest.raises((OSError, EOFError)): self.client.sendcmd('noop') def test_data_timeout_not_reached(self): # Impose a timeout for the data channel, then keep sending data for a # time which is longer than that to make sure that the code checking # whether the transfer stalled for with no progress is executed. self._setUp(data_timeout=0.5 if CI_TESTING else 0.1) with contextlib.closing( self.client.transfercmd('stor ' + self.testfn) ) as sock: if hasattr(self.client_class, 'ssl_version'): sock = ssl.wrap_socket(sock) stop_at = time.time() + 0.2 while time.time() < stop_at: sock.send(b'x' * 1024) sock.close() self.client.voidresp() def test_idle_data_timeout1(self): # Tests that the control connection timeout is suspended while # the data channel is opened self._setUp( idle_timeout=0.5 if CI_TESTING else 0.1, data_timeout=0.6 if CI_TESTING else 0.2, ) addr = self.client.makepasv() with contextlib.closing(socket.socket()) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect(addr) # fail if no msg is received within 1 second self.client.sock.settimeout(1) data = self.client.sock.recv(BUFSIZE) assert data == b"421 Data connection timed out.\r\n" # ensure client has been kicked off with pytest.raises((OSError, EOFError)): self.client.sendcmd('noop') def test_idle_data_timeout2(self): # Tests that the control connection timeout is restarted after # data channel has been closed self._setUp( idle_timeout=0.5 if CI_TESTING else 0.1, data_timeout=0.6 if CI_TESTING else 0.2, ) addr = self.client.makepasv() with contextlib.closing(socket.socket()) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect(addr) # close data channel self.client.sendcmd('abor') self.client.sock.settimeout(1) data = self.client.sock.recv(BUFSIZE) assert data == b"421 Control connection timed out.\r\n" # ensure client has been kicked off with pytest.raises((OSError, EOFError)): self.client.sendcmd('noop') def test_pasv_timeout(self): # Test pasv data channel timeout. The client which does not # connect to the listening data socket within the time specified # in PassiveDTP.timeout is supposed to receive a 421 response. self._setUp(pasv_timeout=0.5 if CI_TESTING else 0.1) self.client.makepasv() # fail if no msg is received within 1 second self.client.sock.settimeout(1) data = self.client.sock.recv(BUFSIZE) assert data == b"421 Passive data channel timed out.\r\n" # client is not expected to be kicked off self.client.sendcmd('noop') def test_disabled_idle_timeout(self): self._setUp(idle_timeout=0) self.client.sendcmd('noop') def test_disabled_data_timeout(self): self._setUp(data_timeout=0) addr = self.client.makepasv() with contextlib.closing(socket.socket()) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect(addr) def test_disabled_pasv_timeout(self): self._setUp(pasv_timeout=0) self.client.makepasv() # reset passive socket addr = self.client.makepasv() with contextlib.closing(socket.socket()) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect(addr) def test_disabled_port_timeout(self): self._setUp(port_timeout=0) with contextlib.closing(self.client.makeport()): with contextlib.closing(self.client.makeport()): pass class TestConfigurableOptions(PyftpdlibTestCase): """Test those daemon options which are commonly modified by user.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = None self.client = None def connect(self): self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) def tearDown(self): if self.client is not None: close_client(self.client) # set back options to their original value if self.server is not None: self.server.server.max_cons = 0 self.server.server.max_cons_per_ip = 0 self.server.handler.banner = "pyftpdlib ready." self.server.handler.max_login_attempts = 3 self.server.handler.auth_failed_timeout = 5 self.server.handler.masquerade_address = None self.server.handler.masquerade_address_map = {} self.server.handler.permit_privileged_ports = False self.server.handler.permit_foreign_addresses = False self.server.handler.passive_ports = None self.server.handler.use_gmt_times = True self.server.handler.tcp_no_delay = hasattr(socket, 'TCP_NODELAY') self.server.handler.encoding = "utf8" self.server.stop() super().tearDown() @disable_log_warning def test_max_connections(self): # Test FTPServer.max_cons attribute self.server = self.server_class() self.server.server.max_cons = 3 self.server.start() c1 = self.client_class() c2 = self.client_class() c3 = self.client_class() try: # on control connection c1.connect(self.server.host, self.server.port) c2.connect(self.server.host, self.server.port) with pytest.raises( ftplib.error_temp, match="Too many connections" ): c3.connect( self.server.host, self.server.port, ) # with passive data channel established c2.quit() c1.login(USER, PASSWD) c1.makepasv() with pytest.raises( ftplib.error_temp, match="Too many connections" ): c2.connect( self.server.host, self.server.port, ) # with passive data socket waiting for connection c1.login(USER, PASSWD) c1.sendcmd('pasv') c2.close() with pytest.raises( ftplib.error_temp, match="Too many connections" ): c2.connect( self.server.host, self.server.port, ) # with active data channel established c1.login(USER, PASSWD) with contextlib.closing(c1.makeport()): c2.close() with pytest.raises( ftplib.error_temp, match="Too many connections" ): c2.connect( self.server.host, self.server.port, ) finally: for c in (c1, c2, c3): try: c.quit() except (OSError, EOFError, ftplib.Error): # already disconnected pass finally: c.close() @disable_log_warning def test_max_connections_per_ip(self): # Test FTPServer.max_cons_per_ip attribute self.server = self.server_class() self.server.server.max_cons_per_ip = 3 self.server.start() c1 = self.client_class() c2 = self.client_class() c3 = self.client_class() c4 = self.client_class() try: c1.connect(self.server.host, self.server.port) c2.connect(self.server.host, self.server.port) c3.connect(self.server.host, self.server.port) with pytest.raises( ftplib.error_temp, match="Too many connections from the same IP address", ): c4.connect( self.server.host, self.server.port, ) # Make sure client has been disconnected. # OSError (Windows) or EOFError (Linux) exception is # supposed to be raised in such a case. with pytest.raises((OSError, EOFError)): c4.sendcmd('noop') finally: for c in (c1, c2, c3, c4): try: c.quit() except (OSError, EOFError): # already disconnected c.close() def test_banner(self): # Test FTPHandler.banner attribute self.server = self.server_class() self.server.handler.banner = 'hello there' self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) assert self.client.getwelcome()[4:] == 'hello there' def test_max_login_attempts(self): # Test FTPHandler.max_login_attempts attribute. self.server = self.server_class() self.server.handler.max_login_attempts = 1 self.server.handler.auth_failed_timeout = 0 self.server.start() self.connect() with pytest.raises(ftplib.error_perm): self.client.login('wrong', 'wrong') # OSError (Windows) or EOFError (Linux) exceptions are # supposed to be raised when attempting to send/recv some data # using a disconnected socket with pytest.raises((OSError, EOFError)): self.client.sendcmd('noop') def test_masquerade_address(self): # Test FTPHandler.masquerade_address attribute self.server = self.server_class() self.server.handler.masquerade_address = "256.256.256.256" self.server.start() self.connect() host = ftplib.parse227(self.client.sendcmd('PASV'))[0] assert host == "256.256.256.256" def test_masquerade_address_map(self): # Test FTPHandler.masquerade_address_map attribute self.server = self.server_class() self.server.handler.masquerade_address_map = { self.server.host: "128.128.128.128" } self.server.start() self.connect() host = ftplib.parse227(self.client.sendcmd('PASV'))[0] assert host == "128.128.128.128" def test_passive_ports(self): # Test FTPHandler.passive_ports attribute self.server = self.server_class() _range = list(range(40000, 60000, 200)) self.server.handler.passive_ports = _range self.server.start() self.connect() assert self.client.makepasv()[1] in _range assert self.client.makepasv()[1] in _range assert self.client.makepasv()[1] in _range assert self.client.makepasv()[1] in _range @disable_log_warning def test_passive_ports_busy(self): # If the ports in the configured range are busy it is expected # that a kernel-assigned port gets chosen with contextlib.closing(socket.socket()) as s: s.settimeout(GLOBAL_TIMEOUT) s.bind((HOST, 0)) port = s.getsockname()[1] self.server = self.server_class() self.server.handler.passive_ports = [port] self.server.start() self.connect() resulting_port = self.client.makepasv()[1] assert port != resulting_port @retry_on_failure def test_use_gmt_times(self): testfn = self.get_testfn() touch(testfn) # use GMT time self.server = self.server_class() self.server.handler.use_gmt_times = True self.server.start() self.connect() gmt1 = self.client.sendcmd('mdtm ' + testfn) gmt2 = self.client.sendcmd('mlst ' + testfn) gmt3 = self.client.sendcmd('stat ' + testfn) # use local time self.tearDown() self.setUp() self.server = self.server_class() self.server.handler.use_gmt_times = False self.server.start() self.connect() loc1 = self.client.sendcmd('mdtm ' + testfn) loc2 = self.client.sendcmd('mlst ' + testfn) loc3 = self.client.sendcmd('stat ' + testfn) # if we're not in a GMT time zone times are supposed to be # different if time.timezone != 0: assert gmt1 != loc1 assert gmt2 != loc2 assert gmt3 != loc3 # ...otherwise they should be the same else: assert gmt1 == loc1 assert gmt2 == loc2 assert gmt3 == loc3 def test_encoding(self): # Make sure that if encoding != UTF-8, FEAT command does not # list UTF-8. self.server = self.server_class() self.server.handler.encoding = "latin-1" self.server.start() self.connect() resp = self.client.sendcmd('feat') assert 'UTF8' not in resp @pytest.mark.xdist_group(name="serial") class TestCallbacks(PyftpdlibTestCase): server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() class Handler(FTPHandler): def write(self, text): with open(testfn, "a") as f: f.write(text) def on_connect(self): self.write("on_connect,") def on_disconnect(self): self.write("on_disconnect,") def on_login(self, username): self.write(f"on_login:{username},") def on_login_failed(self, username, password): self.write(f"on_login_failed:{username}+{password},") def on_logout(self, username): self.write(f"on_logout:{username},") def on_file_sent(self, file): self.write(f"on_file_sent:{os.path.basename(file)},") def on_file_received(self, file): self.write(f"on_file_received:{os.path.basename(file)},") def on_incomplete_file_sent(self, file): self.write( f"on_incomplete_file_sent:{os.path.basename(file)}," ) def on_incomplete_file_received(self, file): self.write( f"on_incomplete_file_received:{os.path.basename(file)}," ) self.testfn = testfn = self.get_testfn() self.testfn2 = self.get_testfn() self.server = self.server_class() self.server.server.handler = Handler self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def read_file(self, text): stop_at = time.time() + 1 while time.time() <= stop_at: with open(self.testfn) as f: data = f.read() if data == text: return time.sleep(0.01) self.fail(f"data: {data!r}; expected: {text!r}") def test_on_disconnect(self): self.client.login(USER, PASSWD) self.client.close() self.read_file(f'on_connect,on_login:{USER},on_disconnect,') def test_on_logout_quit(self): self.client.login(USER, PASSWD) self.client.sendcmd('quit') self.read_file( f'on_connect,on_login:{USER},on_logout:{USER},on_disconnect,' ) def test_on_logout_rein(self): self.client.login(USER, PASSWD) self.client.sendcmd('rein') self.read_file(f'on_connect,on_login:{USER},on_logout:{USER},') def test_on_logout_no_pass(self): # make sure on_logout() is not called if USER was provided # but not PASS self.client.sendcmd("user foo") self.read_file('on_connect,') def test_on_logout_user_issued_twice(self): # At this point user "user" is logged in. Re-login as anonymous, # then quit and expect queue == ["user", "anonymous"] self.client.login(USER, PASSWD) self.client.login("anonymous") self.read_file( f'on_connect,on_login:{USER},on_logout:{USER},on_login:anonymous,' ) def test_on_login_failed(self): with pytest.raises(ftplib.error_perm): self.client.login('foo', 'bar?!?') self.read_file('on_connect,on_login_failed:foo+bar?!?,') def test_on_file_received(self): data = b'abcde12345' * 100000 dummyfile = io.BytesIO() dummyfile.write(data) dummyfile.seek(0) self.client.login(USER, PASSWD) self.client.storbinary('stor ' + self.testfn2, dummyfile) self.read_file( f'on_connect,on_login:{USER},on_file_received:{self.testfn2},' ) def test_on_file_sent(self): self.client.login(USER, PASSWD) data = b'abcde12345' * 100000 with open(self.testfn2, 'wb') as f: f.write(data) self.client.retrbinary("retr " + self.testfn2, lambda x: x) self.read_file( f'on_connect,on_login:{USER},on_file_sent:{self.testfn2},' ) @retry_on_failure def test_on_incomplete_file_received(self): self.client.login(USER, PASSWD) data = b'abcde12345' * 1000000 dummyfile = io.BytesIO() dummyfile.write(data) dummyfile.seek(0) with contextlib.closing( self.client.transfercmd('stor ' + self.testfn2) ) as conn: bytes_sent = 0 while True: chunk = dummyfile.read(BUFSIZE) conn.sendall(chunk) bytes_sent += len(chunk) # stop transfer while it isn't finished yet if bytes_sent >= INTERRUPTED_TRANSF_SIZE or not chunk: self.client.putcmd('abor') break # If a data transfer is in progress server is supposed to send # a 426 reply followed by a 226 reply. resp = self.client.getmultiline() assert resp == "426 Transfer aborted via ABOR." resp = self.client.getmultiline() assert resp.startswith("226") self.read_file( f'on_connect,on_login:{USER},on_incomplete_file_received:{self.testfn2},' ) @retry_on_failure def test_on_incomplete_file_sent(self): self.client.login(USER, PASSWD) data = b'abcde12345' * 1000000 with open(self.testfn2, 'wb') as f: f.write(data) bytes_recv = 0 with contextlib.closing( self.client.transfercmd("retr " + self.testfn2, None) ) as conn: while True: chunk = conn.recv(BUFSIZE) bytes_recv += len(chunk) if bytes_recv >= INTERRUPTED_TRANSF_SIZE or not chunk: break assert self.client.getline()[:3] == "426" self.read_file( f'on_connect,on_login:{USER},on_incomplete_file_sent:{self.testfn2},' ) class _TestNetworkProtocols: """Test PASV, EPSV, PORT and EPRT commands. Do not use this class directly, let TestIPv4Environment and TestIPv6Environment classes use it instead. """ def setUp(self): super().setUp() self.server = self.server_class((self.HOST, 0)) self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) if self.client.af == socket.AF_INET: self.proto = "1" self.other_proto = "2" else: self.proto = "2" self.other_proto = "1" def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def cmdresp(self, cmd): """Send a command and return response, also if the command failed.""" try: return self.client.sendcmd(cmd) except ftplib.Error as err: return str(err) @disable_log_warning def test_eprt(self): if not SUPPORTS_HYBRID_IPV6: # test wrong proto with pytest.raises(ftplib.error_perm, match="522"): self.client.sendcmd( 'eprt |%s|%s|%s|' # noqa: UP031 % (self.other_proto, self.server.host, self.server.port) ) # test bad args msg = "501 Invalid EPRT format." # len('|') > 3 assert self.cmdresp('eprt ||||') == msg # len('|') < 3 assert self.cmdresp('eprt ||') == msg # port > 65535 assert self.cmdresp(f'eprt |{self.proto}|{self.HOST}|65536|') == msg # port < 0 assert self.cmdresp(f'eprt |{self.proto}|{self.HOST}|-1|') == msg # port < 1024 resp = self.cmdresp(f'eprt |{self.proto}|{self.HOST}|222|') assert resp[:3] == '501' assert 'privileged port' in resp # proto > 2 _cmd = f'eprt |3|{self.server.host}|{self.server.port}|' with pytest.raises(ftplib.error_perm): self.client.sendcmd(_cmd) if self.proto == '1': # len(ip.octs) > 4 assert self.cmdresp('eprt |1|1.2.3.4.5|2048|') == msg # ip.oct > 255 assert self.cmdresp('eprt |1|1.2.3.256|2048|') == msg # bad proto resp = self.cmdresp('eprt |2|1.2.3.256|2048|') assert "Network protocol not supported" in resp # test connection with contextlib.closing(socket.socket(self.client.af)) as sock: sock.bind((self.client.sock.getsockname()[0], 0)) sock.listen(5) sock.settimeout(GLOBAL_TIMEOUT) ip, port = sock.getsockname()[:2] self.client.sendcmd(f'eprt |{self.proto}|{ip}|{port}|') try: s = sock.accept() s[0].close() except socket.timeout: self.fail("Server didn't connect to passive socket") def test_epsv(self): # test wrong proto with pytest.raises(ftplib.error_perm, match="522"): self.client.sendcmd('epsv ' + self.other_proto) # proto > 2 with pytest.raises(ftplib.error_perm): self.client.sendcmd('epsv 3') # test connection for cmd in ('EPSV', 'EPSV ' + self.proto): host, port = ftplib.parse229( self.client.sendcmd(cmd), self.client.sock.getpeername() ) with contextlib.closing( socket.socket(self.client.af, socket.SOCK_STREAM) ) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect((host, port)) self.client.sendcmd('abor') def test_epsv_all(self): self.client.sendcmd('epsv all') with pytest.raises(ftplib.error_perm): self.client.sendcmd('pasv') with pytest.raises(ftplib.error_perm): self.client.sendport(self.HOST, 2000) with pytest.raises(ftplib.error_perm): self.client.sendcmd( f'eprt |{self.proto}|{self.HOST}|{2000}|', ) @pytest.mark.skipif(not SUPPORTS_IPV4, reason="IPv4 not supported") class TestIPv4Environment(_TestNetworkProtocols, PyftpdlibTestCase): """Test PASV, EPSV, PORT and EPRT commands. Runs tests contained in _TestNetworkProtocols class by using IPv4 plus some additional specific tests. """ server_class = FtpdThreadWrapper client_class = ftplib.FTP HOST = '127.0.0.1' @disable_log_warning def test_port_v4(self): # test connection with contextlib.closing(self.client.makeport()): self.client.sendcmd('abor') # test bad arguments ae = self.assertEqual msg = "501 Invalid PORT format." ae(self.cmdresp('port 127,0,0,1,1.1'), msg) # sep != ',' ae(self.cmdresp('port X,0,0,1,1,1'), msg) # value != int ae(self.cmdresp('port 127,0,0,1,1,1,1'), msg) # len(args) > 6 ae(self.cmdresp('port 127,0,0,1'), msg) # len(args) < 6 ae(self.cmdresp('port 256,0,0,1,1,1'), msg) # oct > 255 ae(self.cmdresp('port 127,0,0,1,256,1'), msg) # port > 65535 ae(self.cmdresp('port 127,0,0,1,-1,0'), msg) # port < 0 # port < 1024 resp = self.cmdresp(f"port {self.HOST.replace('.', ',')},1,1") assert resp[:3] == '501' assert 'privileged port' in resp if self.HOST != "1.2.3.4": resp = self.cmdresp('port 1,2,3,4,4,4') assert 'foreign address' in resp, resp @disable_log_warning def test_eprt_v4(self): resp = self.cmdresp('eprt |1|0.10.10.10|2222|') assert resp[:3] == '501' assert 'foreign address' in resp def test_pasv_v4(self): host, port = ftplib.parse227(self.client.sendcmd('pasv')) with contextlib.closing( socket.socket(socket.AF_INET, socket.SOCK_STREAM) ) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect((host, port)) @pytest.mark.skipif(not SUPPORTS_IPV6, reason="IPv6 not supported") class TestIPv6Environment(_TestNetworkProtocols, PyftpdlibTestCase): """Test PASV, EPSV, PORT and EPRT commands. Runs tests contained in _TestNetworkProtocols class by using IPv6 plus some additional specific tests. """ server_class = FtpdThreadWrapper client_class = ftplib.FTP HOST = '::1' def test_port_v6(self): # PORT is not supposed to work with pytest.raises(ftplib.error_perm): self.client.sendport( self.server.host, self.server.port, ) def test_pasv_v6(self): # PASV is still supposed to work to support clients using # IPv4 connecting to a server supporting both IPv4 and IPv6 self.client.makepasv() @disable_log_warning def test_eprt_v6(self): resp = self.cmdresp('eprt |2|::foo|2222|') assert resp[:3] == '501' assert 'foreign address' in resp @pytest.mark.skipif( not SUPPORTS_HYBRID_IPV6, reason="IPv4/6 dual stack not supported" ) class TestIPv6MixedEnvironment(PyftpdlibTestCase): """By running the server by specifying "::" as IP address the server is supposed to listen on all interfaces, supporting both IPv4 and IPv6 by using a single socket. What we are going to do here is starting the server in this manner and try to connect by using an IPv4 client. """ server_class = FtpdThreadWrapper client_class = ftplib.FTP HOST = "::" def setUp(self): super().setUp() self.server = self.server_class((self.HOST, 0)) self.server.start() self.client = None def tearDown(self): if self.client is not None: close_client(self.client) self.server.stop() super().tearDown() def test_port_v4(self): def noop(x): return x self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect('127.0.0.1', self.server.port) self.client.set_pasv(False) self.client.login(USER, PASSWD) self.client.retrlines('list', noop) def test_pasv_v4(self): def noop(x): return x self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect('127.0.0.1', self.server.port) self.client.set_pasv(True) self.client.login(USER, PASSWD) self.client.retrlines('list', noop) # make sure pasv response doesn't return an IPv4-mapped address ip = self.client.makepasv()[0] assert not ip.startswith("::ffff:") def test_eprt_v4(self): self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect('127.0.0.1', self.server.port) self.client.login(USER, PASSWD) # test connection with contextlib.closing( socket.socket(socket.AF_INET, socket.SOCK_STREAM) ) as sock: sock.bind((self.client.sock.getsockname()[0], 0)) sock.listen(5) sock.settimeout(2) ip, port = sock.getsockname()[:2] self.client.sendcmd(f'eprt |1|{ip}|{port}|') try: sock2, _ = sock.accept() sock2.close() except socket.timeout: self.fail("Server didn't connect to passive socket") def test_epsv_v4(self): def mlstline(cmd): return self.client.voidcmd(cmd).split('\n')[1] self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect('127.0.0.1', self.server.port) self.client.login(USER, PASSWD) host, port = ftplib.parse229( self.client.sendcmd('EPSV'), self.client.sock.getpeername() ) assert host == '127.0.0.1' with contextlib.closing( socket.socket(socket.AF_INET, socket.SOCK_STREAM) ) as s: s.settimeout(GLOBAL_TIMEOUT) s.connect((host, port)) assert mlstline('mlst /').endswith('/') class TestCornerCases(PyftpdlibTestCase): """Tests for any kind of strange situation for the server to be in, mainly referring to bugs signaled on the bug tracker. """ server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = self.server_class() self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) def tearDown(self): close_client(self.client) self.server.stop() super().tearDown() def test_port_race_condition(self): # Refers to bug #120, first sends PORT, then disconnects the # control channel before accept()ing the incoming data connection. # The original server behavior was to reply with "200 Active # data connection established" *after* the client had already # disconnected the control connection. with contextlib.closing(socket.socket(self.client.af)) as sock: sock.bind((self.client.sock.getsockname()[0], 0)) sock.listen(5) sock.settimeout(GLOBAL_TIMEOUT) host, port = sock.getsockname()[:2] hbytes = host.split('.') pbytes = [repr(port // 256), repr(port % 256)] data = hbytes + pbytes cmd = 'PORT ' + ','.join(data) + '\r\n' self.client.sock.sendall(bytes(cmd, "latin-1")) self.client.getresp() s, _ = sock.accept() s.close() @pytest.mark.skipif(not POSIX, reason="POSIX only") def test_quick_connect(self): # Clients that connected and disconnected quickly could cause # the server to crash, due to a failure to catch errors in the # initial part of the connection process. # Tracked in issues #91, #104 and #105. # See also https://bugs.launchpad.net/zodb/+bug/135108 def connect(addr): with contextlib.closing(socket.socket()) as s: # Set SO_LINGER to 1,0 causes a connection reset (RST) to # be sent when close() is called, instead of the standard # FIN shutdown sequence. s.setsockopt( socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0), ) s.settimeout(GLOBAL_TIMEOUT) try: s.connect(addr) except OSError: pass for _ in range(10): connect((self.server.host, self.server.port)) for _ in range(10): addr = self.client.makepasv() connect(addr) def test_error_on_callback(self): # test that the server do not crash in case an error occurs # while firing a scheduled function self.tearDown() server = FTPServer((HOST, 0), FTPHandler) logger = logging.getLogger('pyftpdlib') logger.disabled = True try: len1 = len(IOLoop.instance().socket_map) IOLoop.instance().call_later(0, lambda: 1 // 0) server.serve_forever(timeout=0.001, blocking=False) len2 = len(IOLoop.instance().socket_map) assert len1 == len2 finally: logger.disabled = False server.close_all() def test_active_conn_error(self): # we open a socket() but avoid to invoke accept() to # reproduce this error condition: # https://code.google.com/p/pyftpdlib/source/detail?r=905 with contextlib.closing(socket.socket()) as sock: sock.bind((HOST, 0)) port = sock.getsockname()[1] self.client.sock.settimeout(0.1) try: resp = self.client.sendport(HOST, port) except ftplib.error_temp as err: assert str(err)[:3] == '425' # noqa: PT017 except (socket.timeout, getattr(ssl, "SSLError", object())): pass else: assert str(resp)[:3] != '200' def test_repr(self): # make sure the FTP/DTP handler classes have a sane repr() with contextlib.closing(self.client.makeport()): for inst in IOLoop.instance().socket_map.values(): repr(inst) str(inst) if hasattr(select, 'epoll') or hasattr(select, 'kqueue'): def test_ioloop_fileno(self): fd = self.server.server.ioloop.fileno() assert isinstance(fd, int), fd # # TODO: disabled as on certain platforms (OSX and Windows) # # produces failures with python3. Will have to get back to # # this and fix it. # @pytest.mark.skipif(OSX or WINDOWS, reason="fails on OSX or Windows") # class TestUnicodePathNames(PyftpdlibTestCase): # """Test FTP commands and responses by using path names with non # ASCII characters. # """ # server_class = ThreadedTestFTPd # client_class = ftplib.FTP # def setUp(self): # super().setUp() # self.server = self.server_class() # self.server.start() # self.client = self.client_class(timeout=GLOBAL_TIMEOUT) # self.client.encoding = 'utf8' # self.client.connect(self.server.host, self.server.port) # self.client.login(USER, PASSWD) # safe_mkdir(bytes(TESTFN_UNICODE, 'utf8')) # touch(bytes(TESTFN_UNICODE_2, 'utf8')) # self.utf8fs = TESTFN_UNICODE in os.listdir('.') # def tearDown(self): # close_client(self.client) # self.server.stop() # super().tearDown() # # --- fs operations # def test_cwd(self): # if self.utf8fs: # resp = self.client.cwd(TESTFN_UNICODE) # self.assertTrue(TESTFN_UNICODE in resp) # else: # self.assertRaises(ftplib.error_perm, self.client.cwd, # TESTFN_UNICODE) # def test_mkd(self): # if self.utf8fs: # os.rmdir(TESTFN_UNICODE) # dirname = self.client.mkd(TESTFN_UNICODE) # self.assertEqual(dirname, '/' + TESTFN_UNICODE) # self.assertTrue(os.path.isdir(TESTFN_UNICODE)) # else: # self.assertRaises(ftplib.error_perm, self.client.mkd, # TESTFN_UNICODE) # def test_rmdir(self): # if self.utf8fs: # self.client.rmd(TESTFN_UNICODE) # else: # self.assertRaises(ftplib.error_perm, self.client.rmd, # TESTFN_UNICODE) # def test_rnfr_rnto(self): # if self.utf8fs: # self.client.rename(TESTFN_UNICODE, TESTFN) # else: # self.assertRaises(ftplib.error_perm, self.client.rename, # TESTFN_UNICODE, TESTFN) # def test_size(self): # self.client.sendcmd('type i') # if self.utf8fs: # self.client.sendcmd('size ' + TESTFN_UNICODE_2) # else: # self.assertRaises(ftplib.error_perm, self.client.sendcmd, # 'size ' + TESTFN_UNICODE_2) # def test_mdtm(self): # if self.utf8fs: # self.client.sendcmd('mdtm ' + TESTFN_UNICODE_2) # else: # self.assertRaises(ftplib.error_perm, self.client.sendcmd, # 'mdtm ' + TESTFN_UNICODE_2) # def test_stou(self): # if self.utf8fs: # resp = self.client.sendcmd('stou ' + TESTFN_UNICODE) # self.assertTrue(TESTFN_UNICODE in resp) # else: # self.assertRaises(ftplib.error_perm, self.client.sendcmd, # 'stou ' + TESTFN_UNICODE) # if hasattr(os, 'chmod'): # def test_site_chmod(self): # if self.utf8fs: # self.client.sendcmd('site chmod 777 ' + TESTFN_UNICODE) # else: # self.assertRaises(ftplib.error_perm, self.client.sendcmd, # 'site chmod 777 ' + TESTFN_UNICODE) # # --- listing cmds # def _test_listing_cmds(self, cmd): # ls = [] # self.client.retrlines(cmd, ls.append) # ls = '\n'.join(ls) # if self.utf8fs: # self.assertTrue(TESTFN_UNICODE in ls) # else: # # Part of the filename which are not encodable are supposed # # to have been replaced. The file should be something like # # 'tmp-pyftpdlib-unicode-????'. In any case it is not # # referenceable (e.g. DELE 'tmp-pyftpdlib-unicode-????' # # won't work). # self.assertTrue('tmp-pyftpdlib-unicode' in ls) # def test_list(self): # self._test_listing_cmds('list') # def test_nlst(self): # self._test_listing_cmds('nlst') # def test_mlsd(self): # self._test_listing_cmds('mlsd') # def test_mlst(self): # # utility function for extracting the line of interest # def mlstline(cmd): # return self.client.voidcmd(cmd).split('\n')[1] # if self.utf8fs: # self.assertTrue('type=dir' in # mlstline('mlst ' + TESTFN_UNICODE)) # self.assertTrue('/' + TESTFN_UNICODE in # mlstline('mlst ' + TESTFN_UNICODE)) # self.assertTrue('type=file' in # mlstline('mlst ' + TESTFN_UNICODE_2)) # self.assertTrue('/' + TESTFN_UNICODE_2 in # mlstline('mlst ' + TESTFN_UNICODE_2)) # else: # self.assertRaises(ftplib.error_perm, # mlstline, 'mlst ' + TESTFN_UNICODE) # # --- file transfer # def test_stor(self): # if self.utf8fs: # data = b'abcde12345' * 500 # os.remove(TESTFN_UNICODE_2) # dummy = io.BytesIO() # dummy.write(data) # dummy.seek(0) # self.client.storbinary('stor ' + TESTFN_UNICODE_2, dummy) # dummy_recv = io.BytesIO() # self.client.retrbinary('retr ' + TESTFN_UNICODE_2, # dummy_recv.write) # dummy_recv.seek(0) # self.assertEqual(dummy_recv.read(), data) # else: # dummy = io.BytesIO() # self.assertRaises(ftplib.error_perm, self.client.storbinary, # 'stor ' + TESTFN_UNICODE_2, dummy) # def test_retr(self): # if self.utf8fs: # data = b'abcd1234' * 500 # with open(TESTFN_UNICODE_2, 'wb') as f: # f.write(data) # dummy = io.BytesIO() # self.client.retrbinary('retr ' + TESTFN_UNICODE_2, dummy.write) # dummy.seek(0) # self.assertEqual(dummy.read(), data) # else: # dummy = io.BytesIO() # self.assertRaises(ftplib.error_perm, self.client.retrbinary, # 'retr ' + TESTFN_UNICODE_2, dummy.write) class ThreadedFTPTests(PyftpdlibTestCase): server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.client = None self.server = None self.tempfile = self.get_testfn() self.tempdir = self.get_testfn() touch(self.tempfile) touch(self.tempdir) self.dummy_recvfile = io.BytesIO() self.dummy_sendfile = io.BytesIO() def tearDown(self): if self.client: close_client(self.client) if self.server: self.server.stop() self.server.handler = FTPHandler self.server.handler.abstracted_fs = AbstractedFS self.dummy_recvfile.close() self.dummy_sendfile.close() super().tearDown() def connect_client(self): self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) self.client.login(USER, PASSWD) @retry_on_failure def test_stou_max_tries(self): # Emulates case where the max number of tries to find out a # unique file name when processing STOU command gets hit. class TestFS(AbstractedFS): def mkstemp(self, *args, **kwargs): raise OSError( errno.EEXIST, "No usable temporary file name found" ) self.server = self.server_class() self.server.handler.abstracted_fs = TestFS self.server.start() self.connect_client() with pytest.raises(ftplib.error_temp, match="No usable unique file"): self.client.sendcmd('stou') @retry_on_failure def test_idle_timeout(self): # Test control channel timeout. The client which does not send # any command within the time specified in FTPHandler.timeout is # supposed to be kicked off. self.server = self.server_class() self.server.handler.timeout = 0.1 self.server.start() self.connect_client() self.client.quit() self.client.connect() self.client.login(USER, PASSWD) # fail if no msg is received within 1 second self.client.sock.settimeout(1) data = self.client.sock.recv(BUFSIZE) assert data == b"421 Control connection timed out.\r\n" # ensure client has been kicked off with pytest.raises((OSError, EOFError)): self.client.sendcmd('noop') @retry_on_failure def test_permit_foreign_address_false(self): self.server = self.server_class() self.server.handler.permit_foreign_addresses = False self.server.start() self.connect_client() handler = get_server_handler() handler.remote_ip = '9.9.9.9' # sync self.client.sendcmd("noop") port = self.client.sock.getsockname()[1] host = self.client.sock.getsockname()[0] with pytest.raises(ftplib.error_perm, match="foreign address"): self.client.sendport(host, port) @retry_on_failure def test_permit_foreign_address_true(self): self.server = self.server_class() self.server.handler.permit_foreign_addresses = True self.server.start() self.connect_client() handler = get_server_handler() handler.remote_ip = '9.9.9.9' self.client.sendcmd("noop") s = self.client.makeport() s.close() @disable_log_warning @retry_on_failure def test_permit_privileged_ports(self): # Test FTPHandler.permit_privileged_ports_active attribute # try to bind a socket on a privileged port sock = None for port in reversed(range(1, 1024)): try: socket.getservbyport(port) except OSError: # not registered port; go on try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.addCleanup(sock.close) sock.settimeout(GLOBAL_TIMEOUT) sock.bind((HOST, port)) break except OSError as err: if err.errno == errno.EACCES: # root privileges needed if sock is not None: sock.close() sock = None break sock.close() continue else: # registered port found; skip to the next one continue else: # no usable privileged port was found sock = None # permit_privileged_ports = False self.server = self.server_class() self.server.handler.permit_privileged_ports = False self.server.start() self.connect_client() with pytest.raises(ftplib.error_perm, match="privileged port"): self.client.sendport(HOST, 1023) # permit_privileged_ports = True if sock: self.tearDown() self.server = self.server_class() self.server.handler.permit_privileged_ports = True self.server.start() self.connect_client() port = sock.getsockname()[1] sock.listen(5) sock.settimeout(GLOBAL_TIMEOUT) self.client.sendport(HOST, port) s, _ = sock.accept() s.close() sock.close() @pytest.mark.skipif(not POSIX, reason="POSIX only") @pytest.mark.skipif( not hasattr(os, "sendfile"), reason="os.sendfile() not available" ) @retry_on_failure def test_sendfile_fails(self): # Makes sure that if sendfile() fails and no bytes were # transmitted yet the server falls back on using plain # send() self.server = self.server_class() self.server.start() self.connect_client() data = b'abcde12345' * 100000 self.dummy_sendfile.write(data) self.dummy_sendfile.seek(0) self.client.storbinary('stor ' + self.tempfile, self.dummy_sendfile) with patch( 'pyftpdlib.handlers.os.sendfile', side_effect=OSError(errno.EINVAL) ) as fun: self.client.retrbinary( 'retr ' + self.tempfile, self.dummy_recvfile.write ) assert fun.called self.dummy_recvfile.seek(0) datafile = self.dummy_recvfile.read() assert len(data) == len(datafile) assert hash(data) == hash(datafile) pyftpdlib-release-2.0.1/pyftpdlib/test/test_functional_ssl.py000066400000000000000000000260001470572577600245570ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import contextlib import ftplib import os import ssl import OpenSSL # requires "pip install pyopenssl" import pytest from pyftpdlib.handlers import TLS_FTPHandler from . import CI_TESTING from . import GLOBAL_TIMEOUT from . import OSX from . import PASSWD from . import USER from . import WINDOWS from . import FtpdThreadWrapper from . import PyftpdlibTestCase from . import close_client from .test_functional import TestConfigurableOptions from .test_functional import TestCornerCases from .test_functional import TestFtpAbort from .test_functional import TestFtpAuthentication from .test_functional import TestFtpCmdsSemantic from .test_functional import TestFtpDummyCmds from .test_functional import TestFtpFsOperations from .test_functional import TestFtpListingCmds from .test_functional import TestFtpRetrieveData from .test_functional import TestFtpStoreData from .test_functional import TestIPv4Environment from .test_functional import TestIPv6Environment from .test_functional import TestTimeouts CERTFILE = os.path.abspath( os.path.join(os.path.dirname(__file__), 'keycert.pem') ) del OpenSSL # ===================================================================== # --- FTPS mixin tests # ===================================================================== # What we're going to do here is repeat the original functional tests # defined in test_functinal.py but by using FTPS. # we secure both control and data connections before running any test. # This is useful as we reuse the existent functional tests which are # supposed to work no matter if the underlying protocol is FTP or FTPS. class FTPSClient(ftplib.FTP_TLS): """A modified version of ftplib.FTP_TLS class which implicitly secure the data connection after login(). """ def login(self, *args, **kwargs): ftplib.FTP_TLS.login(self, *args, **kwargs) self.prot_p() class FTPSServer(FtpdThreadWrapper): """A threaded FTPS server used for functional testing.""" handler = TLS_FTPHandler handler.certfile = CERTFILE class TLSTestMixin: server_class = FTPSServer client_class = FTPSClient class TestFtpAuthenticationTLSMixin(TLSTestMixin, TestFtpAuthentication): pass class TestTFtpDummyCmdsTLSMixin(TLSTestMixin, TestFtpDummyCmds): pass class TestFtpCmdsSemanticTLSMixin(TLSTestMixin, TestFtpCmdsSemantic): pass class TestFtpFsOperationsTLSMixin(TLSTestMixin, TestFtpFsOperations): pass class TestFtpStoreDataTLSMixin(TLSTestMixin, TestFtpStoreData): @pytest.mark.skip(reason="fails with SSL") def test_stou(self): pass @pytest.mark.skipif(WINDOWS, reason="unreliable on Windows + SSL") def test_stor_ascii_2(self): pass # class TestSendFileTLSMixin(TLSTestMixin, TestSendfile): # def test_fallback(self): # self.client.prot_c() # super().test_fallback() class TestFtpRetrieveDataTLSMixin(TLSTestMixin, TestFtpRetrieveData): @pytest.mark.skipif(WINDOWS, reason="may fail on windows") def test_restore_on_retr(self): super().test_restore_on_retr() class TestFtpListingCmdsTLSMixin(TLSTestMixin, TestFtpListingCmds): # TODO: see https://travis-ci.org/giampaolo/pyftpdlib/jobs/87318445 # Fails with: # File "/opt/python/2.7.9/lib/python2.7/ftplib.py", line 735, in retrlines # conn.unwrap() # File "/opt/python/2.7.9/lib/python2.7/ssl.py", line 771, in unwrap # s = self._sslobj.shutdown() # error: [Errno 0] Error @pytest.mark.skipif(CI_TESTING, reason="may fail on CI") def test_nlst(self): super().test_nlst() class TestFtpAbortTLSMixin(TLSTestMixin, TestFtpAbort): @pytest.mark.skip(reason="fails with SSL") def test_oob_abor(self): pass class TestTimeoutsTLSMixin(TLSTestMixin, TestTimeouts): @pytest.mark.skip(reason="fails with SSL") def test_data_timeout_not_reached(self): pass class TestConfigurableOptionsTLSMixin(TLSTestMixin, TestConfigurableOptions): pass class TestIPv4EnvironmentTLSMixin(TLSTestMixin, TestIPv4Environment): pass class TestIPv6EnvironmentTLSMixin(TLSTestMixin, TestIPv6Environment): pass class TestCornerCasesTLSMixin(TLSTestMixin, TestCornerCases): pass # ===================================================================== # dedicated FTPS tests # ===================================================================== class TestFTPS(PyftpdlibTestCase): """Specific tests fot TSL_FTPHandler class.""" def _setup( self, tls_control_required=False, tls_data_required=False, ssl_protocol=ssl.PROTOCOL_SSLv23, ): self.server = FTPSServer() self.server.handler.tls_control_required = tls_control_required self.server.handler.tls_data_required = tls_data_required self.server.handler.ssl_protocol = ssl_protocol self.server.start() self.client = ftplib.FTP_TLS(timeout=GLOBAL_TIMEOUT) self.client.connect(self.server.host, self.server.port) def setUp(self): super().setUp() self.client = None self.server = None def tearDown(self): if self.client is not None: self.client.ssl_version = ssl.PROTOCOL_SSLv23 close_client(self.client) if self.server is not None: self.server.handler.ssl_protocol = ssl.PROTOCOL_SSLv23 self.server.handler.tls_control_required = False self.server.handler.tls_data_required = False self.server.stop() super().tearDown() def assertRaisesWithMsg(self, excClass, msg, callableObj, *args, **kwargs): try: callableObj(*args, **kwargs) except excClass as err: if str(err) == msg: return raise self.failureException(f"{err!s} != {msg}") else: if hasattr(excClass, '__name__'): excName = excClass.__name__ else: excName = str(excClass) raise self.failureException(f"{excName} not raised") def test_auth(self): # unsecured self._setup() self.client.login(secure=False) assert not isinstance(self.client.sock, ssl.SSLSocket) # secured self.client.login() assert isinstance(self.client.sock, ssl.SSLSocket) # AUTH issued twice msg = '503 Already using TLS.' self.assertRaisesWithMsg( ftplib.error_perm, msg, self.client.sendcmd, 'auth tls' ) def test_pbsz(self): # unsecured self._setup() self.client.login(secure=False) msg = "503 PBSZ not allowed on insecure control connection." self.assertRaisesWithMsg( ftplib.error_perm, msg, self.client.sendcmd, 'pbsz 0' ) # secured self.client.login(secure=True) resp = self.client.sendcmd('pbsz 0') assert resp == "200 PBSZ=0 successful." def test_prot(self): self._setup() self.client.login(secure=False) msg = "503 PROT not allowed on insecure control connection." self.assertRaisesWithMsg( ftplib.error_perm, msg, self.client.sendcmd, 'prot p' ) self.client.login(secure=True) # secured self.client.prot_p() sock = self.client.transfercmd('list') with contextlib.closing(sock): while True: if not sock.recv(1024): self.client.voidresp() break assert isinstance(sock, ssl.SSLSocket) # unsecured self.client.prot_c() sock = self.client.transfercmd('list') with contextlib.closing(sock): while True: if not sock.recv(1024): self.client.voidresp() break assert not isinstance(sock, ssl.SSLSocket) def test_feat(self): self._setup() feat = self.client.sendcmd('feat') cmds = ['AUTH TLS', 'AUTH SSL', 'PBSZ', 'PROT'] for cmd in cmds: assert cmd in feat def test_unforseen_ssl_shutdown(self): self._setup() self.client.login() try: sock = self.client.sock.unwrap() except OSError as err: if err.errno == 0: return raise sock.settimeout(GLOBAL_TIMEOUT) sock.sendall(b'noop') try: chunk = sock.recv(1024) except OSError: pass else: assert chunk == b"" def test_tls_control_required(self): self._setup(tls_control_required=True) msg = "550 SSL/TLS required on the control channel." self.assertRaisesWithMsg( ftplib.error_perm, msg, self.client.sendcmd, "user " + USER ) self.assertRaisesWithMsg( ftplib.error_perm, msg, self.client.sendcmd, "pass " + PASSWD ) self.client.login(secure=True) def test_tls_data_required(self): self._setup(tls_data_required=True) self.client.login(secure=True) msg = "550 SSL/TLS required on the data channel." self.assertRaisesWithMsg( ftplib.error_perm, msg, self.client.retrlines, 'list', lambda x: x ) self.client.prot_p() self.client.retrlines('list', lambda x: x) def try_protocol_combo(self, server_protocol, client_protocol): self._setup(ssl_protocol=server_protocol) self.client.ssl_version = client_protocol close_client(self.client) self.client.connect(self.server.host, self.server.port) try: self.client.login() except (ssl.SSLError, OSError): self.client.close() else: self.client.quit() # def test_ssl_version(self): # protos = [ssl.PROTOCOL_SSLv3, ssl.PROTOCOL_SSLv23, # ssl.PROTOCOL_TLSv1] # if hasattr(ssl, "PROTOCOL_SSLv2"): # protos.append(ssl.PROTOCOL_SSLv2) # for proto in protos: # self.try_protocol_combo(ssl.PROTOCOL_SSLv2, proto) # for proto in protos: # self.try_protocol_combo(ssl.PROTOCOL_SSLv3, proto) # for proto in protos: # self.try_protocol_combo(ssl.PROTOCOL_SSLv23, proto) # for proto in protos: # self.try_protocol_combo(ssl.PROTOCOL_TLSv1, proto) if hasattr(ssl, "PROTOCOL_SSLv2"): def test_sslv2(self): self.client.ssl_version = ssl.PROTOCOL_SSLv2 close_client(self.client) if not OSX: with self.server.lock: self.client.connect(self.server.host, self.server.port) with pytest.raises(OSError): self.client.login() else: with self.server.lock, pytest.raises(OSError): self.client.connect( self.server.host, self.server.port, timeout=0.1 ) self.client.ssl_version = ssl.PROTOCOL_SSLv2 pyftpdlib-release-2.0.1/pyftpdlib/test/test_ioloop.py000066400000000000000000000442341470572577600230460ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import contextlib import errno import socket import time from unittest.mock import Mock from unittest.mock import patch import pytest import pyftpdlib.ioloop from pyftpdlib.ioloop import Acceptor from pyftpdlib.ioloop import AsyncChat from pyftpdlib.ioloop import IOLoop from pyftpdlib.ioloop import RetryError from . import POSIX from . import PyftpdlibTestCase if hasattr(socket, 'socketpair'): socketpair = socket.socketpair else: def socketpair(family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0): with contextlib.closing(socket.socket(family, type, proto)) as ls: ls.bind(("localhost", 0)) ls.listen(5) c = socket.socket(family, type, proto) try: c.connect(ls.getsockname()) caddr = c.getsockname() while True: a, addr = ls.accept() # check that we've got the correct client if addr == caddr: return c, a a.close() except OSError: c.close() raise # TODO: write more tests. class BaseIOLoopTestCase: ioloop_class = None def make_socketpair(self): rd, wr = socketpair() self.addCleanup(rd.close) self.addCleanup(wr.close) return rd, wr def register(self): s = self.ioloop_class() self.addCleanup(s.close) rd, wr = self.make_socketpair() handler = AsyncChat(rd) self.addCleanup(handler.close) s.register(rd, handler, s.READ) s.register(wr, handler, s.WRITE) assert rd in s.socket_map assert wr in s.socket_map return (s, rd, wr) def test_unregister(self): s, rd, wr = self.register() s.unregister(rd) s.unregister(wr) assert rd not in s.socket_map assert wr not in s.socket_map def test_unregister_twice(self): s, rd, wr = self.register() s.unregister(rd) s.unregister(rd) s.unregister(wr) s.unregister(wr) def test_modify(self): s, rd, wr = self.register() s.modify(rd, s.WRITE) s.modify(wr, s.READ) def test_loop(self): # no timeout s, _rd, _wr = self.register() s.call_later(0, s.close) s.loop() # with timeout s, _rd, _wr = self.register() s.call_later(0, s.close) s.loop(timeout=0.001) # def test_close(self): # s, rd, wr = self.register() # s.close() # assert s.socket_map == {} def test_close_w_handler_exc(self): # Simulate an exception when close()ing a socket handler. # Exception should be logged and ignored. class Handler(AsyncChat): def close(self): 1 / 0 # noqa def real_close(self): super().close() s = self.ioloop_class() self.addCleanup(s.close) rd, _wr = self.make_socketpair() handler = Handler(rd) try: s.register(rd, handler, s.READ) with patch("pyftpdlib.ioloop.logger.error") as m: s.close() assert m.called assert 'ZeroDivisionError' in m.call_args[0][0] finally: handler.real_close() def test_close_w_handler_ebadf_exc(self): # Simulate an exception when close()ing a socket handler. # Exception should be ignored (and not logged). class Handler(AsyncChat): def close(self): raise OSError(errno.EBADF, "") def real_close(self): super().close() s = self.ioloop_class() self.addCleanup(s.close) rd, _wr = self.make_socketpair() handler = Handler(rd) try: s.register(rd, handler, s.READ) with patch("pyftpdlib.ioloop.logger.error") as m: s.close() assert not m.called finally: handler.real_close() def test_close_w_callback_exc(self): # Simulate an exception when close()ing the IO loop and a # scheduled callback raises an exception on cancel(). with patch("pyftpdlib.ioloop.logger.error") as logerr: with patch( "pyftpdlib.ioloop._CallLater.cancel", side_effect=lambda: 1 / 0 ) as cancel: s = self.ioloop_class() self.addCleanup(s.close) s.call_later(1, lambda: 0) s.close() assert cancel.called assert logerr.called assert 'ZeroDivisionError' in logerr.call_args[0][0] class DefaultIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase): ioloop_class = pyftpdlib.ioloop.IOLoop # =================================================================== # select() # =================================================================== class SelectIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase): ioloop_class = pyftpdlib.ioloop.Select def test_select_eintr(self): # EINTR is supposed to be ignored with patch( 'pyftpdlib.ioloop.select.select', side_effect=InterruptedError ) as m: s, _rd, _wr = self.register() s.poll(0) # ...but just that with patch( 'pyftpdlib.ioloop.select.select', side_effect=OSError() ) as m: m.side_effect.errno = errno.EBADF s, _rd, _wr = self.register() with pytest.raises(OSError): s.poll(0) # =================================================================== # poll() # =================================================================== @pytest.mark.skipif( not hasattr(pyftpdlib.ioloop, 'Poll'), reason="poll() not available on this platform", ) class PollIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase): ioloop_class = getattr(pyftpdlib.ioloop, "Poll", None) poller_mock = "pyftpdlib.ioloop.Poll._poller" def test_eintr_on_poll(self): # EINTR is supposed to be ignored with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.poll.side_effect = OSError(errno.EINTR, "") s, _rd, _wr = self.register() s.poll(0) assert m.called # ...but just that with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.poll.side_effect = OSError(errno.EBADF, "") s, _rd, _wr = self.register() with pytest.raises(OSError): s.poll(0) assert m.called def test_eexist_on_register(self): # EEXIST is supposed to be ignored with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.register.side_effect = OSError(errno.EEXIST, "") _s, _rd, _wr = self.register() # ...but just that with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.register.side_effect = OSError(errno.EBADF, "") with pytest.raises(EnvironmentError): self.register() def test_enoent_ebadf_on_unregister(self): # ENOENT and EBADF are supposed to be ignored for errnum in (errno.EBADF, errno.ENOENT): with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.unregister.side_effect = OSError(errnum, "") s, rd, _wr = self.register() s.unregister(rd) # ...but just those with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.unregister.side_effect = OSError(errno.EEXIST, "") s, rd, _wr = self.register() with pytest.raises(EnvironmentError): s.unregister(rd) def test_enoent_on_modify(self): # ENOENT is supposed to be ignored with patch(self.poller_mock, return_vaue=Mock()) as m: m.return_value.modify.side_effect = OSError(errno.ENOENT, "") s, rd, _wr = self.register() s.modify(rd, s.READ) # =================================================================== # epoll() # =================================================================== @pytest.mark.skipif( not hasattr(pyftpdlib.ioloop, 'Epoll'), reason="epoll() not available on this platform (Linux only)", ) class EpollIOLoopTestCase(PollIOLoopTestCase): ioloop_class = getattr(pyftpdlib.ioloop, "Epoll", None) poller_mock = "pyftpdlib.ioloop.Epoll._poller" # =================================================================== # /dev/poll # =================================================================== @pytest.mark.skipif( not hasattr(pyftpdlib.ioloop, 'DevPoll'), reason="/dev/poll not available on this platform (Solaris only)", ) class DevPollIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase): ioloop_class = getattr(pyftpdlib.ioloop, "DevPoll", None) # =================================================================== # kqueue # =================================================================== @pytest.mark.skipif( not hasattr(pyftpdlib.ioloop, 'Kqueue'), reason="/dev/poll not available on this platform (BSD only)", ) class KqueueIOLoopTestCase(PyftpdlibTestCase, BaseIOLoopTestCase): ioloop_class = getattr(pyftpdlib.ioloop, "Kqueue", None) class TestCallLater(PyftpdlibTestCase): """Tests for CallLater class.""" def setUp(self): super().setUp() self.ioloop = IOLoop.instance() for task in self.ioloop.sched._tasks: if not task.cancelled: task.cancel() del self.ioloop.sched._tasks[:] def tearDown(self): self.ioloop.close() def scheduler(self, timeout=0.01, count=100): while self.ioloop.sched._tasks and count > 0: self.ioloop.sched.poll() count -= 1 time.sleep(timeout) def test_interface(self): def fun(): return 0 with pytest.raises(AssertionError): self.ioloop.call_later(-1, fun) x = self.ioloop.call_later(3, fun) assert not x.cancelled x.cancel() assert x.cancelled with pytest.raises(AssertionError): x.call() with pytest.raises(AssertionError): x.reset() x.cancel() def test_order(self): def fun(x): ls.append(x) ls = [] for x in [0.05, 0.04, 0.03, 0.02, 0.01]: self.ioloop.call_later(x, fun, x) self.scheduler() assert ls == [0.01, 0.02, 0.03, 0.04, 0.05] # The test is reliable only on those systems where time.time() # provides time with a better precision than 1 second. if not str(time.time()).endswith('.0'): def test_reset(self): def fun(x): ls.append(x) ls = [] self.ioloop.call_later(0.01, fun, 0.01) self.ioloop.call_later(0.02, fun, 0.02) self.ioloop.call_later(0.03, fun, 0.03) x = self.ioloop.call_later(0.04, fun, 0.04) self.ioloop.call_later(0.05, fun, 0.05) time.sleep(0.1) x.reset() self.scheduler() assert ls == [0.01, 0.02, 0.03, 0.05, 0.04] def test_cancel(self): def fun(x): ls.append(x) ls = [] self.ioloop.call_later(0.01, fun, 0.01).cancel() self.ioloop.call_later(0.02, fun, 0.02) self.ioloop.call_later(0.03, fun, 0.03) self.ioloop.call_later(0.04, fun, 0.04) self.ioloop.call_later(0.05, fun, 0.05).cancel() self.scheduler() assert ls == [0.02, 0.03, 0.04] def test_errback(self): ls = [] self.ioloop.call_later( 0.0, lambda: 1 // 0, _errback=lambda: ls.append(True) ) self.scheduler() assert ls == [True] def test__repr__(self): repr(self.ioloop.call_later(0.01, lambda: 0, 0.01)) def test__lt__(self): a = self.ioloop.call_later(0.01, lambda: 0, 0.01) b = self.ioloop.call_later(0.02, lambda: 0, 0.02) assert a < b def test__le__(self): a = self.ioloop.call_later(0.01, lambda: 0, 0.01) b = self.ioloop.call_later(0.02, lambda: 0, 0.02) assert a <= b class TestCallEvery(PyftpdlibTestCase): """Tests for CallEvery class.""" def setUp(self): super().setUp() self.ioloop = IOLoop.instance() for task in self.ioloop.sched._tasks: if not task.cancelled: task.cancel() del self.ioloop.sched._tasks[:] def tearDown(self): self.ioloop.close() def scheduler(self, timeout=0.003): stop_at = time.time() + timeout while time.time() < stop_at: self.ioloop.sched.poll() def test_interface(self): def fun(): return 0 with pytest.raises(AssertionError): self.ioloop.call_every(-1, fun) x = self.ioloop.call_every(3, fun) assert x.cancelled is False x.cancel() assert x.cancelled is True with pytest.raises(AssertionError): x.call() with pytest.raises(AssertionError): x.reset() x.cancel() def test_only_once(self): # make sure that callback is called only once per-loop def fun(): ls.append(None) ls = [] self.ioloop.call_every(0, fun) self.ioloop.sched.poll() assert ls == [None] def test_multi_0_timeout(self): # make sure a 0 timeout callback is called as many times # as the number of loops def fun(): ls.append(None) ls = [] self.ioloop.call_every(0, fun) for _ in range(100): self.ioloop.sched.poll() assert len(ls) == 100 # run it on systems where time.time() has a higher precision if POSIX: def test_low_and_high_timeouts(self): # make sure a callback with a lower timeout is called more # frequently than another with a greater timeout def fun_1(): l1.append(None) l1 = [] self.ioloop.call_every(0.001, fun_1) self.scheduler() def fun_2(): l2.append(None) l2 = [] self.ioloop.call_every(0.005, fun_2) self.scheduler(timeout=0.01) assert len(l1) > len(l2) def test_cancel(self): # make sure a cancelled callback doesn't get called anymore def fun(): ls.append(None) ls = [] call = self.ioloop.call_every(0.001, fun) self.scheduler() len_l = len(ls) call.cancel() self.scheduler() assert len_l == len(ls) def test_errback(self): ls = [] self.ioloop.call_every( 0.0, lambda: 1 // 0, _errback=lambda: ls.append(True) ) self.scheduler() assert ls class TestAsyncChat(PyftpdlibTestCase): def get_connected_handler(self): s = socket.socket() self.addCleanup(s.close) ac = AsyncChat(sock=s) self.addCleanup(ac.close) return ac def test_send_retry(self): ac = self.get_connected_handler() for errnum in pyftpdlib.ioloop._ERRNOS_RETRY: with patch( "pyftpdlib.ioloop.socket.socket.send", side_effect=OSError(errnum, ""), ) as m: assert ac.send(b"x") == 0 assert m.called def test_send_disconnect(self): ac = self.get_connected_handler() for errnum in pyftpdlib.ioloop._ERRNOS_DISCONNECTED: with patch( "pyftpdlib.ioloop.socket.socket.send", side_effect=OSError(errnum, ""), ) as send: with patch.object(ac, "handle_close") as handle_close: assert ac.send(b"x") == 0 assert send.called assert handle_close.called def test_recv_retry(self): ac = self.get_connected_handler() for errnum in pyftpdlib.ioloop._ERRNOS_RETRY: with patch( "pyftpdlib.ioloop.socket.socket.recv", side_effect=OSError(errnum, ""), ) as m: with pytest.raises(RetryError): ac.recv(1024) assert m.called def test_recv_disconnect(self): ac = self.get_connected_handler() for errnum in pyftpdlib.ioloop._ERRNOS_DISCONNECTED: with patch( "pyftpdlib.ioloop.socket.socket.recv", side_effect=OSError(errnum, ""), ) as send: with patch.object(ac, "handle_close") as handle_close: assert ac.recv(b"x") == b'' assert send.called assert handle_close.called def test_connect_af_unspecified_err(self): ac = AsyncChat() with patch.object( ac, "connect", side_effect=OSError(errno.EBADF, "") ) as m: with pytest.raises(OSError): ac.connect_af_unspecified(("localhost", 0)) assert m.called assert ac.socket is None class TestAcceptor(PyftpdlibTestCase): def test_bind_af_unspecified_err(self): ac = Acceptor() with patch.object( ac, "bind", side_effect=OSError(errno.EBADF, "") ) as m: with pytest.raises(OSError): ac.bind_af_unspecified(("localhost", 0)) assert m.called assert ac.socket is None def test_handle_accept_econnacorted(self): # https://github.com/giampaolo/pyftpdlib/issues/105 ac = Acceptor() with patch.object( ac, "accept", side_effect=OSError(errno.ECONNABORTED, "") ) as m: ac.handle_accept() assert m.called assert ac.socket is None def test_handle_accept_typeerror(self): # https://github.com/giampaolo/pyftpdlib/issues/91 ac = Acceptor() with patch.object(ac, "accept", side_effect=TypeError) as m: ac.handle_accept() assert m.called assert ac.socket is None pyftpdlib-release-2.0.1/pyftpdlib/test/test_servers.py000066400000000000000000000137021470572577600232320ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import contextlib import ftplib import socket import pytest from pyftpdlib import handlers from pyftpdlib import servers from . import GLOBAL_TIMEOUT from . import HOST from . import PASSWD from . import SUPPORTS_MULTIPROCESSING from . import USER from . import WINDOWS from . import FtpdThreadWrapper from . import PyftpdlibTestCase from . import close_client from .test_functional import TestCornerCases from .test_functional import TestFtpAbort from .test_functional import TestFtpAuthentication from .test_functional import TestFtpCmdsSemantic from .test_functional import TestFtpDummyCmds from .test_functional import TestFtpFsOperations from .test_functional import TestFtpListingCmds from .test_functional import TestFtpRetrieveData from .test_functional import TestFtpStoreData from .test_functional import TestIPv4Environment from .test_functional import TestIPv6Environment class TestFTPServer(PyftpdlibTestCase): """Tests for *FTPServer classes.""" server_class = FtpdThreadWrapper client_class = ftplib.FTP def setUp(self): super().setUp() self.server = None self.client = None def tearDown(self): if self.client is not None: close_client(self.client) if self.server is not None: self.server.stop() super().tearDown() @pytest.mark.skipif(WINDOWS, reason="POSIX only") def test_sock_instead_of_addr(self): # pass a socket object instead of an address tuple to FTPServer # constructor with contextlib.closing(socket.socket()) as sock: sock.bind((HOST, 0)) sock.listen(5) ip, port = sock.getsockname()[:2] self.server = self.server_class(sock) self.server.start() self.client = self.client_class(timeout=GLOBAL_TIMEOUT) self.client.connect(ip, port) self.client.login(USER, PASSWD) self.client.quit() def test_ctx_mgr(self): with servers.FTPServer((HOST, 0), handlers.FTPHandler) as server: assert server is not None # ===================================================================== # --- threaded FTP server mixin tests # ===================================================================== # What we're going to do here is repeat the original functional tests # defined in test_functinal.py but by using different concurrency # modules (multi thread and multi process instead of async. # This is useful as we reuse the existent functional tests which are # supposed to work no matter what the concurrency model is. class _TFTPd(FtpdThreadWrapper): server_class = servers.ThreadedFTPServer class ThreadFTPTestMixin: server_class = _TFTPd class TestFtpAuthenticationThreadMixin( ThreadFTPTestMixin, TestFtpAuthentication ): pass class TestTFtpDummyCmdsThreadMixin(ThreadFTPTestMixin, TestFtpDummyCmds): pass class TestFtpCmdsSemanticThreadMixin(ThreadFTPTestMixin, TestFtpCmdsSemantic): pass class TestFtpFsOperationsThreadMixin(ThreadFTPTestMixin, TestFtpFsOperations): pass class TestFtpStoreDataThreadMixin(ThreadFTPTestMixin, TestFtpStoreData): pass class TestFtpRetrieveDataThreadMixin(ThreadFTPTestMixin, TestFtpRetrieveData): pass class TestFtpListingCmdsThreadMixin(ThreadFTPTestMixin, TestFtpListingCmds): pass class TestFtpAbortThreadMixin(ThreadFTPTestMixin, TestFtpAbort): pass # class TestTimeoutsThreadMixin(ThreadFTPTestMixin, TestTimeouts): # def test_data_timeout_not_reached(self): pass # class TestConfOptsThreadMixin(ThreadFTPTestMixin, TestConfigurableOptions): # pass # class TestCallbacksThreadMixin(ThreadFTPTestMixin, TestCallbacks): # pass class TestIPv4EnvironmentThreadMixin(ThreadFTPTestMixin, TestIPv4Environment): pass class TestIPv6EnvironmentThreadMixin(ThreadFTPTestMixin, TestIPv6Environment): pass class TestCornerCasesThreadMixin(ThreadFTPTestMixin, TestCornerCases): pass # class TestFTPServerThreadMixin(ThreadFTPTestMixin, TestFTPServer): # pass # ===================================================================== # --- multiprocess FTP server mixin tests # ===================================================================== if SUPPORTS_MULTIPROCESSING: class MultiProcFTPd(FtpdThreadWrapper): server_class = servers.MultiprocessFTPServer class MProcFTPTestMixin: server_class = MultiProcFTPd else: @pytest.mark.skip(reason="multiprocessing module not installed") class MProcFTPTestMixin: pass class TestFtpAuthenticationMProcMixin( MProcFTPTestMixin, TestFtpAuthentication ): pass class TestTFtpDummyCmdsMProcMixin(MProcFTPTestMixin, TestFtpDummyCmds): pass class TestFtpCmdsSemanticMProcMixin(MProcFTPTestMixin, TestFtpCmdsSemantic): pass class TestFtpFsOperationsMProcMixin(MProcFTPTestMixin, TestFtpFsOperations): def test_unforeseen_mdtm_event(self): pass class TestFtpStoreDataMProcMixin(MProcFTPTestMixin, TestFtpStoreData): pass class TestFtpRetrieveDataMProcMixin(MProcFTPTestMixin, TestFtpRetrieveData): pass class TestFtpListingCmdsMProcMixin(MProcFTPTestMixin, TestFtpListingCmds): pass class TestFtpAbortMProcMixin(MProcFTPTestMixin, TestFtpAbort): pass # class TestTimeoutsMProcMixin(MProcFTPTestMixin, TestTimeouts): # def test_data_timeout_not_reached(self): pass # class TestConfiOptsMProcMixin(MProcFTPTestMixin, TestConfigurableOptions): # pass # class TestCallbacksMProcMixin(MProcFTPTestMixin, TestCallbacks): pass class TestIPv4EnvironmentMProcMixin(MProcFTPTestMixin, TestIPv4Environment): pass class TestIPv6EnvironmentMProcMixin(MProcFTPTestMixin, TestIPv6Environment): pass class TestCornerCasesMProcMixin(MProcFTPTestMixin, TestCornerCases): pass # class TestFTPServerMProcMixin(MProcFTPTestMixin, TestFTPServer): # pass pyftpdlib-release-2.0.1/pyproject.toml000066400000000000000000000136371470572577600200770ustar00rootroot00000000000000[tool.black] target-version = ["py37"] line-length = 79 skip-string-normalization = true # https://black.readthedocs.io/en/stable/the_black_code_style/future_style.html preview = true enable-unstable-feature = ["hug_parens_with_braces_and_square_brackets", "multiline_string_handling", "string_processing", "wrap_long_dict_values_in_parens"] [tool.ruff] # https://beta.ruff.rs/docs/settings/ target-version = "py37" line-length = 79 [tool.ruff.lint] preview = true select = [ "ALL", # to get a list of all values: `python3 -m ruff linter` "PLR5501", # [*] Use `elif` instead of `else` then `if`, to reduce indentation "PLR6104", # Use `+=` to perform an augmented assignment directly "PLW0602", # Using global for `x` but no assignment is done "RUF005", # Consider iterable unpacking instead of concatenation "RUF010", # [*] f-string, use explicit conversion flag "RUF021", # [*] Parenthesize `a and b` expressions when chaining `and` and `or` together, to make the precedence clear "RUF022", # [*] `__all__` is not sorted "RUF023", # [*] `_CallLater.__slots__` is not sorted ] ignore = [ "A", # flake8-builtins "ANN", # flake8-annotations "ARG", # flake8-unused-arguments "B904", # Use `raise from` to specify exception cause (PYTHON2.7 COMPAT) "BLE001", # Do not catch blind exception: `Exception` "C4", # flake8-comprehensions (PYTHON2.7 COMPAT) # "C408", # Unnecessary `dict` call (rewrite as a literal) "C90", # mccabe (function `X` is too complex) "COM812", # Trailing comma missing "D", # pydocstyle "DOC201", # `return` is not documented in docstring "DOC402", # `yield` is not documented in docstring "DOC501", # Raised exception `X` missing from docstring "DTZ", # flake8-datetimez "E203", # [*] Whitespace before ':' (clashes with black) "EM", # flake8-errmsg "ERA001", # Found commented-out code "FBT", # flake8-boolean-trap "FIX", # Line contains TODO / XXX / ..., consider resolving the issue "FURB101", # `open` and `read` should be replaced by `Path(x).read_text()` "FURB103", # `open` and `write` should be replaced by `Path(...).write_bytes(data)` "FURB113", # Use `x.extend(("foo", "bar"))` instead of repeatedly calling `x.append()` "INP", # `docs/conf.py` is part of an implicit namespace package. Add an `__init__.py`. "N801", # Class name `async_chat` should use CapWords convention (ASYNCORE COMPAT) "N802", # Function name X should be lowercase. "N803", # Argument name X should be lowercase. "N806", # Variable X in function should be lowercase. "N812", # Lowercase `error` imported as non-lowercase `FooBarError` "N818", # Exception name `FooBar` should be named with an Error suffix "PERF", # Perflint "PGH004", # Use specific rule codes when using `noqa` "PLC2701", # Private name import `_winreg` "PLR", # pylint "PLW", # pylint "PT003", # `scope='function'` is implied in `@pytest.fixture()` "PT004", # Fixture `for_each_test_method` does not return anything, add leading underscore "PT011", # `pytest.raises(OSError)` is too broad, set the `match` parameter or use a more specific exception "PT021", # Use `yield` instead of `request.addfinalizer` "PTH", # flake8-use-pathlib "Q000", # Single quotes found but double quotes preferred "RET", # flake8-return "RUF", # Ruff-specific rules "S", # flake8-bandit "SIM102", # Use a single `if` statement instead of nested `if` statements "SIM105", # Use `contextlib.suppress(OSError)` instead of `try`-`except`-`pass` "SIM115", # Use context handler for opening files "SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements "SLF", # flake8-self "TD", # all TODOs, XXXs, etc. "TRY003", # Avoid specifying long messages outside the exception class "TRY300", # Consider moving this statement to an `else` block "TRY301", # Abstract `raise` to an inner function "UP031", # Use format specifiers instead of percent format ] [tool.ruff.lint.per-file-ignores] ".git_pre_commit.py" = ["T201"] # T201 == print() "demo/*" = ["T201"] "doc/*" = ["T201"] "scripts/*" = ["T201"] "setup.py" = ["T201"] [tool.ruff.lint.isort] # https://beta.ruff.rs/docs/settings/#isort force-single-line = true # one import per line lines-after-imports = 2 [tool.coverage.report] omit = [ "pyftpdlib/_compat.py", "pyftpdlib/test/*", "setup.py", ] exclude_lines = [ "except ImportError:", "if __name__ == .__main__.:", "if hasattr(select, 'devpoll'):", "if hasattr(select, 'epoll'):", "if hasattr(select, 'kqueue'):", "if hasattr(select, 'kqueue'):", "if os.name == 'nt':", "pragma: no cover", "raise NotImplementedError('must be implemented in subclass')", ] [tool.pylint.messages_control] disable = [ "broad-except", "consider-using-f-string", "fixme", "import-outside-toplevel", "inconsistent-return-statements", "invalid-name", "logging-not-lazy", "misplaced-bare-raise", "missing-class-docstring", "missing-function-docstring", "missing-module-docstring", "no-else-continue", "no-else-raise", "no-else-return", "protected-access", "raise-missing-from", "redefined-builtin", "too-few-public-methods", "too-many-instance-attributes", "ungrouped-imports", "unspecified-encoding", "use-maxsplit-arg", "useless-object-inheritance", ] [tool.rstcheck] ignore_messages = [ "Unexpected possible title overline or transition", 'Hyperlink target "changing-the-concurrency-model" is not referenced.', 'Hyperlink target "ftps-server" is not referenced.', 'Hyperlink target "pre-fork-model" is not referenced.', ] [tool.tomlsort] in_place = true no_sort_tables = true sort_inline_arrays = true spaces_before_inline_comment = 2 spaces_indent_inline_array = 4 trailing_comma_inline_array = true pyftpdlib-release-2.0.1/scripts/000077500000000000000000000000001470572577600166405ustar00rootroot00000000000000pyftpdlib-release-2.0.1/scripts/ftpbench000077500000000000000000000423011470572577600203570ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007-2016 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ FTP server benchmark script. In order to run this you must have a listening FTP server with a user with writing permissions configured. This is a stand-alone script which does not depend from pyftpdlib. psutil dep (optional) can be installed to keep track of FTP server memory usage). Example usages: ftpbench -u USER -p PASSWORD ftpbench -u USER -p PASSWORD -H ftp.domain.com -P 21 # host / port ftpbench -u USER -p PASSWORD -b transfer ftpbench -u USER -p PASSWORD -b concurrence ftpbench -u USER -p PASSWORD -b all ftpbench -u USER -p PASSWORD -b concurrence -n 500 # 500 clients ftpbench -u USER -p PASSWORD -b concurrence -s 20M # file size ftpbench -u USER -p PASSWORD -b concurrence -p 3521 # memory usage """ # Some benchmarks (Linux 3.0.0, Intel core duo - 3.1 Ghz). # pyftpdlib 1.0.0: # # (starting with 6.7M of memory being used) # STOR (client -> server) 557.97 MB/sec 6.7M # RETR (server -> client) 1613.82 MB/sec 6.8M # 300 concurrent clients (connect, login) 1.20 secs 8.8M # STOR (1 file with 300 idle clients) 567.52 MB/sec 8.8M # RETR (1 file with 300 idle clients) 1561.41 MB/sec 8.8M # 300 concurrent clients (RETR 10.0M file) 3.26 secs 10.8M # 300 concurrent clients (STOR 10.0M file) 8.46 secs 12.6M # 300 concurrent clients (QUIT) 0.07 secs # # # proftpd 1.3.4a: # # (starting with 1.4M of memory being used) # STOR (client -> server) 554.67 MB/sec 3.2M # RETR (server -> client) 1517.12 MB/sec 3.2M # 300 concurrent clients (connect, login) 9.30 secs 568.6M # STOR (1 file with 300 idle clients) 484.11 MB/sec 570.6M # RETR (1 file with 300 idle clients) 1534.61 MB/sec 570.6M # 300 concurrent clients (RETR 10.0M file) 3.67 secs 568.6M # 300 concurrent clients (STOR 10.0M file) 11.21 secs 568.7M # 300 concurrent clients (QUIT) 0.43 secs # # # vsftpd 2.3.2 # # (starting with 352.0K of memory being used) # STOR (client -> server) 607.23 MB/sec 816.0K # RETR (server -> client) 1506.59 MB/sec 816.0K # 300 concurrent clients (connect, login) 18.91 secs 140.9M # STOR (1 file with 300 idle clients) 618.99 MB/sec 141.4M # RETR (1 file with 300 idle clients) 1402.48 MB/sec 141.4M # 300 concurrent clients (RETR 10.0M file) 3.64 secs 140.9M # 300 concurrent clients (STOR 10.0M file) 9.74 secs 140.9M # 300 concurrent clients (QUIT) 0.00 secs import argparse import asynchat import asyncore import atexit import contextlib import ftplib import os import ssl import sys import time try: import resource except ImportError: resource = None try: import psutil except ImportError: psutil = None HOST = 'localhost' PORT = 21 USER = None PASSWORD = None TESTFN = "$testfile" BUFFER_LEN = 8192 SERVER_PROC = None TIMEOUT = None FILE_SIZE = "10M" SSL = False server_memory = [] if not sys.stdout.isatty() or os.name != 'posix': def hilite(s, *args, **kwargs): return s else: # https://goo.gl/6V8Rm def hilite(string, ok=True, bold=False): """Return an highlighted version of 'string'.""" attr = [] if ok is None: # no color pass elif ok: # green attr.append('32') else: # red attr.append('31') if bold: attr.append('1') return '\x1b[%sm%s\x1b[0m' % (';'.join(attr), string) def print_bench(what, value, unit=""): s = "%s %s %-8s" % ( hilite("%-50s" % what, ok=None, bold=0), hilite("%8.2f" % value), unit, ) if server_memory: s += "%s" % hilite(server_memory.pop()) print(s.strip()) # https://goo.gl/zeJZl def bytes2human(n, format="%(value).1f%(symbol)s"): """ >>> bytes2human(10000) '9K' >>> bytes2human(100001221) '95M' """ symbols = ('B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') prefix = {} for i, s in enumerate(symbols[1:]): prefix[s] = 1 << (i + 1) * 10 for symbol in reversed(symbols[1:]): if n >= prefix[symbol]: value = float(n) / prefix[symbol] return format % locals() return format % dict(symbol=symbols[0], value=n) # https://goo.gl/zeJZl def human2bytes(s): """ >>> human2bytes('1M') 1048576 >>> human2bytes('1G') 1073741824 """ symbols = ('B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') letter = s[-1:].strip().upper() num = s[:-1] assert num.isdigit() and letter in symbols, s num = float(num) prefix = {symbols[0]: 1} for i, s in enumerate(symbols[1:]): prefix[s] = 1 << (i + 1) * 10 return int(num * prefix[letter]) def register_memory(): """Register an approximation of memory used by FTP server process and all of its children. """ # XXX How to get a reliable representation of memory being used is # not clear. (rss - shared) seems kind of ok but we might also use # the private working set via get_memory_maps().private*. def get_mem(proc): if os.name == 'posix': mem = proc.memory_info_ex() counter = mem.rss if 'shared' in mem._fields: counter -= mem.shared return counter else: # TODO figure out what to do on Windows return proc.get_memory_info().rss if SERVER_PROC is not None: mem = get_mem(SERVER_PROC) for child in SERVER_PROC.children(): mem += get_mem(child) server_memory.append(bytes2human(mem)) def timethis(what): """Utility function for making simple benchmarks (calculates time calls). It can be used either as a context manager or as a decorator. """ @contextlib.contextmanager def benchmark(): timer = time.clock if sys.platform == "win32" else time.time start = timer() yield stop = timer() res = stop - start print_bench(what, res, "secs") if callable(what): def timed(*args, **kwargs): with benchmark(): return what(*args, **kwargs) return timed else: return benchmark() def connect(): """Connect to FTP server, login and return an ftplib.FTP instance.""" ftp_class = ftplib.FTP if not SSL else ftplib.FTP_TLS ftp = ftp_class(timeout=TIMEOUT) ftp.connect(HOST, PORT) ftp.login(USER, PASSWORD) if SSL: ftp.prot_p() # secure data connection return ftp def retr(ftp): """Same as ftplib's retrbinary() but discard the received data.""" ftp.voidcmd('TYPE I') with contextlib.closing(ftp.transfercmd("RETR " + TESTFN)) as conn: recv_bytes = 0 while True: data = conn.recv(BUFFER_LEN) if not data: break recv_bytes += len(data) ftp.voidresp() def stor(ftp=None): """Same as ftplib's storbinary() but just sends dummy data instead of reading it from a real file. """ if ftp is None: ftp = connect() quit = True else: quit = False ftp.voidcmd('TYPE I') with contextlib.closing(ftp.transfercmd("STOR " + TESTFN)) as conn: chunk = b'x' * BUFFER_LEN total_sent = 0 while True: sent = conn.send(chunk) total_sent += sent if total_sent >= FILE_SIZE: break ftp.voidresp() if quit: ftp.quit() return ftp def bytes_per_second(ftp, retr=True): """Return the number of bytes transmitted in 1 second.""" tot_bytes = 0 if retr: def request_file(): ftp.voidcmd('TYPE I') conn = ftp.transfercmd("retr " + TESTFN) return conn with contextlib.closing(request_file()) as conn: register_memory() stop_at = time.time() + 1.0 while stop_at > time.time(): chunk = conn.recv(BUFFER_LEN) if not chunk: a = time.time() while conn.recv(BUFFER_LEN): break conn.close() ftp.voidresp() conn = request_file() stop_at += time.time() - a tot_bytes += len(chunk) conn.close() try: ftp.voidresp() except (ftplib.error_temp, ftplib.error_perm): pass else: ftp.voidcmd('TYPE I') with contextlib.closing(ftp.transfercmd("STOR " + TESTFN)) as conn: register_memory() chunk = b'x' * BUFFER_LEN stop_at = time.time() + 1 while stop_at > time.time(): tot_bytes += conn.send(chunk) ftp.voidresp() return tot_bytes def cleanup(): ftp = connect() try: if TESTFN in ftp.nlst(): ftp.delete(TESTFN) except (ftplib.error_perm, ftplib.error_temp) as err: msg = "could not delete %r test file on cleanup: %r" % (TESTFN, err) print(hilite(msg, ok=False), file=sys.stderr) ftp.quit() def bench_stor(ftp=None, title="STOR (client -> server)"): if ftp is None: ftp = connect() tot_bytes = bytes_per_second(ftp, retr=False) print_bench(title, round(tot_bytes / 1024.0 / 1024.0, 2), "MB/sec") ftp.quit() def bench_retr(ftp=None, title="RETR (server -> client)"): if ftp is None: ftp = connect() tot_bytes = bytes_per_second(ftp, retr=True) print_bench(title, round(tot_bytes / 1024.0 / 1024.0, 2), "MB/sec") ftp.quit() def bench_multi(howmany): # The OS usually sets a limit of 1024 as the maximum number of # open file descriptors for the current process. # Let's set the highest number possible, just to be sure. if howmany > 500 and resource is not None: soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard)) def bench_multi_connect(): with timethis("%i concurrent clients (connect, login)" % howmany): clients = [] for _ in range(howmany): clients.append(connect()) register_memory() return clients def bench_multi_retr(clients): stor(clients[0]) with timethis( "%s concurrent clients (RETR %s file)" % (howmany, bytes2human(FILE_SIZE)) ): for ftp in clients: ftp.voidcmd('TYPE I') conn = ftp.transfercmd("RETR " + TESTFN) AsyncReader(conn) register_memory() asyncore.loop(use_poll=True) for ftp in clients: ftp.voidresp() def bench_multi_stor(clients): with timethis( "%s concurrent clients (STOR %s file)" % (howmany, bytes2human(FILE_SIZE)) ): for ftp in clients: ftp.voidcmd('TYPE I') conn = ftp.transfercmd("STOR " + TESTFN) AsyncWriter(conn, FILE_SIZE) register_memory() asyncore.loop(use_poll=True) for ftp in clients: ftp.voidresp() def bench_multi_quit(clients): for ftp in clients: AsyncQuit(ftp.sock) with timethis("%i concurrent clients (QUIT)" % howmany): asyncore.loop(use_poll=True) clients = bench_multi_connect() bench_stor(title="STOR (1 file with %s idle clients)" % len(clients)) bench_retr(title="RETR (1 file with %s idle clients)" % len(clients)) bench_multi_retr(clients) bench_multi_stor(clients) bench_multi_quit(clients) @contextlib.contextmanager def handle_ssl_want_rw_errs(): try: yield except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as err: if DEBUG: print(err) class AsyncReader(asyncore.dispatcher): """Just read data from a connected socket, asynchronously.""" def __init__(self, sock): asyncore.dispatcher.__init__(self, sock) def handle_read(self): if SSL: with handle_ssl_want_rw_errs(): chunk = self.socket.recv(65536) else: chunk = self.socket.recv(65536) if not chunk: self.close() def handle_close(self): self.close() def handle_error(self): raise # noqa class AsyncWriter(asyncore.dispatcher): """Just write dummy data to a connected socket, asynchronously.""" def __init__(self, sock, size): asyncore.dispatcher.__init__(self, sock) self.size = size self.sent = 0 self.chunk = b'x' * BUFFER_LEN def handle_write(self): if SSL: with handle_ssl_want_rw_errs(): self.sent += asyncore.dispatcher.send(self, self.chunk) else: self.sent += asyncore.dispatcher.send(self, self.chunk) if self.sent >= self.size: self.handle_close() def handle_error(self): raise # noqa class AsyncQuit(asynchat.async_chat): def __init__(self, sock): asynchat.async_chat.__init__(self, sock) self.in_buffer = [] self.set_terminator(b'\r\n') self.push(b'QUIT\r\n') def collect_incoming_data(self, data): self.in_buffer.append(data) def found_terminator(self): self.handle_close() def handle_error(self): raise def main(): global HOST, PORT, USER, PASSWORD, SERVER_PROC, TIMEOUT, SSL, FILE_SIZE, DEBUG USAGE = ( "%s -u USERNAME -p PASSWORD [-H] [-P] [-b] [-n] [-s] [-k] " "[-t] [-d] [-S]" % (os.path.basename(__file__)) ) parser = argparse.ArgumentParser( usage=USAGE, ) parser.add_argument( '-u', '--user', dest='user', required=True, help='username' ) parser.add_argument( '-p', '--pass', dest='password', required=True, help='password' ) parser.add_argument( '-H', '--host', dest='host', default=HOST, help='hostname' ) parser.add_argument( '-P', '--port', dest='port', default=PORT, type=int, help='port' ) parser.add_argument( '-b', '--benchmark', dest='benchmark', default='transfer', help=( "benchmark type ('transfer', 'download', 'upload', 'concurrence'," " 'all')" ), ) parser.add_argument( '-n', '--clients', dest='clients', default=200, type=int, help="number of concurrent clients used by 'concurrence' benchmark", ) parser.add_argument( '-s', '--filesize', dest='filesize', default="10M", help="file size used by 'concurrence' benchmark (e.g. '10M')", ) parser.add_argument( '-k', '--pid', dest='pid', default=None, type=int, help="the PID of the FTP server process, to track its memory usage", ) parser.add_argument( '-t', '--timeout', dest='timeout', default=TIMEOUT, type=int, help="the socket timeout", ) parser.add_argument( '-d', '--debug', action='store_true', dest='debug', help="whether to print debugging info", ) parser.add_argument( '-S', '--ssl', action='store_true', dest='ssl', help="whether to use FTPS", ) options = parser.parse_args() USER = options.user PASSWORD = options.password HOST = options.host PORT = options.port TIMEOUT = options.timeout SSL = options.ssl DEBUG = options.debug try: FILE_SIZE = human2bytes(options.filesize) except (ValueError, AssertionError): parser.error("invalid file size %r" % options.filesize) if options.pid is not None: if psutil is None: raise ImportError("-k option requires psutil module") SERVER_PROC = psutil.Process(options.pid) # before starting make sure we have write permissions ftp = connect() conn = ftp.transfercmd("STOR " + TESTFN) conn.close() ftp.voidresp() ftp.delete(TESTFN) ftp.quit() atexit.register(cleanup) # start benchmark if SERVER_PROC is not None: register_memory() print( "(starting with %s of memory being used)" % (hilite(SERVER_PROC.memory_info().rss)) ) if options.benchmark == 'download': stor() bench_retr() elif options.benchmark == 'upload': bench_stor() elif options.benchmark == 'transfer': bench_stor() bench_retr() elif options.benchmark == 'concurrence': bench_multi(options.clients) elif options.benchmark == 'all': bench_stor() bench_retr() bench_multi(options.clients) else: sys.exit("invalid 'benchmark' parameter %r" % options.benchmark) if __name__ == '__main__': main() pyftpdlib-release-2.0.1/scripts/internal/000077500000000000000000000000001470572577600204545ustar00rootroot00000000000000pyftpdlib-release-2.0.1/scripts/internal/check_broken_links.py000077500000000000000000000154641470572577600246600ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (c) 2009, Himanshu Shekhar, Giampaolo Rodola. # All rights reserved. Use of this source code is governed by a # BSD-style license. # Imported from: # https://github.com/giampaolo/psutil/blob/master/scripts/internal/check_broken_links.py. """Checks for broken links in file names specified as command line parameters. There are a ton of a solutions available for validating URLs in string using regex, but less for searching, of which very few are accurate. This snippet is intended to just do the required work, and avoid complexities. Django Validator has pretty good regex for validation, but we have to find urls instead of validating them (REFERENCES [7]). There's always room for improvement. Method: * Match URLs using regex (REFERENCES [1]]) * Some URLs need to be fixed, as they have < (or) > due to inefficient regex. * Remove duplicates (because regex is not 100% efficient as of now). * Check validity of URL, using HEAD request. (HEAD to save bandwidth) Uses requests module for others are painful to use. REFERENCES[9] Handles redirects, http, https, ftp as well. REFERENCES: Using [1] with some modifications for including ftp [1] https://stackoverflow.com/a/6883094/5163807 [2] https://stackoverflow.com/a/31952097/5163807 [3] https://daringfireball.net/2010/07/improved_regex_for_matching_urls [4] https://mathiasbynens.be/demo/url-regex [5] https://github.com/django/django/blob/master/django/core/validators.py [6] https://data.iana.org/TLD/tlds-alpha-by-domain.txt [7] https://codereview.stackexchange.com/questions/19663/http-url-validating [8] https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD [9] https://docs.python-requests.org/ Author: Himanshu Shekhar (2017) """ import argparse import concurrent.futures import functools import os import re import sys import traceback import requests HERE = os.path.abspath(os.path.dirname(__file__)) REGEX = re.compile( r'(?:http|ftp|https)?://' r'(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+' ) REQUEST_TIMEOUT = 15 # There are some status codes sent by websites on HEAD request. # Like 503 by Microsoft, and 401 by Apple # They need to be sent GET request RETRY_STATUSES = [503, 401, 403] def memoize(fun): """A memoize decorator.""" @functools.wraps(fun) def wrapper(*args, **kwargs): key = (args, frozenset(sorted(kwargs.items()))) try: return cache[key] except KeyError: ret = cache[key] = fun(*args, **kwargs) return ret cache = {} return wrapper def sanitize_url(url): url = url.rstrip(',') url = url.rstrip('.') url = url.lstrip('(') url = url.rstrip(')') url = url.lstrip('[') url = url.rstrip(']') url = url.lstrip('<') url = url.rstrip('>') return url def find_urls(s): matches = REGEX.findall(s) or [] return list(set([sanitize_url(x) for x in matches])) def parse_rst(fname): """Look for links in a .rst file.""" with open(fname) as f: text = f.read() urls = find_urls(text) # HISTORY file has a lot of dead links. if fname == 'HISTORY.rst' and urls: urls = [ x for x in urls if not x.startswith('https://github.com/giampaolo/psutil/issues') ] return urls def parse_py(fname): """Look for links in a .py file.""" with open(fname) as f: lines = f.readlines() urls = set() for i, line in enumerate(lines): for url in find_urls(line): # comment block if line.lstrip().startswith('# '): subidx = i + 1 while True: nextline = lines[subidx].strip() if re.match('^# .+', nextline): url += nextline[1:].strip() else: break subidx += 1 urls.add(url) return list(urls) def parse_generic(fname): with open(fname, errors='ignore') as f: text = f.read() return find_urls(text) def get_urls(fname): """Extracts all URLs in fname and return them as a list.""" if fname.endswith('.rst'): return parse_rst(fname) elif fname.endswith('.py'): return parse_py(fname) else: with open(fname, errors='ignore') as f: if f.readline().strip().startswith('#!/usr/bin/env python3'): return parse_py(fname) return parse_generic(fname) @memoize def validate_url(url): """Validate the URL by attempting an HTTP connection. Makes an HTTP-HEAD request for each URL. """ try: res = requests.head(url, timeout=REQUEST_TIMEOUT) # some websites deny 503, like Microsoft # and some send 401, like Apple, observations if (not res.ok) and (res.status_code in RETRY_STATUSES): res = requests.get(url, timeout=REQUEST_TIMEOUT) return res.ok except requests.exceptions.RequestException: return False def parallel_validator(urls): """Validates all urls in parallel urls: tuple(filename, url). """ fails = [] # list of tuples (filename, url) current = 0 total = len(urls) with concurrent.futures.ThreadPoolExecutor() as executor: fut_to_url = { executor.submit(validate_url, url[1]): url for url in urls } for fut in concurrent.futures.as_completed(fut_to_url): current += 1 sys.stdout.write("\r%s / %s" % (current, total)) sys.stdout.flush() fname, url = fut_to_url[fut] try: ok = fut.result() except Exception: # noqa: BLE001 fails.append((fname, url)) print() print("warn: error while validating %s" % url, file=sys.stderr) traceback.print_exc() else: if not ok: fails.append((fname, url)) print() return fails def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawTextHelpFormatter ) parser.add_argument('files', nargs="+") parser.parse_args() args = parser.parse_args() all_urls = [] for fname in args.files: urls = get_urls(fname) if urls: print("%4s %s" % (len(urls), fname)) for url in urls: all_urls.append((fname, url)) fails = parallel_validator(all_urls) if not fails: print("all links are valid; cheers!") else: for fail in fails: fname, url = fail print("%-30s: %s " % (fname, url)) print('-' * 20) print("total: %s fails!" % len(fails)) sys.exit(1) if __name__ == '__main__': try: main() except (KeyboardInterrupt, SystemExit): os._exit(0) pyftpdlib-release-2.0.1/scripts/internal/generate_manifest.py000077500000000000000000000014141470572577600245110ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ Generate MANIFEST.in file. """ import os import subprocess SKIP_EXTS = ('.png', '.jpg', '.jpeg', '.svg') SKIP_FILES = tuple() SKIP_PREFIXES = ('.ci/', '.github/') def sh(cmd): return subprocess.check_output(cmd, universal_newlines=True).strip() def main(): files = sh(["git", "ls-files"]).split('\n') for file in files: if ( file.startswith(SKIP_PREFIXES) or os.path.splitext(file)[1].lower() in SKIP_EXTS or file in SKIP_FILES ): continue print("include " + file) if __name__ == '__main__': main() pyftpdlib-release-2.0.1/scripts/internal/git_pre_commit.py000077500000000000000000000077271470572577600240470ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ This gets executed on 'git commit' and rejects the commit in case the submitted code does not pass validation. Validation is run only against the files which were modified in the commit. Install this with "make install-git-hooks". """ import os import shlex import subprocess import sys PYTHON = sys.executable THIS_SCRIPT = os.path.realpath(__file__) def term_supports_colors(): try: import curses # noqa: PLC0415 assert sys.stderr.isatty() curses.setupterm() assert curses.tigetnum("colors") > 0 except Exception: return False return True def hilite(s, ok=True, bold=False): """Return an highlighted version of 'string'.""" if not term_supports_colors(): return s attr = [] if ok is None: # no color pass elif ok: # green attr.append("32") else: # red attr.append("31") if bold: attr.append("1") return f"\x1b[{';'.join(attr)}m{s}\x1b[0m" def exit(msg): print(hilite("commit aborted: " + msg, ok=False), file=sys.stderr) sys.exit(1) def sh(cmd): if isinstance(cmd, str): cmd = shlex.split(cmd) p = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, ) stdout, stderr = p.communicate() if p.returncode != 0: raise RuntimeError(stderr) if stderr: print(stderr, file=sys.stderr) if stdout.endswith("\n"): stdout = stdout[:-1] return stdout def open_text(path): return open(path, encoding="utf8") def git_commit_files(): out = sh(["git", "diff", "--cached", "--name-only"]) py_files = [ x for x in out.split("\n") if x.endswith(".py") and os.path.exists(x) ] rst_files = [ x for x in out.split("\n") if x.endswith(".rst") and os.path.exists(x) ] toml_files = [ x for x in out.split("\n") if x.endswith(".toml") and os.path.exists(x) ] new_rm_mv = sh( ["git", "diff", "--name-only", "--diff-filter=ADR", "--cached"] ) # XXX: we should escape spaces and possibly other amenities here new_rm_mv = new_rm_mv.split() return (py_files, rst_files, toml_files, new_rm_mv) def black(files): print(f"running black ({len(files)})") cmd = [PYTHON, "-m", "black", "--check", "--safe", *files] if subprocess.call(cmd) != 0: return exit( "Python code didn't pass 'ruff' style check." "Try running 'make fix-ruff'." ) def ruff(files): print(f"running ruff ({len(files)})") cmd = [PYTHON, "-m", "ruff", "check", "--no-cache", *files] if subprocess.call(cmd) != 0: return exit( "Python code didn't pass 'ruff' style check." "Try running 'make fix-ruff'." ) def toml_sort(files): print(f"running toml linter ({len(files)})") cmd = ["toml-sort", "--check", *files] if subprocess.call(cmd) != 0: return sys.exit(f"{' '.join(files)} didn't pass style check") def rstcheck(files): print(f"running rst linter ({len(files)})") cmd = ["rstcheck", "--config=pyproject.toml", *files] if subprocess.call(cmd) != 0: return sys.exit("RST code didn't pass style check") def main(): py_files, rst_files, toml_files, new_rm_mv = git_commit_files() if py_files: black(py_files) ruff(py_files) if rst_files: rstcheck(rst_files) if toml_files: toml_sort(toml_files) if new_rm_mv: out = sh([PYTHON, "scripts/internal/generate_manifest.py"]) with open_text("MANIFEST.in") as f: if out.strip() != f.read().strip(): sys.exit( "some files were added, deleted or renamed; " "run 'make generate-manifest' and commit again" ) main() pyftpdlib-release-2.0.1/scripts/internal/install_pip.py000077500000000000000000000023101470572577600233430ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. import sys try: import pip # noqa: F401 except ImportError: pass else: print("pip already installed") sys.exit(0) import os import ssl import tempfile PY3 = sys.version_info[0] >= 3 if PY3: from urllib.request import urlopen URL = "https://bootstrap.pypa.io/get-pip.py" else: from urllib2 import urlopen URL = "https://bootstrap.pypa.io/pip/2.7/get-pip.py" def main(): ssl_context = ( ssl._create_unverified_context() if hasattr(ssl, "_create_unverified_context") else None ) with tempfile.NamedTemporaryFile(suffix=".py") as f: print("downloading %s into %s" % (URL, f.name)) kwargs = dict(context=ssl_context) if ssl_context else {} req = urlopen(URL, **kwargs) data = req.read() req.close() f.write(data) f.flush() print("download finished, installing pip") code = os.system("%s %s --user --upgrade" % (sys.executable, f.name)) sys.exit(code) if __name__ == "__main__": main() pyftpdlib-release-2.0.1/scripts/internal/print_announce.py000077500000000000000000000045771470572577600240700ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ Prints release announce based on HISTORY.rst file content. """ import os import re from pyftpdlib import __ver__ as PRJ_VERSION HERE = os.path.abspath(os.path.dirname(__file__)) HISTORY = os.path.abspath(os.path.join(HERE, '../../HISTORY.rst')) PRJ_NAME = 'pyftpdlib' PRJ_URL_HOME = 'https://github.com/giampaolo/pyftpdlib' PRJ_URL_DOC = 'https://pyftpdlib.readthedocs.io' PRJ_URL_DOWNLOAD = 'https://pypi.org/project/pyftpdlib' PRJ_URL_WHATSNEW = ( 'https://github.com/giampaolo/pyftpdlib/blob/master/HISTORY.rst' ) template = """\ Hello all, I'm glad to announce the release of {prj_name} {prj_version}: {prj_urlhome} About ===== Python FTP server library provides a high-level portable interface to easily \ write very efficient, scalable and asynchronous FTP servers with Python. What's new ========== {changes} Links ===== - Home page: {prj_urlhome} - Download: {prj_urldownload} - Documentation: {prj_urldoc} - What's new: {prj_urlwhatsnew} -- Giampaolo - https://gmpy.dev/ """ def get_changes(): """Get the most recent changes for this release by parsing HISTORY.rst file. """ with open(HISTORY) as f: lines = f.readlines() block = [] # eliminate the part preceding the first block for line in enumerate(lines): line = lines.pop(0) if line.startswith('===='): break lines.pop(0) while lines: line = lines.pop(0) line = line.rstrip() if re.match(r"^- \d+_: ", line): num, _, rest = line.partition(': ') num = ''.join([x for x in num if x.isdigit()]) line = f"- #{num}: {rest}" if line.startswith('===='): break block.append(line) # eliminate bottom empty lines block.pop(-1) while not block[-1]: block.pop(-1) return "\n".join(block) def main(): changes = get_changes() print( template.format( prj_name=PRJ_NAME, prj_version=PRJ_VERSION, prj_urlhome=PRJ_URL_HOME, prj_urldownload=PRJ_URL_DOWNLOAD, prj_urldoc=PRJ_URL_DOC, prj_urlwhatsnew=PRJ_URL_WHATSNEW, changes=changes, ) ) if __name__ == '__main__': main() pyftpdlib-release-2.0.1/scripts/internal/purge_installation.py000077500000000000000000000020131470572577600247300ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """ Purge pyftpdlib installation by removing pyftpdlib-related files and directories found in site-packages directories. This is needed mainly because sometimes "import pyftpdlib" imports a leftover installation from site-packages directory instead of the main working directory. """ import os import shutil import site PKGNAME = "pyftpdlib" def rmpath(path): if os.path.isdir(path): print("rmdir " + path) shutil.rmtree(path) else: print("rm " + path) os.remove(path) def main(): locations = [site.getusersitepackages()] locations.extend(site.getsitepackages()) for root in locations: if os.path.isdir(root): for name in os.listdir(root): if PKGNAME in name: abspath = os.path.join(root, name) rmpath(abspath) main() pyftpdlib-release-2.0.1/scripts/internal/winmake.py000077500000000000000000000265441470572577600224770ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """Shortcuts for various tasks, emulating UNIX "make" on Windows. This is supposed to be invoked by "make.bat" and not used directly. This was originally written as a bat file but they suck so much that they should be deemed illegal! """ import argparse import errno import fnmatch import os import shlex import shutil import site import subprocess import sys PYTHON = os.getenv('PYTHON', sys.executable) PYTEST_ARGS = ["-v", "-s", "--tb=short"] HERE = os.path.abspath(os.path.dirname(__file__)) ROOT_DIR = os.path.realpath(os.path.join(HERE, "..", "..")) WINDOWS = os.name == "nt" sys.path.insert(0, ROOT_DIR) # so that we can import setup.py import setup # NOQA TEST_DEPS = setup.TEST_DEPS DEV_DEPS = setup.DEV_DEPS # =================================================================== # utils # =================================================================== def safe_print(text, file=sys.stdout): """Prints a (unicode) string to the console, encoded depending on the stdout/file encoding (eg. cp437 on Windows). This is to avoid encoding errors in case of funky path names. """ if not isinstance(text, str): return print(text, file=file) try: file.write(text) except UnicodeEncodeError: bytes_string = text.encode(file.encoding, 'backslashreplace') if hasattr(file, 'buffer'): file.buffer.write(bytes_string) else: text = bytes_string.decode(file.encoding, 'strict') file.write(text) file.write("\n") def sh(cmd, nolog=False): assert isinstance(cmd, list), repr(cmd) if not nolog: safe_print("cmd: " + " ".join(cmd)) p = subprocess.Popen(cmd, env=os.environ, cwd=os.getcwd()) # noqa S602 p.communicate() if p.returncode != 0: sys.exit(p.returncode) def rm(pattern, directory=False): """Recursively remove a file or dir by pattern.""" def safe_remove(path): try: os.remove(path) except OSError as err: if err.errno != errno.ENOENT: raise else: safe_print("rm %s" % path) def safe_rmtree(path): def onerror(fun, path, excinfo): exc = excinfo[1] if exc.errno != errno.ENOENT: raise # noqa: PLE0704 existed = os.path.isdir(path) shutil.rmtree(path, onerror=onerror) if existed: safe_print(f"rmdir -f {path}") if "*" not in pattern: if directory: safe_rmtree(pattern) else: safe_remove(pattern) return for root, dirs, files in os.walk('.'): root = os.path.normpath(root) if root.startswith('.git/'): continue found = fnmatch.filter(dirs if directory else files, pattern) for name in found: path = os.path.join(root, name) if directory: safe_print(f"rmdir -f {path}") safe_rmtree(path) else: safe_print(f"rm {path}") safe_remove(path) def safe_remove(path): try: os.remove(path) except FileNotFoundError: pass else: safe_print(f"rm {path}") def safe_rmtree(path): def onerror(fun, path, excinfo): exc = excinfo[1] if exc.errno != errno.ENOENT: raise # noqa: PLE0704 existed = os.path.isdir(path) shutil.rmtree(path, onerror=onerror) if existed: safe_print(f"rmdir -f {path}") def recursive_rm(*patterns): """Recursively remove a file or matching a list of patterns.""" for root, dirs, files in os.walk('.'): root = os.path.normpath(root) if root.startswith('.git/'): continue for file in files: for pattern in patterns: if fnmatch.fnmatch(file, pattern): safe_remove(os.path.join(root, file)) for dir in dirs: for pattern in patterns: if fnmatch.fnmatch(dir, pattern): safe_rmtree(os.path.join(root, dir)) # =================================================================== # commands # =================================================================== def install_pip(): """Install pip.""" sh([PYTHON, os.path.join(HERE, "install_pip.py")]) def install(): """Install in develop / edit mode.""" sh([PYTHON, "setup.py", "develop"]) def uninstall(): """Uninstall.""" clean() install_pip() here = os.getcwd() try: os.chdir('C:\\') while True: try: import pyftpdlib # NOQA except ImportError: break else: sh([PYTHON, "-m", "pip", "uninstall", "-y", "pyftpdlib"]) finally: os.chdir(here) for dir in site.getsitepackages(): for name in os.listdir(dir): if name.startswith('pyftpdlib'): rm(os.path.join(dir, name)) elif name == 'easy-install.pth': # easy_install can add a line (installation path) into # easy-install.pth; that line alters sys.path. path = os.path.join(dir, name) with open(path) as f: lines = f.readlines() hasit = False for line in lines: if 'pyftpdlib' in line: hasit = True break if hasit: with open(path, "w") as f: for line in lines: if 'pyftpdlib' not in line: f.write(line) else: print(f"removed line {line!r} from {path!r}") def clean(): """Deletes dev files.""" recursive_rm( "$testfn*", "*.bak", "*.core", "*.egg-info", "*.orig", "*.pyc", "*.pyd", "*.pyo", "*.rej", "*.so", "*.~", "*__pycache__", ".coverage", ".failed-tests.txt", ) safe_rmtree("build") safe_rmtree(".coverage") safe_rmtree("dist") safe_rmtree("docs/_build") safe_rmtree("htmlcov") safe_rmtree("tmp") def install_pydeps_test(): """Install useful deps.""" install_pip() install_git_hooks() cmd = [PYTHON, "-m", "pip", "install", "--user", "-U", *TEST_DEPS] sh(cmd) def install_pydeps_dev(): """Install useful deps.""" install_pip() install_git_hooks() cmd = [PYTHON, "-m", "pip", "install", "--user", "-U", *DEV_DEPS] sh(cmd) def test(args=None): """Run tests.""" if args is None: args = [] elif isinstance(args, str): args = shlex.split(args) sh([PYTHON, '-m', 'pytest', *PYTEST_ARGS, *args]) def test_authorizers(): sh([ PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_authorizers.py", ]) def test_filesystems(): sh([ PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_filesystems.py", ]) def test_functional(): sh([ PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_functional.py", ]) def test_functional_ssl(): sh([ PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_functional_ssl.py", ]) def test_ioloop(): sh([PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_ioloop.py"]) def test_cli(): sh([PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_cli.py"]) def test_servers(): sh([ PYTHON, "-m", "pytest", *PYTEST_ARGS, "pyftpdlib/test/test_servers.py", ]) def coverage(): """Run coverage tests.""" sh([PYTHON, "-m", "coverage", "run", "-m", "pytest", *PYTEST_ARGS]) sh([PYTHON, "-m", "coverage", "report"]) sh([PYTHON, "-m", "coverage", "html"]) sh([PYTHON, "-m", "webbrowser", "-t", "htmlcov/index.html"]) def test_by_name(name): """Run test by name.""" test(name) def test_last_failed(): """Re-run tests which failed on last run.""" sh([PYTHON, "-m", "pytest", *PYTEST_ARGS, "--last-failed"]) def install_git_hooks(): """Install GIT pre-commit hook.""" if os.path.isdir('.git'): src = os.path.join( ROOT_DIR, "scripts", "internal", "git_pre_commit.py" ) dst = os.path.realpath( os.path.join(ROOT_DIR, ".git", "hooks", "pre-commit") ) with open(src) as s, open(dst, "w") as d: d.write(s.read()) def get_python(path): if not path: return sys.executable if os.path.isabs(path): return path # try to look for a python installation given a shortcut name path = path.replace('.', '') vers = ( '38', '38-32', '38-64', '39-32', '39-64', ) for v in vers: pypath = r'C:\\python%s\python.exe' % v # noqa: UP031 if path in pypath and os.path.isfile(pypath): return pypath def parse_args(): parser = argparse.ArgumentParser() # option shared by all commands parser.add_argument('-p', '--python', help="use python executable path") sp = parser.add_subparsers(dest='command', title='targets') sp.add_parser('clean', help="deletes dev files") sp.add_parser('coverage', help="run coverage tests.") sp.add_parser('help', help="print this help") sp.add_parser('install', help="install in develop/edit mode") sp.add_parser('install-git-hooks', help="install GIT pre-commit hook") sp.add_parser('install-pip', help="install pip") sp.add_parser('install-pydeps-dev', help="install dev python deps") sp.add_parser('install-pydeps-test', help="install python test deps") sp.add_parser('test-authorizers') sp.add_parser('test-filesystems') sp.add_parser('test-functional') sp.add_parser('test-functional-ssl') sp.add_parser('test-ioloop') sp.add_parser('test-misc') sp.add_parser('test-servers') test = sp.add_parser('test', help="[ARG] run tests") test_by_name = sp.add_parser('test-by-name', help=" run test by name") sp.add_parser('uninstall', help="uninstall") for p in (test, test_by_name): p.add_argument('arg', type=str, nargs='?', default="", help="arg") args = parser.parse_args() if not args.command or args.command == 'help': parser.print_help(sys.stderr) sys.exit(1) return args def main(): global PYTHON args = parse_args() # set python exe PYTHON = get_python(args.python) if not PYTHON: return sys.exit( "can't find any python installation matching %r" % args.python ) os.putenv('PYTHON', PYTHON) print("using " + PYTHON) fname = args.command.replace('-', '_') fun = getattr(sys.modules[__name__], fname) # err if fun not defined funargs = [] # mandatory args if args.command in ('test-by-name', 'test-script'): if not args.arg: sys.exit('command needs an argument') funargs = [args.arg] # optional args if args.command == 'test' and args.arg: funargs = [args.arg] fun(*funargs) if __name__ == '__main__': main() pyftpdlib-release-2.0.1/setup.py000066400000000000000000000117431470572577600166710ustar00rootroot00000000000000# Copyright (C) 2007 Giampaolo Rodola' . # Use of this source code is governed by MIT license that can be # found in the LICENSE file. """pyftpdlib installer. $ python setup.py install """ import ast import os import sys import textwrap WINDOWS = os.name == "nt" # Test deps, installable via `pip install .[test]`. TEST_DEPS = [ "psutil", "pyopenssl", "pytest", "pytest-xdist", "setuptools", ] if sys.version_info[:2] >= (3, 12): TEST_DEPS.append("pyasyncore") TEST_DEPS.append("pyasynchat") if WINDOWS: TEST_DEPS.append("pywin32") # Development deps, installable via `pip install .[dev]`. DEV_DEPS = [ "black", "check-manifest", "coverage", "pylint", "pytest-cov", "pytest-xdist", "rstcheck", "ruff", "toml-sort", "twine", ] if WINDOWS: DEV_DEPS.extend(["pyreadline3", "pdbpp"]) def get_version(): INIT = os.path.abspath( os.path.join(os.path.dirname(__file__), 'pyftpdlib', '__init__.py') ) with open(INIT) as f: for line in f: if line.startswith('__ver__'): ret = ast.literal_eval(line.strip().split(' = ')[1]) assert ret.count('.') == 2, ret for num in ret.split('.'): assert num.isdigit(), ret return ret raise ValueError("couldn't find version string") def term_supports_colors(): try: import curses # noqa: PLC0415 assert sys.stderr.isatty() curses.setupterm() assert curses.tigetnum("colors") > 0 except Exception: return False else: return True def hilite(s, ok=True, bold=False): """Return an highlighted version of 's'.""" if not term_supports_colors(): return s else: attr = [] if ok is None: # no color pass elif ok: attr.append('32') # green else: attr.append('31') # red if bold: attr.append('1') return f"\x1b[{';'.join(attr)}m{s}\x1b[0m" with open('README.rst') as f: long_description = f.read() def main(): try: import setuptools # noqa from setuptools import setup # noqa except ImportError: setuptools = None from distutils.core import setup # noqa kwargs = dict( name='pyftpdlib', version=get_version(), description='Very fast asynchronous FTP server library', long_description=long_description, long_description_content_type="text/x-rst", license='MIT', platforms='Platform Independent', author="Giampaolo Rodola'", author_email='g.rodola@gmail.com', url='https://github.com/giampaolo/pyftpdlib/', packages=['pyftpdlib', 'pyftpdlib.test'], scripts=['scripts/ftpbench'], package_data={ "pyftpdlib.test": [ "README", 'keycert.pem', ], }, # fmt: off keywords=['ftp', 'ftps', 'server', 'ftpd', 'daemon', 'python', 'ssl', 'sendfile', 'asynchronous', 'nonblocking', 'eventdriven', 'rfc959', 'rfc1123', 'rfc2228', 'rfc2428', 'rfc2640', 'rfc3659'], # fmt: on install_requires=[ "pyasyncore;python_version>='3.12'", "pyasynchat;python_version>='3.12'", ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Internet :: File Transfer Protocol (FTP)', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Filesystems', 'Programming Language :: Python', 'Programming Language :: Python :: 3', ], ) if setuptools is not None: extras_require = { "dev": DEV_DEPS, "test": TEST_DEPS, "ssl": "PyOpenSSL", } kwargs.update( python_requires=( ">2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" ), extras_require=extras_require, zip_safe=False, ) setup(**kwargs) try: from OpenSSL import SSL # NOQA except ImportError: msg = textwrap.dedent(""" 'pyopenssl' third-party module is not installed. This means FTPS support will be disabled. You can install it with: 'pip install pyopenssl'.""") print(hilite(msg, ok=False), file=sys.stderr) if sys.version_info[0] < 3: # noqa: UP036 sys.exit( 'Python 2 is no longer supported. Latest version is 1.5.10; use:\n' 'python3 -m pip install pyftpdlib==1.5.10' ) if __name__ == '__main__': main()