diff options
author | toofar <toofar@spalge.com> | 2023-12-02 13:34:55 +1300 |
---|---|---|
committer | toofar <toofar@spalge.com> | 2023-12-02 13:36:14 +1300 |
commit | 23a168926262f2304652baee57b7793c87ffe359 (patch) | |
tree | 01806daeb5c9a4e1b9ee51400d46c4d6866c687f /tests | |
parent | 7d5acb697086c903d485679f3dc754777c8e6ef5 (diff) | |
parent | 75c78cadc4c7391df4654798b4b6de0c96007597 (diff) | |
download | qutebrowser-23a168926262f2304652baee57b7793c87ffe359.tar.gz qutebrowser-23a168926262f2304652baee57b7793c87ffe359.zip |
Merge branch 'main' into pr/7992
This is just to get the CI to re-trigger.
Apparently the checkout action doesn't actually checkout the branch
being proposed for merge but checks out an actual merge commit. So if
you push a PR while main is broken it'll say changes from main are on
your branch. That's a little unexpected.
main is fixed now and I tried re-running the CI jobs from the web UI but
they are still failing with the same errors. Hence this merge of main
just to get a change on the branch. I could have gone and found a typo
to fix instead. I know I've left enough of them around.
ref: https://github.com/actions/checkout/issues/881
Diffstat (limited to 'tests')
-rw-r--r-- | tests/conftest.py | 7 | ||||
-rw-r--r-- | tests/end2end/fixtures/quteprocess.py | 3 | ||||
-rw-r--r-- | tests/end2end/test_invocations.py | 89 | ||||
-rw-r--r-- | tests/unit/config/test_qtargs.py | 14 | ||||
-rw-r--r-- | tests/unit/misc/test_elf.py | 6 | ||||
-rw-r--r-- | tests/unit/misc/test_pakjoy.py | 441 |
6 files changed, 530 insertions, 30 deletions
diff --git a/tests/conftest.py b/tests/conftest.py index 2fea48c43..9d7c5c29c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -197,7 +197,12 @@ def qapp_args(): """Make QtWebEngine unit tests run on older Qt versions + newer kernels.""" if testutils.disable_seccomp_bpf_sandbox(): return [sys.argv[0], testutils.DISABLE_SECCOMP_BPF_FLAG] - return [sys.argv[0]] + + # Disabling PaintHoldingCrossOrigin makes tests needing UI interaction with + # QtWebEngine more reliable. + # Only needed with QtWebEngine and Qt 6.5, but Qt just ignores arguments it + # doesn't know about anyways. + return [sys.argv[0], "--webEngineArgs", "--disable-features=PaintHoldingCrossOrigin"] @pytest.fixture(scope='session') diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index a2f870e32..de9b490ca 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -390,7 +390,8 @@ class QuteProc(testprocess.Process): '--json-logging', '--loglevel', 'vdebug', '--backend', backend, '--debug-flag', 'no-sql-history', '--debug-flag', 'werror', '--debug-flag', - 'test-notification-service'] + 'test-notification-service', + '--qt-flag', 'disable-features=PaintHoldingCrossOrigin'] if self.request.config.webengine and testutils.disable_seccomp_bpf_sandbox(): args += testutils.DISABLE_SECCOMP_BPF_ARGS diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index af81781f6..a55efb129 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -15,6 +15,7 @@ import re import json import platform from contextlib import nullcontext as does_not_raise +from unittest.mock import ANY import pytest from qutebrowser.qt.core import QProcess, QPoint @@ -882,30 +883,86 @@ def test_sandboxing( line.expected = True pytest.skip("chrome://sandbox/ not supported") + if len(text.split("\n")) == 1: + # Try again, maybe the JS hasn't run yet? + text = quteproc_new.get_content() + print(text) + bpf_text = "Seccomp-BPF sandbox" yama_text = "Ptrace Protection with Yama LSM" - header, *lines, empty, result = text.split("\n") - assert not empty - - expected_status = { - "Layer 1 Sandbox": "Namespace" if has_namespaces else "None", + if not utils.is_windows: + header, *lines, empty, result = text.split("\n") + assert not empty - "PID namespaces": "Yes" if has_namespaces else "No", - "Network namespaces": "Yes" if has_namespaces else "No", + expected_status = { + "Layer 1 Sandbox": "Namespace" if has_namespaces else "None", - bpf_text: "Yes" if has_seccomp else "No", - f"{bpf_text} supports TSYNC": "Yes" if has_seccomp else "No", + "PID namespaces": "Yes" if has_namespaces else "No", + "Network namespaces": "Yes" if has_namespaces else "No", - f"{yama_text} (Broker)": "Yes" if has_yama else "No", - f"{yama_text} (Non-broker)": "Yes" if has_yama_non_broker else "No", - } + bpf_text: "Yes" if has_seccomp else "No", + f"{bpf_text} supports TSYNC": "Yes" if has_seccomp else "No", - assert header == "Sandbox Status" - assert result == expected_result + f"{yama_text} (Broker)": "Yes" if has_yama else "No", + f"{yama_text} (Non-broker)": "Yes" if has_yama_non_broker else "No", + } - status = dict(line.split("\t") for line in lines) - assert status == expected_status + assert header == "Sandbox Status" + assert result == expected_result + + status = dict(line.split("\t") for line in lines) + assert status == expected_status + + else: # utils.is_windows + # The sandbox page on Windows if different that Linux and macOS. It's + # a lot more complex. There is a table up top with lots of columns and + # a row per tab and helper process then a json object per row down + # below with even more detail (which we ignore). + # https://www.chromium.org/Home/chromium-security/articles/chrome-sandbox-diagnostics-for-windows/ + + # We're not getting full coverage of the table and there doesn't seem + # to be a simple summary like for linux. The "Sandbox" and "Lockdown" + # column are probably the key ones. + # We are looking at all the rows in the table for the sake of + # completeness, but I expect there will always be just one row with a + # renderer process in it for this test. If other helper processes pop + # up we might want to exclude them. + lines = text.split("\n") + assert lines.pop(0) == "Sandbox Status" + header = lines.pop(0).split("\t") + rows = [] + current_line = lines.pop(0) + while current_line.strip(): + if lines[0].startswith("\t"): + # Continuation line. Not sure how to 100% identify them + # but new rows should start with a process ID. + current_line += lines.pop(0) + continue + + columns = current_line.split("\t") + assert len(header) == len(columns) + rows.append(dict(zip(header, columns))) + current_line = lines.pop(0) + + assert rows + + # I'm using has_namespaces as a proxy for "should be sandboxed" here, + # which is a bit lazy but its either that or match on the text + # "sandboxing" arg. The seccomp-bpf arg does nothing on windows, so + # we only have the off and on states. + for row in rows: + assert row == { + "Process": ANY, + "Type": "Renderer", + "Name": "", + "Sandbox": "Renderer" if has_namespaces else "Not Sandboxed", + "Lockdown": "Lockdown" if has_namespaces else "", + "Integrity": ANY if has_namespaces else "", + "Mitigations": ANY if has_namespaces else "", + "Component Filter": ANY if has_namespaces else "", + "Lowbox/AppContainer": "", + } @pytest.mark.not_frozen diff --git a/tests/unit/config/test_qtargs.py b/tests/unit/config/test_qtargs.py index 419faad12..2414d4ba9 100644 --- a/tests/unit/config/test_qtargs.py +++ b/tests/unit/config/test_qtargs.py @@ -156,14 +156,12 @@ class TestWebEngineArgs: assert '--enable-in-process-stack-traces' not in args @pytest.mark.parametrize( - 'qt_version, qt6, value, has_arg', + 'qt6, value, has_arg', [ - ('5.15.2', False, 'auto', False), - ('6.5.3', True, 'auto', True), - ('6.6.0', True, 'auto', False), - ('6.5.3', True, 'always', True), - ('6.5.3', True, 'never', False), - ('6.6.0', True, 'always', True), + (False, 'auto', False), + (True, 'auto', True), + (True, 'always', True), + (True, 'never', False), ], ) def test_accelerated_2d_canvas( @@ -172,12 +170,10 @@ class TestWebEngineArgs: version_patcher, config_stub, monkeypatch, - qt_version, qt6, value, has_arg, ): - version_patcher(qt_version) config_stub.val.qt.workarounds.disable_accelerated_2d_canvas = value monkeypatch.setattr(machinery, 'IS_QT6', qt6) diff --git a/tests/unit/misc/test_elf.py b/tests/unit/misc/test_elf.py index 88b984dcd..6ae23357c 100644 --- a/tests/unit/misc/test_elf.py +++ b/tests/unit/misc/test_elf.py @@ -9,7 +9,7 @@ import pytest import hypothesis from hypothesis import strategies as hst -from qutebrowser.misc import elf +from qutebrowser.misc import elf, binparsing from qutebrowser.utils import utils @@ -117,7 +117,7 @@ def test_find_versions(data, expected): ), ]) def test_find_versions_invalid(data, message): - with pytest.raises(elf.ParseError) as excinfo: + with pytest.raises(binparsing.ParseError) as excinfo: elf._find_versions(data) assert str(excinfo.value) == message @@ -132,5 +132,5 @@ def test_hypothesis(data): fobj = io.BytesIO(data) try: elf._parse_from_file(fobj) - except elf.ParseError as e: + except binparsing.ParseError as e: print(e) diff --git a/tests/unit/misc/test_pakjoy.py b/tests/unit/misc/test_pakjoy.py new file mode 100644 index 000000000..65d02ec7e --- /dev/null +++ b/tests/unit/misc/test_pakjoy.py @@ -0,0 +1,441 @@ +# 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, *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) + + +@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_ID], # 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) |