diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 6779a0b..cf7982d 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -49,7 +49,7 @@ CACHE_PREFS = { } -def _generate_fontconfig(fontconfig_path: str) -> str: +def _generate_fontconfig(fontconfig_path: str, path: Optional[Path] = None) -> str: """ Generates a runtime fontconfig that resolves bundled font paths absolutely. The bundled fonts.conf uses prefix="cwd" relative paths which break when @@ -61,7 +61,8 @@ def _generate_fontconfig(fontconfig_path: str) -> str: """ import hashlib - fonts_dir = get_path("fonts") + # Beside the caller's own binary when they supplied one; see get_env_vars. + fonts_dir = str(path.parent / "fonts") if path else get_path("fonts") fonts_conf_src = os.path.join(fontconfig_path, "fonts.conf") with open(fonts_conf_src, 'r') as f: @@ -86,10 +87,18 @@ def _generate_fontconfig(fontconfig_path: str) -> str: def get_env_vars( - config_map: Dict[str, str], user_agent_os: str + config_map: Dict[str, str], + user_agent_os: str, + path: Optional[Path] = None, ) -> Dict[str, Union[str, float, bool]]: """ Gets a dictionary of environment variables for Camoufox. + + `path` is the caller's own executable, when they supplied one. The bundled + fontconfig is read from beside that binary rather than from the managed + install, the same way _load_properties() already treats properties.json: + a caller running their own build should not be resolved against, or made + to download, a different one. """ env_vars: Dict[str, Union[str, float, bool]] = {} try: @@ -123,9 +132,14 @@ def get_env_vars( os_dir = directory_map.get(user_agent_os, user_agent_os) # v150+ uses "fontconfig/" (matching the Go launcher); older bundles shipped "fontconfigs/". - fontconfig_path = get_path(os.path.join("fontconfig", os_dir)) + def _bundle_path(*parts: str) -> str: + if path: + return str(path.parent.joinpath(*parts)) + return get_path(os.path.join(*parts)) + + fontconfig_path = _bundle_path("fontconfig", os_dir) if not os.path.exists(os.path.join(fontconfig_path, "fonts.conf")): - fontconfig_path = get_path(os.path.join("fontconfigs", os_dir)) + fontconfig_path = _bundle_path("fontconfigs", os_dir) # assert that fonts.conf exists in the directory if not os.path.exists(os.path.join(fontconfig_path, "fonts.conf")): @@ -134,7 +148,7 @@ def get_env_vars( f"fonts.conf not found in {fontconfig_path}! Something ain't right with your camoufox bundle." ) - env_vars['FONTCONFIG_FILE'] = _generate_fontconfig(fontconfig_path) + env_vars['FONTCONFIG_FILE'] = _generate_fontconfig(fontconfig_path, path=path) return env_vars @@ -941,7 +955,7 @@ def launch_options( # Prepare environment variables to pass to Camoufox env_vars = { - **get_env_vars(config, target_os), + **get_env_vars(config, target_os, path=executable_path), **env, } # Prepare the executable path diff --git a/pythonlib/tests/test_executable_path_bundle.py b/pythonlib/tests/test_executable_path_bundle.py new file mode 100644 index 0000000..078cdaa --- /dev/null +++ b/pythonlib/tests/test_executable_path_bundle.py @@ -0,0 +1,56 @@ +"""A caller's own binary must be resolved against itself, not the managed install. + +`executable_path` was honoured for launching and for properties.json, but the +bundled fontconfig and fonts were still read from the managed install via +get_path(). That silently mixed one build's fonts into another's launch, and +once the version floor could actually reject something it became fatal: every +launch raised UnsupportedVersion even though the caller had supplied a perfectly +good binary. +""" + +from pathlib import Path + +import pytest + +from camoufox import pkgman, utils + + +@pytest.fixture +def bundle(tmp_path, monkeypatch): + """A self-contained browser bundle, plus a managed install that must not be touched.""" + bin_dir = tmp_path / "dist" / "bin" + (bin_dir / "fontconfig" / "linux").mkdir(parents=True) + (bin_dir / "fontconfig" / "linux" / "fonts.conf").write_text( + 'fonts' + ) + (bin_dir / "fonts").mkdir() + + def explode(*_args, **_kwargs): + raise AssertionError("resolved against the managed install despite executable_path") + + monkeypatch.setattr(pkgman, "get_path", explode) + monkeypatch.setattr(utils, "get_path", explode) + monkeypatch.setattr(utils, "INSTALL_DIR", tmp_path / "cache") + monkeypatch.setattr(utils, "OS_NAME", "lin") + return bin_dir + + +def test_fontconfig_comes_from_the_supplied_bundle(bundle, monkeypatch): + env = utils.get_env_vars({}, "lin", path=bundle / "camoufox-bin") + + generated = Path(env["FONTCONFIG_FILE"]) + assert generated.is_file() + # The bundled conf's cwd-relative is rewritten to this bundle's fonts. + assert str(bundle / "fonts") in generated.read_text() + + +def test_managed_install_is_used_when_no_path_is_given(tmp_path, monkeypatch): + """Without executable_path the managed install is still the source.""" + calls = [] + monkeypatch.setattr(utils, "OS_NAME", "lin") + monkeypatch.setattr(utils, "get_path", lambda *a: calls.append(a) or "/nonexistent") + + with pytest.raises(Exception): + utils.get_env_vars({}, "lin") + + assert calls, "should have consulted the managed install" diff --git a/pythonlib/tests/test_humanize.py b/pythonlib/tests/test_humanize.py index 1ee2e1d..2275bc1 100644 --- a/pythonlib/tests/test_humanize.py +++ b/pythonlib/tests/test_humanize.py @@ -31,7 +31,7 @@ def captured_launch_config(monkeypatch): monkeypatch.setattr(utils, "launch_path", lambda *_args, **_kwargs: "/camoufox") monkeypatch.setattr(utils.LeakWarning, "warn", lambda *_args, **_kwargs: None) - def capture_env(config, _target_os): + def capture_env(config, _target_os, **_kwargs): captured.clear() captured.update(config) return {}