diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 054eb3bd1..3b68428e8 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -20,6 +20,8 @@ v0.9.0 (unreleased) Added ~~~~~ +- *New dependency:* qutebrowser now depends on the Qt Quick module, which is + packaged separately in some distributions. - New `:rl-backward-kill-word` command which does what `:rl-unix-word-rubout` did before v0.8.0. - New `:rl-unix-filename-rubout` command which is similar to readline's @@ -54,6 +56,7 @@ Added `user-stylesheet` setting. - New `general -> default-open-dispatcher` setting to configure what to open downloads with (instead of e.g. `xdg-open` on Linux). +- Support for PAC (proxy autoconfig) with QtWebKit Changed ~~~~~~~ diff --git a/README.asciidoc b/README.asciidoc index de96063ab..ef255fb47 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -196,6 +196,7 @@ Contributors, sorted by the number of commits in descending order: * Tomasz Kramkowski * Samuel Walladge * Peter Rice +* Nikolay Amiantov * Ismail S * Halfwit * Fritz Reichwald diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index 477a9958a..5e26893f5 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -396,6 +396,14 @@ class NetworkManager(QNetworkAccessManager): Return: A QNetworkReply. """ + proxy_factory = objreg.get('proxy-factory', None) + if proxy_factory is not None: + proxy_error = proxy_factory.get_error() + if proxy_error is not None: + return networkreply.ErrorNetworkReply( + req, proxy_error, QNetworkReply.UnknownProxyError, + self) + scheme = req.url().scheme() if scheme in self._scheme_handlers: result = self._scheme_handlers[scheme].createRequest( diff --git a/qutebrowser/browser/webkit/network/pac.py b/qutebrowser/browser/webkit/network/pac.py new file mode 100644 index 000000000..dfa4d42d6 --- /dev/null +++ b/qutebrowser/browser/webkit/network/pac.py @@ -0,0 +1,305 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Evaluation of PAC scripts.""" + +import sys +import functools + +from PyQt5.QtCore import (QObject, pyqtSignal, pyqtSlot) +from PyQt5.QtNetwork import (QNetworkProxy, QNetworkRequest, QHostInfo, + QNetworkReply, QNetworkAccessManager, + QHostAddress) +from PyQt5.QtQml import QJSEngine, QJSValue + +from qutebrowser.utils import log, utils, qtutils + + +class ParseProxyError(Exception): + + """Error while parsing PAC result string.""" + + pass + + +class EvalProxyError(Exception): + + """Error while evaluating PAC script.""" + + pass + + +def _js_slot(*args): + """Wrap a methods as a JavaScript function. + + Register a PACContext method as a JavaScript function, and catch + exceptions returning them as JavaScript Error objects. + + Args: + args: Types of method arguments. + + Return: Wrapped method. + """ + def _decorator(method): + @functools.wraps(method) + def new_method(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except: + e = str(sys.exc_info()[0]) + log.network.exception("PAC evaluation error") + # pylint: disable=protected-access + return self._error_con.callAsConstructor([e]) + # pylint: enable=protected-access + return pyqtSlot(*args, result=QJSValue)(new_method) + return _decorator + + +class _PACContext(QObject): + + """Implementation of PAC API functions that require native calls. + + See https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Necko/Proxy_Auto-Configuration_(PAC)_file + """ + + JS_DEFINITIONS = """ + function dnsResolve(host) { + return PAC.dnsResolve(host); + } + + function myIpAddress() { + return PAC.myIpAddress(); + } + """ + + def __init__(self, engine): + """Create a new PAC API implementation instance. + + Args: + engine: QJSEngine which is used for running PAC. + """ + super().__init__(parent=engine) + self._engine = engine + self._error_con = engine.globalObject().property("Error") + + @_js_slot(str) + def dnsResolve(self, host): + """Resolve a DNS hostname. + + Resolves the given DNS hostname into an IP address, and returns it + in the dot-separated format as a string. + + Args: + host: hostname to resolve. + """ + ips = QHostInfo.fromName(host) + if ips.error() != QHostInfo.NoError or not ips.addresses(): + err_f = "Failed to resolve host during PAC evaluation: {}" + log.network.info(err_f.format(host)) + return QJSValue(QJSValue.NullValue) + else: + return ips.addresses()[0].toString() + + @_js_slot() + def myIpAddress(self): + """Get host IP address. + + Return the server IP address of the current machine, as a string in + the dot-separated integer format. + """ + return QHostAddress(QHostAddress.LocalHost).toString() + + +class PACResolver(object): + + """Evaluate PAC script files and resolve proxies.""" + + @staticmethod + def _parse_proxy_host(host_str): + host, _colon, port_str = host_str.partition(':') + try: + port = int(port_str) + except ValueError: + raise ParseProxyError("Invalid port number") + return (host, port) + + @staticmethod + def _parse_proxy_entry(proxy_str): + """Parse one proxy string entry, as described in PAC specification.""" + config = [c.strip() for c in proxy_str.split(' ') if c] + if not config: + raise ParseProxyError("Empty proxy entry") + elif config[0] == "DIRECT": + if len(config) != 1: + raise ParseProxyError("Invalid number of parameters for " + + "DIRECT") + return QNetworkProxy(QNetworkProxy.NoProxy) + elif config[0] == "PROXY": + if len(config) != 2: + raise ParseProxyError("Invalid number of parameters for PROXY") + host, port = PACResolver._parse_proxy_host(config[1]) + return QNetworkProxy(QNetworkProxy.HttpProxy, host, port) + elif config[0] == "SOCKS": + if len(config) != 2: + raise ParseProxyError("Invalid number of parameters for SOCKS") + host, port = PACResolver._parse_proxy_host(config[1]) + return QNetworkProxy(QNetworkProxy.Socks5Proxy, host, port) + else: + err = "Unknown proxy type: {}" + raise ParseProxyError(err.format(config[0])) + + @staticmethod + def _parse_proxy_string(proxy_str): + proxies = proxy_str.split(';') + return [PACResolver._parse_proxy_entry(x) for x in proxies] + + def _evaluate(self, js_code, js_file): + ret = self._engine.evaluate(js_code, js_file) + if ret.isError(): + err = "JavaScript error while evaluating PAC file: {}" + raise EvalProxyError(err.format(ret.toString())) + + def __init__(self, pac_str): + """Create a PAC resolver. + + Args: + pac_str: JavaScript code containing PAC resolver. + """ + self._engine = QJSEngine() + + self._ctx = _PACContext(self._engine) + self._engine.globalObject().setProperty( + "PAC", self._engine.newQObject(self._ctx)) + self._evaluate(_PACContext.JS_DEFINITIONS, "pac_js_definitions") + self._evaluate(utils.read_file("javascript/pac_utils.js"), "pac_utils") + proxy_config = self._engine.newObject() + proxy_config.setProperty("bindings", self._engine.newObject()) + self._engine.globalObject().setProperty("ProxyConfig", proxy_config) + + self._evaluate(pac_str, "pac") + global_js_object = self._engine.globalObject() + self._resolver = global_js_object.property("FindProxyForURL") + if not self._resolver.isCallable(): + err = "Cannot resolve FindProxyForURL function, got '{}' instead" + raise EvalProxyError(err.format(self._resolver.toString())) + + def resolve(self, query): + """Resolve a proxy via PAC. + + Args: + query: QNetworkProxyQuery. + + Return: + A list of QNetworkProxy objects in order of preference. + """ + result = self._resolver.call([query.url().toString(), + query.peerHostName()]) + result_str = result.toString() + if not result.isString(): + err = "Got strange value from FindProxyForURL: '{}'" + raise EvalProxyError(err.format(result_str)) + return self._parse_proxy_string(result_str) + + +class PACFetcher(QObject): + + """Asynchronous fetcher of PAC files.""" + + finished = pyqtSignal() + + def __init__(self, url, parent=None): + """Resolve a PAC proxy from URL. + + Args: + url: QUrl of a PAC proxy. + """ + super().__init__(parent) + + pac_prefix = "pac+" + + assert url.scheme().startswith(pac_prefix) + url.setScheme(url.scheme()[len(pac_prefix):]) + + self._manager = QNetworkAccessManager() + self._manager.setProxy(QNetworkProxy(QNetworkProxy.NoProxy)) + self._reply = self._manager.get(QNetworkRequest(url)) + self._reply.finished.connect(self._finish) + self._pac = None + self._error_message = None + + @pyqtSlot() + def _finish(self): + if self._reply.error() != QNetworkReply.NoError: + error = "Can't fetch PAC file from URL, error code {}: {}" + self._error_message = error.format( + self._reply.error(), self._reply.errorString()) + log.network.error(self._error_message) + else: + try: + pacscript = bytes(self._reply.readAll()).decode("utf-8") + except UnicodeError as e: + error = "Invalid encoding of a PAC file: {}" + self._error_message = error.format(e) + log.network.exception(self._error_message) + try: + self._pac = PACResolver(pacscript) + log.network.debug("Successfully evaluated PAC file.") + except EvalProxyError as e: + error = "Error in PAC evaluation: {}" + self._error_message = error.format(e) + log.network.exception(self._error_message) + self._manager = None + self._reply = None + self.finished.emit() + + def _wait(self): + """Wait until a reply from the remote server is received.""" + if self._manager is not None: + loop = qtutils.EventLoop() + self.finished.connect(loop.quit) + loop.exec_() + + def fetch_error(self): + """Check if PAC script is successfully fetched. + + Return None iff PAC script is downloaded and evaluated successfully, + error string otherwise. + """ + self._wait() + return self._error_message + + def resolve(self, query): + """Resolve a query via PAC. + + Args: QNetworkProxyQuery. + + Return a list of QNetworkProxy objects in order of preference. + """ + self._wait() + try: + return self._pac.resolve(query) + except (EvalProxyError, ParseProxyError) as e: + log.network.exception("Error in PAC resolution: {}.".format(e)) + # .invalid is guaranteed to be inaccessible in RFC 6761. + # Port 9 is for DISCARD protocol -- DISCARD servers act like + # /dev/null. + # Later NetworkManager.createRequest will detect this and display + # an error message. + error_host = "pac-resolve-error.qutebrowser.invalid" + return QNetworkProxy(QNetworkProxy.HttpProxy, error_host, 9) diff --git a/qutebrowser/browser/webkit/network/proxy.py b/qutebrowser/browser/webkit/network/proxy.py index 2469bd0c7..db52482d2 100644 --- a/qutebrowser/browser/webkit/network/proxy.py +++ b/qutebrowser/browser/webkit/network/proxy.py @@ -23,17 +23,33 @@ from PyQt5.QtNetwork import QNetworkProxy, QNetworkProxyFactory from qutebrowser.config import config, configtypes +from qutebrowser.utils import objreg +from qutebrowser.browser.webkit.network import pac def init(): """Set the application wide proxy factory.""" - QNetworkProxyFactory.setApplicationProxyFactory(ProxyFactory()) + proxy_factory = ProxyFactory() + objreg.register('proxy-factory', proxy_factory) + QNetworkProxyFactory.setApplicationProxyFactory(proxy_factory) class ProxyFactory(QNetworkProxyFactory): """Factory for proxies to be used by qutebrowser.""" + def get_error(self): + """Check if proxy can't be resolved. + + Return: + None if proxy is correct, otherwise an error message. + """ + proxy = config.get('network', 'proxy') + if isinstance(proxy, pac.PACFetcher): + return proxy.fetch_error() + else: + return None + def queryProxy(self, query): """Get the QNetworkProxies for a query. @@ -46,6 +62,8 @@ class ProxyFactory(QNetworkProxyFactory): proxy = config.get('network', 'proxy') if proxy is configtypes.SYSTEM_PROXY: proxies = QNetworkProxyFactory.systemProxyForQuery(query) + elif isinstance(proxy, pac.PACFetcher): + proxies = proxy.resolve(query) else: proxies = [proxy] for p in proxies: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 457781e77..7c5f9721c 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -28,6 +28,7 @@ import itertools import collections import warnings import datetime +import functools from PyQt5.QtCore import QUrl, Qt from PyQt5.QtGui import QColor, QFont @@ -37,6 +38,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar from qutebrowser.commands import cmdutils from qutebrowser.config import configexc from qutebrowser.utils import standarddir, utils +from qutebrowser.browser.webkit.network import pac SYSTEM_PROXY = object() # Return value for Proxy type @@ -1014,14 +1016,36 @@ class ShellCommand(BaseType): return shlex.split(value) +def proxy_from_url(typ, url): + """Create a QNetworkProxy from QUrl and a proxy type. + + Args: + typ: QNetworkProxy::ProxyType. + url: URL of a proxy (possibly with credentials). + + Return: + New QNetworkProxy. + """ + proxy = QNetworkProxy(typ, url.host()) + if url.port() != -1: + proxy.setPort(url.port()) + if url.userName(): + proxy.setUser(url.userName()) + if url.password(): + proxy.setPassword(url.password()) + return proxy + + class Proxy(BaseType): """A proxy URL or special value.""" PROXY_TYPES = { - 'http': QNetworkProxy.HttpProxy, - 'socks': QNetworkProxy.Socks5Proxy, - 'socks5': QNetworkProxy.Socks5Proxy, + 'http': functools.partial(proxy_from_url, QNetworkProxy.HttpProxy), + 'pac+http': pac.PACFetcher, + 'pac+https': pac.PACFetcher, + 'socks': functools.partial(proxy_from_url, QNetworkProxy.Socks5Proxy), + 'socks5': functools.partial(proxy_from_url, QNetworkProxy.Socks5Proxy), } def __init__(self, none_ok=False): @@ -1053,6 +1077,7 @@ class Proxy(BaseType): out.append(('socks://', 'SOCKS proxy URL')) out.append(('socks://localhost:9050/', 'Tor via SOCKS')) out.append(('http://localhost:8080/', 'Local HTTP proxy')) + out.append(('pac+https://example.com/proxy.pac', 'Proxy autoconfiguration file URL')) return out def transform(self, value): @@ -1063,15 +1088,7 @@ class Proxy(BaseType): elif value == 'none': return QNetworkProxy(QNetworkProxy.NoProxy) url = QUrl(value) - typ = self.PROXY_TYPES[url.scheme()] - proxy = QNetworkProxy(typ, url.host()) - if url.port() != -1: - proxy.setPort(url.port()) - if url.userName(): - proxy.setUser(url.userName()) - if url.password(): - proxy.setPassword(url.password()) - return proxy + return self.PROXY_TYPES[url.scheme()](url) class SearchEngineName(BaseType): diff --git a/qutebrowser/javascript/.eslintignore b/qutebrowser/javascript/.eslintignore new file mode 100644 index 000000000..ca4d3c667 --- /dev/null +++ b/qutebrowser/javascript/.eslintignore @@ -0,0 +1,2 @@ +# Upstream Mozilla's code +pac_utils.js diff --git a/qutebrowser/javascript/pac_utils.js b/qutebrowser/javascript/pac_utils.js new file mode 100644 index 000000000..a6102df9f --- /dev/null +++ b/qutebrowser/javascript/pac_utils.js @@ -0,0 +1,257 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is mozilla.org code. + * + * The Initial Developer of the Original Code is + * Netscape Communications Corporation. + * Portions created by the Initial Developer are Copyright (C) 1998 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Akhil Arora + * Tomi Leppikangas + * Darin Fisher + * + * Alternatively, the contents of this file may be used under the terms of + * either the GNU General Public License Version 2 or later (the "GPL"), or + * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), + * in which case the provisions of the GPL or the LGPL are applicable instead + * of those above. If you wish to allow use of your version of this file only + * under the terms of either the GPL or the LGPL, and not to allow others to + * use your version of this file under the terms of the MPL, indicate your + * decision by deleting the provisions above and replace them with the notice + * and other provisions required by the GPL or the LGPL. If you do not delete + * the provisions above, a recipient may use your version of this file under + * the terms of any one of the MPL, the GPL or the LGPL. + * + * ***** END LICENSE BLOCK ***** */ + +/* + Script for Proxy Auto Config in the new world order. + - Gagan Saksena 04/24/00 +*/ + +function dnsDomainIs(host, domain) { + return (host.length >= domain.length && + host.substring(host.length - domain.length) == domain); +} + +function dnsDomainLevels(host) { + return host.split('.').length-1; +} + +function convert_addr(ipchars) { + var bytes = ipchars.split('.'); + var result = ((bytes[0] & 0xff) << 24) | + ((bytes[1] & 0xff) << 16) | + ((bytes[2] & 0xff) << 8) | + (bytes[3] & 0xff); + return result; +} + +function isInNet(ipaddr, pattern, maskstr) { + var test = /^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/ + .exec(ipaddr); + if (test == null) { + ipaddr = dnsResolve(ipaddr); + if (ipaddr == null) + return false; + } else if (test[1] > 255 || test[2] > 255 || + test[3] > 255 || test[4] > 255) { + return false; // not an IP address + } + var host = convert_addr(ipaddr); + var pat = convert_addr(pattern); + var mask = convert_addr(maskstr); + return ((host & mask) == (pat & mask)); +} + +function isPlainHostName(host) { + return (host.search('\\\\.') == -1); +} + +function isResolvable(host) { + var ip = dnsResolve(host); + return (ip != null); +} + +function localHostOrDomainIs(host, hostdom) { + return (host == hostdom) || + (hostdom.lastIndexOf(host + '.', 0) == 0); +} + +function shExpMatch(url, pattern) { + pattern = pattern.replace(/\\./g, '\\\\.'); + pattern = pattern.replace(/\\*/g, '.*'); + pattern = pattern.replace(/\\?/g, '.'); + var newRe = new RegExp('^'+pattern+'$'); + return newRe.test(url); +} + +var wdays = {SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6}; + +var months = {JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, + AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11}; + +function weekdayRange() { + function getDay(weekday) { + if (weekday in wdays) { + return wdays[weekday]; + } + return -1; + } + var date = new Date(); + var argc = arguments.length; + var wday; + if (argc < 1) + return false; + if (arguments[argc - 1] == 'GMT') { + argc--; + wday = date.getUTCDay(); + } else { + wday = date.getDay(); + } + var wd1 = getDay(arguments[0]); + var wd2 = (argc == 2) ? getDay(arguments[1]) : wd1; + return (wd1 == -1 || wd2 == -1) ? false + : (wd1 <= wday && wday <= wd2); +} + +function dateRange() { + function getMonth(name) { + if (name in months) { + return months[name]; + } + return -1; + } + var date = new Date(); + var argc = arguments.length; + if (argc < 1) { + return false; + } + var isGMT = (arguments[argc - 1] == 'GMT'); + + if (isGMT) { + argc--; + } + // function will work even without explict handling of this case + if (argc == 1) { + var tmp = parseInt(arguments[0]); + if (isNaN(tmp)) { + return ((isGMT ? date.getUTCMonth() : date.getMonth()) == + getMonth(arguments[0])); + } else if (tmp < 32) { + return ((isGMT ? date.getUTCDate() : date.getDate()) == tmp); + } else { + return ((isGMT ? date.getUTCFullYear() : date.getFullYear()) == + tmp); + } + } + var year = date.getFullYear(); + var date1, date2; + date1 = new Date(year, 0, 1, 0, 0, 0); + date2 = new Date(year, 11, 31, 23, 59, 59); + var adjustMonth = false; + for (var i = 0; i < (argc >> 1); i++) { + var tmp = parseInt(arguments[i]); + if (isNaN(tmp)) { + var mon = getMonth(arguments[i]); + date1.setMonth(mon); + } else if (tmp < 32) { + adjustMonth = (argc <= 2); + date1.setDate(tmp); + } else { + date1.setFullYear(tmp); + } + } + for (var i = (argc >> 1); i < argc; i++) { + var tmp = parseInt(arguments[i]); + if (isNaN(tmp)) { + var mon = getMonth(arguments[i]); + date2.setMonth(mon); + } else if (tmp < 32) { + date2.setDate(tmp); + } else { + date2.setFullYear(tmp); + } + } + if (adjustMonth) { + date1.setMonth(date.getMonth()); + date2.setMonth(date.getMonth()); + } + if (isGMT) { + var tmp = date; + tmp.setFullYear(date.getUTCFullYear()); + tmp.setMonth(date.getUTCMonth()); + tmp.setDate(date.getUTCDate()); + tmp.setHours(date.getUTCHours()); + tmp.setMinutes(date.getUTCMinutes()); + tmp.setSeconds(date.getUTCSeconds()); + date = tmp; + } + return ((date1 <= date) && (date <= date2)); +} + +function timeRange() { + var argc = arguments.length; + var date = new Date(); + var isGMT= false; + + if (argc < 1) { + return false; + } + if (arguments[argc - 1] == 'GMT') { + isGMT = true; + argc--; + } + + var hour = isGMT ? date.getUTCHours() : date.getHours(); + var date1, date2; + date1 = new Date(); + date2 = new Date(); + + if (argc == 1) { + return (hour == arguments[0]); + } else if (argc == 2) { + return ((arguments[0] <= hour) && (hour <= arguments[1])); + } else { + switch (argc) { + case 6: + date1.setSeconds(arguments[2]); + date2.setSeconds(arguments[5]); + case 4: + var middle = argc >> 1; + date1.setHours(arguments[0]); + date1.setMinutes(arguments[1]); + date2.setHours(arguments[middle]); + date2.setMinutes(arguments[middle + 1]); + if (middle == 2) { + date2.setSeconds(59); + } + break; + default: + throw 'timeRange: bad number of arguments' + } + } + + if (isGMT) { + date.setFullYear(date.getUTCFullYear()); + date.setMonth(date.getUTCMonth()); + date.setDate(date.getUTCDate()); + date.setHours(date.getUTCHours()); + date.setMinutes(date.getUTCMinutes()); + date.setSeconds(date.getUTCSeconds()); + } + return ((date1 <= date) && (date <= date2)); +} diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 05163577c..07ec78741 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -94,7 +94,7 @@ LOGGER_NAMES = [ 'commands', 'signals', 'downloads', 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', 'save', 'message', 'config', 'sessions', - 'webelem', 'prompt' + 'webelem', 'prompt', 'network' ] @@ -140,6 +140,7 @@ config = logging.getLogger('config') sessions = logging.getLogger('sessions') webelem = logging.getLogger('webelem') prompt = logging.getLogger('prompt') +network = logging.getLogger('network') ram_handler = None diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index f5d75eed5..5317e2135 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -102,7 +102,7 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then exit 0 fi -pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtwebkit" +pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit" pip_install pip pip_install -r misc/requirements/requirements-tox.txt diff --git a/tests/unit/browser/webkit/network/test_pac.py b/tests/unit/browser/webkit/network/test_pac.py new file mode 100644 index 000000000..b99675b6a --- /dev/null +++ b/tests/unit/browser/webkit/network/test_pac.py @@ -0,0 +1,173 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import http.server +import threading +import logging +import sys +import pytest + +from PyQt5.QtCore import QUrl, QT_VERSION_STR +from PyQt5.QtNetwork import (QNetworkProxy, QNetworkProxyQuery, QHostInfo, + QHostAddress) + +from qutebrowser.browser.webkit.network import pac + + +pytestmark = pytest.mark.usefixtures('qapp') + + +def _pac_common_test(test_str): + fun_str_f = """ + function FindProxyForURL(domain, host) {{ + {} + return "DIRECT; PROXY 127.0.0.1:8080; SOCKS 192.168.1.1:4444"; + }} + """ + + fun_str = fun_str_f.format(test_str) + res = pac.PACResolver(fun_str) + proxies = res.resolve(QNetworkProxyQuery(QUrl("https://example.com/test"))) + assert len(proxies) == 3 + assert proxies[0].type() == QNetworkProxy.NoProxy + assert proxies[1].type() == QNetworkProxy.HttpProxy + assert proxies[1].hostName() == "127.0.0.1" + assert proxies[1].port() == 8080 + assert proxies[2].type() == QNetworkProxy.Socks5Proxy + assert proxies[2].hostName() == "192.168.1.1" + assert proxies[2].port() == 4444 + + +def _pac_equality_test(call, expected): + test_str_f = """ + var res = ({0}); + var expected = ({1}); + if(res !== expected) {{ + throw new Error("failed test {0}: got '" + res + "', expected '" + expected + "'"); + }} + """ + _pac_common_test(test_str_f.format(call, expected)) + + +def _pac_except_test(caplog, call): + test_str_f = """ + var thrown = false; + try {{ + var res = ({0}); + }} catch(e) {{ + thrown = true; + }} + if(!thrown) {{ + throw new Error("failed test {0}: got '" + res + "', expected exception"); + }} + """ + with caplog.at_level(logging.ERROR): + _pac_common_test(test_str_f.format(call)) + + +def _pac_noexcept_test(call): + test_str_f = """ + var res = ({0}); + """ + _pac_common_test(test_str_f.format(call)) + + +# pylint: disable=line-too-long, invalid-name + + +@pytest.mark.parametrize("domain, expected", [ + ("known.domain", "'1.2.3.4'"), + ("bogus.domain.foobar", "null") +]) +def test_dnsResolve(monkeypatch, domain, expected): + def mock_fromName(host): + info = QHostInfo() + if host == "known.domain": + info.setAddresses([QHostAddress("1.2.3.4")]) + return info + monkeypatch.setattr(QHostInfo, 'fromName', mock_fromName) + _pac_equality_test("dnsResolve('{}')".format(domain), expected) + + +def test_myIpAddress(): + _pac_equality_test("isResolvable(myIpAddress())", "true") + + +def test_proxyBindings(): + _pac_equality_test("JSON.stringify(ProxyConfig.bindings)", "'{}'") + + +def test_invalid_port(): + test_str = """ + function FindProxyForURL(domain, host) { + return "PROXY 127.0.0.1:FOO"; + } + """ + + res = pac.PACResolver(test_str) + with pytest.raises(pac.ParseProxyError): + res.resolve(QNetworkProxyQuery(QUrl("https://example.com/test"))) + + +# See https://github.com/The-Compiler/qutebrowser/pull/1891#issuecomment-259222615 + +try: + from PyQt5 import QtWebEngineWidgets +except ImportError: + QtWebEngineWidgets = None + + +@pytest.mark.skipif(QT_VERSION_STR.startswith('5.7') and + QtWebEngineWidgets is not None and + sys.platform == "linux", + reason="Segfaults when run with QtWebEngine tests on Linux") +def test_fetch(): + test_str = """ + function FindProxyForURL(domain, host) { + return "DIRECT; PROXY 127.0.0.1:8080; SOCKS 192.168.1.1:4444"; + } + """ + + class PACHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + + self.send_header('Content-type', 'application/x-ns-proxy-autoconfig') + self.end_headers() + + self.wfile.write(test_str.encode("ascii")) + + ready_event = threading.Event() + + def serve(): + httpd = http.server.HTTPServer(("127.0.0.1", 8081), PACHandler) + ready_event.set() + httpd.handle_request() + httpd.server_close() + + serve_thread = threading.Thread(target=serve, daemon=True) + serve_thread.start() + try: + ready_event.wait() + res = pac.PACFetcher(QUrl("pac+http://127.0.0.1:8081")) + assert res.fetch_error() is None + finally: + serve_thread.join() + proxies = res.resolve(QNetworkProxyQuery(QUrl("https://example.com/test"))) + assert len(proxies) == 3 diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 77cfdfdee..37e763999 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -1492,6 +1492,8 @@ class TestProxy: 'http://user:pass@example.com:2323/', 'socks://user:pass@example.com:2323/', 'socks5://user:pass@example.com:2323/', + 'pac+http://example.com/proxy.pac', + 'pac+https://example.com/proxy.pac', ]) def test_validate_valid(self, klass, val): klass(none_ok=True).validate(val) diff --git a/tox.ini b/tox.ini index 6c51695dd..b52550056 100644 --- a/tox.ini +++ b/tox.ini @@ -183,4 +183,5 @@ commands = [testenv:eslint] deps = whitelist_externals = eslint -commands = eslint --color qutebrowser/javascript +changedir = {toxinidir}/qutebrowser/javascript +commands = eslint --color .