diff options
Diffstat (limited to 'tests/unit/misc/test_pakjoy.py')
-rw-r--r-- | tests/unit/misc/test_pakjoy.py | 453 |
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) |