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:
parent
ef7d71bb7d
commit
002ff4fcbd
|
|
@ -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'):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
Loading…
Reference in New Issue