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