~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: Vincent Ladeuil
  • Date: 2007-10-23 07:15:13 UTC
  • mfrom: (2926 +trunk)
  • mto: (2961.1.1 trunk)
  • mto: This revision was merged to the branch mainline in revision 2962.
  • Revision ID: v.ladeuil+lp@free.fr-20071023071513-elryt6g2at34d2ur
merge bzr.dev

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2005, 2006, 2007 Canonical Ltd
 
2
#
 
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.
 
7
#
 
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.
 
12
#
 
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.
 
17
 
 
18
Written by Daniel Silverstone <dsilvers@digital-scurf.org> with serious
 
19
cargo-culting from the sftp transport and the http transport.
 
20
 
 
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.
 
25
"""
 
26
 
 
27
from cStringIO import StringIO
 
28
import asyncore
 
29
import errno
 
30
import ftplib
 
31
import os
 
32
import os.path
 
33
import urllib
 
34
import urlparse
 
35
import select
 
36
import stat
 
37
import threading
 
38
import time
 
39
import random
 
40
from warnings import warn
 
41
 
 
42
from bzrlib import (
 
43
    config,
 
44
    errors,
 
45
    osutils,
 
46
    urlutils,
 
47
    )
 
48
from bzrlib.trace import mutter, warning
 
49
from bzrlib.transport import (
 
50
    AppendBasedFileStream,
 
51
    ConnectedTransport,
 
52
    _file_streams,
 
53
    register_urlparse_netloc_protocol,
 
54
    Server,
 
55
    )
 
56
from bzrlib.transport.local import LocalURLServer
 
57
import bzrlib.ui
 
58
 
 
59
 
 
60
register_urlparse_netloc_protocol('aftp')
 
61
 
 
62
 
 
63
class FtpPathError(errors.PathError):
 
64
    """FTP failed for path: %(path)s%(extra)s"""
 
65
 
 
66
 
 
67
class FtpStatResult(object):
 
68
    def __init__(self, f, relpath):
 
69
        try:
 
70
            self.st_size = f.size(relpath)
 
71
            self.st_mode = stat.S_IFREG
 
72
        except ftplib.error_perm:
 
73
            pwd = f.pwd()
 
74
            try:
 
75
                f.cwd(relpath)
 
76
                self.st_mode = stat.S_IFDIR
 
77
            finally:
 
78
                f.cwd(pwd)
 
79
 
 
80
 
 
81
_number_of_retries = 2
 
82
_sleep_between_retries = 5
 
83
 
 
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):
 
90
    """This is the transport agent for ftp:// access."""
 
91
 
 
92
    def __init__(self, base, _from_transport=None):
 
93
        """Set the base path where files will be stored."""
 
94
        assert base.startswith('ftp://') or base.startswith('aftp://')
 
95
        super(FtpTransport, self).__init__(base,
 
96
                                           _from_transport=_from_transport)
 
97
        self._unqualified_scheme = 'ftp'
 
98
        if self._scheme == 'aftp':
 
99
            self.is_active = True
 
100
        else:
 
101
            self.is_active = False
 
102
 
 
103
    def _get_FTP(self):
 
104
        """Return the ftplib.FTP instance for this object."""
 
105
        # Ensures that a connection is established
 
106
        connection = self._get_connection()
 
107
        if connection is None:
 
108
            # First connection ever
 
109
            connection, credentials = self._create_connection()
 
110
            self._set_connection(connection, credentials)
 
111
        return connection
 
112
 
 
113
    def _create_connection(self, credentials=None):
 
114
        """Create a new connection with the provided credentials.
 
115
 
 
116
        :param credentials: The credentials needed to establish the connection.
 
117
 
 
118
        :return: The created connection and its associated credentials.
 
119
 
 
120
        The credentials are only the password as it may have been entered
 
121
        interactively by the user and may be different from the one provided
 
122
        in base url at transport creation time.
 
123
        """
 
124
        if credentials is None:
 
125
            user, password = self._user, self._password
 
126
        else:
 
127
            user, password = credentials
 
128
 
 
129
        auth = config.AuthenticationConfig()
 
130
        if user is None:
 
131
            user = auth.get_user('ftp', self._host, port=self._port)
 
132
            if user is None:
 
133
                # Default to local user
 
134
                user = getpass.getuser()
 
135
 
 
136
        mutter("Constructing FTP instance against %r" %
 
137
               ((self._host, self._port, user, '********',
 
138
                self.is_active),))
 
139
        try:
 
140
            connection = ftplib.FTP()
 
141
            connection.connect(host=self._host, port=self._port)
 
142
            if user and user != 'anonymous' and \
 
143
                    password is None: # '' is a valid password
 
144
                password = auth.get_password('ftp', self._host, user,
 
145
                                             port=self._port)
 
146
            connection.login(user=user, passwd=password)
 
147
            connection.set_pasv(not self.is_active)
 
148
        except ftplib.error_perm, e:
 
149
            raise errors.TransportError(msg="Error setting up connection:"
 
150
                                        " %s" % str(e), orig_error=e)
 
151
        return connection, (user, password)
 
152
 
 
153
    def _reconnect(self):
 
154
        """Create a new connection with the previously used credentials"""
 
155
        credentials = self._get_credentials()
 
156
        connection, credentials = self._create_connection(credentials)
 
157
        self._set_connection(connection, credentials)
 
158
 
 
159
    def _translate_perm_error(self, err, path, extra=None,
 
160
                              unknown_exc=FtpPathError):
 
161
        """Try to translate an ftplib.error_perm exception.
 
162
 
 
163
        :param err: The error to translate into a bzr error
 
164
        :param path: The path which had problems
 
165
        :param extra: Extra information which can be included
 
166
        :param unknown_exc: If None, we will just raise the original exception
 
167
                    otherwise we raise unknown_exc(path, extra=extra)
 
168
        """
 
169
        s = str(err).lower()
 
170
        if not extra:
 
171
            extra = str(err)
 
172
        else:
 
173
            extra += ': ' + str(err)
 
174
        if ('no such file' in s
 
175
            or 'could not open' in s
 
176
            or 'no such dir' in s
 
177
            or 'could not create file' in s # vsftpd
 
178
            or 'file doesn\'t exist' in s
 
179
            or 'file/directory not found' in s # filezilla server
 
180
            ):
 
181
            raise errors.NoSuchFile(path, extra=extra)
 
182
        if ('file exists' in s):
 
183
            raise errors.FileExists(path, extra=extra)
 
184
        if ('not a directory' in s):
 
185
            raise errors.PathError(path, extra=extra)
 
186
 
 
187
        mutter('unable to understand error for path: %s: %s', path, err)
 
188
 
 
189
        if unknown_exc:
 
190
            raise unknown_exc(path, extra=extra)
 
191
        # TODO: jam 20060516 Consider re-raising the error wrapped in 
 
192
        #       something like TransportError, but this loses the traceback
 
193
        #       Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
 
194
        #       to handle. Consider doing something like that here.
 
195
        #raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
 
196
        raise
 
197
 
 
198
    def _remote_path(self, relpath):
 
199
        # XXX: It seems that ftplib does not handle Unicode paths
 
200
        # at the same time, medusa won't handle utf8 paths So if
 
201
        # we .encode(utf8) here (see ConnectedTransport
 
202
        # implementation), then we get a Server failure.  while
 
203
        # if we use str(), we get a UnicodeError, and the test
 
204
        # suite just skips testing UnicodePaths.
 
205
        relative = str(urlutils.unescape(relpath))
 
206
        remote_path = self._combine_paths(self._path, relative)
 
207
        return remote_path
 
208
 
 
209
    def has(self, relpath):
 
210
        """Does the target location exist?"""
 
211
        # FIXME jam 20060516 We *do* ask about directories in the test suite
 
212
        #       We don't seem to in the actual codebase
 
213
        # XXX: I assume we're never asked has(dirname) and thus I use
 
214
        # the FTP size command and assume that if it doesn't raise,
 
215
        # all is good.
 
216
        abspath = self._remote_path(relpath)
 
217
        try:
 
218
            f = self._get_FTP()
 
219
            mutter('FTP has check: %s => %s', relpath, abspath)
 
220
            s = f.size(abspath)
 
221
            mutter("FTP has: %s", abspath)
 
222
            return True
 
223
        except ftplib.error_perm, e:
 
224
            if ('is a directory' in str(e).lower()):
 
225
                mutter("FTP has dir: %s: %s", abspath, e)
 
226
                return True
 
227
            mutter("FTP has not: %s: %s", abspath, e)
 
228
            return False
 
229
 
 
230
    def get(self, relpath, decode=False, retries=0):
 
231
        """Get the file at the given relative path.
 
232
 
 
233
        :param relpath: The relative path to the file
 
234
        :param retries: Number of retries after temporary failures so far
 
235
                        for this operation.
 
236
 
 
237
        We're meant to return a file-like object which bzr will
 
238
        then read from. For now we do this via the magic of StringIO
 
239
        """
 
240
        # TODO: decode should be deprecated
 
241
        try:
 
242
            mutter("FTP get: %s", self._remote_path(relpath))
 
243
            f = self._get_FTP()
 
244
            ret = StringIO()
 
245
            f.retrbinary('RETR '+self._remote_path(relpath), ret.write, 8192)
 
246
            ret.seek(0)
 
247
            return ret
 
248
        except ftplib.error_perm, e:
 
249
            raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
 
250
        except ftplib.error_temp, e:
 
251
            if retries > _number_of_retries:
 
252
                raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
 
253
                                     % self.abspath(relpath),
 
254
                                     orig_error=e)
 
255
            else:
 
256
                warning("FTP temporary error: %s. Retrying.", str(e))
 
257
                self._reconnect()
 
258
                return self.get(relpath, decode, retries+1)
 
259
        except EOFError, e:
 
260
            if retries > _number_of_retries:
 
261
                raise errors.TransportError("FTP control connection closed during GET %s."
 
262
                                     % self.abspath(relpath),
 
263
                                     orig_error=e)
 
264
            else:
 
265
                warning("FTP control connection closed. Trying to reopen.")
 
266
                time.sleep(_sleep_between_retries)
 
267
                self._reconnect()
 
268
                return self.get(relpath, decode, retries+1)
 
269
 
 
270
    def put_file(self, relpath, fp, mode=None, retries=0):
 
271
        """Copy the file-like or string object into the location.
 
272
 
 
273
        :param relpath: Location to put the contents, relative to base.
 
274
        :param fp:       File-like or string object.
 
275
        :param retries: Number of retries after temporary failures so far
 
276
                        for this operation.
 
277
 
 
278
        TODO: jam 20051215 ftp as a protocol seems to support chmod, but
 
279
        ftplib does not
 
280
        """
 
281
        abspath = self._remote_path(relpath)
 
282
        tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
 
283
                        os.getpid(), random.randint(0,0x7FFFFFFF))
 
284
        bytes = None
 
285
        if getattr(fp, 'read', None) is None:
 
286
            # hand in a string IO
 
287
            bytes = fp
 
288
            fp = StringIO(bytes)
 
289
        else:
 
290
            # capture the byte count; .read() may be read only so
 
291
            # decorate it.
 
292
            class byte_counter(object):
 
293
                def __init__(self, fp):
 
294
                    self.fp = fp
 
295
                    self.counted_bytes = 0
 
296
                def read(self, count):
 
297
                    result = self.fp.read(count)
 
298
                    self.counted_bytes += len(result)
 
299
                    return result
 
300
            fp = byte_counter(fp)
 
301
        try:
 
302
            mutter("FTP put: %s", abspath)
 
303
            f = self._get_FTP()
 
304
            try:
 
305
                f.storbinary('STOR '+tmp_abspath, fp)
 
306
                self._rename_and_overwrite(tmp_abspath, abspath, f)
 
307
                if bytes is not None:
 
308
                    return len(bytes)
 
309
                else:
 
310
                    return fp.counted_bytes
 
311
            except (ftplib.error_temp,EOFError), e:
 
312
                warning("Failure during ftp PUT. Deleting temporary file.")
 
313
                try:
 
314
                    f.delete(tmp_abspath)
 
315
                except:
 
316
                    warning("Failed to delete temporary file on the"
 
317
                            " server.\nFile: %s", tmp_abspath)
 
318
                    raise e
 
319
                raise
 
320
        except ftplib.error_perm, e:
 
321
            self._translate_perm_error(e, abspath, extra='could not store',
 
322
                                       unknown_exc=errors.NoSuchFile)
 
323
        except ftplib.error_temp, e:
 
324
            if retries > _number_of_retries:
 
325
                raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
 
326
                                     % self.abspath(relpath), orig_error=e)
 
327
            else:
 
328
                warning("FTP temporary error: %s. Retrying.", str(e))
 
329
                self._reconnect()
 
330
                self.put_file(relpath, fp, mode, retries+1)
 
331
        except EOFError:
 
332
            if retries > _number_of_retries:
 
333
                raise errors.TransportError("FTP control connection closed during PUT %s."
 
334
                                     % self.abspath(relpath), orig_error=e)
 
335
            else:
 
336
                warning("FTP control connection closed. Trying to reopen.")
 
337
                time.sleep(_sleep_between_retries)
 
338
                self._reconnect()
 
339
                self.put_file(relpath, fp, mode, retries+1)
 
340
 
 
341
    def mkdir(self, relpath, mode=None):
 
342
        """Create a directory at the given path."""
 
343
        abspath = self._remote_path(relpath)
 
344
        try:
 
345
            mutter("FTP mkd: %s", abspath)
 
346
            f = self._get_FTP()
 
347
            f.mkd(abspath)
 
348
        except ftplib.error_perm, e:
 
349
            self._translate_perm_error(e, abspath,
 
350
                unknown_exc=errors.FileExists)
 
351
 
 
352
    def open_write_stream(self, relpath, mode=None):
 
353
        """See Transport.open_write_stream."""
 
354
        self.put_bytes(relpath, "", mode)
 
355
        result = AppendBasedFileStream(self, relpath)
 
356
        _file_streams[self.abspath(relpath)] = result
 
357
        return result
 
358
 
 
359
    def recommended_page_size(self):
 
360
        """See Transport.recommended_page_size().
 
361
 
 
362
        For FTP we suggest a large page size to reduce the overhead
 
363
        introduced by latency.
 
364
        """
 
365
        return 64 * 1024
 
366
 
 
367
    def rmdir(self, rel_path):
 
368
        """Delete the directory at rel_path"""
 
369
        abspath = self._remote_path(rel_path)
 
370
        try:
 
371
            mutter("FTP rmd: %s", abspath)
 
372
            f = self._get_FTP()
 
373
            f.rmd(abspath)
 
374
        except ftplib.error_perm, e:
 
375
            self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
 
376
 
 
377
    def append_file(self, relpath, f, mode=None):
 
378
        """Append the text in the file-like object into the final
 
379
        location.
 
380
        """
 
381
        abspath = self._remote_path(relpath)
 
382
        if self.has(relpath):
 
383
            ftp = self._get_FTP()
 
384
            result = ftp.size(abspath)
 
385
        else:
 
386
            result = 0
 
387
 
 
388
        mutter("FTP appe to %s", abspath)
 
389
        self._try_append(relpath, f.read(), mode)
 
390
 
 
391
        return result
 
392
 
 
393
    def _try_append(self, relpath, text, mode=None, retries=0):
 
394
        """Try repeatedly to append the given text to the file at relpath.
 
395
        
 
396
        This is a recursive function. On errors, it will be called until the
 
397
        number of retries is exceeded.
 
398
        """
 
399
        try:
 
400
            abspath = self._remote_path(relpath)
 
401
            mutter("FTP appe (try %d) to %s", retries, abspath)
 
402
            ftp = self._get_FTP()
 
403
            ftp.voidcmd("TYPE I")
 
404
            cmd = "APPE %s" % abspath
 
405
            conn = ftp.transfercmd(cmd)
 
406
            conn.sendall(text)
 
407
            conn.close()
 
408
            if mode:
 
409
                self._setmode(relpath, mode)
 
410
            ftp.getresp()
 
411
        except ftplib.error_perm, e:
 
412
            self._translate_perm_error(e, abspath, extra='error appending',
 
413
                unknown_exc=errors.NoSuchFile)
 
414
        except ftplib.error_temp, e:
 
415
            if retries > _number_of_retries:
 
416
                raise errors.TransportError("FTP temporary error during APPEND %s." \
 
417
                        "Aborting." % abspath, orig_error=e)
 
418
            else:
 
419
                warning("FTP temporary error: %s. Retrying.", str(e))
 
420
                self._reconnect()
 
421
                self._try_append(relpath, text, mode, retries+1)
 
422
 
 
423
    def _setmode(self, relpath, mode):
 
424
        """Set permissions on a path.
 
425
 
 
426
        Only set permissions if the FTP server supports the 'SITE CHMOD'
 
427
        extension.
 
428
        """
 
429
        try:
 
430
            mutter("FTP site chmod: setting permissions to %s on %s",
 
431
                str(mode), self._remote_path(relpath))
 
432
            ftp = self._get_FTP()
 
433
            cmd = "SITE CHMOD %s %s" % (self._remote_path(relpath), str(mode))
 
434
            ftp.sendcmd(cmd)
 
435
        except ftplib.error_perm, e:
 
436
            # Command probably not available on this server
 
437
            warning("FTP Could not set permissions to %s on %s. %s",
 
438
                    str(mode), self._remote_path(relpath), str(e))
 
439
 
 
440
    # TODO: jam 20060516 I believe ftp allows you to tell an ftp server
 
441
    #       to copy something to another machine. And you may be able
 
442
    #       to give it its own address as the 'to' location.
 
443
    #       So implement a fancier 'copy()'
 
444
 
 
445
    def rename(self, rel_from, rel_to):
 
446
        abs_from = self._remote_path(rel_from)
 
447
        abs_to = self._remote_path(rel_to)
 
448
        mutter("FTP rename: %s => %s", abs_from, abs_to)
 
449
        f = self._get_FTP()
 
450
        return self._rename(abs_from, abs_to, f)
 
451
 
 
452
    def _rename(self, abs_from, abs_to, f):
 
453
        try:
 
454
            f.rename(abs_from, abs_to)
 
455
        except ftplib.error_perm, e:
 
456
            self._translate_perm_error(e, abs_from,
 
457
                ': unable to rename to %r' % (abs_to))
 
458
 
 
459
    def move(self, rel_from, rel_to):
 
460
        """Move the item at rel_from to the location at rel_to"""
 
461
        abs_from = self._remote_path(rel_from)
 
462
        abs_to = self._remote_path(rel_to)
 
463
        try:
 
464
            mutter("FTP mv: %s => %s", abs_from, abs_to)
 
465
            f = self._get_FTP()
 
466
            self._rename_and_overwrite(abs_from, abs_to, f)
 
467
        except ftplib.error_perm, e:
 
468
            self._translate_perm_error(e, abs_from,
 
469
                extra='unable to rename to %r' % (rel_to,), 
 
470
                unknown_exc=errors.PathError)
 
471
 
 
472
    def _rename_and_overwrite(self, abs_from, abs_to, f):
 
473
        """Do a fancy rename on the remote server.
 
474
 
 
475
        Using the implementation provided by osutils.
 
476
        """
 
477
        osutils.fancy_rename(abs_from, abs_to,
 
478
            rename_func=lambda p1, p2: self._rename(p1, p2, f),
 
479
            unlink_func=lambda p: self._delete(p, f))
 
480
 
 
481
    def delete(self, relpath):
 
482
        """Delete the item at relpath"""
 
483
        abspath = self._remote_path(relpath)
 
484
        f = self._get_FTP()
 
485
        self._delete(abspath, f)
 
486
 
 
487
    def _delete(self, abspath, f):
 
488
        try:
 
489
            mutter("FTP rm: %s", abspath)
 
490
            f.delete(abspath)
 
491
        except ftplib.error_perm, e:
 
492
            self._translate_perm_error(e, abspath, 'error deleting',
 
493
                unknown_exc=errors.NoSuchFile)
 
494
 
 
495
    def external_url(self):
 
496
        """See bzrlib.transport.Transport.external_url."""
 
497
        # FTP URL's are externally usable.
 
498
        return self.base
 
499
 
 
500
    def listable(self):
 
501
        """See Transport.listable."""
 
502
        return True
 
503
 
 
504
    def list_dir(self, relpath):
 
505
        """See Transport.list_dir."""
 
506
        basepath = self._remote_path(relpath)
 
507
        mutter("FTP nlst: %s", basepath)
 
508
        f = self._get_FTP()
 
509
        try:
 
510
            paths = f.nlst(basepath)
 
511
        except ftplib.error_perm, e:
 
512
            self._translate_perm_error(e, relpath, extra='error with list_dir')
 
513
        # If FTP.nlst returns paths prefixed by relpath, strip 'em
 
514
        if paths and paths[0].startswith(basepath):
 
515
            entries = [path[len(basepath)+1:] for path in paths]
 
516
        else:
 
517
            entries = paths
 
518
        # Remove . and .. if present
 
519
        return [urlutils.escape(entry) for entry in entries
 
520
                if entry not in ('.', '..')]
 
521
 
 
522
    def iter_files_recursive(self):
 
523
        """See Transport.iter_files_recursive.
 
524
 
 
525
        This is cargo-culted from the SFTP transport"""
 
526
        mutter("FTP iter_files_recursive")
 
527
        queue = list(self.list_dir("."))
 
528
        while queue:
 
529
            relpath = queue.pop(0)
 
530
            st = self.stat(relpath)
 
531
            if stat.S_ISDIR(st.st_mode):
 
532
                for i, basename in enumerate(self.list_dir(relpath)):
 
533
                    queue.insert(i, relpath+"/"+basename)
 
534
            else:
 
535
                yield relpath
 
536
 
 
537
    def stat(self, relpath):
 
538
        """Return the stat information for a file."""
 
539
        abspath = self._remote_path(relpath)
 
540
        try:
 
541
            mutter("FTP stat: %s", abspath)
 
542
            f = self._get_FTP()
 
543
            return FtpStatResult(f, abspath)
 
544
        except ftplib.error_perm, e:
 
545
            self._translate_perm_error(e, abspath, extra='error w/ stat')
 
546
 
 
547
    def lock_read(self, relpath):
 
548
        """Lock the given file for shared (read) access.
 
549
        :return: A lock object, which should be passed to Transport.unlock()
 
550
        """
 
551
        # The old RemoteBranch ignore lock for reading, so we will
 
552
        # continue that tradition and return a bogus lock object.
 
553
        class BogusLock(object):
 
554
            def __init__(self, path):
 
555
                self.path = path
 
556
            def unlock(self):
 
557
                pass
 
558
        return BogusLock(relpath)
 
559
 
 
560
    def lock_write(self, relpath):
 
561
        """Lock the given file for exclusive (write) access.
 
562
        WARNING: many transports do not support this, so trying avoid using it
 
563
 
 
564
        :return: A lock object, which should be passed to Transport.unlock()
 
565
        """
 
566
        return self.lock_read(relpath)
 
567
 
 
568
 
 
569
class FtpServer(Server):
 
570
    """Common code for FTP server facilities."""
 
571
 
 
572
    def __init__(self):
 
573
        self._root = None
 
574
        self._ftp_server = None
 
575
        self._port = None
 
576
        self._async_thread = None
 
577
        # ftp server logs
 
578
        self.logs = []
 
579
 
 
580
    def get_url(self):
 
581
        """Calculate an ftp url to this server."""
 
582
        return 'ftp://foo:bar@localhost:%d/' % (self._port)
 
583
 
 
584
#    def get_bogus_url(self):
 
585
#        """Return a URL which cannot be connected to."""
 
586
#        return 'ftp://127.0.0.1:1'
 
587
 
 
588
    def log(self, message):
 
589
        """This is used by medusa.ftp_server to log connections, etc."""
 
590
        self.logs.append(message)
 
591
 
 
592
    def setUp(self, vfs_server=None):
 
593
        if not _have_medusa:
 
594
            raise RuntimeError('Must have medusa to run the FtpServer')
 
595
 
 
596
        assert vfs_server is None or isinstance(vfs_server, LocalURLServer), \
 
597
            "FtpServer currently assumes local transport, got %s" % vfs_server
 
598
 
 
599
        self._root = os.getcwdu()
 
600
        self._ftp_server = _ftp_server(
 
601
            authorizer=_test_authorizer(root=self._root),
 
602
            ip='localhost',
 
603
            port=0, # bind to a random port
 
604
            resolver=None,
 
605
            logger_object=self # Use FtpServer.log() for messages
 
606
            )
 
607
        self._port = self._ftp_server.getsockname()[1]
 
608
        # Don't let it loop forever, or handle an infinite number of requests.
 
609
        # In this case it will run for 1000s, or 10000 requests
 
610
        self._async_thread = threading.Thread(
 
611
                target=FtpServer._asyncore_loop_ignore_EBADF,
 
612
                kwargs={'timeout':0.1, 'count':10000})
 
613
        self._async_thread.setDaemon(True)
 
614
        self._async_thread.start()
 
615
 
 
616
    def tearDown(self):
 
617
        """See bzrlib.transport.Server.tearDown."""
 
618
        self._ftp_server.close()
 
619
        asyncore.close_all()
 
620
        self._async_thread.join()
 
621
 
 
622
    @staticmethod
 
623
    def _asyncore_loop_ignore_EBADF(*args, **kwargs):
 
624
        """Ignore EBADF during server shutdown.
 
625
 
 
626
        We close the socket to get the server to shutdown, but this causes
 
627
        select.select() to raise EBADF.
 
628
        """
 
629
        try:
 
630
            asyncore.loop(*args, **kwargs)
 
631
            # FIXME: If we reach that point, we should raise an exception
 
632
            # explaining that the 'count' parameter in setUp is too low or
 
633
            # testers may wonder why their test just sits there waiting for a
 
634
            # server that is already dead. Note that if the tester waits too
 
635
            # long under pdb the server will also die.
 
636
        except select.error, e:
 
637
            if e.args[0] != errno.EBADF:
 
638
                raise
 
639
 
 
640
 
 
641
_ftp_channel = None
 
642
_ftp_server = None
 
643
_test_authorizer = None
 
644
 
 
645
 
 
646
def _setup_medusa():
 
647
    global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
 
648
    try:
 
649
        import medusa
 
650
        import medusa.filesys
 
651
        import medusa.ftp_server
 
652
    except ImportError:
 
653
        return False
 
654
 
 
655
    _have_medusa = True
 
656
 
 
657
    class test_authorizer(object):
 
658
        """A custom Authorizer object for running the test suite.
 
659
 
 
660
        The reason we cannot use dummy_authorizer, is because it sets the
 
661
        channel to readonly, which we don't always want to do.
 
662
        """
 
663
 
 
664
        def __init__(self, root):
 
665
            self.root = root
 
666
            # If secured_user is set secured_password will be checked
 
667
            self.secured_user = None
 
668
            self.secured_password = None
 
669
 
 
670
        def authorize(self, channel, username, password):
 
671
            """Return (success, reply_string, filesystem)"""
 
672
            if not _have_medusa:
 
673
                return 0, 'No Medusa.', None
 
674
 
 
675
            channel.persona = -1, -1
 
676
            if username == 'anonymous':
 
677
                channel.read_only = 1
 
678
            else:
 
679
                channel.read_only = 0
 
680
 
 
681
            # Check secured_user if set
 
682
            if (self.secured_user is not None
 
683
                and username == self.secured_user
 
684
                and password != self.secured_password):
 
685
                return 0, 'Password invalid.', None
 
686
            else:
 
687
                return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
 
688
 
 
689
 
 
690
    class ftp_channel(medusa.ftp_server.ftp_channel):
 
691
        """Customized ftp channel"""
 
692
 
 
693
        def log(self, message):
 
694
            """Redirect logging requests."""
 
695
            mutter('_ftp_channel: %s', message)
 
696
 
 
697
        def log_info(self, message, type='info'):
 
698
            """Redirect logging requests."""
 
699
            mutter('_ftp_channel %s: %s', type, message)
 
700
 
 
701
        def cmd_rnfr(self, line):
 
702
            """Prepare for renaming a file."""
 
703
            self._renaming = line[1]
 
704
            self.respond('350 Ready for RNTO')
 
705
            # TODO: jam 20060516 in testing, the ftp server seems to
 
706
            #       check that the file already exists, or it sends
 
707
            #       550 RNFR command failed
 
708
 
 
709
        def cmd_rnto(self, line):
 
710
            """Rename a file based on the target given.
 
711
 
 
712
            rnto must be called after calling rnfr.
 
713
            """
 
714
            if not self._renaming:
 
715
                self.respond('503 RNFR required first.')
 
716
            pfrom = self.filesystem.translate(self._renaming)
 
717
            self._renaming = None
 
718
            pto = self.filesystem.translate(line[1])
 
719
            if os.path.exists(pto):
 
720
                self.respond('550 RNTO failed: file exists')
 
721
                return
 
722
            try:
 
723
                os.rename(pfrom, pto)
 
724
            except (IOError, OSError), e:
 
725
                # TODO: jam 20060516 return custom responses based on
 
726
                #       why the command failed
 
727
                # (bialix 20070418) str(e) on Python 2.5 @ Windows
 
728
                # sometimes don't provide expected error message;
 
729
                # so we obtain such message via os.strerror()
 
730
                self.respond('550 RNTO failed: %s' % os.strerror(e.errno))
 
731
            except:
 
732
                self.respond('550 RNTO failed')
 
733
                # For a test server, we will go ahead and just die
 
734
                raise
 
735
            else:
 
736
                self.respond('250 Rename successful.')
 
737
 
 
738
        def cmd_size(self, line):
 
739
            """Return the size of a file
 
740
 
 
741
            This is overloaded to help the test suite determine if the 
 
742
            target is a directory.
 
743
            """
 
744
            filename = line[1]
 
745
            if not self.filesystem.isfile(filename):
 
746
                if self.filesystem.isdir(filename):
 
747
                    self.respond('550 "%s" is a directory' % (filename,))
 
748
                else:
 
749
                    self.respond('550 "%s" is not a file' % (filename,))
 
750
            else:
 
751
                self.respond('213 %d' 
 
752
                    % (self.filesystem.stat(filename)[stat.ST_SIZE]),)
 
753
 
 
754
        def cmd_mkd(self, line):
 
755
            """Create a directory.
 
756
 
 
757
            Overloaded because default implementation does not distinguish
 
758
            *why* it cannot make a directory.
 
759
            """
 
760
            if len (line) != 2:
 
761
                self.command_not_understood(''.join(line))
 
762
            else:
 
763
                path = line[1]
 
764
                try:
 
765
                    self.filesystem.mkdir (path)
 
766
                    self.respond ('257 MKD command successful.')
 
767
                except (IOError, OSError), e:
 
768
                    # (bialix 20070418) str(e) on Python 2.5 @ Windows
 
769
                    # sometimes don't provide expected error message;
 
770
                    # so we obtain such message via os.strerror()
 
771
                    self.respond ('550 error creating directory: %s' %
 
772
                                  os.strerror(e.errno))
 
773
                except:
 
774
                    self.respond ('550 error creating directory.')
 
775
 
 
776
 
 
777
    class ftp_server(medusa.ftp_server.ftp_server):
 
778
        """Customize the behavior of the Medusa ftp_server.
 
779
 
 
780
        There are a few warts on the ftp_server, based on how it expects
 
781
        to be used.
 
782
        """
 
783
        _renaming = None
 
784
        ftp_channel_class = ftp_channel
 
785
 
 
786
        def __init__(self, *args, **kwargs):
 
787
            mutter('Initializing _ftp_server: %r, %r', args, kwargs)
 
788
            medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
 
789
 
 
790
        def log(self, message):
 
791
            """Redirect logging requests."""
 
792
            mutter('_ftp_server: %s', message)
 
793
 
 
794
        def log_info(self, message, type='info'):
 
795
            """Override the asyncore.log_info so we don't stipple the screen."""
 
796
            mutter('_ftp_server %s: %s', type, message)
 
797
 
 
798
    _test_authorizer = test_authorizer
 
799
    _ftp_channel = ftp_channel
 
800
    _ftp_server = ftp_server
 
801
 
 
802
    return True
 
803
 
 
804
 
 
805
def get_test_permutations():
 
806
    """Return the permutations to be used in testing."""
 
807
    if not _setup_medusa():
 
808
        warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
 
809
        return []
 
810
    else:
 
811
        return [(FtpTransport, FtpServer)]