1
# Copyright (C) 2005 Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
16
"""Implementation of Transport over ftp.
18
Written by Daniel Silverstone <dsilvers@digital-scurf.org> with serious
19
cargo-culting from the sftp transport and the http transport.
21
It provides the ftp:// and aftp:// protocols where ftp:// is passive ftp
22
and aftp:// is active ftp. Most people will want passive ftp for traversing
23
NAT and other firewalls, so it's best to use it unless you explicitly want
24
active, in which case aftp:// will be your friend.
27
from cStringIO import StringIO
38
from warnings import warn
40
from bzrlib.transport import (
45
import bzrlib.errors as errors
46
from bzrlib.trace import mutter, warning
52
class FtpPathError(errors.PathError):
53
"""FTP failed for path: %(path)s%(extra)s"""
57
def _find_FTP(hostname, port, username, password, is_active):
58
"""Find an ftplib.FTP instance attached to this triplet."""
59
key = (hostname, port, username, password, is_active)
60
alt_key = (hostname, port, username, '********', is_active)
61
if key not in _FTP_cache:
62
mutter("Constructing FTP instance against %r" % (alt_key,))
65
conn.connect(host=hostname, port=port)
66
if username and username != 'anonymous' and not password:
67
password = bzrlib.ui.ui_factory.get_password(
68
prompt='FTP %(user)s@%(host)s password',
69
user=username, host=hostname)
70
conn.login(user=username, passwd=password)
71
conn.set_pasv(not is_active)
73
_FTP_cache[key] = conn
75
return _FTP_cache[key]
78
class FtpStatResult(object):
79
def __init__(self, f, relpath):
81
self.st_size = f.size(relpath)
82
self.st_mode = stat.S_IFREG
83
except ftplib.error_perm:
87
self.st_mode = stat.S_IFDIR
92
_number_of_retries = 2
93
_sleep_between_retries = 5
95
class FtpTransport(Transport):
96
"""This is the transport agent for ftp:// access."""
98
def __init__(self, base, _provided_instance=None):
99
"""Set the base path where files will be stored."""
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, '', '', ''))
128
"""Return the ftplib.FTP instance for this object."""
129
if self._FTP_instance is not None:
130
return self._FTP_instance
133
self._FTP_instance = _find_FTP(self._host, self._port,
134
self._username, self._password,
136
return self._FTP_instance
137
except ftplib.error_perm, e:
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.
144
:param err: The error to translate into a bzr error
145
:param path: The path which had problems
146
:param extra: Extra information which can be included
147
:param unknown_exc: If None, we will just raise the original exception
148
otherwise we raise unknown_exc(path, extra=extra)
154
extra += ': ' + str(err)
155
if ('no such file' in s
156
or 'could not open' in s
157
or 'no such dir' in s
159
raise errors.NoSuchFile(path, extra=extra)
160
if ('file exists' in s):
161
raise errors.FileExists(path, extra=extra)
162
if ('not a directory' in s):
163
raise errors.PathError(path, extra=extra)
165
mutter('unable to understand error for path: %s: %s', path, err)
168
raise unknown_exc(path, extra=extra)
169
# TODO: jam 20060516 Consider re-raising the error wrapped in
170
# something like TransportError, but this loses the traceback
171
# Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
172
# to handle. Consider doing something like that here.
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)
224
def has(self, relpath):
225
"""Does the target location exist?"""
226
# FIXME jam 20060516 We *do* ask about directories in the test suite
227
# We don't seem to in the actual codebase
228
# XXX: I assume we're never asked has(dirname) and thus I use
229
# the FTP size command and assume that if it doesn't raise,
231
abspath = self._abspath(relpath)
234
mutter('FTP has check: %s => %s', relpath, abspath)
236
mutter("FTP has: %s", abspath)
238
except ftplib.error_perm, e:
239
if ('is a directory' in str(e).lower()):
240
mutter("FTP has dir: %s: %s", abspath, e)
242
mutter("FTP has not: %s: %s", abspath, e)
245
def get(self, relpath, decode=False, retries=0):
246
"""Get the file at the given relative path.
248
:param relpath: The relative path to the file
249
:param retries: Number of retries after temporary failures so far
252
We're meant to return a file-like object which bzr will
253
then read from. For now we do this via the magic of StringIO
255
# TODO: decode should be deprecated
257
mutter("FTP get: %s", self._abspath(relpath))
260
f.retrbinary('RETR '+self._abspath(relpath), ret.write, 8192)
263
except ftplib.error_perm, e:
264
raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
265
except ftplib.error_temp, e:
266
if retries > _number_of_retries:
267
raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
268
% self.abspath(relpath),
271
warning("FTP temporary error: %s. Retrying.", str(e))
272
self._FTP_instance = None
273
return self.get(relpath, decode, retries+1)
275
if retries > _number_of_retries:
276
raise errors.TransportError("FTP control connection closed during GET %s."
277
% self.abspath(relpath),
280
warning("FTP control connection closed. Trying to reopen.")
281
time.sleep(_sleep_between_retries)
282
self._FTP_instance = None
283
return self.get(relpath, decode, retries+1)
285
def put(self, relpath, fp, mode=None, retries=0):
286
"""Copy the file-like or string object into the location.
288
:param relpath: Location to put the contents, relative to base.
289
:param fp: File-like or string object.
290
:param retries: Number of retries after temporary failures so far
293
TODO: jam 20051215 ftp as a protocol seems to support chmod, but ftplib does not
295
abspath = self._abspath(relpath)
296
tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
297
os.getpid(), random.randint(0,0x7FFFFFFF))
298
if not hasattr(fp, 'read'):
301
mutter("FTP put: %s", abspath)
304
f.storbinary('STOR '+tmp_abspath, fp)
305
f.rename(tmp_abspath, abspath)
306
except (ftplib.error_temp,EOFError), e:
307
warning("Failure during ftp PUT. Deleting temporary file.")
309
f.delete(tmp_abspath)
311
warning("Failed to delete temporary file on the"
312
" server.\nFile: %s", tmp_abspath)
315
except ftplib.error_perm, e:
316
self._translate_perm_error(e, abspath, extra='could not store')
317
except ftplib.error_temp, e:
318
if retries > _number_of_retries:
319
raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
320
% self.abspath(relpath), orig_error=e)
322
warning("FTP temporary error: %s. Retrying.", str(e))
323
self._FTP_instance = None
324
self.put(relpath, fp, mode, retries+1)
326
if retries > _number_of_retries:
327
raise errors.TransportError("FTP control connection closed during PUT %s."
328
% self.abspath(relpath), orig_error=e)
330
warning("FTP control connection closed. Trying to reopen.")
331
time.sleep(_sleep_between_retries)
332
self._FTP_instance = None
333
self.put(relpath, fp, mode, retries+1)
335
def mkdir(self, relpath, mode=None):
336
"""Create a directory at the given path."""
337
abspath = self._abspath(relpath)
339
mutter("FTP mkd: %s", abspath)
342
except ftplib.error_perm, e:
343
self._translate_perm_error(e, abspath,
344
unknown_exc=errors.FileExists)
346
def rmdir(self, rel_path):
347
"""Delete the directory at rel_path"""
348
abspath = self._abspath(rel_path)
350
mutter("FTP rmd: %s", abspath)
353
except ftplib.error_perm, e:
354
self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
356
def append(self, relpath, f, mode=None):
357
"""Append the text in the file-like object into the final
360
abspath = self._abspath(relpath)
361
if self.has(relpath):
362
ftp = self._get_FTP()
363
result = ftp.size(abspath)
367
mutter("FTP appe to %s", abspath)
368
self._try_append(relpath, f.read(), mode)
372
def _try_append(self, relpath, text, mode=None, retries=0):
373
"""Try repeatedly to append the given text to the file at relpath.
375
This is a recursive function. On errors, it will be called until the
376
number of retries is exceeded.
379
abspath = self._abspath(relpath)
380
mutter("FTP appe (try %d) to %s", retries, abspath)
381
ftp = self._get_FTP()
382
ftp.voidcmd("TYPE I")
383
cmd = "APPE %s" % abspath
384
conn = ftp.transfercmd(cmd)
388
self._setmode(relpath, mode)
390
except ftplib.error_perm, e:
391
self._translate_perm_error(e, abspath, extra='error appending',
392
unknown_exc=errors.NoSuchFile)
393
except ftplib.error_temp, e:
394
if retries > _number_of_retries:
395
raise errors.TransportError("FTP temporary error during APPEND %s." \
396
"Aborting." % abspath, orig_error=e)
398
warning("FTP temporary error: %s. Retrying.", str(e))
399
self._FTP_instance = None
400
self._try_append(relpath, text, mode, retries+1)
402
def _setmode(self, relpath, mode):
403
"""Set permissions on a path.
405
Only set permissions if the FTP server supports the 'SITE CHMOD'
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))
419
# TODO: jam 20060516 I believe ftp allows you to tell an ftp server
420
# to copy something to another machine. And you may be able
421
# to give it its own address as the 'to' location.
422
# So implement a fancier 'copy()'
424
def move(self, rel_from, rel_to):
425
"""Move the item at rel_from to the location at rel_to"""
426
abs_from = self._abspath(rel_from)
427
abs_to = self._abspath(rel_to)
429
mutter("FTP mv: %s => %s", abs_from, abs_to)
431
f.rename(abs_from, abs_to)
432
except ftplib.error_perm, e:
433
self._translate_perm_error(e, abs_from,
434
extra='unable to rename to %r' % (rel_to,),
435
unknown_exc=errors.PathError)
439
def delete(self, relpath):
440
"""Delete the item at relpath"""
441
abspath = self._abspath(relpath)
443
mutter("FTP rm: %s", abspath)
446
except ftplib.error_perm, e:
447
self._translate_perm_error(e, abspath, 'error deleting',
448
unknown_exc=errors.NoSuchFile)
451
"""See Transport.listable."""
454
def list_dir(self, relpath):
455
"""See Transport.list_dir."""
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')
469
def iter_files_recursive(self):
470
"""See Transport.iter_files_recursive.
472
This is cargo-culted from the SFTP transport"""
473
mutter("FTP iter_files_recursive")
474
queue = list(self.list_dir("."))
476
relpath = urllib.quote(queue.pop(0))
477
st = self.stat(relpath)
478
if stat.S_ISDIR(st.st_mode):
479
for i, basename in enumerate(self.list_dir(relpath)):
480
queue.insert(i, relpath+"/"+basename)
484
def stat(self, relpath):
485
"""Return the stat information for a file."""
486
abspath = self._abspath(relpath)
488
mutter("FTP stat: %s", abspath)
490
return FtpStatResult(f, abspath)
491
except ftplib.error_perm, e:
492
self._translate_perm_error(e, abspath, extra='error w/ stat')
494
def lock_read(self, relpath):
495
"""Lock the given file for shared (read) access.
496
:return: A lock object, which should be passed to Transport.unlock()
498
# The old RemoteBranch ignore lock for reading, so we will
499
# continue that tradition and return a bogus lock object.
500
class BogusLock(object):
501
def __init__(self, path):
505
return BogusLock(relpath)
507
def lock_write(self, relpath):
508
"""Lock the given file for exclusive (write) access.
509
WARNING: many transports do not support this, so trying avoid using it
511
:return: A lock object, which should be passed to Transport.unlock()
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(''.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
713
def get_test_permutations():
714
"""Return the permutations to be used in testing."""
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)]