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