75
95
_number_of_retries = 2
76
96
_sleep_between_retries = 5
78
# FIXME: there are inconsistencies in the way temporary errors are
79
# handled. Sometimes we reconnect, sometimes we raise an exception. Care should
80
# be taken to analyze the implications for write operations (read operations
81
# are safe to retry). Overall even some read operations are never
82
# retried. --vila 20070720 (Bug #127164)
83
class FtpTransport(ConnectedTransport):
98
class FtpTransport(Transport):
84
99
"""This is the transport agent for ftp:// access."""
86
def __init__(self, base, _from_transport=None):
101
def __init__(self, base, _provided_instance=None):
87
102
"""Set the base path where files will be stored."""
88
if not (base.startswith('ftp://') or base.startswith('aftp://')):
89
raise ValueError(base)
90
super(FtpTransport, self).__init__(base,
91
_from_transport=_from_transport)
92
self._unqualified_scheme = 'ftp'
93
if self._scheme == 'aftp':
96
self.is_active = False
98
# Most modern FTP servers support the APPE command. If ours doesn't, we
99
# (re)set this flag accordingly later.
100
self._has_append = True
103
assert base.startswith('ftp://') or base.startswith('aftp://')
105
self.is_active = base.startswith('aftp://')
107
# urlparse won't handle aftp://
109
if not base.endswith('/'):
111
(self._proto, self._username,
112
self._password, self._host,
113
self._port, self._path) = split_url(base)
114
base = self._unparse_url()
116
super(FtpTransport, self).__init__(base)
117
self._FTP_instance = _provided_instance
119
def _unparse_url(self, path=None):
122
path = urllib.quote(path)
123
netloc = urllib.quote(self._host)
124
if self._username is not None:
125
netloc = '%s@%s' % (urllib.quote(self._username), netloc)
126
if self._port is not None:
127
netloc = '%s:%d' % (netloc, self._port)
131
return urlparse.urlunparse((proto, netloc, path, '', '', ''))
102
133
def _get_FTP(self):
103
134
"""Return the ftplib.FTP instance for this object."""
104
# Ensures that a connection is established
105
connection = self._get_connection()
106
if connection is None:
107
# First connection ever
108
connection, credentials = self._create_connection()
109
self._set_connection(connection, credentials)
112
connection_class = ftplib.FTP
114
def _create_connection(self, credentials=None):
115
"""Create a new connection with the provided credentials.
117
:param credentials: The credentials needed to establish the connection.
119
:return: The created connection and its associated credentials.
121
The input credentials are only the password as it may have been
122
entered interactively by the user and may be different from the one
123
provided in base url at transport creation time. The returned
124
credentials are username, password.
126
if credentials is None:
127
user, password = self._user, self._password
129
user, password = credentials
131
auth = config.AuthenticationConfig()
133
user = auth.get_user('ftp', self._host, port=self._port,
134
default=getpass.getuser())
135
mutter("Constructing FTP instance against %r" %
136
((self._host, self._port, user, '********',
135
if self._FTP_instance is not None:
136
return self._FTP_instance
139
connection = self.connection_class()
140
connection.connect(host=self._host, port=self._port)
141
self._login(connection, auth, user, password)
142
connection.set_pasv(not self.is_active)
143
# binary mode is the default
144
connection.voidcmd('TYPE I')
145
except socket.error, e:
146
raise errors.SocketConnectionError(self._host, self._port,
147
msg='Unable to connect to',
139
self._FTP_instance = _find_FTP(self._host, self._port,
140
self._username, self._password,
142
return self._FTP_instance
149
143
except ftplib.error_perm, e:
150
raise errors.TransportError(msg="Error setting up connection:"
151
" %s" % str(e), orig_error=e)
152
return connection, (user, password)
154
def _login(self, connection, auth, user, password):
155
# '' is a valid password
156
if user and user != 'anonymous' and password is None:
157
password = auth.get_password('ftp', self._host,
158
user, port=self._port)
159
connection.login(user=user, passwd=password)
161
def _reconnect(self):
162
"""Create a new connection with the previously used credentials"""
163
credentials = self._get_credentials()
164
connection, credentials = self._create_connection(credentials)
165
self._set_connection(connection, credentials)
167
def _translate_ftp_error(self, err, path, extra=None,
168
unknown_exc=FtpPathError):
169
"""Try to translate an ftplib exception to a bzrlib exception.
144
raise errors.TransportError(msg="Error setting up connection: %s"
145
% str(e), orig_error=e)
147
def _translate_perm_error(self, err, path, extra=None, unknown_exc=FtpPathError):
148
"""Try to translate an ftplib.error_perm exception.
171
150
:param err: The error to translate into a bzr error
172
151
:param path: The path which had problems
185
161
if ('no such file' in s
186
162
or 'could not open' in s
187
163
or 'no such dir' in s
188
or 'could not create file' in s # vsftpd
189
or 'file doesn\'t exist' in s
190
or 'rnfr command failed.' in s # vsftpd RNFR reply if file not found
191
or 'file/directory not found' in s # filezilla server
192
# Microsoft FTP-Service RNFR reply if file not found
193
or (s.startswith('550 ') and 'unable to rename to' in extra)
195
165
raise errors.NoSuchFile(path, extra=extra)
196
elif ('file exists' in s):
166
if ('file exists' in s):
197
167
raise errors.FileExists(path, extra=extra)
198
elif ('not a directory' in s):
168
if ('not a directory' in s):
199
169
raise errors.PathError(path, extra=extra)
200
elif 'directory not empty' in s:
201
raise errors.DirectoryNotEmpty(path, extra=extra)
203
171
mutter('unable to understand error for path: %s: %s', path, err)
206
174
raise unknown_exc(path, extra=extra)
207
# TODO: jam 20060516 Consider re-raising the error wrapped in
175
# TODO: jam 20060516 Consider re-raising the error wrapped in
208
176
# something like TransportError, but this loses the traceback
209
177
# Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
210
178
# to handle. Consider doing something like that here.
211
179
#raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
182
def should_cache(self):
183
"""Return True if the data pulled across should be cached locally.
187
def clone(self, offset=None):
188
"""Return a new FtpTransport with root at self.base + offset.
192
return FtpTransport(self.base, self._FTP_instance)
194
return FtpTransport(self.abspath(offset), self._FTP_instance)
196
def _abspath(self, relpath):
197
assert isinstance(relpath, basestring)
198
relpath = urlutils.unescape(relpath)
199
relpath_parts = relpath.split('/')
200
if len(relpath_parts) > 1:
201
if relpath_parts[0] == '':
202
raise ValueError("path %r within branch %r seems to be absolute"
203
% (relpath, self._path))
204
basepath = self._path.split('/')
205
if len(basepath) > 0 and basepath[-1] == '':
206
basepath = basepath[:-1]
207
for p in relpath_parts:
209
if len(basepath) == 0:
210
# In most filesystems, a request for the parent
211
# of root, just returns root.
214
elif p == '.' or p == '':
218
# Possibly, we could use urlparse.urljoin() here, but
219
# I'm concerned about when it chooses to strip the last
220
# portion of the path, and when it doesn't.
222
# XXX: It seems that ftplib does not handle Unicode paths
223
# at the same time, medusa won't handle utf8 paths
224
# So if we .encode(utf8) here, then we get a Server failure.
225
# while if we use str(), we get a UnicodeError, and the test suite
226
# just skips testing UnicodePaths.
227
return str('/'.join(basepath) or '/')
229
def abspath(self, relpath):
230
"""Return the full url to the given relative path.
231
This can be supplied with a string or a list
233
path = self._abspath(relpath)
234
return self._unparse_url(path)
214
236
def has(self, relpath):
215
237
"""Does the target location exist?"""
216
238
# FIXME jam 20060516 We *do* ask about directories in the test suite
342
342
warning("FTP control connection closed. Trying to reopen.")
343
343
time.sleep(_sleep_between_retries)
345
self.put_file(relpath, fp, mode, retries+1)
344
self._FTP_instance = None
345
self.put(relpath, fp, mode, retries+1)
347
347
def mkdir(self, relpath, mode=None):
348
348
"""Create a directory at the given path."""
349
abspath = self._remote_path(relpath)
349
abspath = self._abspath(relpath)
351
351
mutter("FTP mkd: %s", abspath)
352
352
f = self._get_FTP()
354
self._setmode(relpath, mode)
355
354
except ftplib.error_perm, e:
356
self._translate_ftp_error(e, abspath,
355
self._translate_perm_error(e, abspath,
357
356
unknown_exc=errors.FileExists)
359
def open_write_stream(self, relpath, mode=None):
360
"""See Transport.open_write_stream."""
361
self.put_bytes(relpath, "", mode)
362
result = AppendBasedFileStream(self, relpath)
363
_file_streams[self.abspath(relpath)] = result
366
def recommended_page_size(self):
367
"""See Transport.recommended_page_size().
369
For FTP we suggest a large page size to reduce the overhead
370
introduced by latency.
374
358
def rmdir(self, rel_path):
375
359
"""Delete the directory at rel_path"""
376
abspath = self._remote_path(rel_path)
360
abspath = self._abspath(rel_path)
378
362
mutter("FTP rmd: %s", abspath)
379
363
f = self._get_FTP()
381
365
except ftplib.error_perm, e:
382
self._translate_ftp_error(e, abspath, unknown_exc=errors.PathError)
366
self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
384
def append_file(self, relpath, f, mode=None):
368
def append(self, relpath, f, mode=None):
385
369
"""Append the text in the file-like object into the final
389
abspath = self._remote_path(relpath)
372
abspath = self._abspath(relpath)
390
373
if self.has(relpath):
391
374
ftp = self._get_FTP()
392
375
result = ftp.size(abspath)
397
mutter("FTP appe to %s", abspath)
398
self._try_append(relpath, text, mode)
400
self._fallback_append(relpath, text, mode)
379
mutter("FTP appe to %s", abspath)
380
self._try_append(relpath, f.read(), mode)
404
384
def _try_append(self, relpath, text, mode=None, retries=0):
405
385
"""Try repeatedly to append the given text to the file at relpath.
407
387
This is a recursive function. On errors, it will be called until the
408
388
number of retries is exceeded.
411
abspath = self._remote_path(relpath)
391
abspath = self._abspath(relpath)
412
392
mutter("FTP appe (try %d) to %s", retries, abspath)
413
393
ftp = self._get_FTP()
394
ftp.voidcmd("TYPE I")
414
395
cmd = "APPE %s" % abspath
415
396
conn = ftp.transfercmd(cmd)
416
397
conn.sendall(text)
418
self._setmode(relpath, mode)
400
self._setmode(relpath, mode)
420
402
except ftplib.error_perm, e:
421
# Check whether the command is not supported (reply code 502)
422
if str(e).startswith('502 '):
423
warning("FTP server does not support file appending natively. "
424
"Performance may be severely degraded! (%s)", e)
425
self._has_append = False
426
self._fallback_append(relpath, text, mode)
428
self._translate_ftp_error(e, abspath, extra='error appending',
429
unknown_exc=errors.NoSuchFile)
403
self._translate_perm_error(e, abspath, extra='error appending',
404
unknown_exc=errors.NoSuchFile)
430
405
except ftplib.error_temp, e:
431
406
if retries > _number_of_retries:
432
raise errors.TransportError(
433
"FTP temporary error during APPEND %s. Aborting."
434
% abspath, orig_error=e)
407
raise errors.TransportError("FTP temporary error during APPEND %s." \
408
"Aborting." % abspath, orig_error=e)
436
410
warning("FTP temporary error: %s. Retrying.", str(e))
411
self._FTP_instance = None
438
412
self._try_append(relpath, text, mode, retries+1)
440
def _fallback_append(self, relpath, text, mode = None):
441
remote = self.get(relpath)
442
remote.seek(0, os.SEEK_END)
445
return self.put_file(relpath, remote, mode)
447
414
def _setmode(self, relpath, mode):
448
415
"""Set permissions on a path.
450
417
Only set permissions if the FTP server supports the 'SITE CHMOD'
455
mutter("FTP site chmod: setting permissions to %s on %s",
456
oct(mode), self._remote_path(relpath))
457
ftp = self._get_FTP()
458
cmd = "SITE CHMOD %s %s" % (oct(mode),
459
self._remote_path(relpath))
461
except ftplib.error_perm, e:
462
# Command probably not available on this server
463
warning("FTP Could not set permissions to %s on %s. %s",
464
oct(mode), self._remote_path(relpath), str(e))
421
mutter("FTP site chmod: setting permissions to %s on %s",
422
str(mode), self._abspath(relpath))
423
ftp = self._get_FTP()
424
cmd = "SITE CHMOD %s %s" % (self._abspath(relpath), str(mode))
426
except ftplib.error_perm, e:
427
# Command probably not available on this server
428
warning("FTP Could not set permissions to %s on %s. %s",
429
str(mode), self._abspath(relpath), str(e))
466
431
# TODO: jam 20060516 I believe ftp allows you to tell an ftp server
467
432
# to copy something to another machine. And you may be able
468
433
# to give it its own address as the 'to' location.
469
434
# So implement a fancier 'copy()'
471
def rename(self, rel_from, rel_to):
472
abs_from = self._remote_path(rel_from)
473
abs_to = self._remote_path(rel_to)
474
mutter("FTP rename: %s => %s", abs_from, abs_to)
476
return self._rename(abs_from, abs_to, f)
478
def _rename(self, abs_from, abs_to, f):
480
f.rename(abs_from, abs_to)
481
except (ftplib.error_temp, ftplib.error_perm), e:
482
self._translate_ftp_error(e, abs_from,
483
': unable to rename to %r' % (abs_to))
485
436
def move(self, rel_from, rel_to):
486
437
"""Move the item at rel_from to the location at rel_to"""
487
abs_from = self._remote_path(rel_from)
488
abs_to = self._remote_path(rel_to)
438
abs_from = self._abspath(rel_from)
439
abs_to = self._abspath(rel_to)
490
441
mutter("FTP mv: %s => %s", abs_from, abs_to)
491
442
f = self._get_FTP()
492
self._rename_and_overwrite(abs_from, abs_to, f)
443
f.rename(abs_from, abs_to)
493
444
except ftplib.error_perm, e:
494
self._translate_ftp_error(e, abs_from,
495
extra='unable to rename to %r' % (rel_to,),
445
self._translate_perm_error(e, abs_from,
446
extra='unable to rename to %r' % (rel_to,),
496
447
unknown_exc=errors.PathError)
498
def _rename_and_overwrite(self, abs_from, abs_to, f):
499
"""Do a fancy rename on the remote server.
501
Using the implementation provided by osutils.
503
osutils.fancy_rename(abs_from, abs_to,
504
rename_func=lambda p1, p2: self._rename(p1, p2, f),
505
unlink_func=lambda p: self._delete(p, f))
507
451
def delete(self, relpath):
508
452
"""Delete the item at relpath"""
509
abspath = self._remote_path(relpath)
511
self._delete(abspath, f)
513
def _delete(self, abspath, f):
453
abspath = self._abspath(relpath)
515
455
mutter("FTP rm: %s", abspath)
516
457
f.delete(abspath)
517
458
except ftplib.error_perm, e:
518
self._translate_ftp_error(e, abspath, 'error deleting',
459
self._translate_perm_error(e, abspath, 'error deleting',
519
460
unknown_exc=errors.NoSuchFile)
521
def external_url(self):
522
"""See bzrlib.transport.Transport.external_url."""
523
# FTP URL's are externally usable.
526
462
def listable(self):
527
463
"""See Transport.listable."""
530
466
def list_dir(self, relpath):
531
467
"""See Transport.list_dir."""
532
basepath = self._remote_path(relpath)
533
mutter("FTP nlst: %s", basepath)
537
paths = f.nlst(basepath)
538
except ftplib.error_perm, e:
539
self._translate_ftp_error(e, relpath,
540
extra='error with list_dir')
541
except ftplib.error_temp, e:
542
# xs4all's ftp server raises a 450 temp error when listing an
543
# empty directory. Check for that and just return an empty list
544
# in that case. See bug #215522
545
if str(e).lower().startswith('450 no files found'):
546
mutter('FTP Server returned "%s" for nlst.'
547
' Assuming it means empty directory',
552
# Restore binary mode as nlst switch to ascii mode to retrieve file
556
# If FTP.nlst returns paths prefixed by relpath, strip 'em
557
if paths and paths[0].startswith(basepath):
558
entries = [path[len(basepath)+1:] for path in paths]
561
# Remove . and .. if present
562
return [urlutils.escape(entry) for entry in entries
563
if entry not in ('.', '..')]
469
mutter("FTP nlst: %s", self._abspath(relpath))
471
basepath = self._abspath(relpath)
472
paths = f.nlst(basepath)
473
# If FTP.nlst returns paths prefixed by relpath, strip 'em
474
if paths and paths[0].startswith(basepath):
475
paths = [path[len(basepath)+1:] for path in paths]
476
# Remove . and .. if present, and return
477
return [path for path in paths if path not in (".", "..")]
478
except ftplib.error_perm, e:
479
self._translate_perm_error(e, relpath, extra='error with list_dir')
565
481
def iter_files_recursive(self):
566
482
"""See Transport.iter_files_recursive.
609
525
return self.lock_read(relpath)
528
class FtpServer(Server):
529
"""Common code for SFTP server facilities."""
533
self._ftp_server = None
535
self._async_thread = None
540
"""Calculate an ftp url to this server."""
541
return 'ftp://foo:bar@localhost:%d/' % (self._port)
543
# def get_bogus_url(self):
544
# """Return a URL which cannot be connected to."""
545
# return 'ftp://127.0.0.1:1'
547
def log(self, message):
548
"""This is used by medusa.ftp_server to log connections, etc."""
549
self.logs.append(message)
554
raise RuntimeError('Must have medusa to run the FtpServer')
556
self._root = os.getcwdu()
557
self._ftp_server = _ftp_server(
558
authorizer=_test_authorizer(root=self._root),
560
port=0, # bind to a random port
562
logger_object=self # Use FtpServer.log() for messages
564
self._port = self._ftp_server.getsockname()[1]
565
# Don't let it loop forever, or handle an infinite number of requests.
566
# In this case it will run for 100s, or 1000 requests
567
self._async_thread = threading.Thread(target=asyncore.loop,
568
kwargs={'timeout':0.1, 'count':1000})
569
self._async_thread.setDaemon(True)
570
self._async_thread.start()
573
"""See bzrlib.transport.Server.tearDown."""
574
# have asyncore release the channel
575
self._ftp_server.del_channel()
577
self._async_thread.join()
582
_test_authorizer = None
586
global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
589
import medusa.filesys
590
import medusa.ftp_server
596
class test_authorizer(object):
597
"""A custom Authorizer object for running the test suite.
599
The reason we cannot use dummy_authorizer, is because it sets the
600
channel to readonly, which we don't always want to do.
603
def __init__(self, root):
606
def authorize(self, channel, username, password):
607
"""Return (success, reply_string, filesystem)"""
609
return 0, 'No Medusa.', None
611
channel.persona = -1, -1
612
if username == 'anonymous':
613
channel.read_only = 1
615
channel.read_only = 0
617
return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
620
class ftp_channel(medusa.ftp_server.ftp_channel):
621
"""Customized ftp channel"""
623
def log(self, message):
624
"""Redirect logging requests."""
625
mutter('_ftp_channel: %s', message)
627
def log_info(self, message, type='info'):
628
"""Redirect logging requests."""
629
mutter('_ftp_channel %s: %s', type, message)
631
def cmd_rnfr(self, line):
632
"""Prepare for renaming a file."""
633
self._renaming = line[1]
634
self.respond('350 Ready for RNTO')
635
# TODO: jam 20060516 in testing, the ftp server seems to
636
# check that the file already exists, or it sends
637
# 550 RNFR command failed
639
def cmd_rnto(self, line):
640
"""Rename a file based on the target given.
642
rnto must be called after calling rnfr.
644
if not self._renaming:
645
self.respond('503 RNFR required first.')
646
pfrom = self.filesystem.translate(self._renaming)
647
self._renaming = None
648
pto = self.filesystem.translate(line[1])
650
os.rename(pfrom, pto)
651
except (IOError, OSError), e:
652
# TODO: jam 20060516 return custom responses based on
653
# why the command failed
654
self.respond('550 RNTO failed: %s' % (e,))
656
self.respond('550 RNTO failed')
657
# For a test server, we will go ahead and just die
660
self.respond('250 Rename successful.')
662
def cmd_size(self, line):
663
"""Return the size of a file
665
This is overloaded to help the test suite determine if the
666
target is a directory.
669
if not self.filesystem.isfile(filename):
670
if self.filesystem.isdir(filename):
671
self.respond('550 "%s" is a directory' % (filename,))
673
self.respond('550 "%s" is not a file' % (filename,))
675
self.respond('213 %d'
676
% (self.filesystem.stat(filename)[stat.ST_SIZE]),)
678
def cmd_mkd(self, line):
679
"""Create a directory.
681
Overloaded because default implementation does not distinguish
682
*why* it cannot make a directory.
685
self.command_not_understood(''.join(line))
689
self.filesystem.mkdir (path)
690
self.respond ('257 MKD command successful.')
691
except (IOError, OSError), e:
692
self.respond ('550 error creating directory: %s' % (e,))
694
self.respond ('550 error creating directory.')
697
class ftp_server(medusa.ftp_server.ftp_server):
698
"""Customize the behavior of the Medusa ftp_server.
700
There are a few warts on the ftp_server, based on how it expects
704
ftp_channel_class = ftp_channel
706
def __init__(self, *args, **kwargs):
707
mutter('Initializing _ftp_server: %r, %r', args, kwargs)
708
medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
710
def log(self, message):
711
"""Redirect logging requests."""
712
mutter('_ftp_server: %s', message)
714
def log_info(self, message, type='info'):
715
"""Override the asyncore.log_info so we don't stipple the screen."""
716
mutter('_ftp_server %s: %s', type, message)
718
_test_authorizer = test_authorizer
719
_ftp_channel = ftp_channel
720
_ftp_server = ftp_server
612
725
def get_test_permutations():
613
726
"""Return the permutations to be used in testing."""
614
from bzrlib.tests import ftp_server
615
return [(FtpTransport, ftp_server.FTPTestServer)]
727
if not _setup_medusa():
728
warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
731
return [(FtpTransport, FtpServer)]