From 817a1adef32fecb96951f8b273c012748e219693 Mon Sep 17 00:00:00 2001 From: pentago <876756+pentago@users.noreply.github.com> Date: Sun, 17 May 2026 21:25:02 +0200 Subject: [PATCH] Add AUR helper installation support Adds an "Enable AUR" entry to the global menu allowing the user to choose between paru and yay. The selected helper is built from source inside the chroot, running as the first sudo user under a temporary NOPASSWD-pacman sudoers entry that is removed in a finally block. Source builds avoid the libalpm soname mismatch that can occur with prebuilt -bin packages. The feature gracefully no-ops when no AUR helper is configured. The menu item is gated behind the presence of a regular user account; attempting --silent installs without one raises RequirementError. Signed-off-by: pentago <876756+pentago@users.noreply.github.com> --- archinstall/applications/aur_helper.py | 69 ++++++++ archinstall/lib/args.py | 11 ++ archinstall/lib/aur/__init__.py | 0 archinstall/lib/aur/aur_handler.py | 40 +++++ archinstall/lib/aur/aur_menu.py | 77 +++++++++ archinstall/lib/global_menu.py | 26 +++ archinstall/lib/models/aur.py | 57 +++++++ archinstall/locales/base.pot | 24 +++ archinstall/scripts/guided.py | 3 + tests/conftest.py | 5 + tests/data/test_config_aur.json | 214 +++++++++++++++++++++++++ tests/test_configuration_output.py | 25 +++ 12 files changed, 551 insertions(+) create mode 100644 archinstall/applications/aur_helper.py create mode 100644 archinstall/lib/aur/__init__.py create mode 100644 archinstall/lib/aur/aur_handler.py create mode 100644 archinstall/lib/aur/aur_menu.py create mode 100644 archinstall/lib/models/aur.py create mode 100644 tests/data/test_config_aur.json diff --git a/archinstall/applications/aur_helper.py b/archinstall/applications/aur_helper.py new file mode 100644 index 0000000000..90aee9a632 --- /dev/null +++ b/archinstall/applications/aur_helper.py @@ -0,0 +1,69 @@ +import shlex +from typing import TYPE_CHECKING + +from archinstall.lib.exceptions import PackageError, SysCallError +from archinstall.lib.models.aur import AURHelperConfiguration +from archinstall.lib.models.users import User +from archinstall.lib.output import debug, info +from archinstall.lib.translationhandler import tr + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AURHelperApp: + """Bootstraps an AUR helper (paru / yay) inside the chroot. + + The helper package name comes from a controlled ``AURHelper`` enum value, + and the build username is validated upstream by archinstall's user-creation + path, so shell interpolation of these values is safe. Dynamic file system + paths are still passed through ``shlex.quote``. + """ + + _SUDOERS_FILE = 'etc/sudoers.d/99_aur_build' + + def install( + self, + install_session: Installer, + helper_config: AURHelperConfiguration, + build_user: User, + ) -> None: + install_session.add_additional_packages(['base-devel', 'git']) + + sudoers_path = install_session.target / self._SUDOERS_FILE + sudoers_path.write_text(f'{build_user.username} ALL=(ALL) NOPASSWD: /usr/bin/pacman\n') + sudoers_path.chmod(0o440) + + helper_pkg = helper_config.helper.value + # Build inside the user's $HOME rather than /tmp: arch-chroot -S spawns a + # transient systemd-run unit and the chroot's /tmp is the on-disk + # directory (mode 0755, root-owned) since no boot-time tmpfs mount has + # happened, so non-root writes to /tmp fail. Build tools like Go also + # default TMPDIR to /tmp, so we redirect TMPDIR for the same reason. + build_subdir = f'.cache/aur-build/{helper_pkg}' + quoted_build = shlex.quote(build_subdir) + tmp_env = 'TMPDIR="$HOME/.cache/aur-build/tmp"' + + try: + info(tr('Installing AUR helper {}').format(helper_config.helper.value)) + install_session.arch_chroot( + f'rm -rf -- {quoted_build} && mkdir -p -- "$HOME/.cache/aur-build/tmp" "$(dirname -- {quoted_build})"', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'git clone https://aur.archlinux.org/{helper_pkg}.git {quoted_build}', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'cd {quoted_build} && {tmp_env} makepkg -si --noconfirm', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'rm -rf -- {quoted_build} "$HOME/.cache/aur-build/tmp"', + run_as=build_user.username, + ) + except SysCallError as e: + debug(f'AUR helper install failed: {e}') + raise PackageError(tr('Failed to install AUR helper: {}').format(e)) from e + finally: + sudoers_path.unlink(missing_ok=True) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 14aff6c69c..fafa4cdb86 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -16,6 +16,7 @@ from archinstall.lib.crypt import decrypt from archinstall.lib.menu.util import get_password from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration +from archinstall.lib.models.aur import AURConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.config import SubConfig @@ -76,6 +77,7 @@ class ArchConfigType(StrEnum): NETWORK_CONFIG = 'network_config' BOOTLOADER_CONFIG = 'bootloader_config' APP_CONFIG = 'app_config' + AUR_CONFIG = 'aur_config' AUTH_CONFIG = 'auth_config' SWAP = 'swap' USERS = 'users' @@ -112,6 +114,8 @@ def text(self) -> str: return tr('Bootloader') case ArchConfigType.APP_CONFIG: return tr('Application') + case ArchConfigType.AUR_CONFIG: + return tr('Enable AUR') case ArchConfigType.AUTH_CONFIG: return tr('Authentication') case ArchConfigType.SWAP: @@ -152,6 +156,7 @@ class ArchConfig: network_config: NetworkConfiguration | None = None bootloader_config: BootloaderConfiguration | None = None app_config: ApplicationConfiguration | None = None + aur_config: AURConfiguration | None = None auth_config: AuthenticationConfiguration | None = None swap: ZramConfiguration | None = None hostname: str = 'archlinux' @@ -240,6 +245,9 @@ def sub_cfg(self) -> dict[ArchConfigType, SubConfig]: if self.app_config: cfg[ArchConfigType.APP_CONFIG] = self.app_config + if self.aur_config: + cfg[ArchConfigType.AUR_CONFIG] = self.aur_config + return cfg @classmethod @@ -307,6 +315,9 @@ def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self: if audio_config_args is not None or app_config_args is not None: arch_config.app_config = ApplicationConfiguration.parse_arg(app_config_args, audio_config_args) + if aur_config_args := args_config.get('aur_config', None): + arch_config.aur_config = AURConfiguration.parse_arg(aur_config_args) + if auth_config_args := args_config.get('auth_config', None): arch_config.auth_config = AuthenticationConfiguration.parse_arg(auth_config_args) diff --git a/archinstall/lib/aur/__init__.py b/archinstall/lib/aur/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/archinstall/lib/aur/aur_handler.py b/archinstall/lib/aur/aur_handler.py new file mode 100644 index 0000000000..21bef4d5a5 --- /dev/null +++ b/archinstall/lib/aur/aur_handler.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +from archinstall.applications.aur_helper import AURHelperApp +from archinstall.lib.exceptions import RequirementError +from archinstall.lib.models.aur import AURConfiguration +from archinstall.lib.models.users import User +from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AURHandler: + """Coordinates AUR helper installation as part of a guided install. + + The build user is resolved deterministically: first user with ``sudo=True``, + falling back to the first user in ``users``. If no users are configured the + handler raises ``RequirementError`` so ``--silent`` runs surface the missing + dependency immediately. + """ + + def install_aur( + self, + install_session: Installer, + aur_config: AURConfiguration | None, + users: list[User], + ) -> None: + if aur_config is None or aur_config.helper_config is None: + debug('AUR: no helper configured, skipping') + return + + build_user = next((u for u in users if u.sudo), None) or (users[0] if users else None) + + if build_user is None: + raise RequirementError( + tr('AUR helper requires a non-root user account. Configure one under Authentication.'), + ) + + AURHelperApp().install(install_session, aur_config.helper_config, build_user) diff --git a/archinstall/lib/aur/aur_menu.py b/archinstall/lib/aur/aur_menu.py new file mode 100644 index 0000000000..f12fb854b2 --- /dev/null +++ b/archinstall/lib/aur/aur_menu.py @@ -0,0 +1,77 @@ +from typing import override + +from archinstall.lib.menu.abstract_menu import AbstractSubMenu +from archinstall.lib.menu.helpers import Selection +from archinstall.lib.models.aur import ( + AURConfiguration, + AURHelper, + AURHelperConfiguration, +) +from archinstall.lib.translationhandler import tr +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType + + +class AURMenu(AbstractSubMenu[AURConfiguration]): + def __init__( + self, + preset: AURConfiguration | None = None, + ): + if preset: + self._aur_config = preset + else: + self._aur_config = AURConfiguration() + + menu_options = self._define_menu_options() + self._item_group = MenuItemGroup(menu_options, checkmarks=True) + + super().__init__( + self._item_group, + config=self._aur_config, + allow_reset=True, + ) + + @override + async def show(self) -> AURConfiguration | None: + _ = await super().show() + return self._aur_config + + def _define_menu_options(self) -> list[MenuItem]: + return [ + MenuItem( + text=tr('Enable AUR Helper'), + action=select_aur_helper, + value=self._aur_config.helper_config, + preview_action=self._prev_helper, + key='helper_config', + ), + ] + + def _prev_helper(self, item: MenuItem) -> str | None: + if item.value is not None: + config: AURHelperConfiguration = item.value + return f'{tr("AUR helper")}: {config.helper.value}' + return None + + +async def select_aur_helper(preset: AURHelperConfiguration | None = None) -> AURHelperConfiguration | None: + items = [MenuItem(h.value, value=h) for h in AURHelper] + group = MenuItemGroup(items) + + if preset: + group.set_focus_by_value(preset.helper) + + result = await Selection[AURHelper]( + group, + header=tr('Enable AUR Helper'), + allow_skip=True, + allow_reset=True, + ).show() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return AURHelperConfiguration(helper=result.get_value()) + case ResultType.Reset: + return None diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index eac936bdd0..17362549c7 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -3,6 +3,7 @@ from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu from archinstall.lib.args import ArchConfig +from archinstall.lib.aur.aur_menu import AURMenu from archinstall.lib.authentication.authentication_menu import AuthenticationMenu from archinstall.lib.bootloader.bootloader_menu import BootloaderMenu from archinstall.lib.bootloader.utils import validate_bootloader_layout @@ -16,6 +17,7 @@ from archinstall.lib.mirror.mirror_handler import MirrorListHandler from archinstall.lib.mirror.mirror_menu import MirrorMenu from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration +from archinstall.lib.models.aur import AURConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, PartitionModification @@ -136,6 +138,12 @@ def _get_menu_options(self) -> list[MenuItem]: preview_action=self._prev_applications, key='app_config', ), + MenuItem( + text=tr('Enable AUR'), + action=self._select_aur, + preview_action=self._prev_aur, + key='aur_config', + ), MenuItem( text=tr('Network configuration'), action=select_network, @@ -235,6 +243,13 @@ def check(s: str) -> bool: if not check(item.key): missing.add(item.text) + aur_item: MenuItem = self._item_group.find_by_key('aur_config') + aur_config: AURConfiguration | None = aur_item.value + if aur_config and aur_config.helper_config and not (auth_config and auth_config.has_regular_user()): + missing.add( + tr('AUR helper requires a non-root user account. Configure one under Authentication.'), + ) + return list(missing) @override @@ -267,6 +282,17 @@ async def _select_applications(self, preset: ApplicationConfiguration | None) -> app_config = await ApplicationMenu(preset).show() return app_config + async def _select_aur(self, preset: AURConfiguration | None) -> AURConfiguration | None: + aur_config = await AURMenu(preset).show() + return aur_config + + def _prev_aur(self, item: MenuItem) -> str | None: + if item.value is not None: + aur_config: AURConfiguration = item.value + if aur_config.helper_config: + return f'{tr("AUR helper")}: {aur_config.helper_config.helper.value}' + return None + async def _select_authentication(self, preset: AuthenticationConfiguration | None) -> AuthenticationConfiguration | None: auth_config = await AuthenticationMenu(preset).show() return auth_config diff --git a/archinstall/lib/models/aur.py b/archinstall/lib/models/aur.py new file mode 100644 index 0000000000..29c8eb2d83 --- /dev/null +++ b/archinstall/lib/models/aur.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, NotRequired, Self, TypedDict, override + +from archinstall.lib.models.config import SubConfig +from archinstall.lib.translationhandler import tr + + +class AURHelper(StrEnum): + PARU = 'paru' + YAY = 'yay' + + +class AURHelperConfigSerialization(TypedDict): + helper: str + + +class AURConfigSerialization(TypedDict, total=False): + helper_config: NotRequired[AURHelperConfigSerialization] + + +@dataclass +class AURHelperConfiguration: + helper: AURHelper + + def json(self) -> AURHelperConfigSerialization: + return {'helper': self.helper.value} + + @classmethod + def parse_arg(cls, arg: dict[str, Any]) -> Self: + return cls(helper=AURHelper(arg['helper'])) + + +@dataclass +class AURConfiguration(SubConfig): + helper_config: AURHelperConfiguration | None = None + + @classmethod + def parse_arg(cls, args: dict[str, Any] | None = None) -> Self: + cfg = cls() + if args and (helper_config := args.get('helper_config')) is not None: + cfg.helper_config = AURHelperConfiguration.parse_arg(helper_config) + return cfg + + @override + def json(self) -> AURConfigSerialization: + config: AURConfigSerialization = {} + if self.helper_config: + config['helper_config'] = self.helper_config.json() + return config + + @override + def summary(self) -> list[str]: + out: list[str] = [] + if self.helper_config: + out.append(tr('AUR helper "{}"').format(self.helper_config.helper.value)) + return out diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index e16815264a..383a166d71 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -2463,3 +2463,27 @@ msgstr "" msgid "Enter a repository name" msgstr "" + +msgid "Enable AUR" +msgstr "" + +msgid "Enable AUR Helper" +msgstr "" + +msgid "AUR helper" +msgstr "" + +msgid "AUR helper requires a non-root user account. Configure one under Authentication." +msgstr "" + +#, python-brace-format +msgid "Installing AUR helper {}" +msgstr "" + +#, python-brace-format +msgid "Failed to install AUR helper: {}" +msgstr "" + +#, python-brace-format +msgid "AUR helper \"{}\"" +msgstr "" diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index aab02cfd4d..e373e3361f 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -4,6 +4,7 @@ from archinstall.lib.applications.application_handler import ApplicationHandler from archinstall.lib.args import ArchConfig, ArchConfigHandler +from archinstall.lib.aur.aur_handler import AURHandler from archinstall.lib.authentication.authentication_handler import AuthenticationHandler from archinstall.lib.bootloader.utils import validate_bootloader_layout from archinstall.lib.configuration import ConfigurationOutput @@ -138,6 +139,8 @@ def perform_installation( if app_config := config.app_config: application_handler.install_applications(installation, app_config) + AURHandler().install_aur(installation, config.aur_config, users or []) + if profile_config := config.profile_config: profile_handler.install_profile_config(installation, profile_config) diff --git a/tests/conftest.py b/tests/conftest.py index 819c839716..d3efe8af7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,11 @@ def config_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_config.json' +@pytest.fixture(scope='session') +def aur_config_fixture() -> Path: + return Path(__file__).parent / 'data' / 'test_config_aur.json' + + @pytest.fixture(scope='session') def btrfs_config_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_config_btrfs.json' diff --git a/tests/data/test_config_aur.json b/tests/data/test_config_aur.json new file mode 100644 index 0000000000..15d37b6815 --- /dev/null +++ b/tests/data/test_config_aur.json @@ -0,0 +1,214 @@ +{ + "archinstall-language": "English", + "script": "test_script", + "app_config": { + "bluetooth_config": { + "enabled": true + }, + "audio_config": { + "audio": "pipewire" + }, + "print_service_config": { + "enabled": true + } + }, + "auth_config": { + "u2f_config": { + "passwordless_sudo": true, + "u2f_login_method": "passwordless" + } + }, + "audio_config": { + "audio": "pipewire" + }, + "bootloader_config": { + "bootloader": "Systemd-boot", + "uki": false, + "removable": false + }, + "services": [ + "service_1", + "service_2" + ], + "disk_config": { + "config_type": "default_layout", + "btrfs_options": { + "snapshot_config": { + "type": "Snapper" + } + }, + "device_modifications": [ + { + "device": "/dev/sda", + "partitions": [ + { + "btrfs": [], + "dev_path": null, + "flags": [ + "boot" + ], + "fs_type": "fat32", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 512 + }, + "mount_options": [], + "mountpoint": "/boot", + "obj_id": "2c3fa2d5-2c79-4fab-86ec-22d0ea1543c0", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 1 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "dev_path": null, + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "GiB", + "value": 32 + }, + "mount_options": [], + "mountpoint": "/", + "obj_id": "3e7018a0-363b-4d05-ab83-8e82d13db208", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 513 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "dev_path": null, + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "GiB", + "value": 32 + }, + "mount_options": [], + "mountpoint": "/home", + "obj_id": "ce58b139-f041-4a06-94da-1f8bad775d3f", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 33281 + }, + "status": "create", + "type": "primary" + } + ], + "wipe": true + } + ] + }, + "hostname": "archy", + "kernels": [ + "linux-zen" + ], + "locale_config": { + "kb_layout": "us", + "sys_enc": "UTF-8", + "sys_lang": "en_US" + }, + "mirror_config": { + "custom_servers": [ + { + "url": "https://mymirror.com/$repo/os/$arch" + } + ], + "mirror_regions": { + "Australia": [ + "http://archlinux.mirror.digitalpacific.com.au/$repo/os/$arch" + ] + }, + "optional_repositories": [ + "testing" + ], + "custom_repositories": [ + { + "name": "myrepo", + "url": "https://myrepo.com/$repo/os/$arch", + "sign_check": "Required", + "sign_option": "TrustAll" + } + ] + }, + "network_config": { + "type": "manual", + "nics": [ + { + "iface": "eno1", + "ip": "192.168.1.15/24", + "dhcp": true, + "gateway": "192.168.1.1", + "dns": [ + "192.168.1.1", + "9.9.9.9" + ] + } + ] + }, + "ntp": true, + "packages": [ + "firefox" + ], + "parallel_downloads": 66, + "profile_config": { + "gfx_driver": "All open-source", + "greeter": "lightdm-gtk-greeter", + "profile": { + "custom_settings": { + "Hyprland": { + "seat_access": "polkit" + }, + "Sway": { + "seat_access": "seatd" + } + }, + "details": [ + "Sway", + "Hyprland" + ], + "main": "Desktop" + } + }, + "custom_commands": [ + "echo 'Hello, World!'" + ], + "swap": false, + "timezone": "UTC", + "version": "3.0.2", + "aur_config": { + "helper_config": { + "helper": "paru" + } + } +} \ No newline at end of file diff --git a/tests/test_configuration_output.py b/tests/test_configuration_output.py index 7d9f1f97cf..8454b8f46d 100644 --- a/tests/test_configuration_output.py +++ b/tests/test_configuration_output.py @@ -66,3 +66,28 @@ def test_creds_roundtrip( expected = json.loads(creds_fixture.read_text()) assert sorted(result.items()) == sorted(expected.items()) + + +def test_aur_config_roundtrip( + monkeypatch: MonkeyPatch, + aur_config_fixture: Path, +) -> None: + monkeypatch.setattr('sys.argv', ['archinstall', '--config', str(aur_config_fixture)]) + + handler = ArchConfigHandler() + arch_config = handler.config + arch_config.version = '3.0.2' + + assert arch_config.aur_config is not None + assert arch_config.aur_config.helper_config is not None + assert arch_config.aur_config.helper_config.helper.value == 'paru' + + config_output = ConfigurationOutput(arch_config) + test_out_dir = Path('/tmp/') + test_out_file = test_out_dir / config_output.user_configuration_file + config_output.save(test_out_dir) + + result = json.loads(test_out_file.read_text()) + expected = json.loads(aur_config_fixture.read_text()) + + assert result['aur_config'] == expected['aur_config']