summaryrefslogtreecommitdiff
path: root/tests/unit/misc/test_pakjoy.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/unit/misc/test_pakjoy.py')
-rw-r--r--tests/unit/misc/test_pakjoy.py453
1 files changed, 453 insertions, 0 deletions
diff --git a/tests/unit/misc/test_pakjoy.py b/tests/unit/misc/test_pakjoy.py
new file mode 100644
index 000000000..2dcfbd5b1
--- /dev/null
+++ b/tests/unit/misc/test_pakjoy.py
@@ -0,0 +1,453 @@
+# 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.misc import pakjoy, binparsing
+from qutebrowser.utils import utils, version, standarddir
+
+
+pytest.importorskip("qutebrowser.qt.webenginecore")
+
+
+pytestmark = pytest.mark.usefixtures("cache_tmpdir")
+
+
+versions = version.qtwebengine_versions(avoid_init=True)
+
+
+# Used to skip happy path tests with the real resources file.
+#
+# Since we don't know how reliably the Google Meet hangouts extensions is
+# reliably in the resource files, and this quirk is only targeting 6.6
+# anyway.
+skip_if_unsupported = pytest.mark.skipif(
+ versions.webengine != utils.VersionNumber(6, 6),
+ reason="Code under test only runs on 6.6",
+)
+
+
+@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, 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()
+
+
+def test_escape_hatch(affected_version, mocker, monkeypatch):
+ 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(
+ binparsing.ParseError, match="Couldn't find webengine resources dir"
+ ):
+ 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)
+
+
+@pytest.mark.usefixtures("affected_version")
+class TestWithRealResourcesFile:
+ """Tests that use the real pak file form the Qt installation."""
+
+ @skip_if_unsupported
+ 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 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()
+
+ def test_url_not_found_high_level(self, cache_tmpdir, caplog, affected_version):
+ 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())
+
+ with caplog.at_level(logging.ERROR, "misc"):
+ pakjoy._patch(tmpfile)
+
+ assert caplog.messages == ["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
+
+ 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
+
+ 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)