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