From 92f725a31f3f22fcf21688d16d3d5ca9c416eba9 Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 27 Aug 2026 12:01:00 -0300 Subject: [PATCH 1/5] Add a discard option to the disk encryption model dm-crypt drops discard requests unless it is told not to, so a LUKS root never gets trimmed. Carry the intent on DiskEncryption so the disk layer can act on it; nothing reads it yet. Off by default, matching the dm-crypt upstream default. Enabling TRIM on an encrypted device leaks which blocks are free, which is enough to infer the filesystem in use, so this has to be a deliberate choice rather than something that happens to everyone. --- archinstall/lib/models/device.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/archinstall/lib/models/device.py b/archinstall/lib/models/device.py index b4c069e659..911f38b5bb 100644 --- a/archinstall/lib/models/device.py +++ b/archinstall/lib/models/device.py @@ -1608,6 +1608,7 @@ class _DiskEncryptionSerialization(TypedDict): lvm_volumes: list[str] hsm_device: NotRequired[_Fido2DeviceSerialization] iter_time: NotRequired[int] + allow_discards: NotRequired[bool] @dataclass @@ -1618,6 +1619,7 @@ class DiskEncryption(SubConfig): lvm_volumes: list[LvmVolume] = field(default_factory=list) hsm_device: Fido2Device | None = None iter_time: int = DEFAULT_ITER_TIME + allow_discards: bool = False NAME: str = tr('Disk encryption') @@ -1648,6 +1650,9 @@ def json(self) -> _DiskEncryptionSerialization: if self.iter_time != DEFAULT_ITER_TIME: # Only include if not default obj['iter_time'] = self.iter_time + if self.allow_discards: + obj['allow_discards'] = self.allow_discards + return obj @override @@ -1726,6 +1731,8 @@ def parse_arg( if iter_time := disk_encryption.get('iter_time', None): enc.iter_time = iter_time + enc.allow_discards = disk_encryption.get('allow_discards', False) is True + return enc From e55f39cd69b46ca279dc1507ad8d3c64cc63e692 Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 27 Aug 2026 12:01:04 -0300 Subject: [PATCH 2/5] Set the LUKS2 allow-discards flag when opening the device Passing --allow-discards --persistent to cryptsetup open writes the flag into the LUKS2 header, so every later unlock honours it: the encrypt and sd-encrypt initramfs hooks, crypttab, LVM on LUKS and LUKS on LVM alike. That is the LUKS2 route; the cryptdevice= and rd.luks.options= kernel parameters the wiki leads with are for LUKS1 and plain dm-crypt, which archinstall never creates. --- archinstall/lib/disk/device_handler.py | 5 +++-- archinstall/lib/disk/filesystem.py | 2 ++ archinstall/lib/disk/luks.py | 10 +++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index be6290bb76..f773fa8ce9 100644 --- a/archinstall/lib/disk/device_handler.py +++ b/archinstall/lib/disk/device_handler.py @@ -285,6 +285,7 @@ def encrypt( enc_password: Password | None, lock_after_create: bool = True, iter_time: int = DEFAULT_ITER_TIME, + allow_discards: bool = False, ) -> Luks2: luks_handler = Luks2( dev_path, @@ -296,7 +297,7 @@ def encrypt( udev_sync() - luks_handler.unlock(key_file=key_file) + luks_handler.unlock(key_file=key_file, allow_discards=allow_discards) if not luks_handler.mapper_dev: raise DiskError('Failed to unlock luks device') @@ -327,7 +328,7 @@ def format_encrypted( udev_sync() - luks_handler.unlock(key_file=key_file) + luks_handler.unlock(key_file=key_file, allow_discards=enc_conf.allow_discards) if not luks_handler.mapper_dev: raise DiskError('Failed to unlock luks device') diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index 1bb3523caa..fc4bbbe45c 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -277,6 +277,7 @@ def _encrypt_lvm_vols( enc_config.encryption_password, lock_after_create, iter_time=enc_config.iter_time, + allow_discards=enc_config.allow_discards, ) enc_vols[vol] = luks_handler @@ -308,6 +309,7 @@ def _encrypt_partitions( enc_config.encryption_password, lock_after_create=lock_after_create, iter_time=enc_config.iter_time, + allow_discards=enc_config.allow_discards, ) enc_mods[part_mod] = luks_handler diff --git a/archinstall/lib/disk/luks.py b/archinstall/lib/disk/luks.py index 6289c250ca..1edeb8a2f4 100644 --- a/archinstall/lib/disk/luks.py +++ b/archinstall/lib/disk/luks.py @@ -124,13 +124,17 @@ def _get_luks_uuid(self) -> str: def is_unlocked(self) -> bool: return (mapper_dev := self.mapper_dev) is not None and mapper_dev.is_symlink() - def unlock(self, key_file: Path | None = None) -> None: + def unlock(self, key_file: Path | None = None, allow_discards: bool = False) -> None: """ Unlocks the luks device, an optional key file location for unlocking can be specified, otherwise a default location for the key file will be used. :param key_file: An alternative key file :type key_file: Path + + :param allow_discards: Pass discard requests through to the underlying device. Stored as a + persistent flag in the luks2 header, which cryptsetup replaces rather than merges. + :type allow_discards: bool """ debug(f'Unlocking luks2 device: {self.luks_dev_path}') @@ -149,6 +153,10 @@ def unlock(self, key_file: Path | None = None) -> None: 'luks2', ] + if allow_discards: + # --persistent stores the flag in the luks2 header so later unlocks inherit it + cmd += ['--allow-discards', '--persistent'] + try: result = run(cmd, input_data=passphrase) except CalledProcessError as err: From b3b60f41be9421de4c4fbd4d1d21dc02fc588149 Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 27 Aug 2026 12:01:05 -0300 Subject: [PATCH 3/5] Offer the discard option in the encryption menu Opt-in, and behind a confirmation that spells out the tradeoff: TRIM keeps write performance up on an SSD, but on an encrypted device it reveals which blocks are in use, which can give away the filesystem type and how full the disk is. That is why dm-crypt drops discards by default and why this stays off unless it is asked for. --- archinstall/lib/disk/disk_menu.py | 3 ++ archinstall/lib/disk/encryption_menu.py | 44 ++++++++++++++++++++++++- archinstall/locales/base.pot | 13 ++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index d0dd518086..7d65472255 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -272,6 +272,9 @@ def _prev_disk_encryption(self, item: MenuItem) -> str | None: if enc_type != EncryptionType.NO_ENCRYPTION: output += tr('Iteration time') + f': {enc_config.iter_time or DEFAULT_ITER_TIME}ms\n' + if enc_config.allow_discards: + output += tr('Discard/TRIM support') + f': {tr("Enabled")}\n' + if enc_config.partitions: output += f'Partitions: {len(enc_config.partitions)} selected\n' elif enc_config.lvm_volumes: diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index cdb61b8683..2f3d9c2cff 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -3,7 +3,7 @@ from archinstall.lib.disk.fido import Fido2 from archinstall.lib.menu.abstract_menu import AbstractSubMenu -from archinstall.lib.menu.helpers import Input, Selection, Table +from archinstall.lib.menu.helpers import Confirmation, Input, Selection, Table from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.menu.util import get_password from archinstall.lib.models.device import ( @@ -96,6 +96,14 @@ def _define_menu_options(self) -> list[MenuItem]: preview_action=self._prev_hsm, key='hsm_device', ), + MenuItem( + text=tr('Discard/TRIM support'), + action=self._select_allow_discards, + value=self._enc_config.allow_discards, + dependencies=[self._check_dep_enc_type], + preview_action=self._prev_allow_discards, + key='allow_discards', + ), ] async def _select_lvm_vols(self, preset: list[LvmVolume]) -> list[LvmVolume]: @@ -103,6 +111,26 @@ async def _select_lvm_vols(self, preset: list[LvmVolume]) -> list[LvmVolume]: return await select_lvm_vols_to_encrypt(self._lvm_config, preset=preset) return [] + async def _select_allow_discards(self, preset: bool) -> bool: + prompt = ( + tr('TRIM lets an SSD reclaim blocks the filesystem has freed, which keeps write performance up as it fills.') + + '\n\n' + + tr('On an encrypted device it also reveals which blocks are in use, which can give away the filesystem type and how full the disk is.') + + '\n\n' + + tr('Would you like to enable it?') + + '\n' + ) + + result = await Confirmation(header=prompt, allow_skip=True, preset=preset).show() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') + def _check_dep_enc_type(self) -> bool: enc_type: EncryptionType | None = self._item_group.find_by_key('encryption_type').value if enc_type and enc_type != EncryptionType.NO_ENCRYPTION: @@ -151,6 +179,7 @@ async def show(self) -> DiskEncryption | None: lvm_volumes=enc_lvm_vols, hsm_device=enc_config.hsm_device, iter_time=iter_time or DEFAULT_ITER_TIME, + allow_discards=enc_config.allow_discards, ) return None @@ -167,6 +196,9 @@ def _preview(self, item: MenuItem) -> str | None: if (iter_time := self._prev_iter_time(item)) is not None: output += f'\n{iter_time}' + if (allow_discards := self._prev_allow_discards(item)) is not None: + output += f'\n{allow_discards}' + if (fido_device := self._prev_hsm(item)) is not None: output += f'\n{fido_device}' @@ -232,6 +264,16 @@ def _prev_iter_time(self, item: MenuItem) -> str | None: return None + def _prev_allow_discards(self, item: MenuItem) -> str | None: + enc_type = self._item_group.find_by_key('encryption_type').value + + if enc_type and enc_type != EncryptionType.NO_ENCRYPTION: + output = f'{tr("Discard/TRIM support")}: ' + output += tr('Enabled') if item.value else tr('Disabled') + return output + + return None + async def select_encryption_type( lvm_config: LvmConfiguration | None = None, diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index 61a063a9fb..5359a72f4a 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -344,6 +344,9 @@ msgstr "" msgid "Iteration time" msgstr "" +msgid "Discard/TRIM support" +msgstr "" + msgid "" "No disks were detected. A disk is required to be able to install Arch Linux" msgstr "" @@ -406,6 +409,16 @@ msgstr "" msgid "HSM" msgstr "" +msgid "" +"TRIM lets an SSD reclaim blocks the filesystem has freed, which keeps write " +"performance up as it fills." +msgstr "" + +msgid "" +"On an encrypted device it also reveals which blocks are in use, which can " +"give away the filesystem type and how full the disk is." +msgstr "" + msgid "Partitions to be encrypted" msgstr "" From 5863a86f7f9868c71b2019f498d13fdce8053021 Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 27 Aug 2026 12:39:51 -0300 Subject: [PATCH 4/5] Document the discard option and cover it with tests The documentation spells out the tradeoff as well as the option, since the reason it is off by default is not something a config key can convey. There were no encryption tests at all, so the new file also puts a first fixture in place for the model. --- .../cli_parameters/config/disk_encryption.rst | 7 +- tests/test_disk_encryption.py | 75 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/test_disk_encryption.py diff --git a/docs/cli_parameters/config/disk_encryption.rst b/docs/cli_parameters/config/disk_encryption.rst index 9be5e48087..57d8166653 100644 --- a/docs/cli_parameters/config/disk_encryption.rst +++ b/docs/cli_parameters/config/disk_encryption.rst @@ -12,8 +12,13 @@ Disk encryption consists of a top level entry in the user configuration. "encryption_type": "luks", "partitions": [ "d712357f-97cc-40f8-a095-24ff244d4539" - ] + ], + "allow_discards": false } } The ``UID`` in the ``partitions`` list is an internal reference to the ``obj_id`` in the :ref:`disk config` entries. + +``allow_discards`` passes TRIM requests through to the underlying device, which is what lets an SSD reclaim blocks the filesystem has freed. The flag is written into the LUKS2 header when the device is created, so it applies to every later unlock without any extra kernel parameter. + +It is off by default: on an encrypted device the pattern of used and unused blocks becomes visible to anyone who can read the disk, which can give away the filesystem type and how full it is. diff --git a/tests/test_disk_encryption.py b/tests/test_disk_encryption.py new file mode 100644 index 0000000000..03d3b455d2 --- /dev/null +++ b/tests/test_disk_encryption.py @@ -0,0 +1,75 @@ +from pathlib import Path + +from archinstall.lib.models.device import ( + DiskEncryption, + DiskLayoutConfiguration, + DiskLayoutType, + EncryptionType, + FilesystemType, + LvmConfiguration, + LvmLayoutType, + LvmVolume, + LvmVolumeGroup, + ModificationStatus, + SectorSize, + Size, + Unit, +) +from archinstall.lib.models.users import Password + + +def _disk_config() -> tuple[DiskLayoutConfiguration, str]: + volume = LvmVolume( + status=ModificationStatus.CREATE, + name='root', + fs_type=FilesystemType.EXT4, + length=Size(20, Unit.GiB, SectorSize.default()), + mountpoint=Path('/'), + ) + + disk_config = DiskLayoutConfiguration( + config_type=DiskLayoutType.Default, + lvm_config=LvmConfiguration( + config_type=LvmLayoutType.Default, + vol_groups=[LvmVolumeGroup(name='ArchinstallVg', pvs=[], volumes=[volume])], + ), + ) + + return disk_config, volume.obj_id + + +def test_allow_discards_defaults_to_off() -> None: + disk_config, vol_id = _disk_config() + + enc = DiskEncryption.parse_arg( + disk_config, + { + 'encryption_type': EncryptionType.LUKS_ON_LVM.value, + 'partitions': [], + 'lvm_volumes': [vol_id], + }, + Password(enc_password='password_hash'), + ) + + assert enc is not None + assert enc.allow_discards is False + assert 'allow_discards' not in enc.json() + + +def test_allow_discards_round_trip() -> None: + disk_config, vol_id = _disk_config() + + enc = DiskEncryption.parse_arg( + disk_config, + { + 'encryption_type': EncryptionType.LUKS_ON_LVM.value, + 'partitions': [], + 'lvm_volumes': [vol_id], + 'allow_discards': True, + }, + Password(enc_password='password_hash'), + ) + + assert enc is not None + assert enc.allow_discards is True + assert enc.json()['allow_discards'] is True From 16d393b5c3285ef1f91ee9f29cc1b6f08594119a Mon Sep 17 00:00:00 2001 From: AMMAAR-IC Date: Sun, 18 Oct 2026 00:25:09 +0530 Subject: [PATCH 5/5] Import datetime as dt in mirrors model Ruff 0.17 flags 'import datetime' under ICN001 (flake8-import-conventions), which breaks the ruff check linting workflow. (cherry picked from commit e143605bbfdd5b0b6ece222f05f30018a3234e3a) --- archinstall/lib/models/mirrors.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/archinstall/lib/models/mirrors.py b/archinstall/lib/models/mirrors.py index 67811b2c4e..824505ed54 100644 --- a/archinstall/lib/models/mirrors.py +++ b/archinstall/lib/models/mirrors.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import http.client import urllib.error import urllib.parse @@ -30,7 +30,7 @@ class MirrorStatusEntryV3(BaseModel): ipv6: bool details: str delay: int | None = None - last_sync: datetime.datetime | None = None + last_sync: dt.datetime | None = None duration_avg: float | None = None duration_stddev: float | None = None completion_pct: float | None = None @@ -120,7 +120,7 @@ def debug_output(self, info: ValidationInfo) -> Self: class MirrorStatusListV3(BaseModel): cutoff: int - last_check: datetime.datetime + last_check: dt.datetime num_checks: int urls: list[MirrorStatusEntryV3] version: int