1# Copyright (C) 2010 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 16# 17# Author: Mattias Eriksson 18 19"""Implementation of Transport over gio. 20 21Written by Mattias Eriksson <snaggen@acc.umu.se> based on the ftp transport. 22 23It provides the gio+XXX:// protocols where XXX is any of the protocols 24supported by gio. 25""" 26 27from io import BytesIO 28import os 29import random 30import stat 31import time 32from urllib.parse import ( 33 urlparse, 34 urlunparse, 35 ) 36 37from .. import ( 38 config, 39 errors, 40 osutils, 41 urlutils, 42 debug, 43 ui, 44 ) 45from ..trace import mutter 46from . import ( 47 FileStream, 48 ConnectedTransport, 49 _file_streams, 50 ) 51 52from ..tests.test_server import TestServer 53 54try: 55 import glib 56except ImportError as e: 57 raise errors.DependencyNotPresent('glib', e) 58try: 59 import gio 60except ImportError as e: 61 raise errors.DependencyNotPresent('gio', e) 62 63 64class GioLocalURLServer(TestServer): 65 """A pretend server for local transports, using file:// urls. 66 67 Of course no actual server is required to access the local filesystem, so 68 this just exists to tell the test code how to get to it. 69 """ 70 71 def start_server(self): 72 pass 73 74 def get_url(self): 75 """See Transport.Server.get_url.""" 76 return "gio+" + urlutils.local_path_to_url('') 77 78 79class GioFileStream(FileStream): 80 """A file stream object returned by open_write_stream. 81 82 This version uses GIO to perform writes. 83 """ 84 85 def __init__(self, transport, relpath): 86 FileStream.__init__(self, transport, relpath) 87 self.gio_file = transport._get_GIO(relpath) 88 self.stream = self.gio_file.create() 89 90 def _close(self): 91 self.stream.close() 92 93 def write(self, bytes): 94 try: 95 # Using pump_string_file seems to make things crash 96 osutils.pumpfile(BytesIO(bytes), self.stream) 97 except gio.Error as e: 98 # self.transport._translate_gio_error(e,self.relpath) 99 raise errors.BzrError(str(e)) 100 101 102class GioStatResult(object): 103 104 def __init__(self, f): 105 info = f.query_info('standard::size,standard::type') 106 self.st_size = info.get_size() 107 type = info.get_file_type() 108 if (type == gio.FILE_TYPE_REGULAR): 109 self.st_mode = stat.S_IFREG 110 elif type == gio.FILE_TYPE_DIRECTORY: 111 self.st_mode = stat.S_IFDIR 112 113 114class GioTransport(ConnectedTransport): 115 """This is the transport agent for gio+XXX:// access.""" 116 117 def __init__(self, base, _from_transport=None): 118 """Initialize the GIO transport and make sure the url is correct.""" 119 120 if not base.startswith('gio+'): 121 raise ValueError(base) 122 123 (scheme, netloc, path, params, query, fragment) = \ 124 urlparse(base[len('gio+'):], allow_fragments=False) 125 if '@' in netloc: 126 user, netloc = netloc.rsplit('@', 1) 127 # Seems it is not possible to list supported backends for GIO 128 # so a hardcoded list it is then. 129 gio_backends = ['dav', 'file', 'ftp', 'obex', 'sftp', 'ssh', 'smb'] 130 if scheme not in gio_backends: 131 raise urlutils.InvalidURL(base, 132 extra="GIO support is only available for " + 133 ', '.join(gio_backends)) 134 135 # Remove the username and password from the url we send to GIO 136 # by rebuilding the url again. 137 u = (scheme, netloc, path, '', '', '') 138 self.url = urlunparse(u) 139 140 # And finally initialize super 141 super(GioTransport, self).__init__(base, 142 _from_transport=_from_transport) 143 144 def _relpath_to_url(self, relpath): 145 full_url = urlutils.join(self.url, relpath) 146 if isinstance(full_url, str): 147 raise urlutils.InvalidURL(full_url) 148 return full_url 149 150 def _get_GIO(self, relpath): 151 """Return the ftplib.GIO instance for this object.""" 152 # Ensures that a connection is established 153 connection = self._get_connection() 154 if connection is None: 155 # First connection ever 156 connection, credentials = self._create_connection() 157 self._set_connection(connection, credentials) 158 fileurl = self._relpath_to_url(relpath) 159 file = gio.File(fileurl) 160 return file 161 162 def _auth_cb(self, op, message, default_user, default_domain, flags): 163 # really use breezy.auth get_password for this 164 # or possibly better gnome-keyring? 165 auth = config.AuthenticationConfig() 166 parsed_url = urlutils.URL.from_string(self.url) 167 user = None 168 if (flags & gio.ASK_PASSWORD_NEED_USERNAME and 169 flags & gio.ASK_PASSWORD_NEED_DOMAIN): 170 prompt = (u'%s' % (parsed_url.scheme.upper(),) + 171 u' %(host)s DOMAIN\\username') 172 user_and_domain = auth.get_user(parsed_url.scheme, 173 parsed_url.host, port=parsed_url.port, ask=True, 174 prompt=prompt) 175 (domain, user) = user_and_domain.split('\\', 1) 176 op.set_username(user) 177 op.set_domain(domain) 178 elif flags & gio.ASK_PASSWORD_NEED_USERNAME: 179 user = auth.get_user(parsed_url.scheme, parsed_url.host, 180 port=parsed_url.port, ask=True) 181 op.set_username(user) 182 elif flags & gio.ASK_PASSWORD_NEED_DOMAIN: 183 # Don't know how common this case is, but anyway 184 # a DOMAIN and a username prompt should be the 185 # same so I will missuse the ui_factory get_username 186 # a little bit here. 187 prompt = (u'%s' % (parsed_url.scheme.upper(),) + 188 u' %(host)s DOMAIN') 189 domain = ui.ui_factory.get_username(prompt=prompt) 190 op.set_domain(domain) 191 192 if flags & gio.ASK_PASSWORD_NEED_PASSWORD: 193 if user is None: 194 user = op.get_username() 195 password = auth.get_password(parsed_url.scheme, parsed_url.host, 196 user, port=parsed_url.port) 197 op.set_password(password) 198 op.reply(gio.MOUNT_OPERATION_HANDLED) 199 200 def _mount_done_cb(self, obj, res): 201 try: 202 obj.mount_enclosing_volume_finish(res) 203 self.loop.quit() 204 except gio.Error as e: 205 self.loop.quit() 206 raise errors.BzrError( 207 "Failed to mount the given location: " + str(e)) 208 209 def _create_connection(self, credentials=None): 210 if credentials is None: 211 user, password = self._parsed_url.user, self._parsed_url.password 212 else: 213 user, password = credentials 214 215 try: 216 connection = gio.File(self.url) 217 mount = None 218 try: 219 mount = connection.find_enclosing_mount() 220 except gio.Error as e: 221 if (e.code == gio.ERROR_NOT_MOUNTED): 222 self.loop = glib.MainLoop() 223 ui.ui_factory.show_message('Mounting %s using GIO' % 224 self.url) 225 op = gio.MountOperation() 226 if user: 227 op.set_username(user) 228 if password: 229 op.set_password(password) 230 op.connect('ask-password', self._auth_cb) 231 m = connection.mount_enclosing_volume(op, 232 self._mount_done_cb) 233 self.loop.run() 234 except gio.Error as e: 235 raise errors.TransportError(msg="Error setting up connection:" 236 " %s" % str(e), orig_error=e) 237 return connection, (user, password) 238 239 def disconnect(self): 240 # FIXME: Nothing seems to be necessary here, which sounds a bit strange 241 # -- vila 20100601 242 pass 243 244 def _reconnect(self): 245 # FIXME: This doesn't seem to be used -- vila 20100601 246 """Create a new connection with the previously used credentials""" 247 credentials = self._get_credentials() 248 connection, credentials = self._create_connection(credentials) 249 self._set_connection(connection, credentials) 250 251 def _remote_path(self, relpath): 252 return self._parsed_url.clone(relpath).path 253 254 def has(self, relpath): 255 """Does the target location exist?""" 256 try: 257 if 'gio' in debug.debug_flags: 258 mutter('GIO has check: %s' % relpath) 259 f = self._get_GIO(relpath) 260 st = GioStatResult(f) 261 if stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode): 262 return True 263 return False 264 except gio.Error as e: 265 if e.code == gio.ERROR_NOT_FOUND: 266 return False 267 else: 268 self._translate_gio_error(e, relpath) 269 270 def get(self, relpath, retries=0): 271 """Get the file at the given relative path. 272 273 :param relpath: The relative path to the file 274 :param retries: Number of retries after temporary failures so far 275 for this operation. 276 277 We're meant to return a file-like object which bzr will 278 then read from. For now we do this via the magic of BytesIO 279 """ 280 try: 281 if 'gio' in debug.debug_flags: 282 mutter("GIO get: %s" % relpath) 283 f = self._get_GIO(relpath) 284 fin = f.read() 285 buf = fin.read() 286 fin.close() 287 return BytesIO(buf) 288 except gio.Error as e: 289 # If we get a not mounted here it might mean 290 # that a bad path has been entered (or that mount failed) 291 if (e.code == gio.ERROR_NOT_MOUNTED): 292 raise errors.PathError(relpath, 293 extra='Failed to get file, make sure the path is correct. ' 294 + str(e)) 295 else: 296 self._translate_gio_error(e, relpath) 297 298 def put_file(self, relpath, fp, mode=None): 299 """Copy the file-like object into the location. 300 301 :param relpath: Location to put the contents, relative to base. 302 :param fp: File-like or string object. 303 """ 304 if 'gio' in debug.debug_flags: 305 mutter("GIO put_file %s" % relpath) 306 tmppath = '%s.tmp.%.9f.%d.%d' % (relpath, time.time(), 307 os.getpid(), random.randint(0, 0x7FFFFFFF)) 308 f = None 309 fout = None 310 try: 311 closed = True 312 try: 313 f = self._get_GIO(tmppath) 314 fout = f.create() 315 closed = False 316 length = self._pump(fp, fout) 317 fout.close() 318 closed = True 319 self.stat(tmppath) 320 dest = self._get_GIO(relpath) 321 f.move(dest, flags=gio.FILE_COPY_OVERWRITE) 322 f = None 323 if mode is not None: 324 self._setmode(relpath, mode) 325 return length 326 except gio.Error as e: 327 self._translate_gio_error(e, relpath) 328 finally: 329 if not closed and fout is not None: 330 fout.close() 331 if f is not None and f.query_exists(): 332 f.delete() 333 334 def mkdir(self, relpath, mode=None): 335 """Create a directory at the given path.""" 336 try: 337 if 'gio' in debug.debug_flags: 338 mutter("GIO mkdir: %s" % relpath) 339 f = self._get_GIO(relpath) 340 f.make_directory() 341 self._setmode(relpath, mode) 342 except gio.Error as e: 343 self._translate_gio_error(e, relpath) 344 345 def open_write_stream(self, relpath, mode=None): 346 """See Transport.open_write_stream.""" 347 if 'gio' in debug.debug_flags: 348 mutter("GIO open_write_stream %s" % relpath) 349 if mode is not None: 350 self._setmode(relpath, mode) 351 result = GioFileStream(self, relpath) 352 _file_streams[self.abspath(relpath)] = result 353 return result 354 355 def recommended_page_size(self): 356 """See Transport.recommended_page_size(). 357 358 For FTP we suggest a large page size to reduce the overhead 359 introduced by latency. 360 """ 361 if 'gio' in debug.debug_flags: 362 mutter("GIO recommended_page") 363 return 64 * 1024 364 365 def rmdir(self, relpath): 366 """Delete the directory at rel_path""" 367 try: 368 if 'gio' in debug.debug_flags: 369 mutter("GIO rmdir %s" % relpath) 370 st = self.stat(relpath) 371 if stat.S_ISDIR(st.st_mode): 372 f = self._get_GIO(relpath) 373 f.delete() 374 else: 375 raise errors.NotADirectory(relpath) 376 except gio.Error as e: 377 self._translate_gio_error(e, relpath) 378 except errors.NotADirectory as e: 379 # just pass it forward 380 raise e 381 except Exception as e: 382 mutter('failed to rmdir %s: %s' % (relpath, e)) 383 raise errors.PathError(relpath) 384 385 def append_file(self, relpath, file, mode=None): 386 """Append the text in the file-like object into the final 387 location. 388 """ 389 # GIO append_to seems not to append but to truncate 390 # Work around this. 391 if 'gio' in debug.debug_flags: 392 mutter("GIO append_file: %s" % relpath) 393 tmppath = '%s.tmp.%.9f.%d.%d' % (relpath, time.time(), 394 os.getpid(), random.randint(0, 0x7FFFFFFF)) 395 try: 396 result = 0 397 fo = self._get_GIO(tmppath) 398 fi = self._get_GIO(relpath) 399 fout = fo.create() 400 try: 401 info = GioStatResult(fi) 402 result = info.st_size 403 fin = fi.read() 404 self._pump(fin, fout) 405 fin.close() 406 # This separate except is to catch and ignore the 407 # gio.ERROR_NOT_FOUND for the already existing file. 408 # It is valid to open a non-existing file for append. 409 # This is caused by the broken gio append_to... 410 except gio.Error as e: 411 if e.code != gio.ERROR_NOT_FOUND: 412 self._translate_gio_error(e, relpath) 413 length = self._pump(file, fout) 414 fout.close() 415 info = GioStatResult(fo) 416 if info.st_size != result + length: 417 raise errors.BzrError("Failed to append size after " 418 "(%d) is not original (%d) + written (%d) total (%d)" % 419 (info.st_size, result, length, result + length)) 420 fo.move(fi, flags=gio.FILE_COPY_OVERWRITE) 421 return result 422 except gio.Error as e: 423 self._translate_gio_error(e, relpath) 424 425 def _setmode(self, relpath, mode): 426 """Set permissions on a path. 427 428 Only set permissions on Unix systems 429 """ 430 if 'gio' in debug.debug_flags: 431 mutter("GIO _setmode %s" % relpath) 432 if mode: 433 try: 434 f = self._get_GIO(relpath) 435 f.set_attribute_uint32(gio.FILE_ATTRIBUTE_UNIX_MODE, mode) 436 except gio.Error as e: 437 if e.code == gio.ERROR_NOT_SUPPORTED: 438 # Command probably not available on this server 439 mutter("GIO Could not set permissions to %s on %s. %s", 440 oct(mode), self._remote_path(relpath), str(e)) 441 else: 442 self._translate_gio_error(e, relpath) 443 444 def rename(self, rel_from, rel_to): 445 """Rename without special overwriting""" 446 try: 447 if 'gio' in debug.debug_flags: 448 mutter("GIO move (rename): %s => %s", rel_from, rel_to) 449 f = self._get_GIO(rel_from) 450 t = self._get_GIO(rel_to) 451 f.move(t) 452 except gio.Error as e: 453 self._translate_gio_error(e, rel_from) 454 455 def move(self, rel_from, rel_to): 456 """Move the item at rel_from to the location at rel_to""" 457 try: 458 if 'gio' in debug.debug_flags: 459 mutter("GIO move: %s => %s", rel_from, rel_to) 460 f = self._get_GIO(rel_from) 461 t = self._get_GIO(rel_to) 462 f.move(t, flags=gio.FILE_COPY_OVERWRITE) 463 except gio.Error as e: 464 self._translate_gio_error(e, relfrom) 465 466 def delete(self, relpath): 467 """Delete the item at relpath""" 468 try: 469 if 'gio' in debug.debug_flags: 470 mutter("GIO delete: %s", relpath) 471 f = self._get_GIO(relpath) 472 f.delete() 473 except gio.Error as e: 474 self._translate_gio_error(e, relpath) 475 476 def external_url(self): 477 """See breezy.transport.Transport.external_url.""" 478 if 'gio' in debug.debug_flags: 479 mutter("GIO external_url", self.base) 480 # GIO external url 481 return self.base 482 483 def listable(self): 484 """See Transport.listable.""" 485 if 'gio' in debug.debug_flags: 486 mutter("GIO listable") 487 return True 488 489 def list_dir(self, relpath): 490 """See Transport.list_dir.""" 491 if 'gio' in debug.debug_flags: 492 mutter("GIO list_dir") 493 try: 494 entries = [] 495 f = self._get_GIO(relpath) 496 children = f.enumerate_children(gio.FILE_ATTRIBUTE_STANDARD_NAME) 497 for child in children: 498 entries.append(urlutils.escape(child.get_name())) 499 return entries 500 except gio.Error as e: 501 self._translate_gio_error(e, relpath) 502 503 def iter_files_recursive(self): 504 """See Transport.iter_files_recursive. 505 506 This is cargo-culted from the SFTP transport""" 507 if 'gio' in debug.debug_flags: 508 mutter("GIO iter_files_recursive") 509 queue = list(self.list_dir(".")) 510 while queue: 511 relpath = queue.pop(0) 512 st = self.stat(relpath) 513 if stat.S_ISDIR(st.st_mode): 514 for i, basename in enumerate(self.list_dir(relpath)): 515 queue.insert(i, relpath + "/" + basename) 516 else: 517 yield relpath 518 519 def stat(self, relpath): 520 """Return the stat information for a file.""" 521 try: 522 if 'gio' in debug.debug_flags: 523 mutter("GIO stat: %s", relpath) 524 f = self._get_GIO(relpath) 525 return GioStatResult(f) 526 except gio.Error as e: 527 self._translate_gio_error(e, relpath, extra='error w/ stat') 528 529 def lock_read(self, relpath): 530 """Lock the given file for shared (read) access. 531 :return: A lock object, which should be passed to Transport.unlock() 532 """ 533 if 'gio' in debug.debug_flags: 534 mutter("GIO lock_read", relpath) 535 536 class BogusLock(object): 537 # The old RemoteBranch ignore lock for reading, so we will 538 # continue that tradition and return a bogus lock object. 539 540 def __init__(self, path): 541 self.path = path 542 543 def unlock(self): 544 pass 545 546 return BogusLock(relpath) 547 548 def lock_write(self, relpath): 549 """Lock the given file for exclusive (write) access. 550 WARNING: many transports do not support this, so trying avoid using it 551 552 :return: A lock object, whichshould be passed to Transport.unlock() 553 """ 554 if 'gio' in debug.debug_flags: 555 mutter("GIO lock_write", relpath) 556 return self.lock_read(relpath) 557 558 def _translate_gio_error(self, err, path, extra=None): 559 if 'gio' in debug.debug_flags: 560 mutter("GIO Error: %s %s" % (str(err), path)) 561 if extra is None: 562 extra = str(err) 563 if err.code == gio.ERROR_NOT_FOUND: 564 raise errors.NoSuchFile(path, extra=extra) 565 elif err.code == gio.ERROR_EXISTS: 566 raise errors.FileExists(path, extra=extra) 567 elif err.code == gio.ERROR_NOT_DIRECTORY: 568 raise errors.NotADirectory(path, extra=extra) 569 elif err.code == gio.ERROR_NOT_EMPTY: 570 raise errors.DirectoryNotEmpty(path, extra=extra) 571 elif err.code == gio.ERROR_BUSY: 572 raise errors.ResourceBusy(path, extra=extra) 573 elif err.code == gio.ERROR_PERMISSION_DENIED: 574 raise errors.PermissionDenied(path, extra=extra) 575 elif err.code == gio.ERROR_HOST_NOT_FOUND: 576 raise errors.PathError(path, extra=extra) 577 elif err.code == gio.ERROR_IS_DIRECTORY: 578 raise errors.PathError(path, extra=extra) 579 else: 580 mutter('unable to understand error for path: %s: %s', path, err) 581 raise errors.PathError(path, 582 extra="Unhandled gio error: " + str(err)) 583 584 585def get_test_permutations(): 586 """Return the permutations to be used in testing.""" 587 from breezy.tests import test_server 588 return [(GioTransport, GioLocalURLServer)] 589