qutebrowser/tests/unit/misc/test_pakjoy.py

491 lines
17 KiB
Python

# SPDX-FileCopyrightText: Florian Bruhin (The-Compiler) <mail@qutebrowser.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import io
import json
import struct
import pathlib
import logging
import shutil
import pytest
from qutebrowser.qt import machinery
from qutebrowser.misc import pakjoy, binparsing
from qutebrowser.utils import utils, version, standarddir, usertypes
pytest.importorskip("qutebrowser.qt.webenginecore")
pytestmark = pytest.mark.usefixtures("cache_tmpdir")
versions = version.qtwebengine_versions(avoid_init=True)
@pytest.fixture(autouse=True)
def prepare_env(qapp, monkeypatch):
monkeypatch.setattr(pakjoy.objects, "qapp", qapp)
monkeypatch.delenv(pakjoy.RESOURCES_ENV_VAR, raising=False)
monkeypatch.delenv(pakjoy.DISABLE_ENV_VAR, raising=False)
def patch_version(monkeypatch: pytest.MonkeyPatch, qtwe_version: utils.VersionNumber):
monkeypatch.setattr(
pakjoy.version,
"qtwebengine_versions",
lambda **kwargs: version.WebEngineVersions(
webengine=qtwe_version,
chromium=None,
source="unittest",
),
)
@pytest.fixture(params=[
utils.VersionNumber(6, 4),
utils.VersionNumber(6, 5, 3),
utils.VersionNumber(6, 6, 1),
utils.VersionNumber(6, 7),
])
def unaffected_version(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, config_stub):
config_stub.val.colors.webpage.darkmode.enabled = True
patch_version(monkeypatch, request.param)
@pytest.fixture(params=[
utils.VersionNumber(6, 5),
utils.VersionNumber(6, 5, 1),
utils.VersionNumber(6, 5, 2),
utils.VersionNumber(6, 6),
])
def affected_version(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, config_stub):
config_stub.val.colors.webpage.darkmode.enabled = True
patch_version(monkeypatch, request.param)
@pytest.mark.parametrize("workdir_exists", [True, False])
def test_version_gate(cache_tmpdir, unaffected_version, mocker, config_stub, workdir_exists):
workdir = cache_tmpdir / pakjoy.CACHE_DIR_NAME
if workdir_exists:
workdir.mkdir()
(workdir / "some_patched_file.pak").ensure()
fake_open = mocker.patch("qutebrowser.misc.pakjoy.open")
with pakjoy.patch_webengine():
pass
assert not fake_open.called
assert not workdir.exists()
@pytest.mark.parametrize("explicit", [True, False])
def test_escape_hatch(affected_version, mocker, monkeypatch, config_stub, explicit):
config_stub.val.qt.workarounds.disable_hangouts_extension = explicit
fake_open = mocker.patch("qutebrowser.misc.pakjoy.open")
monkeypatch.setenv(pakjoy.DISABLE_ENV_VAR, "1")
with pakjoy.patch_webengine():
pass
assert not fake_open.called
class TestFindWebengineResources:
@pytest.fixture
def qt_data_path(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path):
"""Patch qtutils.library_path() to return a temp dir."""
qt_data_path = tmp_path / "qt_data"
qt_data_path.mkdir()
monkeypatch.setattr(pakjoy.qtutils, "library_path", lambda _which: qt_data_path)
return qt_data_path
@pytest.fixture
def application_dir_path(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: pathlib.Path,
qt_data_path: pathlib.Path, # needs patching
):
"""Patch QApplication.applicationDirPath() to return a temp dir."""
app_dir_path = tmp_path / "app_dir"
app_dir_path.mkdir()
monkeypatch.setattr(
pakjoy.objects.qapp, "applicationDirPath", lambda: app_dir_path
)
return app_dir_path
@pytest.fixture
def fallback_path(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: pathlib.Path,
qt_data_path: pathlib.Path, # needs patching
application_dir_path: pathlib.Path, # needs patching
):
"""Patch the fallback path to return a temp dir."""
home_path = tmp_path / "home"
monkeypatch.setattr(pakjoy.pathlib.Path, "home", lambda: home_path)
app_path = home_path / f".{pakjoy.objects.qapp.applicationName()}"
app_path.mkdir(parents=True)
return app_path
@pytest.mark.parametrize("create_file", [True, False])
def test_overridden(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, create_file: bool
):
"""Test the overridden path is used."""
override_path = tmp_path / "override"
override_path.mkdir()
monkeypatch.setenv(pakjoy.RESOURCES_ENV_VAR, str(override_path))
if create_file: # should get this no matter if file exists or not
(override_path / pakjoy.PAK_FILENAME).touch()
assert pakjoy._find_webengine_resources() == override_path
@pytest.mark.parametrize("with_subfolder", [True, False])
def test_qt_data_path(self, qt_data_path: pathlib.Path, with_subfolder: bool):
"""Test qtutils.library_path() is used."""
resources_path = qt_data_path
if with_subfolder:
resources_path /= "resources"
resources_path.mkdir()
(resources_path / pakjoy.PAK_FILENAME).touch()
assert pakjoy._find_webengine_resources() == resources_path
def test_application_dir_path(self, application_dir_path: pathlib.Path):
"""Test QApplication.applicationDirPath() is used."""
(application_dir_path / pakjoy.PAK_FILENAME).touch()
assert pakjoy._find_webengine_resources() == application_dir_path
def test_fallback_path(self, fallback_path: pathlib.Path):
"""Test fallback path is used."""
(fallback_path / pakjoy.PAK_FILENAME).touch()
assert pakjoy._find_webengine_resources() == fallback_path
def test_nowhere(self, fallback_path: pathlib.Path):
"""Test we raise if we can't find the resources."""
with pytest.raises(
FileNotFoundError, match="Couldn't find webengine resources dir, candidates:\n*"
):
pakjoy._find_webengine_resources()
def json_without_comments(bytestring):
str_without_comments = "\n".join(
[
line
for line in bytestring.decode("utf-8").split("\n")
if not line.strip().startswith("//")
]
)
return json.loads(str_without_comments)
def read_patched_manifest():
patched_resources = pathlib.Path(os.environ[pakjoy.RESOURCES_ENV_VAR])
with open(patched_resources / pakjoy.PAK_FILENAME, "rb") as fd:
reparsed = pakjoy.PakParser(fd)
return json_without_comments(reparsed.manifest)
skip_if_incompatible = pytest.mark.skipif(
not machinery.IS_QT6
or version.qtwebengine_versions(avoid_init=True).webengine
>= utils.VersionNumber(6, 10),
reason="Only needed for Qt 6; Qt 6.10+ uses gzip storage for manifest",
)
@pytest.mark.usefixtures("affected_version")
@skip_if_incompatible
class TestWithRealResourcesFile:
"""Tests that use the real pak file form the Qt installation."""
def test_happy_path(self):
# Go through the full patching processes with the real resources file from
# the current installation. Make sure our replacement string is in it
# afterwards.
with pakjoy.patch_webengine():
json_manifest = read_patched_manifest()
assert (
pakjoy.REPLACEMENT_URL.decode("utf-8")
in json_manifest["externally_connectable"]["matches"]
)
def test_copying_resources(self):
# Test we managed to copy some files over
work_dir = pakjoy.copy_webengine_resources()
assert work_dir is not None
assert work_dir.exists()
assert work_dir == pathlib.Path(standarddir.cache()) / pakjoy.CACHE_DIR_NAME
assert (work_dir / pakjoy.PAK_FILENAME).exists()
assert len(list(work_dir.glob("*"))) > 1
def test_copying_resources_overwrites(self):
work_dir = pakjoy.copy_webengine_resources()
assert work_dir is not None
tmpfile = work_dir / "tmp.txt"
tmpfile.touch()
pakjoy.copy_webengine_resources()
assert not tmpfile.exists()
@pytest.mark.parametrize("osfunc", ["copytree", "rmtree"])
def test_copying_resources_oserror(self, monkeypatch, caplog, osfunc):
# Test errors from the calls to shutil are handled
pakjoy.copy_webengine_resources() # run twice so we hit rmtree too
caplog.clear()
def raiseme(err):
raise err
monkeypatch.setattr(
pakjoy.shutil, osfunc, lambda *_args: raiseme(PermissionError(osfunc))
)
with caplog.at_level(logging.ERROR, "misc"):
with pakjoy.patch_webengine():
pass
assert caplog.messages == [
"Failed to copy webengine resources, not applying quirk"
]
def test_expected_file_not_found(self, cache_tmpdir, monkeypatch, caplog):
with caplog.at_level(logging.ERROR, "misc"):
pakjoy._patch(pathlib.Path(cache_tmpdir) / "doesntexist")
assert caplog.messages[-1].startswith(
"Resource pak doesn't exist at expected location! "
"Not applying quirks. Expected location: "
)
def test_hardcoded_ids(self):
"""Make sure we hardcoded the currently valid ID.
This avoids users having to iterate through the whole resource file on
every start. It will probably break on every QtWebEngine upgrade and can
be fixed by adding the respective ID to HANGOUTS_IDS.
"""
resources_dir = pakjoy._find_webengine_resources()
file_to_patch = resources_dir / pakjoy.PAK_FILENAME
with open(file_to_patch, "rb") as f:
parser = pakjoy.PakParser(f)
error_msg = (
"Encountered hangouts extension with resource ID which isn't in pakjoy.HANGOUTS_IDS: "
f"found_resource_id={parser.manifest_entry.resource_id} "
f"webengine_version={versions.webengine}"
)
assert parser.manifest_entry.resource_id in pakjoy.HANGOUTS_IDS, error_msg
def json_manifest_factory(extension_id=pakjoy.HANGOUTS_MARKER, url=pakjoy.TARGET_URL):
assert isinstance(extension_id, bytes)
assert isinstance(url, bytes)
return f"""
{{
{extension_id.decode("utf-8")}
"key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAQt2ZDdPfoSe/JI6ID5bgLHRCnCu9T36aYczmhw/tnv6QZB2I6WnOCMZXJZlRdqWc7w9jo4BWhYS50Vb4weMfh/I0On7VcRwJUgfAxW2cHB+EkmtI1v4v/OU24OqIa1Nmv9uRVeX0GjhQukdLNhAE6ACWooaf5kqKlCeK+1GOkQIDAQAB",
"name": "Google Hangouts",
// Note: Always update the version number when this file is updated. Chrome
// triggers extension preferences update on the version increase.
"version": "1.3.21",
"manifest_version": 2,
"externally_connectable": {{
"matches": [
"{url.decode("utf-8")}",
"http://localhost:*/*"
]
}}
}}
""".strip().encode(
"utf-8"
)
def pak_factory(version=5, entries=None, encoding=1, sentinel_position=-1):
if entries is None:
entries = [json_manifest_factory()]
buffer = io.BytesIO()
buffer.write(struct.pack("<I", version))
buffer.write(struct.pack(pakjoy.PakHeader._FORMAT, encoding, len(entries), 0))
entry_headers_size = (len(entries) + 1) * 6
start_of_data = buffer.tell() + entry_headers_size
# Normally the sentinel sits between the headers and the data. But to get
# full coverage we want to insert it in other positions.
with_indices = list(enumerate(entries, 1))
if sentinel_position == -1:
with_indices.append((0, b""))
elif sentinel_position is not None:
with_indices.insert(sentinel_position, (0, b""))
accumulated_data_offset = start_of_data
for idx, entry in with_indices:
buffer.write(struct.pack(pakjoy.PakEntry._FORMAT, idx, accumulated_data_offset))
accumulated_data_offset += len(entry)
for entry in entries:
assert isinstance(entry, bytes)
buffer.write(entry)
buffer.seek(0)
return buffer
@pytest.mark.usefixtures("affected_version")
class TestWithConstructedResourcesFile:
"""Tests that use a constructed pak file to give us more control over it."""
@pytest.mark.parametrize(
"offset",
[0, 42, *pakjoy.HANGOUTS_IDS], # test both slow search and fast path
)
def test_happy_path(self, offset):
entries = [b""] * offset + [json_manifest_factory()]
assert entries[offset] != b""
buffer = pak_factory(entries=entries)
parser = pakjoy.PakParser(buffer)
json_manifest = json_without_comments(parser.manifest)
assert (
pakjoy.TARGET_URL.decode("utf-8")
in json_manifest["externally_connectable"]["matches"]
)
def test_bad_version(self):
buffer = pak_factory(version=99)
with pytest.raises(
binparsing.ParseError,
match="Unsupported .pak version 99",
):
pakjoy.PakParser(buffer)
@pytest.mark.parametrize(
"position, error",
[
(0, "Unexpected sentinel entry"),
(None, "Missing sentinel entry"),
],
)
def test_bad_sentinal_position(self, position, error):
buffer = pak_factory(sentinel_position=position)
with pytest.raises(binparsing.ParseError):
pakjoy.PakParser(buffer)
@pytest.mark.parametrize(
"entry",
[
b"{foo}",
b"V2VsbCBoZWxsbyB0aGVyZQo=",
],
)
def test_marker_not_found(self, entry):
buffer = pak_factory(entries=[entry])
with pytest.raises(
binparsing.ParseError,
match="Couldn't find hangouts manifest",
):
pakjoy.PakParser(buffer)
def test_url_not_found(self):
buffer = pak_factory(entries=[json_manifest_factory(url=b"example.com")])
parser = pakjoy.PakParser(buffer)
with pytest.raises(
binparsing.ParseError,
match="Couldn't find URL in manifest",
):
parser.find_patch_offset()
@pytest.mark.parametrize("explicit", [True, False])
def test_url_not_found_high_level(self, cache_tmpdir, caplog, affected_version, config_stub, message_mock, explicit):
config_stub.val.qt.workarounds.disable_hangouts_extension = explicit
buffer = pak_factory(entries=[json_manifest_factory(url=b"example.com")])
# Write bytes to file so we can test pakjoy._patch()
tmpfile = pathlib.Path(cache_tmpdir) / "bad.pak"
with open(tmpfile, "wb") as fd:
fd.write(buffer.read())
logger = "message" if explicit else "misc"
with caplog.at_level(logging.ERROR, logger):
pakjoy._patch(tmpfile)
if explicit:
msg = message_mock.getmsg(usertypes.MessageLevel.error)
assert msg.text == (
"Failed to disable Hangouts extension:\n"
"Failed to apply quirk to resources pak."
)
else:
assert caplog.messages[-1] == "Failed to apply quirk to resources pak."
@pytest.fixture
def resources_path(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> pathlib.Path:
resources_path = tmp_path / "resources"
resources_path.mkdir()
buffer = pak_factory()
with open(resources_path / pakjoy.PAK_FILENAME, "wb") as fd:
fd.write(buffer.read())
monkeypatch.setattr(pakjoy.qtutils, "library_path", lambda _which: tmp_path)
return resources_path
@pytest.fixture
def quirk_dir_path(self, tmp_path: pathlib.Path) -> pathlib.Path:
return tmp_path / "cache" / pakjoy.CACHE_DIR_NAME
@skip_if_incompatible
def test_patching(self, resources_path: pathlib.Path, quirk_dir_path: pathlib.Path):
"""Go through the full patching processes with a fake resources file."""
with pakjoy.patch_webengine():
assert os.environ[pakjoy.RESOURCES_ENV_VAR] == str(quirk_dir_path)
json_manifest = read_patched_manifest()
assert (
pakjoy.REPLACEMENT_URL.decode("utf-8")
in json_manifest["externally_connectable"]["matches"]
)
assert pakjoy.RESOURCES_ENV_VAR not in os.environ
@skip_if_incompatible
def test_explicitly_enabled(self, monkeypatch: pytest.MonkeyPatch, config_stub):
patch_version(monkeypatch, utils.VersionNumber(6, 7)) # unaffected
config_stub.val.qt.workarounds.disable_hangouts_extension = True
with pakjoy.patch_webengine():
assert pakjoy.RESOURCES_ENV_VAR in os.environ
@skip_if_incompatible
def test_preset_env_var(
self,
resources_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
quirk_dir_path: pathlib.Path,
):
new_resources_path = resources_path.with_name(resources_path.name + "_moved")
shutil.move(resources_path, new_resources_path)
monkeypatch.setenv(pakjoy.RESOURCES_ENV_VAR, str(new_resources_path))
with pakjoy.patch_webengine():
assert os.environ[pakjoy.RESOURCES_ENV_VAR] == str(quirk_dir_path)
assert os.environ[pakjoy.RESOURCES_ENV_VAR] == str(new_resources_path)