~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/sftp.py

  • Committer: Robert Collins
  • Date: 2005-10-17 11:56:54 UTC
  • mfrom: (1185.16.59)
  • Revision ID: robertc@robertcollins.net-20051017115654-662239e1587524a8
mergeĀ fromĀ martin.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005 Robey Pointer <robey@lag.net>, 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
 
 
17
 
"""Implementation of Transport over SFTP, using paramiko."""
18
 
 
19
 
import errno
20
 
import getpass
21
 
import os
22
 
import re
23
 
import stat
24
 
import sys
25
 
import urllib
26
 
 
27
 
from bzrlib.errors import (FileExists, 
28
 
                           TransportNotPossible, NoSuchFile, NonRelativePath,
29
 
                           TransportError)
30
 
from bzrlib.config import config_dir
31
 
from bzrlib.trace import mutter, warning, error
32
 
from bzrlib.transport import Transport, register_transport
33
 
 
34
 
try:
35
 
    import paramiko
36
 
except ImportError:
37
 
    error('The SFTP transport requires paramiko.')
38
 
    raise
39
 
 
40
 
 
41
 
SYSTEM_HOSTKEYS = {}
42
 
BZR_HOSTKEYS = {}
43
 
 
44
 
def load_host_keys():
45
 
    """
46
 
    Load system host keys (probably doesn't work on windows) and any
47
 
    "discovered" keys from previous sessions.
48
 
    """
49
 
    global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
50
 
    try:
51
 
        SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
52
 
    except Exception, e:
53
 
        mutter('failed to load system host keys: ' + str(e))
54
 
    bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
55
 
    try:
56
 
        BZR_HOSTKEYS = paramiko.util.load_host_keys(bzr_hostkey_path)
57
 
    except Exception, e:
58
 
        mutter('failed to load bzr host keys: ' + str(e))
59
 
        save_host_keys()
60
 
 
61
 
def save_host_keys():
62
 
    """
63
 
    Save "discovered" host keys in $(config)/ssh_host_keys/.
64
 
    """
65
 
    global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
66
 
    bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
67
 
    if not os.path.isdir(config_dir()):
68
 
        os.mkdir(config_dir())
69
 
    try:
70
 
        f = open(bzr_hostkey_path, 'w')
71
 
        f.write('# SSH host keys collected by bzr\n')
72
 
        for hostname, keys in BZR_HOSTKEYS.iteritems():
73
 
            for keytype, key in keys.iteritems():
74
 
                f.write('%s %s %s\n' % (hostname, keytype, key.get_base64()))
75
 
        f.close()
76
 
    except IOError, e:
77
 
        mutter('failed to save bzr host keys: ' + str(e))
78
 
 
79
 
 
80
 
 
81
 
class SFTPTransportError (TransportError):
82
 
    pass
83
 
 
84
 
 
85
 
class SFTPTransport (Transport):
86
 
    """
87
 
    Transport implementation for SFTP access.
88
 
    """
89
 
 
90
 
    _url_matcher = re.compile(r'^sftp://([^@]*@)?(.*?)(:\d+)?(/.*)?$')
91
 
    
92
 
    def __init__(self, base, clone_from=None):
93
 
        assert base.startswith('sftp://')
94
 
        super(SFTPTransport, self).__init__(base)
95
 
        self._parse_url(base)
96
 
        if clone_from is None:
97
 
            self._sftp_connect()
98
 
        else:
99
 
            # use the same ssh connection, etc
100
 
            self._sftp = clone_from._sftp
101
 
        # super saves 'self.base'
102
 
    
103
 
    def should_cache(self):
104
 
        """
105
 
        Return True if the data pulled across should be cached locally.
106
 
        """
107
 
        return True
108
 
 
109
 
    def clone(self, offset=None):
110
 
        """
111
 
        Return a new SFTPTransport with root at self.base + offset.
112
 
        We share the same SFTP session between such transports, because it's
113
 
        fairly expensive to set them up.
114
 
        """
115
 
        if offset is None:
116
 
            return SFTPTransport(self.base, self)
117
 
        else:
118
 
            return SFTPTransport(self.abspath(offset), self)
119
 
 
120
 
    def abspath(self, relpath):
121
 
        """
122
 
        Return the full url to the given relative path.
123
 
        
124
 
        @param relpath: the relative path or path components
125
 
        @type relpath: str or list
126
 
        """
127
 
        return self._unparse_url(self._abspath(relpath))
128
 
    
129
 
    def _abspath(self, relpath):
130
 
        """Return the absolute path segment without the SFTP URL."""
131
 
        # FIXME: share the common code across transports
132
 
        assert isinstance(relpath, basestring)
133
 
        relpath = [urllib.unquote(relpath)]
134
 
        basepath = self._path.split('/')
135
 
        if len(basepath) > 0 and basepath[-1] == '':
136
 
            basepath = basepath[:-1]
137
 
 
138
 
        for p in relpath:
139
 
            if p == '..':
140
 
                if len(basepath) == 0:
141
 
                    # In most filesystems, a request for the parent
142
 
                    # of root, just returns root.
143
 
                    continue
144
 
                basepath.pop()
145
 
            elif p == '.':
146
 
                continue # No-op
147
 
            else:
148
 
                basepath.append(p)
149
 
 
150
 
        path = '/'.join(basepath)
151
 
        if path[0] != '/':
152
 
            path = '/' + path
153
 
        return path
154
 
 
155
 
    def relpath(self, abspath):
156
 
        # FIXME: this is identical to HttpTransport -- share it
157
 
        if not abspath.startswith(self.base):
158
 
            raise NonRelativePath('path %r is not under base URL %r'
159
 
                           % (abspath, self.base))
160
 
        pl = len(self.base)
161
 
        return abspath[pl:].lstrip('/')
162
 
 
163
 
    def has(self, relpath):
164
 
        """
165
 
        Does the target location exist?
166
 
        """
167
 
        try:
168
 
            self._sftp.stat(self._abspath(relpath))
169
 
            return True
170
 
        except IOError:
171
 
            return False
172
 
 
173
 
    def get(self, relpath, decode=False):
174
 
        """
175
 
        Get the file at the given relative path.
176
 
 
177
 
        :param relpath: The relative path to the file
178
 
        """
179
 
        try:
180
 
            path = self._abspath(relpath)
181
 
            return self._sftp.file(path)
182
 
        except (IOError, paramiko.SSHException), x:
183
 
            raise NoSuchFile('Error retrieving %s: %s' % (path, str(x)), x)
184
 
 
185
 
    def get_partial(self, relpath, start, length=None):
186
 
        """
187
 
        Get just part of a file.
188
 
 
189
 
        :param relpath: Path to the file, relative to base
190
 
        :param start: The starting position to read from
191
 
        :param length: The length to read. A length of None indicates
192
 
                       read to the end of the file.
193
 
        :return: A file-like object containing at least the specified bytes.
194
 
                 Some implementations may return objects which can be read
195
 
                 past this length, but this is not guaranteed.
196
 
        """
197
 
        f = self.get(relpath)
198
 
        f.seek(start)
199
 
        return f
200
 
 
201
 
    def put(self, relpath, f):
202
 
        """
203
 
        Copy the file-like or string object into the location.
204
 
 
205
 
        :param relpath: Location to put the contents, relative to base.
206
 
        :param f:       File-like or string object.
207
 
        """
208
 
        # FIXME: should do something atomic or locking here, this is unsafe
209
 
        try:
210
 
            path = self._abspath(relpath)
211
 
            fout = self._sftp.file(path, 'wb')
212
 
        except IOError, e:
213
 
            self._translate_io_exception(e, relpath)
214
 
        except (IOError, paramiko.SSHException), x:
215
 
            raise SFTPTransportError('Unable to write file %r' % (path,), x)
216
 
        try:
217
 
            self._pump(f, fout)
218
 
        finally:
219
 
            fout.close()
220
 
 
221
 
    def iter_files_recursive(self):
222
 
        """Walk the relative paths of all files in this transport."""
223
 
        queue = list(self.list_dir('.'))
224
 
        while queue:
225
 
            relpath = urllib.quote(queue.pop(0))
226
 
            st = self.stat(relpath)
227
 
            if stat.S_ISDIR(st.st_mode):
228
 
                for i, basename in enumerate(self.list_dir(relpath)):
229
 
                    queue.insert(i, relpath+'/'+basename)
230
 
            else:
231
 
                yield relpath
232
 
 
233
 
    def mkdir(self, relpath):
234
 
        """Create a directory at the given path."""
235
 
        try:
236
 
            path = self._abspath(relpath)
237
 
            self._sftp.mkdir(path)
238
 
        except IOError, e:
239
 
            self._translate_io_exception(e, relpath)
240
 
        except (IOError, paramiko.SSHException), x:
241
 
            raise SFTPTransportError('Unable to mkdir %r' % (path,), x)
242
 
 
243
 
    def _translate_io_exception(self, e, relpath):
244
 
        # paramiko seems to generate detailless errors.
245
 
        if (e.errno == errno.ENOENT or
246
 
            e.args == ('No such file or directory',) or
247
 
            e.args == ('No such file',)):
248
 
            raise NoSuchFile(relpath)
249
 
        if (e.args == ('mkdir failed',)):
250
 
            raise FileExists(relpath)
251
 
        # strange but true, for the paramiko server.
252
 
        if (e.args == ('Failure',)):
253
 
            raise FileExists(relpath)
254
 
        raise
255
 
 
256
 
    def append(self, relpath, f):
257
 
        """
258
 
        Append the text in the file-like object into the final
259
 
        location.
260
 
        """
261
 
        try:
262
 
            path = self._abspath(relpath)
263
 
            fout = self._sftp.file(path, 'ab')
264
 
            self._pump(f, fout)
265
 
        except (IOError, paramiko.SSHException), x:
266
 
            raise SFTPTransportError('Unable to append file %r' % (path,), x)
267
 
 
268
 
    def copy(self, rel_from, rel_to):
269
 
        """Copy the item at rel_from to the location at rel_to"""
270
 
        path_from = self._abspath(rel_from)
271
 
        path_to = self._abspath(rel_to)
272
 
        try:
273
 
            fin = self._sftp.file(path_from, 'rb')
274
 
            try:
275
 
                fout = self._sftp.file(path_to, 'wb')
276
 
                try:
277
 
                    fout.set_pipelined(True)
278
 
                    self._pump(fin, fout)
279
 
                finally:
280
 
                    fout.close()
281
 
            finally:
282
 
                fin.close()
283
 
        except (IOError, paramiko.SSHException), x:
284
 
            raise SFTPTransportError('Unable to copy %r to %r' % (path_from, path_to), x)
285
 
 
286
 
    def move(self, rel_from, rel_to):
287
 
        """Move the item at rel_from to the location at rel_to"""
288
 
        path_from = self._abspath(rel_from)
289
 
        path_to = self._abspath(rel_to)
290
 
        try:
291
 
            self._sftp.rename(path_from, path_to)
292
 
        except (IOError, paramiko.SSHException), x:
293
 
            raise SFTPTransportError('Unable to move %r to %r' % (path_from, path_to), x)
294
 
 
295
 
    def delete(self, relpath):
296
 
        """Delete the item at relpath"""
297
 
        path = self._abspath(relpath)
298
 
        try:
299
 
            self._sftp.remove(path)
300
 
        except (IOError, paramiko.SSHException), x:
301
 
            raise SFTPTransportError('Unable to delete %r' % (path,), x)
302
 
            
303
 
    def listable(self):
304
 
        """Return True if this store supports listing."""
305
 
        return True
306
 
 
307
 
    def list_dir(self, relpath):
308
 
        """
309
 
        Return a list of all files at the given location.
310
 
        """
311
 
        # does anything actually use this?
312
 
        path = self._abspath(relpath)
313
 
        try:
314
 
            return self._sftp.listdir(path)
315
 
        except (IOError, paramiko.SSHException), x:
316
 
            raise SFTPTransportError('Unable to list folder %r' % (path,), x)
317
 
 
318
 
    def stat(self, relpath):
319
 
        """Return the stat information for a file."""
320
 
        path = self._abspath(relpath)
321
 
        try:
322
 
            return self._sftp.stat(path)
323
 
        except (IOError, paramiko.SSHException), x:
324
 
            raise SFTPTransportError('Unable to stat %r' % (path,), x)
325
 
 
326
 
    def lock_read(self, relpath):
327
 
        """
328
 
        Lock the given file for shared (read) access.
329
 
        :return: A lock object, which should be passed to Transport.unlock()
330
 
        """
331
 
        # FIXME: there should be something clever i can do here...
332
 
        class BogusLock(object):
333
 
            def __init__(self, path):
334
 
                self.path = path
335
 
            def unlock(self):
336
 
                pass
337
 
        return BogusLock(relpath)
338
 
 
339
 
    def lock_write(self, relpath):
340
 
        """
341
 
        Lock the given file for exclusive (write) access.
342
 
        WARNING: many transports do not support this, so trying avoid using it
343
 
 
344
 
        :return: A lock object, which should be passed to Transport.unlock()
345
 
        """
346
 
        # FIXME: there should be something clever i can do here...
347
 
        class BogusLock(object):
348
 
            def __init__(self, path):
349
 
                self.path = path
350
 
            def unlock(self):
351
 
                pass
352
 
        return BogusLock(relpath)
353
 
 
354
 
 
355
 
    def _unparse_url(self, path=None):
356
 
        if path is None:
357
 
            path = self._path
358
 
        if self._port == 22:
359
 
            return 'sftp://%s@%s%s' % (self._username, self._host, path)
360
 
        return 'sftp://%s@%s:%d%s' % (self._username, self._host, self._port, path)
361
 
 
362
 
    def _parse_url(self, url):
363
 
        assert url[:7] == 'sftp://'
364
 
        m = self._url_matcher.match(url)
365
 
        if m is None:
366
 
            raise SFTPTransportError('Unable to parse SFTP URL %r' % (url,))
367
 
        self._username, self._host, self._port, self._path = m.groups()
368
 
        if self._username is None:
369
 
            self._username = getpass.getuser()
370
 
        else:
371
 
            self._username = self._username[:-1]
372
 
        if self._port is None:
373
 
            self._port = 22
374
 
        else:
375
 
            self._port = int(self._port[1:])
376
 
        if (self._path is None) or (self._path == ''):
377
 
            self._path = '/'
378
 
 
379
 
    def _sftp_connect(self):
380
 
        global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
381
 
        
382
 
        load_host_keys()
383
 
        
384
 
        try:
385
 
            t = paramiko.Transport((self._host, self._port))
386
 
            t.start_client()
387
 
        except paramiko.SSHException:
388
 
            raise SFTPTransportError('Unable to reach SSH host %s:%d' % (self._host, self._port))
389
 
            
390
 
        server_key = t.get_remote_server_key()
391
 
        server_key_hex = paramiko.util.hexify(server_key.get_fingerprint())
392
 
        keytype = server_key.get_name()
393
 
        if SYSTEM_HOSTKEYS.has_key(self._host) and SYSTEM_HOSTKEYS[self._host].has_key(keytype):
394
 
            our_server_key = SYSTEM_HOSTKEYS[self._host][keytype]
395
 
            our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
396
 
        elif BZR_HOSTKEYS.has_key(self._host) and BZR_HOSTKEYS[self._host].has_key(keytype):
397
 
            our_server_key = BZR_HOSTKEYS[self._host][keytype]
398
 
            our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
399
 
        else:
400
 
            warning('Adding %s host key for %s: %s' % (keytype, self._host, server_key_hex))
401
 
            if not BZR_HOSTKEYS.has_key(self._host):
402
 
                BZR_HOSTKEYS[self._host] = {}
403
 
            BZR_HOSTKEYS[self._host][keytype] = server_key
404
 
            our_server_key = server_key
405
 
            our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
406
 
            save_host_keys()
407
 
        if server_key != our_server_key:
408
 
            filename1 = os.path.expanduser('~/.ssh/known_hosts')
409
 
            filename2 = os.path.join(config_dir(), 'ssh_host_keys')
410
 
            raise SFTPTransportError('Host keys for %s do not match!  %s != %s' % \
411
 
                (self._host, our_server_key_hex, server_key_hex),
412
 
                ['Try editing %s or %s' % (filename1, filename2)])
413
 
 
414
 
        self._sftp_auth(t, self._username, self._host)
415
 
        
416
 
        try:
417
 
            self._sftp = t.open_sftp_client()
418
 
        except paramiko.SSHException:
419
 
            raise BzrError('Unable to find path %s on SFTP server %s' % \
420
 
                (self._path, self._host))
421
 
 
422
 
    def _sftp_auth(self, transport, username, host):
423
 
        agent = paramiko.Agent()
424
 
        for key in agent.get_keys():
425
 
            mutter('Trying SSH agent key %s' % paramiko.util.hexify(key.get_fingerprint()))
426
 
            try:
427
 
                transport.auth_publickey(self._username, key)
428
 
                return
429
 
            except paramiko.SSHException, e:
430
 
                pass
431
 
        
432
 
        # okay, try finding id_rsa or id_dss?  (posix only)
433
 
        if self._try_pkey_auth(transport, paramiko.RSAKey, 'id_rsa'):
434
 
            return
435
 
        if self._try_pkey_auth(transport, paramiko.DSSKey, 'id_dsa'):
436
 
            return
437
 
 
438
 
        # give up and ask for a password
439
 
        password = getpass.getpass('SSH %s@%s password: ' % (self._username, self._host))
440
 
        try:
441
 
            transport.auth_password(self._username, password)
442
 
        except paramiko.SSHException:
443
 
            raise SFTPTransportError('Unable to authenticate to SSH host as %s@%s' % \
444
 
                (self._username, self._host))
445
 
 
446
 
    def _try_pkey_auth(self, transport, pkey_class, filename):
447
 
        filename = os.path.expanduser('~/.ssh/' + filename)
448
 
        try:
449
 
            key = pkey_class.from_private_key_file(filename)
450
 
            transport.auth_publickey(self._username, key)
451
 
            return True
452
 
        except paramiko.PasswordRequiredException:
453
 
            password = getpass.getpass('SSH %s password: ' % (os.path.basename(filename),))
454
 
            try:
455
 
                key = pkey_class.from_private_key_file(filename, password)
456
 
                transport.auth_publickey(self._username, key)
457
 
                return True
458
 
            except paramiko.SSHException:
459
 
                mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
460
 
        except paramiko.SSHException:
461
 
            mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
462
 
        except IOError:
463
 
            pass
464
 
        return False