From 90974f884ec538c5e22f295f2fb33d706cccc0da Mon Sep 17 00:00:00 2001 From: Linyu Yang Date: Mon, 5 Oct 2026 14:37:06 +0800 Subject: [PATCH 1/2] Honor ArkOS4Clone HTTP proxy settings across sudo --- PortMaster/PortMaster.sh | 5 +- PortMaster/arkos_proxy.py | 85 +++++++++++++++++++++++++ tests/test_arkos_proxy.py | 126 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 PortMaster/arkos_proxy.py create mode 100644 tests/test_arkos_proxy.py diff --git a/PortMaster/PortMaster.sh b/PortMaster/PortMaster.sh index 042fb984..85ac0a72 100755 --- a/PortMaster/PortMaster.sh +++ b/PortMaster/PortMaster.sh @@ -21,6 +21,9 @@ source $controlfolder/control.txt get_controls +# Pass proxy settings after sudo, which filters the launcher's environment. +mapfile -t pm_proxy_env < <(python3 "$controlfolder/arkos_proxy.py") + ## TODO: Change to PortMaster/tty when Johnnyonflame merges the changes in, CUR_TTY=/dev/tty0 @@ -172,7 +175,7 @@ fi export PYSDL2_DLL_PATH="/usr/lib" $ESUDO rm -f "${controlfolder}/.pugwash-reboot" while true; do - $ESUDO ./pugwash $PORTMASTER_CMDS + $ESUDO env "${pm_proxy_env[@]}" ./pugwash $PORTMASTER_CMDS if [ ! -f "${controlfolder}/.pugwash-reboot" ]; then break; diff --git a/PortMaster/arkos_proxy.py b/PortMaster/arkos_proxy.py new file mode 100644 index 00000000..99915072 --- /dev/null +++ b/PortMaster/arkos_proxy.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: MIT + +"""Print proxy environment assignments for PortMaster's sudo launch.""" + +import os +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from urllib.parse import urlsplit + + +PROXY_KEYS = ('http_proxy', 'https_proxy', 'all_proxy', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY') +BYPASS_KEYS = ('no_proxy', 'NO_PROXY') + + +def check_value(value): + # Each assignment is one line consumed by Bash's mapfile, never eval. + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError('control character in proxy setting') + return value + + +def proxy_environment(settings_file, environ): + """Prefer explicit proxy variables, otherwise import ArkOS HTTP settings.""" + inherited = {key: check_value(environ[key]) + for key in PROXY_KEYS + BYPASS_KEYS if key in environ} + if any(key in inherited for key in PROXY_KEYS): + return inherited + + settings_file = Path(settings_file) + if not settings_file.is_file(): + return inherited + + # ES settings can contain an XML declaration and multiple top-level nodes. + text = re.sub(r'<\?xml[^>]*\?>', '', settings_file.read_text()) + root = ET.fromstring('' + text + '') + settings = {node.get('name'): node.get('value', '') for node in root.iter()} + if settings.get('ProxyEnabled', '').lower() != 'true': + return inherited + if settings.get('ProxyType', 'http').strip().lower() != 'http': + raise ValueError('only HTTP proxies are supported by this importer') + + raw = check_value(settings.get('ProxyHost', '').strip()) + parsed = urlsplit(raw if '://' in raw else 'http://' + raw) + host = parsed.hostname + if (parsed.scheme != 'http' or not host or + any(char.isspace() for char in host) or + parsed.path not in ('', '/') or parsed.query or parsed.fragment): + raise ValueError('invalid HTTP proxy host') + port = int(settings.get('ProxyPort', '').strip() or parsed.port or 80) + if not 1 <= port <= 65535: + raise ValueError('invalid HTTP proxy port') + if ':' in host: + host = '[' + host + ']' + credentials = parsed.netloc.rsplit('@', 1)[0] + '@' if '@' in parsed.netloc else '' + proxy = 'http://' + credentials + host + ':' + str(port) + bypass = check_value(inherited.get('no_proxy', inherited.get('NO_PROXY', + settings.get('ProxyNoProxy', 'localhost,127.0.0.1,::1')))) + result = {key: proxy for key in ('http_proxy', 'https_proxy', + 'HTTP_PROXY', 'HTTPS_PROXY')} + result.update({key: bypass for key in BYPASS_KEYS}) + result.update(inherited) + return result + + +def main(): + settings_file = Path.home() / '.emulationstation/es_settings.cfg' + # A root launch may retain neither the user's HOME nor SUDO_USER. + if not settings_file.is_file(): + settings_file = Path('/home/ark/.emulationstation/es_settings.cfg') + try: + environment = proxy_environment(settings_file, os.environ) + except (OSError, ValueError, ET.ParseError) as err: + # Do not include configuration values or proxy credentials in logs. + print('PortMaster: unable to import proxy settings (%s).' % + type(err).__name__, file=sys.stderr) + return + for key, value in environment.items(): + print(key + '=' + value) + + +if __name__ == '__main__': + main() diff --git a/tests/test_arkos_proxy.py b/tests/test_arkos_proxy.py new file mode 100644 index 00000000..1817420d --- /dev/null +++ b/tests/test_arkos_proxy.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: MIT + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from xml.sax.saxutils import quoteattr + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'PortMaster/arkos_proxy.py' +spec = importlib.util.spec_from_file_location('arkos_proxy', HELPER) +proxy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(proxy) + + +class ProxyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.settings = Path(self.temp.name) / 'es_settings.cfg' + + def configure(self, **overrides): + values = dict(ProxyEnabled='true', ProxyType='http', + ProxyHost='192.0.2.10', ProxyPort='7890') + values.update(overrides) + self.settings.write_text('\n' + ''.join( + '\n' % (quoteattr(key), quoteattr(value)) + for key, value in values.items())) + + def test_http_and_multi_root_xml(self): + self.configure() + env = proxy.proxy_environment(self.settings, {}) + for key in ('http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY'): + self.assertEqual(env[key], 'http://192.0.2.10:7890') + self.assertEqual(env['no_proxy'], 'localhost,127.0.0.1,::1') + + def test_disabled_and_missing_settings(self): + self.assertEqual(proxy.proxy_environment(self.settings, {}), {}) + self.configure(ProxyEnabled='false') + self.assertEqual(proxy.proxy_environment(self.settings, {}), {}) + + def test_explicit_environment_takes_precedence(self): + # Existing proxy settings work even when the ArkOS file is malformed. + self.settings.write_text('') + for key in proxy.PROXY_KEYS: + with self.subTest(key=key): + inherited = {key: 'http://proxy.example:8080', 'NO_PROXY': '*'} + self.assertEqual(proxy.proxy_environment(self.settings, inherited), inherited) + self.assertEqual(proxy.proxy_environment(self.settings, {'https_proxy': ''}), + {'https_proxy': ''}) + + def test_explicit_bypass_is_preserved(self): + self.configure(ProxyNoProxy='localhost,.example') + for key in proxy.BYPASS_KEYS: + with self.subTest(key=key): + env = proxy.proxy_environment(self.settings, {key: 'custom.example'}) + self.assertEqual(env['no_proxy'], 'custom.example') + self.assertEqual(env['NO_PROXY'], 'custom.example') + + def test_malformed_xml(self): + self.settings.write_text('') + with self.assertRaises(proxy.ET.ParseError): + proxy.proxy_environment(self.settings, {}) + + def test_url_host_ipv6_and_credentials(self): + for host, expected in ( + ('http://proxy.example:8000', 'http://proxy.example:7890'), + ('[::1]', 'http://[::1]:7890'), + ('http://user:pass%40word@proxy.example', + 'http://user:pass%40word@proxy.example:7890')): + with self.subTest(host=host): + self.configure(ProxyHost=host) + self.assertEqual(proxy.proxy_environment(self.settings, {})['http_proxy'], + expected) + + def test_port_in_url(self): + self.configure(ProxyHost='http://proxy.example:8080', ProxyPort='') + self.assertEqual(proxy.proxy_environment(self.settings, {})['http_proxy'], + 'http://proxy.example:8080') + + def test_invalid_settings_are_rejected(self): + for overrides in (dict(ProxyPort='70000'), dict(ProxyPort='0'), + dict(ProxyPort='not-a-port'), dict(ProxyHost=''), + dict(ProxyHost='http://proxy.example/path'), + dict(ProxyType='socks5'), + dict(ProxyNoProxy='localhost\nLD_PRELOAD=bad.so')): + with self.subTest(overrides=overrides): + self.configure(**overrides) + with self.assertRaises(ValueError): + proxy.proxy_environment(self.settings, {}) + + def test_launcher_passes_values_after_environment_filtering(self): + self.configure(ProxyNoProxy='localhost,.example') + env = proxy.proxy_environment(self.settings, {}) + # Execute the actual launch line with a sudo substitute that clears env. + launcher = (ROOT / 'PortMaster/PortMaster.sh').read_text() + launch_line = next(line.strip() for line in launcher.splitlines() + if line.strip().startswith('$ESUDO env ')) + sudo = Path(self.temp.name) / 'filtered-sudo' + sudo.write_text('#!/bin/bash\nexec env -i PATH="$PATH" "$@"\n') + sudo.chmod(0o755) + pugwash = Path(self.temp.name) / 'pugwash' + pugwash.write_text('#!' + sys.executable + '\nimport os,json\n' + 'print(json.dumps(dict(os.environ)))\n') + pugwash.chmod(0o755) + # Include metacharacters to establish they are literal argv values. + env['no_proxy'] = 'localhost;$(touch should-not-exist)' + payload = ''.join(key + '=' + value + '\n' for key, value in env.items()) + command = ('ESUDO=./filtered-sudo\nPORTMASTER_CMDS=\n' + 'mapfile -t pm_proxy_env\n' + launch_line) + bash = os.environ.get('BASH_TEST_EXECUTABLE', 'bash') + result = subprocess.run([bash, '-c', command], input=payload, text=True, + capture_output=True, cwd=self.temp.name, check=True) + child = json.loads(result.stdout) + for key, value in env.items(): + self.assertEqual(child[key], value) + self.assertFalse((Path(self.temp.name) / 'should-not-exist').exists()) + + +if __name__ == '__main__': + unittest.main() From b7b1e195c73bb534fccdfc1c204fd4330a3ed3a6 Mon Sep 17 00:00:00 2001 From: kloptops Date: Fri, 23 Oct 2026 19:43:15 +0800 Subject: [PATCH 2/2] Proxy: read EmulationStation's HTTP proxy in Python, after sudo Reworks the launcher-side import onto the restructured launcher (#350). Instead of a helper run before sudo and passed through `env` on the GUI's launch line, harbourmaster.util.setup_proxy() runs at the start of both main()s: - pugwash's (the GUI and fifo_control, so autoinstall's runtime downloads too) and harbourmaster's CLI, which the launch line alone missed. - No extra python process on every launch, on every CFW. - es_settings.cfg is found from SUDO_USER's home, then HOME, then /home/ark. Kept from the original: the multi-root XML parsing, HTTP only (no PySocks), host/port/control character checks, credentials kept out of the log, and a proxy already in the environment (even an empty one) winning, with an explicit no_proxy kept. A host with a bad port of its own ("proxy:abc") is now refused even when ProxyPort is set. Tests move to pytest (tests/test_proxy.py), including that the bundled requests picks the proxy up and that both main()s set it up before HarbourMaster. conftest keeps main() away from the real settings files. Co-Authored-By: Linyu Yang Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- CLAUDE.md | 1 + PortMaster/arkos_proxy.py | 85 ------ PortMaster/pylibs/harbourmaster/cli.py | 3 + .../pylibs/harbourmaster/util/__init__.py | 4 + PortMaster/pylibs/harbourmaster/util/proxy.py | 145 ++++++++++ PortMaster/pylibs/pugwash/main.py | 4 + tests/conftest.py | 7 + tests/test_arkos_proxy.py | 126 --------- tests/test_cli_commands.py | 10 + tests/test_main.py | 10 + tests/test_proxy.py | 248 ++++++++++++++++++ 11 files changed, 432 insertions(+), 211 deletions(-) delete mode 100644 PortMaster/arkos_proxy.py create mode 100644 PortMaster/pylibs/harbourmaster/util/proxy.py delete mode 100644 tests/test_arkos_proxy.py create mode 100644 tests/test_proxy.py diff --git a/CLAUDE.md b/CLAUDE.md index cfd2f97b..0a1351b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ Known bugs are pinned as `xfail(strict=True)`, so a test fails once its bug is f - `util/`: - `callback.py`: the `Callback` progress interface, `CancelEvent` - `net.py`: fetch and download + - `proxy.py`: `setup_proxy()`, called by both `main()`s after sudo, adds EmulationStation's HTTP proxy (es_settings.cfg, ArkOS4Clone's Wi-Fi settings) to the environment for requests, unless a proxy is set already - `text.py`, `files.py`, `data.py` Every name is re-exported from `harbourmaster.util`. diff --git a/PortMaster/arkos_proxy.py b/PortMaster/arkos_proxy.py deleted file mode 100644 index 99915072..00000000 --- a/PortMaster/arkos_proxy.py +++ /dev/null @@ -1,85 +0,0 @@ -# SPDX-License-Identifier: MIT - -"""Print proxy environment assignments for PortMaster's sudo launch.""" - -import os -import re -import sys -import xml.etree.ElementTree as ET -from pathlib import Path -from urllib.parse import urlsplit - - -PROXY_KEYS = ('http_proxy', 'https_proxy', 'all_proxy', - 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY') -BYPASS_KEYS = ('no_proxy', 'NO_PROXY') - - -def check_value(value): - # Each assignment is one line consumed by Bash's mapfile, never eval. - if any(ord(char) < 32 or ord(char) == 127 for char in value): - raise ValueError('control character in proxy setting') - return value - - -def proxy_environment(settings_file, environ): - """Prefer explicit proxy variables, otherwise import ArkOS HTTP settings.""" - inherited = {key: check_value(environ[key]) - for key in PROXY_KEYS + BYPASS_KEYS if key in environ} - if any(key in inherited for key in PROXY_KEYS): - return inherited - - settings_file = Path(settings_file) - if not settings_file.is_file(): - return inherited - - # ES settings can contain an XML declaration and multiple top-level nodes. - text = re.sub(r'<\?xml[^>]*\?>', '', settings_file.read_text()) - root = ET.fromstring('' + text + '') - settings = {node.get('name'): node.get('value', '') for node in root.iter()} - if settings.get('ProxyEnabled', '').lower() != 'true': - return inherited - if settings.get('ProxyType', 'http').strip().lower() != 'http': - raise ValueError('only HTTP proxies are supported by this importer') - - raw = check_value(settings.get('ProxyHost', '').strip()) - parsed = urlsplit(raw if '://' in raw else 'http://' + raw) - host = parsed.hostname - if (parsed.scheme != 'http' or not host or - any(char.isspace() for char in host) or - parsed.path not in ('', '/') or parsed.query or parsed.fragment): - raise ValueError('invalid HTTP proxy host') - port = int(settings.get('ProxyPort', '').strip() or parsed.port or 80) - if not 1 <= port <= 65535: - raise ValueError('invalid HTTP proxy port') - if ':' in host: - host = '[' + host + ']' - credentials = parsed.netloc.rsplit('@', 1)[0] + '@' if '@' in parsed.netloc else '' - proxy = 'http://' + credentials + host + ':' + str(port) - bypass = check_value(inherited.get('no_proxy', inherited.get('NO_PROXY', - settings.get('ProxyNoProxy', 'localhost,127.0.0.1,::1')))) - result = {key: proxy for key in ('http_proxy', 'https_proxy', - 'HTTP_PROXY', 'HTTPS_PROXY')} - result.update({key: bypass for key in BYPASS_KEYS}) - result.update(inherited) - return result - - -def main(): - settings_file = Path.home() / '.emulationstation/es_settings.cfg' - # A root launch may retain neither the user's HOME nor SUDO_USER. - if not settings_file.is_file(): - settings_file = Path('/home/ark/.emulationstation/es_settings.cfg') - try: - environment = proxy_environment(settings_file, os.environ) - except (OSError, ValueError, ET.ParseError) as err: - # Do not include configuration values or proxy credentials in logs. - print('PortMaster: unable to import proxy settings (%s).' % - type(err).__name__, file=sys.stderr) - return - for key, value in environment.items(): - print(key + '=' + value) - - -if __name__ == '__main__': - main() diff --git a/PortMaster/pylibs/harbourmaster/cli.py b/PortMaster/pylibs/harbourmaster/cli.py index 921116cf..2bd23e50 100644 --- a/PortMaster/pylibs/harbourmaster/cli.py +++ b/PortMaster/pylibs/harbourmaster/cli.py @@ -22,6 +22,7 @@ add_list_unique, runtime_nicename, ) +from harbourmaster.util import setup_proxy LOG_FILE = harbourmaster.HM_TOOLS_DIR / "PortMaster" / "harbourmaster.txt" @@ -623,6 +624,8 @@ def main(argv): elif config['force-colour']: console.do_color(True) + setup_proxy() + ccb = ConsoleCallback(config) hm = HarbourMaster(config, temp_dir=temp_dir, callback=ccb) diff --git a/PortMaster/pylibs/harbourmaster/util/__init__.py b/PortMaster/pylibs/harbourmaster/util/__init__.py index df900aa9..87dd6dd0 100644 --- a/PortMaster/pylibs/harbourmaster/util/__init__.py +++ b/PortMaster/pylibs/harbourmaster/util/__init__.py @@ -36,6 +36,9 @@ get_path_fs, make_temp_directory, ) +from .proxy import ( + setup_proxy, + ) from .data import ( json_safe_loads, json_safe_load, @@ -85,6 +88,7 @@ 'remove_dict_list', 'remove_pm_signature', 'runtime_nicename', + 'setup_proxy', 'timeit', 'version_parse', 'PORT_SORT_FUNCS', diff --git a/PortMaster/pylibs/harbourmaster/util/proxy.py b/PortMaster/pylibs/harbourmaster/util/proxy.py new file mode 100644 index 00000000..f92d0026 --- /dev/null +++ b/PortMaster/pylibs/harbourmaster/util/proxy.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: MIT +# +# HTTP proxy settings from EmulationStation (ArkOS4Clone's Wi-Fi settings). +# +# EmulationStation keeps the proxy in es_settings.cfg and uses it for its own requests, +# without exporting http_proxy & co, and sudo drops those anyway. PortMaster reads it +# itself after sudo, so the GUI, autoinstall and the CLI all use it (requests honours +# the variables). + +import os +import re +import xml.etree.ElementTree as ET +from pathlib import Path +from urllib.parse import urlsplit + +from loguru import logger + + +PROXY_KEYS = ('http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY') +NO_PROXY_KEYS = ('no_proxy', 'NO_PROXY') + +DEFAULT_NO_PROXY = "localhost,127.0.0.1,::1" + + +def es_settings_files(environ): + """ + Where es_settings.cfg may be. PortMaster runs as root through sudo, so the + frontend user's home comes first: SUDO_USER's, then HOME, then ArkOS' ark. + """ + homes = [] + + sudo_user = environ.get('SUDO_USER') + if sudo_user: + try: + import pwd + homes.append(pwd.getpwnam(sudo_user).pw_dir) + except (ImportError, KeyError): + pass + + if environ.get('HOME'): + homes.append(environ['HOME']) + + homes.append('/home/ark') + + files = [] + for home in homes: + settings_file = Path(home) / '.emulationstation' / 'es_settings.cfg' + if settings_file not in files: + files.append(settings_file) + + return files + + +def _check_value(value): + ## These end up in the environment of everything PortMaster runs. + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError('control character in proxy setting') + + return value + + +def es_proxy_settings(settings_file): + """ + {key: value} to add to the environment for es_settings.cfg's HTTP proxy, or {} when + it has none enabled. Raises ValueError (or ET.ParseError, OSError) for bad settings. + """ + ## ES settings can have an XML declaration and several top-level nodes. + text = re.sub(r'<\?xml[^>]*\?>', '', Path(settings_file).read_text()) + root = ET.fromstring('' + text + '') + settings = {node.get('name'): node.get('value', '') for node in root.iter()} + + if settings.get('ProxyEnabled', '').strip().lower() != 'true': + return {} + + ## SOCKS would need PySocks, which isn't bundled. + if settings.get('ProxyType', 'http').strip().lower() != 'http': + raise ValueError('only HTTP proxies are supported') + + raw = _check_value(settings.get('ProxyHost', '').strip()) + parsed = urlsplit(raw if '://' in raw else 'http://' + raw) + host = parsed.hostname + if (parsed.scheme != 'http' or not host or + any(char.isspace() for char in host) or + parsed.path not in ('', '/') or parsed.query or parsed.fragment): + raise ValueError('invalid HTTP proxy host') + + ## (.port raises ValueError for a bad one, eg: "proxy:abc", even when ProxyPort is set.) + host_port = parsed.port + port = int(settings.get('ProxyPort', '').strip() or host_port or 80) + if not 1 <= port <= 65535: + raise ValueError('invalid HTTP proxy port') + + if ':' in host: + host = '[' + host + ']' + + credentials = parsed.netloc.rsplit('@', 1)[0] + '@' if '@' in parsed.netloc else '' + proxy = 'http://' + credentials + host + ':' + str(port) + no_proxy = _check_value(settings.get('ProxyNoProxy', DEFAULT_NO_PROXY).strip()) + + result = {key: proxy for key in ('http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY')} + result.update({key: no_proxy for key in NO_PROXY_KEYS}) + return result + + +def setup_proxy(environ=None, settings_files=None): + """ + Adds EmulationStation's HTTP proxy to `environ` (os.environ), unless a proxy is set + there already (even an empty one). A no_proxy already set is kept. Returns True when + a proxy was added. Never raises, and never logs the proxy (it can hold a password). + """ + if environ is None: + environ = os.environ + + if any(key in environ for key in PROXY_KEYS): + return False + + if settings_files is None: + settings_files = es_settings_files(environ) + + for settings_file in settings_files: + try: + if not settings_file.is_file(): + continue + + settings = es_proxy_settings(settings_file) + + except (OSError, ValueError, ET.ParseError) as err: + logger.warning(f"Unable to use the proxy settings in {settings_file} ({type(err).__name__}).") + return False + + if not settings: + ## The frontend's settings say no proxy. + return False + + explicit_no_proxy = next((environ[key] for key in NO_PROXY_KEYS if key in environ), None) + for key, value in settings.items(): + if key in NO_PROXY_KEYS and explicit_no_proxy is not None: + value = explicit_no_proxy + + environ[key] = value + + logger.info(f"Using the HTTP proxy from {settings_file}.") + return True + + return False diff --git a/PortMaster/pylibs/pugwash/main.py b/PortMaster/pylibs/pugwash/main.py index 80b4eceb..74bcbc18 100644 --- a/PortMaster/pylibs/pugwash/main.py +++ b/PortMaster/pylibs/pugwash/main.py @@ -8,6 +8,7 @@ from loguru import logger from pugwash.scenes import BlankScene from harbourmaster import HarbourMaster, make_temp_directory +from harbourmaster.util import setup_proxy from gettext import gettext as _ from pugwash import PORTMASTER_DEBUG from pugwash.app import PortMasterGUI @@ -93,6 +94,9 @@ def main(argv): elif config['force-colour']: console.do_color(True) + ## Before anything goes online: the update check, sources, autoinstall's runtimes. + setup_proxy() + if not get_ip_address(): config['offline'] = True logger.warning("No internet connection found, running in offline mode.") diff --git a/tests/conftest.py b/tests/conftest.py index 09ac4cbe..3bb690e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -431,3 +431,10 @@ def reset_hardware_cache(): harbourmaster.hardware._CACHED_DICT = None yield harbourmaster.hardware._CACHED_DICT = None + + +@pytest.fixture(autouse=True) +def no_real_proxy_settings(monkeypatch): + """main() reads EmulationStation's proxy settings: never the real ones (devices run the suite too).""" + from harbourmaster.util import proxy + monkeypatch.setattr(proxy, "es_settings_files", lambda environ: []) diff --git a/tests/test_arkos_proxy.py b/tests/test_arkos_proxy.py deleted file mode 100644 index 1817420d..00000000 --- a/tests/test_arkos_proxy.py +++ /dev/null @@ -1,126 +0,0 @@ -# SPDX-License-Identifier: MIT - -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from xml.sax.saxutils import quoteattr - - -ROOT = Path(__file__).resolve().parents[1] -HELPER = ROOT / 'PortMaster/arkos_proxy.py' -spec = importlib.util.spec_from_file_location('arkos_proxy', HELPER) -proxy = importlib.util.module_from_spec(spec) -spec.loader.exec_module(proxy) - - -class ProxyTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.settings = Path(self.temp.name) / 'es_settings.cfg' - - def configure(self, **overrides): - values = dict(ProxyEnabled='true', ProxyType='http', - ProxyHost='192.0.2.10', ProxyPort='7890') - values.update(overrides) - self.settings.write_text('\n' + ''.join( - '\n' % (quoteattr(key), quoteattr(value)) - for key, value in values.items())) - - def test_http_and_multi_root_xml(self): - self.configure() - env = proxy.proxy_environment(self.settings, {}) - for key in ('http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY'): - self.assertEqual(env[key], 'http://192.0.2.10:7890') - self.assertEqual(env['no_proxy'], 'localhost,127.0.0.1,::1') - - def test_disabled_and_missing_settings(self): - self.assertEqual(proxy.proxy_environment(self.settings, {}), {}) - self.configure(ProxyEnabled='false') - self.assertEqual(proxy.proxy_environment(self.settings, {}), {}) - - def test_explicit_environment_takes_precedence(self): - # Existing proxy settings work even when the ArkOS file is malformed. - self.settings.write_text('') - for key in proxy.PROXY_KEYS: - with self.subTest(key=key): - inherited = {key: 'http://proxy.example:8080', 'NO_PROXY': '*'} - self.assertEqual(proxy.proxy_environment(self.settings, inherited), inherited) - self.assertEqual(proxy.proxy_environment(self.settings, {'https_proxy': ''}), - {'https_proxy': ''}) - - def test_explicit_bypass_is_preserved(self): - self.configure(ProxyNoProxy='localhost,.example') - for key in proxy.BYPASS_KEYS: - with self.subTest(key=key): - env = proxy.proxy_environment(self.settings, {key: 'custom.example'}) - self.assertEqual(env['no_proxy'], 'custom.example') - self.assertEqual(env['NO_PROXY'], 'custom.example') - - def test_malformed_xml(self): - self.settings.write_text('') - with self.assertRaises(proxy.ET.ParseError): - proxy.proxy_environment(self.settings, {}) - - def test_url_host_ipv6_and_credentials(self): - for host, expected in ( - ('http://proxy.example:8000', 'http://proxy.example:7890'), - ('[::1]', 'http://[::1]:7890'), - ('http://user:pass%40word@proxy.example', - 'http://user:pass%40word@proxy.example:7890')): - with self.subTest(host=host): - self.configure(ProxyHost=host) - self.assertEqual(proxy.proxy_environment(self.settings, {})['http_proxy'], - expected) - - def test_port_in_url(self): - self.configure(ProxyHost='http://proxy.example:8080', ProxyPort='') - self.assertEqual(proxy.proxy_environment(self.settings, {})['http_proxy'], - 'http://proxy.example:8080') - - def test_invalid_settings_are_rejected(self): - for overrides in (dict(ProxyPort='70000'), dict(ProxyPort='0'), - dict(ProxyPort='not-a-port'), dict(ProxyHost=''), - dict(ProxyHost='http://proxy.example/path'), - dict(ProxyType='socks5'), - dict(ProxyNoProxy='localhost\nLD_PRELOAD=bad.so')): - with self.subTest(overrides=overrides): - self.configure(**overrides) - with self.assertRaises(ValueError): - proxy.proxy_environment(self.settings, {}) - - def test_launcher_passes_values_after_environment_filtering(self): - self.configure(ProxyNoProxy='localhost,.example') - env = proxy.proxy_environment(self.settings, {}) - # Execute the actual launch line with a sudo substitute that clears env. - launcher = (ROOT / 'PortMaster/PortMaster.sh').read_text() - launch_line = next(line.strip() for line in launcher.splitlines() - if line.strip().startswith('$ESUDO env ')) - sudo = Path(self.temp.name) / 'filtered-sudo' - sudo.write_text('#!/bin/bash\nexec env -i PATH="$PATH" "$@"\n') - sudo.chmod(0o755) - pugwash = Path(self.temp.name) / 'pugwash' - pugwash.write_text('#!' + sys.executable + '\nimport os,json\n' - 'print(json.dumps(dict(os.environ)))\n') - pugwash.chmod(0o755) - # Include metacharacters to establish they are literal argv values. - env['no_proxy'] = 'localhost;$(touch should-not-exist)' - payload = ''.join(key + '=' + value + '\n' for key, value in env.items()) - command = ('ESUDO=./filtered-sudo\nPORTMASTER_CMDS=\n' - 'mapfile -t pm_proxy_env\n' + launch_line) - bash = os.environ.get('BASH_TEST_EXECUTABLE', 'bash') - result = subprocess.run([bash, '-c', command], input=payload, text=True, - capture_output=True, cwd=self.temp.name, check=True) - child = json.loads(result.stdout) - for key, value in env.items(): - self.assertEqual(child[key], value) - self.assertFalse((Path(self.temp.name) / 'should-not-exist').exists()) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index baa55b9e..6624969d 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -486,3 +486,13 @@ def test_main_no_log_without_a_log_file(run_main, monkeypatch, capsys): assert run_main("--offline", "--no-log", "ports")[0] == 0 assert "No ports found." in capsys.readouterr().out assert None not in removed + + +def test_main_sets_up_the_proxy(run_main, monkeypatch): + calls = [] + monkeypatch.setattr(cli, "setup_proxy", lambda: calls.append(True)) + + result, config = run_main("--offline", "nothing") + + assert result == 0 + assert calls == [True] diff --git a/tests/test_main.py b/tests/test_main.py index 8dc58b2a..a616fd4b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -203,3 +203,13 @@ def test_frontend_refresh(on_device, es_name, changed, expected): calls = [call if isinstance(call, str) else call[0] for call in FakeGUI.made[-1].calls] assert calls == ['run'] + expected + ['quit'] + + +def test_proxy_is_set_up_first(main, monkeypatch): + ## Before the update check, HarbourMaster's sources and autoinstall go online. + calls = [] + monkeypatch.setattr(main, "setup_proxy", lambda: calls.append(len(main.hms))) + + assert run(main) == 0 + assert run(main, "fifo_control", "pipe", "done") == 0 + assert calls == [0, 1] diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 00000000..a2946a58 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: MIT +# +# HTTP proxy settings from EmulationStation's es_settings.cfg (ArkOS4Clone's Wi-Fi +# settings), set up by pugwash and harbourmaster after sudo. + +from pathlib import Path +from types import SimpleNamespace +from xml.sax.saxutils import quoteattr + +import pytest + +from harbourmaster.util import proxy + + +PROXY = 'http://192.0.2.10:7890' + +## conftest's no_real_proxy_settings swaps it out during tests. +es_settings_files = proxy.es_settings_files + + +def capture_log(level): + from loguru import logger + + messages = [] + handler_id = logger.add(lambda message: messages.append(message.record["message"]), level=level) + return messages, handler_id + + +@pytest.fixture +def log_warnings(): + from loguru import logger + + messages, handler_id = capture_log("WARNING") + yield messages + logger.remove(handler_id) + + +@pytest.fixture +def log_messages(): + from loguru import logger + + messages, handler_id = capture_log("DEBUG") + yield messages + logger.remove(handler_id) + + +@pytest.fixture +def settings(tmp_path): + settings_file = tmp_path / "home" / ".emulationstation" / "es_settings.cfg" + settings_file.parent.mkdir(parents=True) + + def write(**overrides): + values = dict(ProxyEnabled='true', ProxyType='http', ProxyHost='192.0.2.10', ProxyPort='7890') + values.update(overrides) + settings_file.write_text('\n' + ''.join( + f'\n' + for key, value in values.items() if value is not None)) + return settings_file + + write.path = settings_file + return write + + +def setup_with(environ, *files): + return proxy.setup_proxy(environ, settings_files=list(files)) + + +################################################################################ +## es_settings.cfg +def test_http_proxy(settings): + env = proxy.es_proxy_settings(settings()) + + for key in ('http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY'): + assert env[key] == PROXY + assert env['no_proxy'] == env['NO_PROXY'] == 'localhost,127.0.0.1,::1' + + +def test_settings_in_a_config_root(settings): + ## Some ES builds write one root, others several top-level nodes. + settings.path.write_text( + '\n\n' + '\t\n' + '\t\n' + '\t\n' + '\n') + + assert proxy.es_proxy_settings(settings.path)['http_proxy'] == 'http://proxy.lan:3128' + + +@pytest.mark.parametrize('enabled', ['false', '', None]) +def test_proxy_disabled(settings, enabled): + assert proxy.es_proxy_settings(settings(ProxyEnabled=enabled)) == {} + + +@pytest.mark.parametrize('host, expected', [ + ('http://proxy.example:8000', 'http://proxy.example:7890'), + ('[::1]', 'http://[::1]:7890'), + ('http://user:pass%40word@proxy.example', 'http://user:pass%40word@proxy.example:7890'), + ]) +def test_proxy_hosts(settings, host, expected): + assert proxy.es_proxy_settings(settings(ProxyHost=host))['http_proxy'] == expected + + +def test_port_from_the_host(settings): + assert proxy.es_proxy_settings(settings(ProxyHost='http://proxy.example:8080', ProxyPort=''))['http_proxy'] == 'http://proxy.example:8080' + + +def test_default_port(settings): + assert proxy.es_proxy_settings(settings(ProxyPort=''))['http_proxy'] == 'http://192.0.2.10:80' + + +def test_no_proxy_from_the_settings(settings): + assert proxy.es_proxy_settings(settings(ProxyNoProxy='localhost,.lan'))['no_proxy'] == 'localhost,.lan' + + +@pytest.mark.parametrize('overrides', [ + dict(ProxyPort='70000'), dict(ProxyPort='0'), dict(ProxyPort='not-a-port'), + dict(ProxyHost=''), dict(ProxyHost='http://proxy.example/path'), dict(ProxyHost='proxy.example:abc'), + dict(ProxyHost='https://proxy.example'), dict(ProxyType='socks5'), + dict(ProxyHost='proxy\nLD_PRELOAD=bad.so'), dict(ProxyNoProxy='localhost\nLD_PRELOAD=bad.so'), + ], ids=lambda overrides: ','.join(overrides)) +def test_bad_settings(settings, overrides): + with pytest.raises(ValueError): + proxy.es_proxy_settings(settings(**overrides)) + + +################################################################################ +## setup_proxy +def test_setup_adds_the_proxy(settings): + environ = {} + + assert setup_with(environ, settings()) is True + assert environ['https_proxy'] == PROXY + assert environ['no_proxy'] == 'localhost,127.0.0.1,::1' + + +@pytest.mark.parametrize('key', proxy.PROXY_KEYS) +@pytest.mark.parametrize('value', ['http://other:8080', '']) +def test_a_proxy_already_set_wins(settings, key, value): + ## Even an empty one: that's someone saying no proxy. The settings aren't even read. + settings.path.write_text('