Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions fsspec/implementations/ftp.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,45 @@
import os
import ssl
import uuid
from ftplib import FTP, FTP_TLS, Error, error_perm
from typing import Any

from ..spec import AbstractBufferedFile, AbstractFileSystem
from ..utils import infer_storage_options, isfilelike

SECURITY_PROTOCOL_MAP = {
"tls": ssl.PROTOCOL_TLS,
"tlsv1": ssl.PROTOCOL_TLSv1,
"tlsv1_1": ssl.PROTOCOL_TLSv1_1,
"tlsv1_2": ssl.PROTOCOL_TLSv1_2,
"sslv2": "sslv2 has been deprecated due to security issues",
"sslv23": ssl.PROTOCOL_SSLv23,
"sslv3": "sslv3 has been deprecated due to security issues",
}


class ImplicitFTPTLS(FTP_TLS):
"""
FTP_TLS subclass that automatically wraps sockets in SSL
to support implicit FTPS.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._sock = None

@property
def sock(self):
"""Return the socket."""
return self._sock

@sock.setter
def sock(self, value):
"""When modifying the socket, ensure that it is ssl wrapped."""
if value is not None and not isinstance(value, ssl.SSLSocket):
value = self.context.wrap_socket(value)
self._sock = value


class FTPFileSystem(AbstractFileSystem):
"""A filesystem over classic FTP"""
Expand All @@ -26,6 +60,7 @@ def __init__(
timeout=30,
encoding="utf-8",
tls=False,
security_tls=None,
**kwargs,
):
"""
Expand Down Expand Up @@ -57,6 +92,14 @@ def __init__(
Encoding to use for directories and filenames in FTP connection
tls: bool
Use FTP-TLS, by default False
security_tls: str or None, by default None
SSL/TLS implicit protocol to use when tls=True. Options:
- None: Use explicit tls protocol
- "tls": Auto-negotiate highest protocol (default)
- "tlsv1": TLS v1.0
- "tlsv1_1": TLS v1.1
- "tlsv1_2": TLS v1.2
- "sslv23": SSL v2/v3 (deprecated, not recommended)
"""
super().__init__(**kwargs)
self.host = host
Expand All @@ -70,16 +113,29 @@ def __init__(
else:
self.blocksize = 2**16
self.tls = tls
self.security_tls = security_tls
self._connect()
if self.tls:
if self.tls and not self.security_tls:
self.ftp.prot_p()

def _connect(self):
security = None
if self.tls:
ftp_cls = FTP_TLS
if self.security_tls:
ftp_cls = ImplicitFTPTLS
security = SECURITY_PROTOCOL_MAP.get(
self.security_tls,
f"Not supported {self.security_tls} protocol",
)
if isinstance(security, str):
raise ValueError(security)
else:
ftp_cls = FTP_TLS
else:
ftp_cls = FTP
self.ftp = ftp_cls(timeout=self.timeout, encoding=self.encoding)
if security:
self.ftp.ssl_version = security
self.ftp.connect(self.host, self.port)
self.ftp.login(*self.cred)

Expand Down
43 changes: 43 additions & 0 deletions fsspec/implementations/tests/ftp_tlsv12.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import os
import ssl

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer


def ftp():
"""Script to run FTP server that accepts TLS v1.2 implicitly"""
# Set up FTP server parameters
FTP_HOST = "localhost"
FTP_PORT = 2122
FTP_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

# Instantiate a dummy authorizer
authorizer = DummyAuthorizer()
authorizer.add_user(
"user",
"pass",
FTP_DIRECTORY,
"elradfmwMT",
)
authorizer.add_anonymous(FTP_DIRECTORY)

# Instantiate FTPHandler for implicit TLS
handler = FTPHandler
handler.authorizer = authorizer

# Instantiate FTP server
server = FTPServer((FTP_HOST, FTP_PORT), handler)

# Wrap socket for implicit TLS
certfile = os.path.join(os.path.dirname(__file__), "keycert.pem")
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
context.load_cert_chain(certfile)
server.socket = context.wrap_socket(server.socket, server_side=True)

server.serve_forever()


if __name__ == "__main__":
ftp()
28 changes: 27 additions & 1 deletion fsspec/implementations/tests/test_ftp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import fsspec
from fsspec import open_files
from fsspec.implementations.ftp import FTPFileSystem
from fsspec.implementations.ftp import FTPFileSystem, ImplicitFTPTLS

ftplib = pytest.importorskip("ftplib")
here = os.path.dirname(os.path.abspath(__file__))
Expand All @@ -30,6 +30,22 @@ def ftp():
P.wait()


@pytest.fixture()
def ftp_tlsv12():
pytest.importorskip("pyftpdlib")
P = subprocess.Popen(
[sys.executable, os.path.join(here, "ftp_tlsv12.py")],
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
)
try:
time.sleep(1)
yield "localhost", 2122
finally:
P.terminate()
P.wait()


@pytest.mark.parametrize(
"tls,exp_cls",
(
Expand All @@ -43,6 +59,16 @@ def test_tls(ftp, tls, exp_cls):
assert isinstance(fs.ftp, exp_cls)


@pytest.mark.parametrize(
"tls,exp_cls,security_tls",
((True, ImplicitFTPTLS, "tlsv1_2"),),
)
def test_tls_v12(ftp_tlsv12, tls, exp_cls, security_tls):
host, port = ftp_tlsv12
fs = FTPFileSystem(host, port, tls=tls, security_tls=security_tls)
assert isinstance(fs.ftp, exp_cls)


@pytest.mark.parametrize(
"tls,username,password",
(
Expand Down