1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this file,
3# You can obtain one at http://mozilla.org/MPL/2.0/.
4
5
6"""
7add permissions to the profile
8"""
9
10from __future__ import absolute_import
11
12import codecs
13import os
14import sqlite3
15
16from six import string_types
17from six.moves.urllib import parse
18import six
19
20__all__ = [
21    "MissingPrimaryLocationError",
22    "MultiplePrimaryLocationsError",
23    "DEFAULT_PORTS",
24    "DuplicateLocationError",
25    "BadPortLocationError",
26    "LocationsSyntaxError",
27    "Location",
28    "ServerLocations",
29    "Permissions",
30]
31
32# http://hg.mozilla.org/mozilla-central/file/b871dfb2186f/build/automation.py.in#l28
33DEFAULT_PORTS = {"http": "8888", "https": "4443", "ws": "4443", "wss": "4443"}
34
35
36class LocationError(Exception):
37    """Signifies an improperly formed location."""
38
39    def __str__(self):
40        s = "Bad location"
41        m = str(Exception.__str__(self))
42        if m:
43            s += ": %s" % m
44        return s
45
46
47class MissingPrimaryLocationError(LocationError):
48    """No primary location defined in locations file."""
49
50    def __init__(self):
51        LocationError.__init__(self, "missing primary location")
52
53
54class MultiplePrimaryLocationsError(LocationError):
55    """More than one primary location defined."""
56
57    def __init__(self):
58        LocationError.__init__(self, "multiple primary locations")
59
60
61class DuplicateLocationError(LocationError):
62    """Same location defined twice."""
63
64    def __init__(self, url):
65        LocationError.__init__(self, "duplicate location: %s" % url)
66
67
68class BadPortLocationError(LocationError):
69    """Location has invalid port value."""
70
71    def __init__(self, given_port):
72        LocationError.__init__(self, "bad value for port: %s" % given_port)
73
74
75class LocationsSyntaxError(Exception):
76    """Signifies a syntax error on a particular line in server-locations.txt."""
77
78    def __init__(self, lineno, err=None):
79        self.err = err
80        self.lineno = lineno
81
82    def __str__(self):
83        s = "Syntax error on line %s" % self.lineno
84        if self.err:
85            s += ": %s." % self.err
86        else:
87            s += "."
88        return s
89
90
91class Location(object):
92    """Represents a location line in server-locations.txt."""
93
94    attrs = ("scheme", "host", "port")
95
96    def __init__(self, scheme, host, port, options):
97        for attr in self.attrs:
98            setattr(self, attr, locals()[attr])
99        self.options = options
100        try:
101            int(self.port)
102        except ValueError:
103            raise BadPortLocationError(self.port)
104
105    def isEqual(self, location):
106        """compare scheme://host:port, but ignore options"""
107        return len(
108            [i for i in self.attrs if getattr(self, i) == getattr(location, i)]
109        ) == len(self.attrs)
110
111    __eq__ = isEqual
112
113    def __hash__(self):
114        # pylint --py3k: W1641
115        return hash(tuple(getattr(attr) for attr in self.attrs))
116
117    def url(self):
118        return "%s://%s:%s" % (self.scheme, self.host, self.port)
119
120    def __str__(self):
121        return "%s  %s" % (self.url(), ",".join(self.options))
122
123
124class ServerLocations(object):
125    """Iterable collection of locations.
126    Use provided functions to add new locations, rather that manipulating
127    _locations directly, in order to check for errors and to ensure the
128    callback is called, if given.
129    """
130
131    def __init__(self, filename=None, add_callback=None):
132        self.add_callback = add_callback
133        self._locations = []
134        self.hasPrimary = False
135        if filename:
136            self.read(filename)
137
138    def __iter__(self):
139        return self._locations.__iter__()
140
141    def __len__(self):
142        return len(self._locations)
143
144    def add(self, location, suppress_callback=False):
145        if "primary" in location.options:
146            if self.hasPrimary:
147                raise MultiplePrimaryLocationsError()
148            self.hasPrimary = True
149
150        self._locations.append(location)
151        if self.add_callback and not suppress_callback:
152            self.add_callback([location])
153
154    def add_host(self, host, port="80", scheme="http", options="privileged"):
155        if isinstance(options, string_types):
156            options = options.split(",")
157        self.add(Location(scheme, host, port, options))
158
159    def read(self, filename, check_for_primary=True):
160        """
161        Reads the file and adds all valid locations to the ``self._locations`` array.
162
163        :param filename: in the format of server-locations.txt_
164        :param check_for_primary: if True, a ``MissingPrimaryLocationError`` exception is raised
165          if no primary is found
166
167        .. _server-locations.txt: http://searchfox.org/mozilla-central/source/build/pgo/server-locations.txt # noqa
168
169        The only exception is that the port, if not defined, defaults to 80 or 443.
170
171        FIXME: Shouldn't this default to the protocol-appropriate port?  Is
172        there any reason to have defaults at all?
173        """
174
175        locationFile = codecs.open(filename, "r", "UTF-8")
176        lineno = 0
177        new_locations = []
178
179        for line in locationFile:
180            line = line.strip()
181            lineno += 1
182
183            # check for comments and blank lines
184            if line.startswith("#") or not line:
185                continue
186
187            # split the server from the options
188            try:
189                server, options = line.rsplit(None, 1)
190                options = options.split(",")
191            except ValueError:
192                server = line
193                options = []
194
195            # parse the server url
196            if "://" not in server:
197                server = "http://" + server
198            scheme, netloc, path, query, fragment = parse.urlsplit(server)
199            # get the host and port
200            try:
201                host, port = netloc.rsplit(":", 1)
202            except ValueError:
203                host = netloc
204                port = DEFAULT_PORTS.get(scheme, "80")
205
206            try:
207                location = Location(scheme, host, port, options)
208                self.add(location, suppress_callback=True)
209            except LocationError as e:
210                raise LocationsSyntaxError(lineno, e)
211
212            new_locations.append(location)
213
214        # ensure that a primary is found
215        if check_for_primary and not self.hasPrimary:
216            raise LocationsSyntaxError(lineno + 1, MissingPrimaryLocationError())
217
218        if self.add_callback:
219            self.add_callback(new_locations)
220
221
222class Permissions(object):
223    """Allows handling of permissions for ``mozprofile``"""
224
225    def __init__(self, profileDir, locations=None):
226        self._profileDir = profileDir
227        self._locations = ServerLocations(add_callback=self.write_db)
228        if locations:
229            if isinstance(locations, ServerLocations):
230                self._locations = locations
231                self._locations.add_callback = self.write_db
232                self.write_db(self._locations._locations)
233            elif isinstance(locations, list):
234                for l in locations:
235                    self._locations.add_host(**l)
236            elif isinstance(locations, dict):
237                self._locations.add_host(**locations)
238            elif os.path.exists(locations):
239                self._locations.read(locations)
240
241    def write_db(self, locations):
242        """write permissions to the sqlite database"""
243
244        # Open database and create table
245        permDB = sqlite3.connect(os.path.join(self._profileDir, "permissions.sqlite"))
246        cursor = permDB.cursor()
247
248        # SQL copied from
249        # http://searchfox.org/mozilla-central/source/extensions/permissions/PermissionManager.cpp
250        cursor.execute(
251            """CREATE TABLE IF NOT EXISTS moz_hosts (
252              id INTEGER PRIMARY KEY
253             ,origin TEXT
254             ,type TEXT
255             ,permission INTEGER
256             ,expireType INTEGER
257             ,expireTime INTEGER
258             ,modificationTime INTEGER
259           )"""
260        )
261
262        rows = cursor.execute("PRAGMA table_info(moz_hosts)")
263        count = len(rows.fetchall())
264
265        using_origin = False
266        # if the db contains 7 columns, we're using user_version 5
267        if count == 7:
268            statement = "INSERT INTO moz_hosts values(NULL, ?, ?, ?, 0, 0, 0)"
269            cursor.execute("PRAGMA user_version=5;")
270            using_origin = True
271        # if the db contains 9 columns, we're using user_version 4
272        elif count == 9:
273            statement = "INSERT INTO moz_hosts values(NULL, ?, ?, ?, 0, 0, 0, 0, 0)"
274            cursor.execute("PRAGMA user_version=4;")
275        # if the db contains 8 columns, we're using user_version 3
276        elif count == 8:
277            statement = "INSERT INTO moz_hosts values(NULL, ?, ?, ?, 0, 0, 0, 0)"
278            cursor.execute("PRAGMA user_version=3;")
279        else:
280            statement = "INSERT INTO moz_hosts values(NULL, ?, ?, ?, 0, 0)"
281            cursor.execute("PRAGMA user_version=2;")
282
283        for location in locations:
284            # set the permissions
285            permissions = {"allowXULXBL": "noxul" not in location.options}
286            for perm, allow in six.iteritems(permissions):
287                if allow:
288                    permission_type = 1
289                else:
290                    permission_type = 2
291
292                if using_origin:
293                    # This is a crude approximation of the origin generation
294                    # logic from ContentPrincipal and nsStandardURL. It should
295                    # suffice for the permissions which the test runners will
296                    # want to insert into the system.
297                    origin = location.scheme + "://" + location.host
298                    if (location.scheme != "http" or location.port != "80") and (
299                        location.scheme != "https" or location.port != "443"
300                    ):
301                        origin += ":" + str(location.port)
302
303                    cursor.execute(statement, (origin, perm, permission_type))
304                else:
305                    # The database is still using a legacy system based on hosts
306                    # We can insert the permission as a host
307                    #
308                    # XXX This codepath should not be hit, as tests are run with
309                    # fresh profiles. However, if it was hit, permissions would
310                    # not be added to the database correctly (bug 1183185).
311                    cursor.execute(statement, (location.host, perm, permission_type))
312
313        # Commit and close
314        permDB.commit()
315        cursor.close()
316
317    def network_prefs(self, proxy=None):
318        """
319        take known locations and generate preferences to handle permissions and proxy
320        returns a tuple of prefs, user_prefs
321        """
322
323        prefs = []
324
325        if proxy:
326            user_prefs = self.pac_prefs(proxy)
327        else:
328            user_prefs = []
329
330        return prefs, user_prefs
331
332    def pac_prefs(self, user_proxy=None):
333        """
334        return preferences for Proxy Auto Config.
335        """
336        proxy = DEFAULT_PORTS.copy()
337
338        # We need to proxy every server but the primary one.
339        origins = ["'%s'" % l.url() for l in self._locations]
340        origins = ", ".join(origins)
341        proxy["origins"] = origins
342
343        for l in self._locations:
344            if "primary" in l.options:
345                proxy["remote"] = l.host
346                proxy[l.scheme] = l.port
347
348        # overwrite defaults with user specified proxy
349        if isinstance(user_proxy, dict):
350            proxy.update(user_proxy)
351
352        # TODO: this should live in a template!
353        # If you must escape things in this string with backslashes, be aware
354        # of the multiple layers of escaping at work:
355        #
356        # - Python will unescape backslashes;
357        # - Writing out the prefs will escape things via JSON serialization;
358        # - The prefs file reader will unescape backslashes;
359        # - The JS engine parser will unescape backslashes.
360        pacURL = (
361            """data:text/plain,
362var knownOrigins = (function () {
363  return [%(origins)s].reduce(function(t, h) { t[h] = true; return t; }, {})
364})();
365var uriRegex = new RegExp('^([a-z][-a-z0-9+.]*)' +
366                          '://' +
367                          '(?:[^/@]*@)?' +
368                          '(.*?)' +
369                          '(?::(\\\\d+))?/');
370var defaultPortsForScheme = {
371  'http': 80,
372  'ws': 80,
373  'https': 443,
374  'wss': 443
375};
376var originSchemesRemap = {
377  'ws': 'http',
378  'wss': 'https'
379};
380var proxyForScheme = {
381  'http': 'PROXY %(remote)s:%(http)s',
382  'https': 'PROXY %(remote)s:%(https)s',
383  'ws': 'PROXY %(remote)s:%(ws)s',
384  'wss': 'PROXY %(remote)s:%(wss)s'
385};
386
387function FindProxyForURL(url, host)
388{
389  var matches = uriRegex.exec(url);
390  if (!matches)
391    return 'DIRECT';
392  var originalScheme = matches[1];
393  var host = matches[2];
394  var port = matches[3];
395  if (!port && originalScheme in defaultPortsForScheme) {
396    port = defaultPortsForScheme[originalScheme];
397  }
398  var schemeForOriginChecking = originSchemesRemap[originalScheme] || originalScheme;
399
400  var origin = schemeForOriginChecking + '://' + host + ':' + port;
401  if (!(origin in knownOrigins))
402    return 'DIRECT';
403  return proxyForScheme[originalScheme] || 'DIRECT';
404}"""
405            % proxy
406        )
407        pacURL = "".join(pacURL.splitlines())
408
409        prefs = []
410        prefs.append(("network.proxy.type", 2))
411        prefs.append(("network.proxy.autoconfig_url", pacURL))
412
413        return prefs
414
415    def clean_db(self):
416        """Removed permissions added by mozprofile."""
417
418        sqlite_file = os.path.join(self._profileDir, "permissions.sqlite")
419        if not os.path.exists(sqlite_file):
420            return
421
422        # Open database and create table
423        permDB = sqlite3.connect(sqlite_file)
424        cursor = permDB.cursor()
425
426        # TODO: only delete values that we add, this would require sending
427        # in the full permissions object
428        cursor.execute("DROP TABLE IF EXISTS moz_hosts")
429
430        # Commit and close
431        permDB.commit()
432        cursor.close()
433