fix(plugins): restore args parsing and complete #3021 fixes

Pass plugin paths as strings so HTTPS URLs are not mangled by pathlib.
Fix mixed tab/space indentation in ArchConfig.from_config that broke
imports. Harden plugin import (spec handling, failed-import cleanup),
and skip version comparison when version tuples are empty. Add tests
for URL handling and HTTP rejection.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hariomphulre 2026-05-15 02:06:25 +05:30
parent ef7d71bb7d
commit 002ff4fcbd
3 changed files with 230 additions and 191 deletions

View File

@ -549,8 +549,8 @@ class ArchConfigHandler:
warn(f'Warning: --debug mode will write certain credentials to {logger.path}!') warn(f'Warning: --debug mode will write certain credentials to {logger.path}!')
if args.plugin: if args.plugin:
plugin_path = Path(args.plugin) # pathlib collapses "https://..." to "https:/..." which breaks URL loading (#3021).
load_plugin(plugin_path) load_plugin(args.plugin)
if args.creds_decryption_key is None: if args.creds_decryption_key is None:
if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'): if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'):

View File

@ -44,7 +44,6 @@ def _localize_path(path: str | Path) -> Path:
url = urllib.parse.urlparse(path_str) url = urllib.parse.urlparse(path_str)
if url.scheme and url.scheme in ('https', 'http'): if url.scheme and url.scheme in ('https', 'http'):
# FIXED: Prevent arbitrary code execution over unencrypted HTTP
if url.scheme == 'http': if url.scheme == 'http':
error(f'Insecure HTTP URL {path_str} is not allowed for downloading plugins. Please use HTTPS.') error(f'Insecure HTTP URL {path_str} is not allowed for downloading plugins. Please use HTTPS.')
raise ValueError('Insecure HTTP URLs are blocked for security reasons.') raise ValueError('Insecure HTTP URLs are blocked for security reasons.')
@ -61,10 +60,8 @@ def _localize_path(path: str | Path) -> Path:
converted_path = Path(f'/tmp/{filename_base}_{hashlib.md5(os.urandom(12)).hexdigest()}.py') converted_path = Path(f'/tmp/{filename_base}_{hashlib.md5(os.urandom(12)).hexdigest()}.py')
# FIXED: Open in 'wb' (write-binary) mode to safely write downloaded bytes without assuming UTF-8
with open(converted_path, 'wb') as temp_file: with open(converted_path, 'wb') as temp_file:
try: try:
# FIXED: Added a 15-second timeout and wrapped urlopen in a `with` statement to close the socket cleanly
with urllib.request.urlopen(path_str, timeout=15) as response: with urllib.request.urlopen(path_str, timeout=15) as response:
temp_file.write(response.read()) temp_file.write(response.read())
except urllib.error.URLError as e: except urllib.error.URLError as e:
@ -85,10 +82,16 @@ def _import_via_path(path: Path, namespace: str | None = None) -> str:
try: try:
spec = importlib.util.spec_from_file_location(namespace, path) spec = importlib.util.spec_from_file_location(namespace, path)
if spec and spec.loader: if spec is None or spec.loader is None:
error(
f'Could not load plugin module spec from {path}',
f'The above error was detected when loading the plugin: {path}',
)
return ''
imported = importlib.util.module_from_spec(spec) imported = importlib.util.module_from_spec(spec)
sys.modules[namespace] = imported sys.modules[namespace] = imported
spec.loader.exec_module(sys.modules[namespace]) spec.loader.exec_module(imported)
return namespace return namespace
except Exception as err: except Exception as err:
@ -102,7 +105,7 @@ def _import_via_path(path: Path, namespace: str | None = None) -> str:
except Exception: except Exception:
pass pass
return namespace return ''
def load_plugin(path: str | Path) -> None: def load_plugin(path: str | Path) -> None:
@ -129,17 +132,15 @@ def load_plugin(path: str | Path) -> None:
if version is not None: if version is not None:
version_major_and_minor = version.rsplit('.', 1)[0] version_major_and_minor = version.rsplit('.', 1)[0]
# FIXED: Safely fetch the plugin version attribute, defaulting to "0.0" if missing
plugin_version_raw = getattr(sys.modules[namespace], '__archinstall__version__', '0.0') plugin_version_raw = getattr(sys.modules[namespace], '__archinstall__version__', '0.0')
# FIXED: Safely parse versions into a tuple for integer comparison, preventing the float("2.10") == 2.1 bug
def parse_version(v: str | float) -> tuple[int, ...]: def parse_version(v: str | float) -> tuple[int, ...]:
return tuple(int(x) for x in str(v).split('.') if x.isdigit()) return tuple(int(x) for x in str(v).split('.') if x.isdigit())
plugin_version = parse_version(plugin_version_raw) plugin_version = parse_version(plugin_version_raw)
system_version = parse_version(version_major_and_minor) system_version = parse_version(version_major_and_minor)
if plugin_version < system_version: if plugin_version and system_version and plugin_version < system_version:
error(f'Plugin {sys.modules[namespace]} does not support the current Archinstall version.') error(f'Plugin {sys.modules[namespace]} does not support the current Archinstall version.')
# Locate the plugin entry-point called Plugin() # Locate the plugin entry-point called Plugin()

38
tests/test_plugins.py Normal file
View File

@ -0,0 +1,38 @@
import urllib.parse
from pathlib import Path
import pytest
from pytest import MonkeyPatch
from archinstall.lib.args import ArchConfigHandler
def test_path_corrupts_https_url_authority_issue_3021() -> None:
"""pathlib.Path is not safe for URL strings: POSIX normalization drops one slash after the scheme."""
url = 'https://raw.githubusercontent.com/phisch/archinstall-aur/refs/heads/master/archinstall-aur.py'
broken = urllib.parse.urlparse(str(Path(url)))
assert broken.netloc == ''
assert broken.scheme == 'https'
def test_cli_https_plugin_passes_unparsed_string_to_load_plugin(monkeypatch: MonkeyPatch) -> None:
url = 'https://raw.githubusercontent.com/phisch/archinstall-aur/refs/heads/master/archinstall-aur.py'
received: list[object] = []
def capture(path: object) -> None:
received.append(path)
monkeypatch.setattr('archinstall.lib.args.load_plugin', capture)
monkeypatch.setattr('sys.argv', ['archinstall', '--plugin', url])
ArchConfigHandler()
assert len(received) == 1
parsed = urllib.parse.urlparse(str(received[0]))
assert parsed.scheme == 'https'
assert parsed.netloc == 'raw.githubusercontent.com'
def test_localize_path_rejects_http() -> None:
from archinstall.lib.plugins import _localize_path
with pytest.raises(ValueError, match='Insecure HTTP'):
_localize_path('http://example.com/plugin.py')