From 71cd6d46f862e6cc48be1111300507952de00112 Mon Sep 17 00:00:00 2001 From: Softer Date: Sat, 18 Apr 2026 08:49:19 +0300 Subject: [PATCH 1/4] Fix Limine install with ESP mounted outside /boot Place limine.conf next to the EFI binary on the ESP so it is found regardless of ESP mountpoint, and block unbootable layouts (non-UKI Limine with ESP not at /boot and no separate /boot partition) in GlobalMenu validation, guided.main() and _add_limine_bootloader(). Fixes #4333 --- archinstall/lib/global_menu.py | 6 ++++++ archinstall/lib/installer.py | 16 ++++++++++----- archinstall/scripts/guided.py | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 2baef93d41..2fe28cefab 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import override from archinstall.default_profiles.profile import GreeterType @@ -492,6 +493,11 @@ def _validate_bootloader(self) -> str | None: if bootloader == Bootloader.Limine: if boot_partition.fs_type not in [FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32]: return 'Limine does not support booting with a non-FAT boot partition' + if self._uefi and efi_partition and boot_partition == efi_partition and efi_partition.mountpoint != Path('/boot') and not bootloader_config.uki: + return ( + f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_partition.mountpoint}, ' + 'enable UKI or add a separate /boot partition' + ) elif bootloader == Bootloader.Refind: if not self._uefi: diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 9f8c51b2bd..9978d64764 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1466,6 +1466,16 @@ def _add_limine_bootloader( elif not efi_partition.mountpoint: raise ValueError('EFI partition is not mounted') + # Limine can only read FAT filesystems. When the ESP doubles as + # the boot partition but is mounted outside /boot (e.g. /efi, + # /boot/efi) and UKI is disabled, kernels end up on the root + # filesystem under /boot/ which Limine cannot access. + if boot_partition == efi_partition and efi_partition.mountpoint != Path('/boot') and not uki_enabled: + raise DiskError( + f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_partition.mountpoint}, ' + 'so enable UKI or add a separate /boot partition to install Limine.', + ) + info(f'Limine EFI partition: {efi_partition.dev_path}') parent_dev_path = get_parent_device_path(efi_partition.safe_dev_path) @@ -1476,15 +1486,11 @@ def _add_limine_bootloader( if bootloader_removable: efi_dir_path = efi_dir_path / 'BOOT' efi_dir_path_target = efi_dir_path_target / 'BOOT' - - boot_limine_path = self.target / 'boot' / 'limine' - boot_limine_path.mkdir(parents=True, exist_ok=True) - config_path = boot_limine_path / 'limine.conf' else: efi_dir_path = efi_dir_path / 'arch-limine' efi_dir_path_target = efi_dir_path_target / 'arch-limine' - config_path = efi_dir_path / 'limine.conf' + config_path = efi_dir_path / 'limine.conf' efi_dir_path.mkdir(parents=True, exist_ok=True) diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 3413372253..882806dc14 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -1,6 +1,7 @@ import os import sys import time +from pathlib import Path from archinstall.lib.applications.application_handler import ApplicationHandler from archinstall.lib.args import ArchConfig, ArchConfigHandler @@ -195,6 +196,35 @@ def perform_installation( pass +def _check_bootloader_layout(config: ArchConfig) -> str | None: + """Validate bootloader configuration against disk layout. + + Returns an error message if the configuration would produce an + unbootable system, or None if it is valid. + """ + # Limine can only read FAT. When the ESP is the boot partition but + # mounted outside /boot and UKI is disabled, the kernel ends up on the + # root filesystem which Limine cannot access. + if not (config.bootloader_config and config.bootloader_config.bootloader == Bootloader.Limine and not config.bootloader_config.uki and config.disk_config): + return None + + efi_part = next( + (p for m in config.disk_config.device_modifications if (p := m.get_efi_partition())), + None, + ) + boot_part = next( + (p for m in config.disk_config.device_modifications if (p := m.get_boot_partition())), + None, + ) + + if efi_part and boot_part == efi_part and efi_part.mountpoint != Path('/boot'): + return ( + f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_part.mountpoint}, ' + 'enable UKI or add a separate /boot partition to install Limine.' + ) + return None + + def main(arch_config_handler: ArchConfigHandler | None = None) -> None: if arch_config_handler is None: arch_config_handler = ArchConfigHandler() @@ -211,6 +241,12 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None: config.write_debug() config.save() + # Safety net for silent/config-file flow. The TUI menu blocks Install via + # GlobalMenu._validate_bootloader() before reaching this point. + if err_msg := _check_bootloader_layout(arch_config_handler.config): + error(err_msg) + return + if arch_config_handler.args.dry_run: return From d7f6b7f871e06e1508560194b93585018a9addcf Mon Sep 17 00:00:00 2001 From: Softer Date: Mon, 20 Apr 2026 15:11:28 +0300 Subject: [PATCH 2/4] Extract bootloader layout validation into lib/bootloader/utils --- archinstall/lib/bootloader/utils.py | 36 +++++++++++++++++++++++++++++ archinstall/scripts/guided.py | 36 ++++------------------------- 2 files changed, 41 insertions(+), 31 deletions(-) create mode 100644 archinstall/lib/bootloader/utils.py diff --git a/archinstall/lib/bootloader/utils.py b/archinstall/lib/bootloader/utils.py new file mode 100644 index 0000000000..0f1ae9b5c6 --- /dev/null +++ b/archinstall/lib/bootloader/utils.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration +from archinstall.lib.models.device import DiskLayoutConfiguration + + +def validate_bootloader_layout( + bootloader_config: BootloaderConfiguration | None, + disk_config: DiskLayoutConfiguration | None, +) -> str | None: + """Validate bootloader configuration against disk layout. + + Returns an error message if the configuration would produce an + unbootable system, or None if it is valid. + """ + # Limine can only read FAT. When the ESP is the boot partition but + # mounted outside /boot and UKI is disabled, the kernel ends up on the + # root filesystem which Limine cannot access. + if not (bootloader_config and bootloader_config.bootloader == Bootloader.Limine and not bootloader_config.uki and disk_config): + return None + + efi_part = next( + (p for m in disk_config.device_modifications if (p := m.get_efi_partition())), + None, + ) + boot_part = next( + (p for m in disk_config.device_modifications if (p := m.get_boot_partition())), + None, + ) + + if efi_part and boot_part == efi_part and efi_part.mountpoint != Path('/boot'): + return ( + f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_part.mountpoint}, ' + 'enable UKI or add a separate /boot partition to install Limine.' + ) + return None diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 882806dc14..30cb14da88 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -1,11 +1,11 @@ import os import sys import time -from pathlib import Path from archinstall.lib.applications.application_handler import ApplicationHandler from archinstall.lib.args import ArchConfig, ArchConfigHandler from archinstall.lib.authentication.authentication_handler import AuthenticationHandler +from archinstall.lib.bootloader.utils import validate_bootloader_layout from archinstall.lib.configuration import ConfigurationOutput from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.disk.utils import disk_layouts @@ -196,35 +196,6 @@ def perform_installation( pass -def _check_bootloader_layout(config: ArchConfig) -> str | None: - """Validate bootloader configuration against disk layout. - - Returns an error message if the configuration would produce an - unbootable system, or None if it is valid. - """ - # Limine can only read FAT. When the ESP is the boot partition but - # mounted outside /boot and UKI is disabled, the kernel ends up on the - # root filesystem which Limine cannot access. - if not (config.bootloader_config and config.bootloader_config.bootloader == Bootloader.Limine and not config.bootloader_config.uki and config.disk_config): - return None - - efi_part = next( - (p for m in config.disk_config.device_modifications if (p := m.get_efi_partition())), - None, - ) - boot_part = next( - (p for m in config.disk_config.device_modifications if (p := m.get_boot_partition())), - None, - ) - - if efi_part and boot_part == efi_part and efi_part.mountpoint != Path('/boot'): - return ( - f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_part.mountpoint}, ' - 'enable UKI or add a separate /boot partition to install Limine.' - ) - return None - - def main(arch_config_handler: ArchConfigHandler | None = None) -> None: if arch_config_handler is None: arch_config_handler = ArchConfigHandler() @@ -243,7 +214,10 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None: # Safety net for silent/config-file flow. The TUI menu blocks Install via # GlobalMenu._validate_bootloader() before reaching this point. - if err_msg := _check_bootloader_layout(arch_config_handler.config): + if err_msg := validate_bootloader_layout( + arch_config_handler.config.bootloader_config, + arch_config_handler.config.disk_config, + ): error(err_msg) return From 70a9797774587fffdad319eb4460cf63dba62bda Mon Sep 17 00:00:00 2001 From: Softer Date: Tue, 21 Apr 2026 16:59:11 +0300 Subject: [PATCH 3/4] Consolidate Limine layout validation in bootloader utils Move the boot-partition FAT check from GlobalMenu into validate_bootloader_layout so all three call sites (GlobalMenu, guided.py, Installer._add_limine_bootloader) share one function. Return a BootloaderValidationFailure dataclass (kind + description) instead of str | None, so callers can match on the failure kind and the description is built where partition context is in scope. --- archinstall/lib/bootloader/utils.py | 71 ++++++++++++++++++++--------- archinstall/lib/global_menu.py | 19 +++----- archinstall/lib/installer.py | 19 ++++---- archinstall/scripts/guided.py | 4 +- 4 files changed, 67 insertions(+), 46 deletions(-) diff --git a/archinstall/lib/bootloader/utils.py b/archinstall/lib/bootloader/utils.py index 0f1ae9b5c6..3d77bafe57 100644 --- a/archinstall/lib/bootloader/utils.py +++ b/archinstall/lib/bootloader/utils.py @@ -1,36 +1,65 @@ +from dataclasses import dataclass +from enum import Enum, auto from pathlib import Path from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration -from archinstall.lib.models.device import DiskLayoutConfiguration +from archinstall.lib.models.device import DiskLayoutConfiguration, FilesystemType + +_FAT_FILESYSTEMS = (FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32) + + +class BootloaderValidationFailureKind(Enum): + LimineNonFatBoot = auto() + LimineLayout = auto() + + +@dataclass(frozen=True) +class BootloaderValidationFailure: + kind: BootloaderValidationFailureKind + description: str def validate_bootloader_layout( bootloader_config: BootloaderConfiguration | None, disk_config: DiskLayoutConfiguration | None, -) -> str | None: +) -> BootloaderValidationFailure | None: """Validate bootloader configuration against disk layout. - Returns an error message if the configuration would produce an - unbootable system, or None if it is valid. + Returns a failure with a human-readable description if the configuration + would produce an unbootable system, or None if it is valid. """ - # Limine can only read FAT. When the ESP is the boot partition but - # mounted outside /boot and UKI is disabled, the kernel ends up on the - # root filesystem which Limine cannot access. - if not (bootloader_config and bootloader_config.bootloader == Bootloader.Limine and not bootloader_config.uki and disk_config): + if not (bootloader_config and disk_config): return None - efi_part = next( - (p for m in disk_config.device_modifications if (p := m.get_efi_partition())), - None, - ) - boot_part = next( - (p for m in disk_config.device_modifications if (p := m.get_boot_partition())), - None, - ) - - if efi_part and boot_part == efi_part and efi_part.mountpoint != Path('/boot'): - return ( - f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_part.mountpoint}, ' - 'enable UKI or add a separate /boot partition to install Limine.' + if bootloader_config.bootloader == Bootloader.Limine: + boot_part = next( + (p for m in disk_config.device_modifications if (p := m.get_boot_partition())), + None, ) + + # Limine reads its config and kernels from the boot partition, which + # must be FAT. + if boot_part and boot_part.fs_type not in _FAT_FILESYSTEMS: + return BootloaderValidationFailure( + kind=BootloaderValidationFailureKind.LimineNonFatBoot, + description='Limine does not support booting with a non-FAT boot partition.', + ) + + # When the ESP is the boot partition but mounted outside /boot and + # UKI is disabled, kernels end up on the root filesystem which + # Limine cannot access. + if not bootloader_config.uki: + efi_part = next( + (p for m in disk_config.device_modifications if (p := m.get_efi_partition())), + None, + ) + if efi_part and efi_part == boot_part and efi_part.mountpoint != Path('/boot'): + return BootloaderValidationFailure( + kind=BootloaderValidationFailureKind.LimineLayout, + description=( + f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_part.mountpoint}, ' + 'enable UKI or add a separate /boot partition to install Limine.' + ), + ) + return None diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 2fe28cefab..57405985db 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import override from archinstall.default_profiles.profile import GreeterType @@ -6,6 +5,7 @@ from archinstall.lib.args import ArchConfig 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 from archinstall.lib.configuration import save_config from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu from archinstall.lib.general.general_menu import select_hostname, select_ntp, select_timezone @@ -490,18 +490,11 @@ def _validate_bootloader(self) -> str | None: if efi_partition.fs_type not in [FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32]: return 'ESP must be formatted as a FAT filesystem' - if bootloader == Bootloader.Limine: - if boot_partition.fs_type not in [FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32]: - return 'Limine does not support booting with a non-FAT boot partition' - if self._uefi and efi_partition and boot_partition == efi_partition and efi_partition.mountpoint != Path('/boot') and not bootloader_config.uki: - return ( - f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_partition.mountpoint}, ' - 'enable UKI or add a separate /boot partition' - ) - - elif bootloader == Bootloader.Refind: - if not self._uefi: - return 'rEFInd can only be used on UEFI systems' + if bootloader == Bootloader.Refind and not self._uefi: + return 'rEFInd can only be used on UEFI systems' + + if failure := validate_bootloader_layout(bootloader_config, disk_config): + return failure.description return None diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 9978d64764..3dbcc8f670 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -12,6 +12,7 @@ from typing import Any, Self from archinstall.lib.boot import Boot +from archinstall.lib.bootloader.utils import validate_bootloader_layout from archinstall.lib.command import SysCommand, run from archinstall.lib.disk.fido import Fido2 from archinstall.lib.disk.luks import Luks2, unlock_luks2_dev @@ -30,7 +31,7 @@ from archinstall.lib.locale.utils import verify_keyboard_layout, verify_x11_keyboard_layout from archinstall.lib.mirror.mirror_handler import MirrorListHandler from archinstall.lib.models.application import ZramAlgorithm -from archinstall.lib.models.bootloader import Bootloader +from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.device import ( DiskEncryption, DiskLayoutConfiguration, @@ -1466,15 +1467,13 @@ def _add_limine_bootloader( elif not efi_partition.mountpoint: raise ValueError('EFI partition is not mounted') - # Limine can only read FAT filesystems. When the ESP doubles as - # the boot partition but is mounted outside /boot (e.g. /efi, - # /boot/efi) and UKI is disabled, kernels end up on the root - # filesystem under /boot/ which Limine cannot access. - if boot_partition == efi_partition and efi_partition.mountpoint != Path('/boot') and not uki_enabled: - raise DiskError( - f'Limine requires kernels on a FAT partition. The ESP is mounted at {efi_partition.mountpoint}, ' - 'so enable UKI or add a separate /boot partition to install Limine.', - ) + # Safety net for programmatic callers that bypass GlobalMenu and + # guided.py validation. + if failure := validate_bootloader_layout( + BootloaderConfiguration(bootloader=Bootloader.Limine, uki=uki_enabled), + self._disk_config, + ): + raise DiskError(failure.description) info(f'Limine EFI partition: {efi_partition.dev_path}') diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 30cb14da88..5cdcdad9d2 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -214,11 +214,11 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None: # Safety net for silent/config-file flow. The TUI menu blocks Install via # GlobalMenu._validate_bootloader() before reaching this point. - if err_msg := validate_bootloader_layout( + if failure := validate_bootloader_layout( arch_config_handler.config.bootloader_config, arch_config_handler.config.disk_config, ): - error(err_msg) + error(failure.description) return if arch_config_handler.args.dry_run: From a7a193be50e479fb85b5a7d1846cead72a9007f5 Mon Sep 17 00:00:00 2001 From: Softer Date: Wed, 22 Apr 2026 10:34:33 +0300 Subject: [PATCH 4/4] Encapsulate FAT filesystem detection in FilesystemType.is_fat() --- archinstall/lib/bootloader/utils.py | 6 ++---- archinstall/lib/disk/device_handler.py | 2 +- archinstall/lib/global_menu.py | 4 ++-- archinstall/lib/models/device.py | 3 +++ 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/archinstall/lib/bootloader/utils.py b/archinstall/lib/bootloader/utils.py index 3d77bafe57..a732f4e117 100644 --- a/archinstall/lib/bootloader/utils.py +++ b/archinstall/lib/bootloader/utils.py @@ -3,9 +3,7 @@ from pathlib import Path from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration -from archinstall.lib.models.device import DiskLayoutConfiguration, FilesystemType - -_FAT_FILESYSTEMS = (FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32) +from archinstall.lib.models.device import DiskLayoutConfiguration class BootloaderValidationFailureKind(Enum): @@ -39,7 +37,7 @@ def validate_bootloader_layout( # Limine reads its config and kernels from the boot partition, which # must be FAT. - if boot_part and boot_part.fs_type not in _FAT_FILESYSTEMS: + if boot_part and (boot_part.fs_type is None or not boot_part.fs_type.is_fat()): return BootloaderValidationFailure( kind=BootloaderValidationFailureKind.LimineNonFatBoot, description='Limine does not support booting with a non-FAT boot partition.', diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index 7ac05a3120..2e51d3d226 100644 --- a/archinstall/lib/disk/device_handler.py +++ b/archinstall/lib/disk/device_handler.py @@ -250,7 +250,7 @@ def format( case FilesystemType.EXT2 | FilesystemType.EXT3 | FilesystemType.EXT4: # Force create options.append('-F') - case FilesystemType.FAT12 | FilesystemType.FAT16 | FilesystemType.FAT32: + case _ if fs_type.is_fat(): mkfs_type = 'fat' # Set FAT size options.extend(('-F', fs_type.value.removeprefix(mkfs_type))) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 57405985db..3d94f31e2b 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -18,7 +18,7 @@ from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration -from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, FilesystemType, PartitionModification +from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, PartitionModification from archinstall.lib.models.locale import LocaleConfiguration from archinstall.lib.models.mirrors import MirrorConfiguration from archinstall.lib.models.network import NetworkConfiguration, NicType @@ -487,7 +487,7 @@ def _validate_bootloader(self) -> str | None: if efi_partition is None: return 'EFI system partition (ESP) not found' - if efi_partition.fs_type not in [FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32]: + if efi_partition.fs_type is None or not efi_partition.fs_type.is_fat(): return 'ESP must be formatted as a FAT filesystem' if bootloader == Bootloader.Refind and not self._uefi: diff --git a/archinstall/lib/models/device.py b/archinstall/lib/models/device.py index 3015833d80..7fa05407f9 100644 --- a/archinstall/lib/models/device.py +++ b/archinstall/lib/models/device.py @@ -802,6 +802,9 @@ class FilesystemType(StrEnum): def is_crypto(self) -> bool: return self == FilesystemType.CRYPTO_LUKS + def is_fat(self) -> bool: + return self in (FilesystemType.FAT12, FilesystemType.FAT16, FilesystemType.FAT32) + @property def parted_value(self) -> str: return self.value + '(v1)' if self == FilesystemType.LINUX_SWAP else self.value