~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/sftp.py

Lalos fix to diff -r x..y ../path/to/branch

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