75
97
_number_of_retries = 2
76
98
_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):
100
class FtpTransport(Transport):
84
101
"""This is the transport agent for ftp:// access."""
86
def __init__(self, base, _from_transport=None):
103
def __init__(self, base, _provided_instance=None):
87
104
"""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
105
assert base.startswith('ftp://') or base.startswith('aftp://')
107
self.is_active = base.startswith('aftp://')
109
# urlparse won't handle aftp://
111
if not base.endswith('/'):
113
(self._proto, self._username,
114
self._password, self._host,
115
self._port, self._path) = split_url(base)
116
base = self._unparse_url()
118
super(FtpTransport, self).__init__(base)
119
self._FTP_instance = _provided_instance
121
def _unparse_url(self, path=None):
124
path = urllib.quote(path)
125
netloc = urllib.quote(self._host)
126
if self._username is not None:
127
netloc = '%s@%s' % (urllib.quote(self._username), netloc)
128
if self._port is not None:
129
netloc = '%s:%d' % (netloc, self._port)
133
return urlparse.urlunparse((proto, netloc, path, '', '', ''))
102
135
def _get_FTP(self):
103
136
"""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, '********',
137
if self._FTP_instance is not None:
138
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',
141
self._FTP_instance = _find_FTP(self._host, self._port,
142
self._username, self._password,
144
return self._FTP_instance
149
145
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.
146
raise errors.TransportError(msg="Error setting up connection: %s"
147
% str(e), orig_error=e)
149
def _translate_perm_error(self, err, path, extra=None, unknown_exc=FtpPathError):
150
"""Try to translate an ftplib.error_perm exception.
171
152
:param err: The error to translate into a bzr error
172
153
:param path: The path which had problems
186
164
or 'could not open' in s
187
165
or 'no such dir' in s
188
166
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
168
raise errors.NoSuchFile(path, extra=extra)
196
elif ('file exists' in s):
169
if ('file exists' in s):
197
170
raise errors.FileExists(path, extra=extra)
198
elif ('not a directory' in s):
171
if ('not a directory' in s):
199
172
raise errors.PathError(path, extra=extra)
200
elif 'directory not empty' in s:
201
raise errors.DirectoryNotEmpty(path, extra=extra)
203
174
mutter('unable to understand error for path: %s: %s', path, err)
206
177
raise unknown_exc(path, extra=extra)
207
# TODO: jam 20060516 Consider re-raising the error wrapped in
178
# TODO: jam 20060516 Consider re-raising the error wrapped in
208
179
# something like TransportError, but this loses the traceback
209
180
# Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
210
181
# to handle. Consider doing something like that here.
211
182
#raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
185
def should_cache(self):
186
"""Return True if the data pulled across should be cached locally.
190
def clone(self, offset=None):
191
"""Return a new FtpTransport with root at self.base + offset.
195
return FtpTransport(self.base, self._FTP_instance)
197
return FtpTransport(self.abspath(offset), self._FTP_instance)
199
def _abspath(self, relpath):
200
assert isinstance(relpath, basestring)
201
relpath = urlutils.unescape(relpath)
202
if relpath.startswith('/'):
205
basepath = self._path.split('/')
206
if len(basepath) > 0 and basepath[-1] == '':
207
basepath = basepath[:-1]
208
for p in relpath.split('/'):
210
if len(basepath) == 0:
211
# In most filesystems, a request for the parent
212
# of root, just returns root.
215
elif p == '.' or p == '':
219
# Possibly, we could use urlparse.urljoin() here, but
220
# I'm concerned about when it chooses to strip the last
221
# portion of the path, and when it doesn't.
223
# XXX: It seems that ftplib does not handle Unicode paths
224
# at the same time, medusa won't handle utf8 paths
225
# So if we .encode(utf8) here, then we get a Server failure.
226
# while if we use str(), we get a UnicodeError, and the test suite
227
# just skips testing UnicodePaths.
228
return str('/'.join(basepath) or '/')
230
def abspath(self, relpath):
231
"""Return the full url to the given relative path.
232
This can be supplied with a string or a list
234
path = self._abspath(relpath)
235
return self._unparse_url(path)
214
237
def has(self, relpath):
215
238
"""Does the target location exist?"""
216
239
# FIXME jam 20060516 We *do* ask about directories in the test suite
342
343
warning("FTP control connection closed. Trying to reopen.")
343
344
time.sleep(_sleep_between_retries)
345
self._FTP_instance = None
345
346
self.put_file(relpath, fp, mode, retries+1)
347
348
def mkdir(self, relpath, mode=None):
348
349
"""Create a directory at the given path."""
349
abspath = self._remote_path(relpath)
350
abspath = self._abspath(relpath)
351
352
mutter("FTP mkd: %s", abspath)
352
353
f = self._get_FTP()
354
self._setmode(relpath, mode)
355
355
except ftplib.error_perm, e:
356
self._translate_ftp_error(e, abspath,
356
self._translate_perm_error(e, abspath,
357
357
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
359
def rmdir(self, rel_path):
375
360
"""Delete the directory at rel_path"""
376
abspath = self._remote_path(rel_path)
361
abspath = self._abspath(rel_path)
378
363
mutter("FTP rmd: %s", abspath)
379
364
f = self._get_FTP()
381
366
except ftplib.error_perm, e:
382
self._translate_ftp_error(e, abspath, unknown_exc=errors.PathError)
367
self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
384
369
def append_file(self, relpath, f, mode=None):
385
370
"""Append the text in the file-like object into the final
389
abspath = self._remote_path(relpath)
373
abspath = self._abspath(relpath)
390
374
if self.has(relpath):
391
375
ftp = self._get_FTP()
392
376
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)
380
mutter("FTP appe to %s", abspath)
381
self._try_append(relpath, f.read(), mode)
404
385
def _try_append(self, relpath, text, mode=None, retries=0):
405
386
"""Try repeatedly to append the given text to the file at relpath.
407
388
This is a recursive function. On errors, it will be called until the
408
389
number of retries is exceeded.
411
abspath = self._remote_path(relpath)
392
abspath = self._abspath(relpath)
412
393
mutter("FTP appe (try %d) to %s", retries, abspath)
413
394
ftp = self._get_FTP()
395
ftp.voidcmd("TYPE I")
414
396
cmd = "APPE %s" % abspath
415
397
conn = ftp.transfercmd(cmd)
416
398
conn.sendall(text)
418
self._setmode(relpath, mode)
401
self._setmode(relpath, mode)
420
403
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)
404
self._translate_perm_error(e, abspath, extra='error appending',
405
unknown_exc=errors.NoSuchFile)
430
406
except ftplib.error_temp, e:
431
407
if retries > _number_of_retries:
432
raise errors.TransportError(
433
"FTP temporary error during APPEND %s. Aborting."
434
% abspath, orig_error=e)
408
raise errors.TransportError("FTP temporary error during APPEND %s." \
409
"Aborting." % abspath, orig_error=e)
436
411
warning("FTP temporary error: %s. Retrying.", str(e))
412
self._FTP_instance = None
438
413
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
415
def _setmode(self, relpath, mode):
448
416
"""Set permissions on a path.
450
418
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))
422
mutter("FTP site chmod: setting permissions to %s on %s",
423
str(mode), self._abspath(relpath))
424
ftp = self._get_FTP()
425
cmd = "SITE CHMOD %s %s" % (self._abspath(relpath), str(mode))
427
except ftplib.error_perm, e:
428
# Command probably not available on this server
429
warning("FTP Could not set permissions to %s on %s. %s",
430
str(mode), self._abspath(relpath), str(e))
466
432
# TODO: jam 20060516 I believe ftp allows you to tell an ftp server
467
433
# to copy something to another machine. And you may be able
468
434
# to give it its own address as the 'to' location.
469
435
# 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
437
def move(self, rel_from, rel_to):
486
438
"""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)
439
abs_from = self._abspath(rel_from)
440
abs_to = self._abspath(rel_to)
490
442
mutter("FTP mv: %s => %s", abs_from, abs_to)
491
443
f = self._get_FTP()
492
self._rename_and_overwrite(abs_from, abs_to, f)
444
f.rename(abs_from, abs_to)
493
445
except ftplib.error_perm, e:
494
self._translate_ftp_error(e, abs_from,
495
extra='unable to rename to %r' % (rel_to,),
446
self._translate_perm_error(e, abs_from,
447
extra='unable to rename to %r' % (rel_to,),
496
448
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
452
def delete(self, relpath):
508
453
"""Delete the item at relpath"""
509
abspath = self._remote_path(relpath)
511
self._delete(abspath, f)
513
def _delete(self, abspath, f):
454
abspath = self._abspath(relpath)
515
456
mutter("FTP rm: %s", abspath)
516
458
f.delete(abspath)
517
459
except ftplib.error_perm, e:
518
self._translate_ftp_error(e, abspath, 'error deleting',
460
self._translate_perm_error(e, abspath, 'error deleting',
519
461
unknown_exc=errors.NoSuchFile)
521
def external_url(self):
522
"""See bzrlib.transport.Transport.external_url."""
523
# FTP URL's are externally usable.
526
463
def listable(self):
527
464
"""See Transport.listable."""
530
467
def list_dir(self, relpath):
531
468
"""See Transport.list_dir."""
532
basepath = self._remote_path(relpath)
469
basepath = self._abspath(relpath)
533
470
mutter("FTP nlst: %s", basepath)
534
471
f = self._get_FTP()
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
473
paths = f.nlst(basepath)
474
except ftplib.error_perm, e:
475
self._translate_perm_error(e, relpath, extra='error with list_dir')
556
476
# If FTP.nlst returns paths prefixed by relpath, strip 'em
557
477
if paths and paths[0].startswith(basepath):
558
478
entries = [path[len(basepath)+1:] for path in paths]
609
529
return self.lock_read(relpath)
532
class FtpServer(Server):
533
"""Common code for SFTP server facilities."""
537
self._ftp_server = None
539
self._async_thread = None
544
"""Calculate an ftp url to this server."""
545
return 'ftp://foo:bar@localhost:%d/' % (self._port)
547
# def get_bogus_url(self):
548
# """Return a URL which cannot be connected to."""
549
# return 'ftp://127.0.0.1:1'
551
def log(self, message):
552
"""This is used by medusa.ftp_server to log connections, etc."""
553
self.logs.append(message)
555
def setUp(self, vfs_server=None):
557
raise RuntimeError('Must have medusa to run the FtpServer')
559
assert vfs_server is None or isinstance(vfs_server, LocalURLServer), \
560
"FtpServer currently assumes local transport, got %s" % vfs_server
562
self._root = os.getcwdu()
563
self._ftp_server = _ftp_server(
564
authorizer=_test_authorizer(root=self._root),
566
port=0, # bind to a random port
568
logger_object=self # Use FtpServer.log() for messages
570
self._port = self._ftp_server.getsockname()[1]
571
# Don't let it loop forever, or handle an infinite number of requests.
572
# In this case it will run for 100s, or 1000 requests
573
self._async_thread = threading.Thread(
574
target=FtpServer._asyncore_loop_ignore_EBADF,
575
kwargs={'timeout':0.1, 'count':1000})
576
self._async_thread.setDaemon(True)
577
self._async_thread.start()
580
"""See bzrlib.transport.Server.tearDown."""
581
# have asyncore release the channel
582
self._ftp_server.del_channel()
584
self._async_thread.join()
587
def _asyncore_loop_ignore_EBADF(*args, **kwargs):
588
"""Ignore EBADF during server shutdown.
590
We close the socket to get the server to shutdown, but this causes
591
select.select() to raise EBADF.
594
asyncore.loop(*args, **kwargs)
595
except select.error, e:
596
if e.args[0] != errno.EBADF:
602
_test_authorizer = None
606
global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
609
import medusa.filesys
610
import medusa.ftp_server
616
class test_authorizer(object):
617
"""A custom Authorizer object for running the test suite.
619
The reason we cannot use dummy_authorizer, is because it sets the
620
channel to readonly, which we don't always want to do.
623
def __init__(self, root):
626
def authorize(self, channel, username, password):
627
"""Return (success, reply_string, filesystem)"""
629
return 0, 'No Medusa.', None
631
channel.persona = -1, -1
632
if username == 'anonymous':
633
channel.read_only = 1
635
channel.read_only = 0
637
return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
640
class ftp_channel(medusa.ftp_server.ftp_channel):
641
"""Customized ftp channel"""
643
def log(self, message):
644
"""Redirect logging requests."""
645
mutter('_ftp_channel: %s', message)
647
def log_info(self, message, type='info'):
648
"""Redirect logging requests."""
649
mutter('_ftp_channel %s: %s', type, message)
651
def cmd_rnfr(self, line):
652
"""Prepare for renaming a file."""
653
self._renaming = line[1]
654
self.respond('350 Ready for RNTO')
655
# TODO: jam 20060516 in testing, the ftp server seems to
656
# check that the file already exists, or it sends
657
# 550 RNFR command failed
659
def cmd_rnto(self, line):
660
"""Rename a file based on the target given.
662
rnto must be called after calling rnfr.
664
if not self._renaming:
665
self.respond('503 RNFR required first.')
666
pfrom = self.filesystem.translate(self._renaming)
667
self._renaming = None
668
pto = self.filesystem.translate(line[1])
670
os.rename(pfrom, pto)
671
except (IOError, OSError), e:
672
# TODO: jam 20060516 return custom responses based on
673
# why the command failed
674
self.respond('550 RNTO failed: %s' % (e,))
676
self.respond('550 RNTO failed')
677
# For a test server, we will go ahead and just die
680
self.respond('250 Rename successful.')
682
def cmd_size(self, line):
683
"""Return the size of a file
685
This is overloaded to help the test suite determine if the
686
target is a directory.
689
if not self.filesystem.isfile(filename):
690
if self.filesystem.isdir(filename):
691
self.respond('550 "%s" is a directory' % (filename,))
693
self.respond('550 "%s" is not a file' % (filename,))
695
self.respond('213 %d'
696
% (self.filesystem.stat(filename)[stat.ST_SIZE]),)
698
def cmd_mkd(self, line):
699
"""Create a directory.
701
Overloaded because default implementation does not distinguish
702
*why* it cannot make a directory.
705
self.command_not_understood(''.join(line))
709
self.filesystem.mkdir (path)
710
self.respond ('257 MKD command successful.')
711
except (IOError, OSError), e:
712
self.respond ('550 error creating directory: %s' % (e,))
714
self.respond ('550 error creating directory.')
717
class ftp_server(medusa.ftp_server.ftp_server):
718
"""Customize the behavior of the Medusa ftp_server.
720
There are a few warts on the ftp_server, based on how it expects
724
ftp_channel_class = ftp_channel
726
def __init__(self, *args, **kwargs):
727
mutter('Initializing _ftp_server: %r, %r', args, kwargs)
728
medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
730
def log(self, message):
731
"""Redirect logging requests."""
732
mutter('_ftp_server: %s', message)
734
def log_info(self, message, type='info'):
735
"""Override the asyncore.log_info so we don't stipple the screen."""
736
mutter('_ftp_server %s: %s', type, message)
738
_test_authorizer = test_authorizer
739
_ftp_channel = ftp_channel
740
_ftp_server = ftp_server
612
745
def get_test_permutations():
613
746
"""Return the permutations to be used in testing."""
614
from bzrlib.tests import ftp_server
615
return [(FtpTransport, ftp_server.FTPTestServer)]
747
if not _setup_medusa():
748
warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
751
return [(FtpTransport, FtpServer)]