| Index: watchextensions.py | 
| diff --git a/watchextensions.py b/watchextensions.py | 
| new file mode 100644 | 
| index 0000000000000000000000000000000000000000..fd36a002a8d00edc3e18015a65e6533b738aa272 | 
| --- /dev/null | 
| +++ b/watchextensions.py | 
| @@ -0,0 +1,260 @@ | 
| +# This Source Code Form is subject to the terms of the Mozilla Public | 
| +# License, v. 2.0. If a copy of the MPL was not distributed with this | 
| +# file, You can obtain one at http://mozilla.org/MPL/2.0/. | 
| +""" | 
| +Track differences between versions of arbitrary extensions. | 
| + | 
| +Running this module direcly will attempt to fetch new versions for every | 
| +in ~/extwatcher.ini or /etc/extwatcher.ini configured and enabled extension | 
| +from the Chrome Web Store. When no parameters are given, you will simply be | 
| +notified about whether a new version was found. | 
| + | 
| +Providing -k/--keep-repository will leave the git repository (with unstaged | 
| +differences between the latest tracked version and the newest) on your drive, | 
| +in order to examine the differences locally. | 
| + | 
| +Providing -p/--push will stage all differences and push them to the configured | 
| +tracking repository. | 
| +""" | 
| +from __future__ import print_function | 
| + | 
| +import argparse | 
| +from configparser import ConfigParser, NoSectionError, NoOptionError | 
| +import json | 
| +import logging | 
| +import os | 
| +import shutil | 
| +import subprocess | 
| +import sys | 
| +import tempfile | 
| +from zipfile import ZipFile | 
| + | 
| +try:  # Python 3 only | 
| +    from urllib import request as urllib | 
| +except ImportError:  # Python 2 only | 
| +    import urllib | 
| +from xml.etree import ElementTree | 
| + | 
| +logging.basicConfig(level=logging.INFO) | 
| + | 
| +OMAHA_URL = 'https://omahaproxy.appspot.com/all.json?os=win' | 
| +SERVICE_URL = 'https://clients2.google.com/service/update2/crx' | 
| + | 
| + | 
| +class SensitiveFilter(logging.Filter): | 
| +    """Filter sensitive ouput from the logs. | 
| + | 
| +    This class is meant to be registered in python's logging facility. It will | 
| +    mask potentially sensitive data from the message that is to be logged. | 
| + | 
| + | 
| +    """ | 
| + | 
| +    def __init__(self, patterns): | 
| +        """Initialize a SensitiveFilter with the desired patterns.""" | 
| +        self.patterns = patterns | 
| +        super(SensitiveFilter, self).__init__() | 
| + | 
| +    def filter(self, record): | 
| +        msg = record.msg | 
| +        if isinstance(msg, BaseException): | 
| +            msg = str(msg) | 
| +        record.msg = self.mask(msg) | 
| +        return True | 
| + | 
| +    def mask(self, msg): | 
| +        try: | 
| +            for pattern in self.patterns: | 
| +                msg = msg.replace( | 
| +                    pattern, | 
| +                    '/'.join(['******', pattern.rsplit('/', 1)[-1]]), | 
| +                ) | 
| +        except AttributeError: | 
| +            pass | 
| +        return msg | 
| + | 
| + | 
| +class ConfigurationError(Exception): | 
| +    pass | 
| + | 
| + | 
| +def read_config(path=None): | 
| +    config_path = path or [ | 
| +        os.path.expanduser('~/watchextensions.ini'), | 
| +        '/etc/watchextensions.ini', | 
| +    ] | 
| + | 
| +    config = ConfigParser() | 
| +    if not config.read(config_path): | 
| +        raise ConfigurationError('No configuration file was found. Please ' | 
| +                                 'provide ~/watchextensions.ini or ' | 
| +                                 '/etc/watchextensions.ini or specify a valid ' | 
| +                                 'path.') | 
| +    return config | 
| + | 
| + | 
| +class ExtWatcher(object): | 
| +    section = 'extensions' | 
| + | 
| +    def __init__(self, ext_name, config, push, keep_repo): | 
| +        self.logger = logging.getLogger(name=ext_name) | 
| +        self.config = config | 
| + | 
| +        self.ext_name = ext_name | 
| +        self.push = push | 
| +        self.keep_repo = keep_repo | 
| + | 
| +        self._cws_ext_url = None | 
| +        self._current_ext_version = None | 
| + | 
| +        self.downloaded_file = None | 
| + | 
| +        super(ExtWatcher, self).__init__() | 
| + | 
| +    def _git_cmd(self, cmds, relative=False): | 
| +        base = ['git'] | 
| +        if not relative: | 
| +            base += ['-C', self.tempdir] | 
| +        suffix = [] | 
| +        if not any(x in cmds for x in ['status', 'add', 'diff']): | 
| +            suffix += ['--quiet'] | 
| +        return subprocess.check_output(base + list(cmds) + suffix) | 
| + | 
| +    def _clone_repository(self): | 
| +        self.logger.info('Cloning ' + self.repository) | 
| +        self._git_cmd(['clone', '-b', 'master', '--single-branch', | 
| +                       self.repository, self.tempdir], | 
| +                      relative=True) | 
| + | 
| +    def _parse_config(self): | 
| +        err_msg = '"{}" is not fully configured!'.format(self.ext_name) | 
| +        try: | 
| +            self.ext_id = self.config.get(self.section, self.ext_name + '_id') | 
| +            self.repository = self.config.get(self.section, | 
| +                                              self.ext_name + '_repository') | 
| +            if not self.ext_id or not self.repository: | 
| +                raise ConfigurationError(err_msg) | 
| +        except NoOptionError: | 
| +            raise ConfigurationError(err_msg) | 
| +        except NoSectionError: | 
| +            raise ConfigurationError('Section [{}] is missing!' | 
| +                                     ''.format(self.section)) | 
| + | 
| +        self.logger.addFilter(SensitiveFilter([self.repository])) | 
| + | 
| +    def __enter__(self): | 
| +        self.tempdir = tempfile.mkdtemp(prefix='watched_extension_') | 
| +        return self | 
| + | 
| +    def __exit__(self, exc_type, exc_value, exc_traceback): | 
| +        if exc_value: | 
| +            self.logger.error(exc_value) | 
| + | 
| +        if not self.keep_repo: | 
| +            shutil.rmtree(self.tempdir, ignore_errors=True) | 
| +        else: | 
| +            print('Repository for {} available at {}'.format(self.ext_name, | 
| +                                                             self.tempdir)) | 
| +        if self.downloaded_file: | 
| +            os.remove(self.downloaded_file) | 
| + | 
| +        return True | 
| + | 
| +    def _fetch_latest_chrome_version(self): | 
| +        response = urllib.urlopen(OMAHA_URL).read() | 
| + | 
| +        data = json.loads(response.decode('utf-8')) | 
| + | 
| +        stable = [x for x in data[0]['versions'] if x['channel'] == 'stable'] | 
| +        return stable[0]['current_version'] | 
| + | 
| +    @property | 
| +    def cws_ext_url(self): | 
| +        if not self._cws_ext_url: | 
| +            ext_url = SERVICE_URL + '?prodversion={}&x=id%3D{}%26uc'.format( | 
| +                self._fetch_latest_chrome_version(), self.ext_id, | 
| +            ) | 
| +            self._cws_ext_url = ext_url | 
| + | 
| +        return self._cws_ext_url | 
| + | 
| +    @property | 
| +    def current_ext_version(self): | 
| +        if not self._current_ext_version: | 
| +            updatecheck_url = self.cws_ext_url + '&response=updatecheck' | 
| +            manifest = urllib.urlopen(updatecheck_url).read() | 
| +            root = ElementTree.fromstring(manifest) | 
| + | 
| +            ns = {'g': 'http://www.google.com/update2/response'} | 
| +            update = root.find('g:app/g:updatecheck', ns) | 
| + | 
| +            self._current_ext_version = update.attrib['version'] | 
| +        return self._current_ext_version | 
| + | 
| +    def _download_ext_crx(self): | 
| +        download_url = self.cws_ext_url + '&response=redirect' | 
| +        filename = '{}-{}.crx'.format(self.ext_id, self.current_ext_version) | 
| + | 
| +        urllib.urlretrieve(download_url, filename) | 
| +        self.downloaded_file = filename | 
| +        self.logger.info('Downloaded ' + filename) | 
| + | 
| +    def _unzip_to_repository(self): | 
| +        with ZipFile(self.downloaded_file, 'r') as zip_fp: | 
| +            zip_fp.extractall(self.tempdir) | 
| + | 
| +    def _get_tracked_version(self): | 
| +        return [x.decode() for x in | 
| +                self._git_cmd(['log', '--pretty=format:%s']).splitlines()] | 
| + | 
| +    def _track_new_contents(self, message): | 
| +        status = self._git_cmd(['status']) | 
| +        if b'nothing to commit' not in status: | 
| +            self._git_cmd(['add', '--all']) | 
| +            self._git_cmd(['commit', '-m', message]) | 
| +            self._git_cmd(['push', 'origin', 'master']) | 
| + | 
| +    def run(self): | 
| +        self._parse_config() | 
| +        self._clone_repository() | 
| + | 
| +        next_version = self.current_ext_version | 
| +        if next_version not in self._get_tracked_version(): | 
| +            self.logger.info('New untracked version {} found!' | 
| +                             ''.format(next_version)) | 
| +            if self.push or self.keep_repo: | 
| +                self._download_ext_crx() | 
| +                self._unzip_to_repository() | 
| +            if self.push: | 
| +                self._track_new_contents(next_version) | 
| +        else: | 
| +            self.logger.info('No new version found.') | 
| + | 
| + | 
| +if __name__ == '__main__': | 
| +    parser = argparse.ArgumentParser( | 
| +        description=__doc__.strip('\n'), | 
| +        formatter_class=argparse.RawDescriptionHelpFormatter, | 
| +    ) | 
| +    parser.add_argument('-q', '--quiet', action='store_true', default=False, | 
| +                        help='Suppress informational output.') | 
| +    parser.add_argument('-p', '--push', action='store_true', default=False, | 
| +                        help='Perfom a PUSH to the tracking repository.') | 
| +    parser.add_argument('-k', '--keep-repository', action='store_true', | 
| +                        default=False, help='Keep the local repository') | 
| +    parser.add_argument('-c', '--config-path', default=None, | 
| +                        help='Absolute path to a custom config file.') | 
| + | 
| +    args = parser.parse_args() | 
| +    if args.quiet: | 
| +        logging.disable(logging.INFO) | 
| + | 
| +    try: | 
| +        config = read_config(args.config_path) | 
| + | 
| +        for ext_name in config.options('enabled'): | 
| +            with ExtWatcher(ext_name, config, args.push, | 
| +                            args.keep_repository) as watcher: | 
| +                watcher.run() | 
| +    except ConfigurationError as e: | 
| +        sys.exit(e.message) | 
|  |