~bzr-pqm/bzr/bzr.dev

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