diff options
-rw-r--r-- | qutebrowser/misc/binparsing.py | 1 | ||||
-rw-r--r-- | qutebrowser/misc/pakjoy.py | 10 | ||||
-rw-r--r-- | tests/unit/misc/test_pakjoy.py | 277 |
3 files changed, 284 insertions, 4 deletions
diff --git a/qutebrowser/misc/binparsing.py b/qutebrowser/misc/binparsing.py index 7627ef6cf..81e2e6dbb 100644 --- a/qutebrowser/misc/binparsing.py +++ b/qutebrowser/misc/binparsing.py @@ -41,4 +41,3 @@ def safe_seek(fobj: IO[bytes], pos: int) -> None: fobj.seek(pos) except (OSError, OverflowError) as e: raise ParseError(e) - diff --git a/qutebrowser/misc/pakjoy.py b/qutebrowser/misc/pakjoy.py index ce856bea7..84ab5218b 100644 --- a/qutebrowser/misc/pakjoy.py +++ b/qutebrowser/misc/pakjoy.py @@ -77,8 +77,7 @@ class PakEntry: class PakParser: - """Parse webengine pak and find patch location to disable Google Meet extension. - """ + """Parse webengine pak and find patch location to disable Google Meet extension.""" def __init__(self, fobj: IO[bytes]) -> None: """Parse the .pak file from the given file object.""" @@ -173,7 +172,11 @@ def patch(file_to_patch: pathlib.Path = None): return if not file_to_patch: - file_to_patch = copy_webengine_resources() / "qtwebengine_resources.pak" + try: + file_to_patch = copy_webengine_resources() / "qtwebengine_resources.pak" + except OSError: + log.misc.exception("Failed to copy webengine resources, not applying quirk") + return if not file_to_patch.exists(): log.misc.error( @@ -195,6 +198,7 @@ def patch(file_to_patch: pathlib.Path = None): if __name__ == "__main__": output_test_file = pathlib.Path("/tmp/test.pak") + #shutil.copy("/opt/google/chrome/resources.pak", output_test_file) shutil.copy("/usr/share/qt6/resources/qtwebengine_resources.pak", output_test_file) patch(output_test_file) diff --git a/tests/unit/misc/test_pakjoy.py b/tests/unit/misc/test_pakjoy.py new file mode 100644 index 000000000..5c35d0111 --- /dev/null +++ b/tests/unit/misc/test_pakjoy.py @@ -0,0 +1,277 @@ +# 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 pytest + +from qutebrowser.misc import pakjoy, binparsing +from qutebrowser.utils import utils, version, standarddir + + +pytest.importorskip("qutebrowser.qt.webenginecore") + + +versions = version.qtwebengine_versions(avoid_init=True) + + +@pytest.fixture +def skipifneeded(): + """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. + """ + if versions.webengine != utils.VersionNumber(6, 6): + raise pytest.skip("Code under test only runs on 6.6") + + +@pytest.fixture(autouse=True) +def clean_env(): + yield + if "QTWEBENGINE_RESOURCES_PATH" in os.environ: + del os.environ["QTWEBENGINE_RESOURCES_PATH"] + + +def patch_version(monkeypatch, *args): + monkeypatch.setattr( + pakjoy.version, + "qtwebengine_versions", + lambda **kwargs: version.WebEngineVersions( + webengine=utils.VersionNumber(*args), + chromium=None, + source="unittest", + ) + ) + + +@pytest.fixture +def unaffected_version(monkeypatch): + patch_version(monkeypatch, 6, 6, 1) + + +@pytest.fixture +def affected_version(monkeypatch): + patch_version(monkeypatch, 6, 6) + + +def test_version_gate(unaffected_version, mocker): + + fake_open = mocker.patch("qutebrowser.misc.pakjoy.open") + pakjoy.patch() + assert not fake_open.called + + +@pytest.fixture(autouse=True) +def tmp_cache(tmp_path, monkeypatch): + monkeypatch.setattr(pakjoy.standarddir, "cache", lambda: tmp_path) + return str(tmp_path) + + +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) + + +@pytest.mark.usefixtures("affected_version") +class TestWithRealResourcesFile: + """Tests that use the real pak file form the Qt installation.""" + + def test_happy_path(self, skipifneeded): + # Go through the full patching processes with the real resources file from + # the current installation. Make sure our replacement string is in it + # afterwards. + pakjoy.patch() + + patched_resources = pathlib.Path(os.environ["QTWEBENGINE_RESOURCES_PATH"]) + + with open(patched_resources / "qtwebengine_resources.pak", "rb") as fd: + reparsed = pakjoy.PakParser(fd) + + json_manifest = json_without_comments(reparsed.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.exists() + assert work_dir == standarddir.cache() / "webengine_resources_pak_quirk" + assert (work_dir / "qtwebengine_resources.pak").exists() + assert len(list(work_dir.glob("*"))) > 1 + + def test_copying_resources_overwrites(self): + work_dir = pakjoy.copy_webengine_resources() + 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"): + pakjoy.patch() + assert caplog.messages == ["Failed to copy webengine resources, not applying quirk"] + + def test_expected_file_not_found(self, tmp_cache, monkeypatch, caplog): + with caplog.at_level(logging.ERROR, "misc"): + pakjoy.patch(pathlib.Path(tmp_cache) / "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.Pak5Header._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.""" + + def test_happy_path(self): + buffer = pak_factory() + + 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, tmp_cache, 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(tmp_cache) / "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." + ] |