1
# Copyright (C) 2005 Robey Pointer <robey@lag.net>, Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18
Implementation of Transport over SFTP, using paramiko.
28
from bzrlib.errors import TransportNotPossible, NoSuchFile, NonRelativePath, TransportError
29
from bzrlib.config import config_dir
30
from bzrlib.trace import mutter, warning, error
31
from bzrlib.transport import Transport, register_transport
36
error('The SFTP plugin requires paramiko.')
45
Load system host keys (probably doesn't work on windows) and any
46
"discovered" keys from previous sessions.
48
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
50
SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
52
mutter('failed to load system host keys: ' + str(e))
53
bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
55
BZR_HOSTKEYS = paramiko.util.load_host_keys(bzr_hostkey_path)
57
mutter('failed to load bzr host keys: ' + str(e))
62
Save "discovered" host keys in $(config)/ssh_host_keys/.
64
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
65
bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
66
if not os.path.isdir(config_dir()):
67
os.mkdir(config_dir())
69
f = open(bzr_hostkey_path, 'w')
70
f.write('# SSH host keys collected by bzr\n')
71
for hostname, keys in BZR_HOSTKEYS.iteritems():
72
for keytype, key in keys.iteritems():
73
f.write('%s %s %s\n' % (hostname, keytype, key.get_base64()))
76
mutter('failed to save bzr host keys: ' + str(e))
80
class SFTPTransportError (TransportError):
84
class SFTPTransport (Transport):
86
Transport implementation for SFTP access.
89
_url_matcher = re.compile(r'^sftp://([^@]*@)?(.*?)(:\d+)?(/.*)?$')
91
def __init__(self, base, clone_from=None):
92
assert base.startswith('sftp://')
93
super(SFTPTransport, self).__init__(base)
95
if clone_from is None:
98
# use the same ssh connection, etc
99
self._sftp = clone_from._sftp
100
# super saves 'self.base'
102
def should_cache(self):
104
Return True if the data pulled across should be cached locally.
108
def clone(self, offset=None):
110
Return a new SFTPTransport with root at self.base + offset.
111
We share the same SFTP session between such transports, because it's
112
fairly expensive to set them up.
115
return SFTPTransport(self.base, self)
117
return SFTPTransport(self.abspath(offset), self)
119
def abspath(self, relpath):
121
Return the full url to the given relative path.
123
@param relpath: the relative path or path components
124
@type relpath: str or list
126
return self._unparse_url(self._abspath(relpath))
128
def _abspath(self, relpath):
129
"""Return the absolute path segment without the SFTP URL."""
130
# FIXME: share the common code across transports
131
assert isinstance(relpath, basestring)
132
relpath = [urllib.unquote(relpath)]
133
basepath = self._path.split('/')
134
if len(basepath) > 0 and basepath[-1] == '':
135
basepath = basepath[:-1]
139
if len(basepath) == 0:
140
# In most filesystems, a request for the parent
141
# of root, just returns root.
149
path = '/'.join(basepath)
154
def relpath(self, abspath):
155
# FIXME: this is identical to HttpTransport -- share it
156
if not abspath.startswith(self.base):
157
raise NonRelativePath('path %r is not under base URL %r'
158
% (abspath, self.base))
160
return abspath[pl:].lstrip('/')
162
def has(self, relpath):
164
Does the target location exist?
167
self._sftp.stat(self._abspath(relpath))
172
def get(self, relpath, decode=False):
174
Get the file at the given relative path.
176
:param relpath: The relative path to the file
179
path = self._abspath(relpath)
180
return self._sftp.file(path)
181
except (IOError, paramiko.SSHException), x:
182
raise NoSuchFile('Error retrieving %s: %s' % (path, str(x)), x)
184
def get_partial(self, relpath, start, length=None):
186
Get just part of a file.
188
:param relpath: Path to the file, relative to base
189
:param start: The starting position to read from
190
:param length: The length to read. A length of None indicates
191
read to the end of the file.
192
:return: A file-like object containing at least the specified bytes.
193
Some implementations may return objects which can be read
194
past this length, but this is not guaranteed.
196
f = self.get(relpath)
200
def put(self, relpath, f):
202
Copy the file-like or string object into the location.
204
:param relpath: Location to put the contents, relative to base.
205
:param f: File-like or string object.
207
# FIXME: should do something atomic or locking here, this is unsafe
209
path = self._abspath(relpath)
210
fout = self._sftp.file(path, 'wb')
211
except (IOError, paramiko.SSHException), x:
212
raise SFTPTransportError('Unable to write file %r' % (path,), x)
218
def iter_files_recursive(self):
219
"""Walk the relative paths of all files in this transport."""
220
queue = list(self.list_dir('.'))
222
relpath = queue.pop(0)
223
st = self.stat(relpath)
224
if stat.S_ISDIR(st.st_mode):
225
for i, basename in enumerate(self.list_dir(relpath)):
226
queue.insert(i, relpath+'/'+basename)
230
def mkdir(self, relpath):
231
"""Create a directory at the given path."""
233
path = self._abspath(relpath)
234
self._sftp.mkdir(path)
235
except (IOError, paramiko.SSHException), x:
236
raise SFTPTransportError('Unable to mkdir %r' % (path,), x)
238
def append(self, relpath, f):
240
Append the text in the file-like object into the final
244
path = self._abspath(relpath)
245
fout = self._sftp.file(path, 'ab')
247
except (IOError, paramiko.SSHException), x:
248
raise SFTPTransportError('Unable to append file %r' % (path,), x)
250
def copy(self, rel_from, rel_to):
251
"""Copy the item at rel_from to the location at rel_to"""
252
path_from = self._abspath(rel_from)
253
path_to = self._abspath(rel_to)
255
fin = self._sftp.file(path_from, 'rb')
257
fout = self._sftp.file(path_to, 'wb')
259
fout.set_pipelined(True)
260
self._pump(fin, fout)
265
except (IOError, paramiko.SSHException), x:
266
raise SFTPTransportError('Unable to copy %r to %r' % (path_from, path_to), x)
268
def move(self, rel_from, rel_to):
269
"""Move 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)
273
self._sftp.rename(path_from, path_to)
274
except (IOError, paramiko.SSHException), x:
275
raise SFTPTransportError('Unable to move %r to %r' % (path_from, path_to), x)
277
def delete(self, relpath):
278
"""Delete the item at relpath"""
279
path = self._abspath(relpath)
281
self._sftp.remove(path)
282
except (IOError, paramiko.SSHException), x:
283
raise SFTPTransportError('Unable to delete %r' % (path,), x)
286
"""Return True if this store supports listing."""
289
def list_dir(self, relpath):
291
Return a list of all files at the given location.
293
# does anything actually use this?
294
path = self._abspath(relpath)
296
return self._sftp.listdir(path)
297
except (IOError, paramiko.SSHException), x:
298
raise SFTPTransportError('Unable to list folder %r' % (path,), x)
300
def stat(self, relpath):
301
"""Return the stat information for a file."""
302
path = self._abspath(relpath)
304
return self._sftp.stat(path)
305
except (IOError, paramiko.SSHException), x:
306
raise SFTPTransportError('Unable to stat %r' % (path,), x)
308
def lock_read(self, relpath):
310
Lock the given file for shared (read) access.
311
:return: A lock object, which should be passed to Transport.unlock()
313
# FIXME: there should be something clever i can do here...
314
class BogusLock(object):
315
def __init__(self, path):
319
return BogusLock(relpath)
321
def lock_write(self, relpath):
323
Lock the given file for exclusive (write) access.
324
WARNING: many transports do not support this, so trying avoid using it
326
:return: A lock object, which should be passed to Transport.unlock()
328
# FIXME: there should be something clever i can do here...
329
class BogusLock(object):
330
def __init__(self, path):
334
return BogusLock(relpath)
337
def _unparse_url(self, path=None):
341
return 'sftp://%s@%s%s' % (self._username, self._host, path)
342
return 'sftp://%s@%s:%d%s' % (self._username, self._host, self._port, path)
344
def _parse_url(self, url):
345
assert url[:7] == 'sftp://'
346
m = self._url_matcher.match(url)
348
raise SFTPTransportError('Unable to parse SFTP URL %r' % (url,))
349
self._username, self._host, self._port, self._path = m.groups()
350
if self._username is None:
351
self._username = getpass.getuser()
353
self._username = self._username[:-1]
354
if self._port is None:
357
self._port = int(self._port[1:])
358
if (self._path is None) or (self._path == ''):
361
def _sftp_connect(self):
362
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
367
t = paramiko.Transport((self._host, self._port))
369
except paramiko.SSHException:
370
raise SFTPTransportError('Unable to reach SSH host %s:%d' % (self._host, self._port))
372
server_key = t.get_remote_server_key()
373
server_key_hex = paramiko.util.hexify(server_key.get_fingerprint())
374
keytype = server_key.get_name()
375
if SYSTEM_HOSTKEYS.has_key(self._host) and SYSTEM_HOSTKEYS[self._host].has_key(keytype):
376
our_server_key = SYSTEM_HOSTKEYS[self._host][keytype]
377
our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
378
elif BZR_HOSTKEYS.has_key(self._host) and BZR_HOSTKEYS[self._host].has_key(keytype):
379
our_server_key = BZR_HOSTKEYS[self._host][keytype]
380
our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
382
warning('Adding %s host key for %s: %s' % (keytype, self._host, server_key_hex))
383
if not BZR_HOSTKEYS.has_key(self._host):
384
BZR_HOSTKEYS[self._host] = {}
385
BZR_HOSTKEYS[self._host][keytype] = server_key
386
our_server_key = server_key
387
our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
389
if server_key != our_server_key:
390
filename1 = os.path.expanduser('~/.ssh/known_hosts')
391
filename2 = os.path.join(config_dir(), 'ssh_host_keys')
392
raise SFTPTransportError('Host keys for %s do not match! %s != %s' % \
393
(self._host, our_server_key_hex, server_key_hex),
394
['Try editing %s or %s' % (filename1, filename2)])
396
self._sftp_auth(t, self._username, self._host)
399
self._sftp = t.open_sftp_client()
400
except paramiko.SSHException:
401
raise BzrError('Unable to find path %s on SFTP server %s' % \
402
(self._path, self._host))
404
def _sftp_auth(self, transport, username, host):
405
agent = paramiko.Agent()
406
for key in agent.get_keys():
407
mutter('Trying SSH agent key %s' % paramiko.util.hexify(key.get_fingerprint()))
409
transport.auth_publickey(self._username, key)
411
except paramiko.SSHException, e:
414
# okay, try finding id_rsa or id_dss? (posix only)
415
if self._try_pkey_auth(transport, paramiko.RSAKey, 'id_rsa'):
417
if self._try_pkey_auth(transport, paramiko.DSSKey, 'id_dsa'):
420
# give up and ask for a password
421
password = getpass.getpass('SSH %s@%s password: ' % (self._username, self._host))
423
transport.auth_password(self._username, password)
424
except paramiko.SSHException:
425
raise SFTPTransportError('Unable to authenticate to SSH host as %s@%s' % \
426
(self._username, self._host))
428
def _try_pkey_auth(self, transport, pkey_class, filename):
429
filename = os.path.expanduser('~/.ssh/' + filename)
431
key = pkey_class.from_private_key_file(filename)
432
transport.auth_publickey(self._username, key)
434
except paramiko.PasswordRequiredException:
435
password = getpass.getpass('SSH %s password: ' % (os.path.basename(filename),))
437
key = pkey_class.from_private_key_file(filename, password)
438
transport.auth_publickey(self._username, key)
440
except paramiko.SSHException:
441
mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
442
except paramiko.SSHException:
443
mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
449
register_transport('sftp://', SFTPTransport)