Skip to content

fix: report a disk configuration that cannot be carried out - #4863

Closed
xander-lin wants to merge 3 commits into
archlinux:masterfrom
xander-lin:fix/configuration-that-cannot-be-carried-out
Closed

xander-lin wants to merge 3 commits into
archlinux:masterfrom
xander-lin:fix/configuration-that-cannot-be-carried-out

Conversation

@xander-lin

Copy link
Copy Markdown

Summary

The configuration file is read key by key, and nothing checks that what it describes can actually
be carried out. Three separate cases end with the installation doing something other than what the
file asked for, and none of them says a word.

1. A device entry that cannot be found is dropped

DiskLayoutConfiguration.parse_arg() skipped any device_modifications entry whose device was not
probed:

device = device_handler.get_device(device_path)

if not device:
	continue

The result is a layout that describes no disk and no partition at all and is still truthy, so every
later "is a layout set?" guard passes and the installation continues with nothing to install onto.

2. An existing partition entry is trusted to belong to the device it is listed under

Nothing checked that its dev_path is one of that device's partitions, so a hand-written or
machine-translated file could point the blueprint at a partition nothing had probed.

3. An encryption type with no LVM recipe is accepted together with lvm_config

FilesystemHandler._setup_lvm_encrypted() only builds a volume group for LVM_ON_LUKS and
LUKS_ON_LVM. With luks or no_encryption plus lvm_config no volume group and no volume are
created, and the installation goes on into a mount phase with nothing to mount.

Reproducer

Needs root only for the loop device named in the configurations. Each case prints what the loader
does with it.

"""sudo .venv/bin/python repro.py"""
from archinstall.lib.disk.device_handler import device_handler
from archinstall.lib.models.device import DiskLayoutConfiguration

SECTOR = {'value': 512, 'unit': 'B'}

device_handler.load_devices()

def part(status='create', dev_path=None):
	return {
		'obj_id': 'x', 'status': status, 'type': 'primary',
		'start': {'value': 1, 'unit': 'MiB', 'sector_size': SECTOR},
		'size': {'value': 100, 'unit': 'MiB', 'sector_size': SECTOR},
		'mount_options': [], 'mountpoint': '/', 'dev_path': dev_path,
		'fs_type': 'ext4', 'flags': [], 'btrfs': [],
	}

def show(label, device, status='create', dev_path=None):
	cfg = {
		'config_type': 'manual_partitioning',
		'device_modifications': [{'device': device, 'wipe': True,
		                          'partitions': [part(status, dev_path)]}],
	}
	try:
		layout = DiskLayoutConfiguration.parse_arg(cfg)
		mods = layout.device_modifications
		parts = sum(len(m.partitions) for m in mods)
		print(f'{label}: accepted -> {len(mods)} device modification(s), {parts} partition(s)')
	except ValueError as err:
		print(f'{label}: rejected -> {err}')

show('1. a device that is not there', '/dev/loop99')

# a device that does exist, on this machine
real = next(d for d in device_handler.devices if d.partition_infos)
show(f'2. "existing" partition from another device ({real.device_info.path})',
     str(real.device_info.path), status='existing', dev_path='/dev/loop98p1')

On master:

1. a device that is not there: accepted -> 0 device modification(s), 0 partition(s)
2. "existing" partition from another device (/dev/nvme0n1): accepted -> 1 device modification(s), 1 partition(s)

The first line is the one that matters. The layout that comes back describes no disk and no
partition at all, and it is still a truthy object, so every later "is a layout set?" guard passes and
the installation carries on with nothing to install onto.

With the patch:

1. a device that is not there: rejected -> Device "/dev/loop99" from the configuration could not be found, known devices are ['/dev/nvme0n1']
2. "existing" partition from another device (/dev/nvme0n1): rejected -> Partition "/dev/loop98p1" is not a partition of "/dev/nvme0n1", which has ['/dev/nvme0n1p1', ...]

The third case needs a password to get past DiskEncryption.parse_arg(), which returns None
without one, so it is easier to call the loader directly:

from archinstall.lib.disk.device_handler import device_handler
from archinstall.lib.models.device import DiskLayoutConfiguration
from archinstall.lib.models.users import Password

SECTOR = {'value': 512, 'unit': 'B'}

device_handler.load_devices()

def part():
	return {
		'obj_id': 'x', 'status': 'create', 'type': 'primary',
		'start': {'value': 1, 'unit': 'MiB', 'sector_size': SECTOR},
		'size': {'value': 100, 'unit': 'MiB', 'sector_size': SECTOR},
		'mount_options': [], 'mountpoint': '/', 'dev_path': None,
		'fs_type': 'ext4', 'flags': [], 'btrfs': [],
	}

lvm = {'config_type': 'default', 'vol_groups': [{'name': 'vg0', 'objs': [], 'volumes': [{
	'obj_id': 'v', 'status': 'create', 'name': 'root', 'fs_type': 'ext4', 'mountpoint': '/',
	'mount_options': [], 'length': {'value': 50, 'unit': 'MiB', 'sector_size': SECTOR}, 'btrfs': []}]}]}

# a device that exists on this machine, with at least one partition
dev = str(next(d for d in device_handler.devices if d.partition_infos).device_info.path)

for enc_type in ('luks', 'no_encryption', 'lvm_on_luks', 'luks_on_lvm'):
	cfg = {
		'config_type': 'manual_partitioning',
		'device_modifications': [{'device': dev, 'wipe': True, 'partitions': [part()]}],
		'lvm_config': lvm,
		'disk_encryption': {'encryption_type': enc_type, 'partitions': ['x'], 'lvm_volumes': ['v']},
	}
	try:
		c = DiskLayoutConfiguration.parse_arg(cfg, Password(plaintext='pw'))
		print(f'{enc_type}: accepted, encryption={c.disk_encryption.encryption_type if c.disk_encryption else None}')
	except ValueError as err:
		print(f'{enc_type}: rejected -> {err}')

On master:

luks: accepted, encryption=luks
no_encryption: accepted, encryption=no_encryption
lvm_on_luks: accepted, encryption=lvm_on_luks
luks_on_lvm: accepted, encryption=luks_on_lvm

The first two describe a volume group that _setup_lvm_encrypted() will never build, so the
installation continues into a mount phase with nothing to mount. With the patch:

luks: rejected -> Encryption type "luks" cannot be combined with LVM, use one of ['lvm_on_luks', 'luks_on_lvm']
no_encryption: rejected -> Encryption type "no_encryption" cannot be combined with LVM, use one of ['lvm_on_luks', 'luks_on_lvm']
lvm_on_luks: accepted, encryption=lvm_on_luks
luks_on_lvm: accepted, encryption=luks_on_lvm

4. Encrypted swap is accepted but never activated

The mount code has no swap branch for a mapper device, so an encrypted swap area is never
swaponed: the installation finishes without swap and without an fstab entry for it. The menu does
not offer swap for encryption, but a hand-written configuration can ask for it:

encrypted swap: rejected -> Encrypted swap is not supported, "None" cannot be encrypted
control (swap not encrypted): ACCEPTED

This is a rejection rather than support - archinstall does not allow encrypted swap, and this patch
does not change that; it says so instead of dropping it silently.

A key that is not spelled right is not an error either

The same "read key by key" property means a misspelled key is not reported anywhere: the option it
was meant to set keeps its default. The existing check only looked at the top level of the file, so
a misspelled key one level down - where the layout that is applied to the disks is described - was
still dropped without a word.

This patch walks the serialisation types themselves, so the message can name the path and the check
cannot drift away from what is parsed:

$ archinstall --config bad.json --dry-run
Ignoring unknown configuration keys: ['this_key_does_not_exist']
Ignoring unknown keys in the disk configuration: ['device_modifications[0].partitions[0].mountpiont']

The file may also record the release that wrote it. That value was never read, so a file written by
an incompatible release was applied as if it were current; it is now compared with the running
version:

$ archinstall --config ancient.json --dry-run
The configuration was written by archinstall 0.0.1-ancient, this is 4.5

Two keys that are documented but only exist as command line flags (debug, silent, offline,
no_pkg_lookups) are named as such rather than reported as misspellings, because pointing at the
user's file would be misleading when the option is simply not wired up to it.

Why warnings and not errors

Everything above is something the installation can detect but not correct on its own, and the file
may be perfectly usable with that one key fixed. A warning names it and the run continues with the
same behaviour as before, so no configuration that works today stops working.

The three parse_arg() cases are different in kind: they describe something that cannot be carried
out at all, so they raise.

Tests

tests/test_config_validation.py, tests/test_config_keys.py, and additions to tests/test_args.py:

  • a device entry naming an unknown device raises and the message lists the probed devices;
  • an existing entry whose dev_path belongs to another device raises;
  • luks and no_encryption combined with lvm_config raise, lvm_on_luks and luks_on_lvm do not;
  • an unknown key at the top level, one nested under device_modifications[0].partitions[0], and one
    nested under a further level are each reported with their path;
  • documented-but-command-line-only keys are not reported as misspellings;

The configuration is read key by key, so a key name that is not spelled
exactly right, or one from an older release, was not an error anywhere: the
option it was meant to set kept its default and the installation ran with
something other than what the file asks for. Name the keys that were not
understood.

The `version` field of the file was never read at all - it was overwritten
with the running version right after parsing - so a file written by an
incompatible release was applied as if it were current. Report the
mismatch.
`DiskLayoutConfiguration.parse_arg()` dropped a device entry it could not
find and returned a layout describing no disk and no partition at all -
still truthy, so every later "is a layout set?" guard passed and the
installation went on to do nothing. Raise instead, and report the devices
that were probed.

An EXIST or MODIFY partition entry was taken as-is even when its `dev_path`
belongs to another disk, so the blueprint pointed at a partition nothing had
seen. Require it to be one of the partitions of the listed device.

Combining an encryption type that has no LVM recipe (LUKS, or no encryption)
with `lvm_config` built no volume group and no volume, and the installation
continued into a mount phase with nothing to mount; reject the combination
both when parsing the configuration and in `_setup_lvm_encrypted()`.

Encrypted swap is never activated - the mount code has no swap branch for a
mapper device - so a hand written configuration describing it finished with
no swap and no fstab entry for it. Reject it while the configuration is being
read.
The unknown-key check only looked at the top level of the file, so a misspelled
key one level down - where the layout applied to the disks is described - was
still dropped without a word. Walk the serialisation types so the message can
name the path (device_modifications[0].partitions[0].mountpiont) instead of
guessing, and so the check cannot drift away from what is parsed.
@xander-lin
xander-lin requested a review from Torxed as a code owner October 28, 2026 05:04
@Torxed

Torxed commented Oct 28, 2026

Copy link
Copy Markdown
Member

AI_POLICY, first bullet point:

Disclose all AI usage.

I don't mind merging the contributions, but I wonder how much of this is actually verified as sane and needed vs solved in a different way?

h8d13 added a commit to h8d13/archinstoo that referenced this pull request Oct 28, 2026
* fix(disk): skip deleted partitions when mounting and booting

A DELETE entry keeps its dev_path, mountpoint and flags. The menu
sorts deletes first, so on a reinstall the ESP/boot/root lookups
returned the old partitions, and the mount stage mounted or swapon'd
them. Filter is_delete() there and when preparing fs packages.

Port of archlinux/archinstall#4861.

Signed-off-by: h8d13 <hadean-eon-dev@proton.me>

* fix(disk): never offer or accept a zero-length partition

A free gap between off-grain neighbours can round below 1 MiB once
snapped, and a config could carry a 0 B create. Keep only the aligned
span in FreeSpace so the menu's buffer check drops such gaps, and
reject a create without a length.

Port of archlinux/archinstall#4862.

Signed-off-by: h8d13 <hadean-eon-dev@proton.me>

* fix(disk): swapoff and unmount by device during cleanup

[SWAP] is dropped from lsblk mountpoints for the menu, so umount()
never saw an active swap and left it on before partitioning.
Luks2.lock() passed mountpoints to umount(), which lsblk rejects, so
any mounted mapper failed with 'Failed to read disk'. Swapoff swap
devices and unmount the mapper by its path.

Port of archlinux/archinstall#4864 and #4865.

Signed-off-by: h8d13 <hadean-eon-dev@proton.me>

* fix(disk): reject lvm with plain luks, no_encryption is plain lvm

perform_lvm_operations took the encrypted path for any enc config,
which only handles lvm_on_luks/luks_on_lvm, so luks or no_encryption
with lvm_config built no volume group. Reject luks at parse time and
route no_encryption to the plain lvm setup.

Port of archlinux/archinstall#4863 (that part only).

Signed-off-by: h8d13 <hadean-eon-dev@proton.me>

---------

Signed-off-by: h8d13 <hadean-eon-dev@proton.me>
@xander-lin

Copy link
Copy Markdown
Author

Closing for now.

I checked every entry point this PR touches, and none of them is reachable from the TUI:

  • a device_modifications entry naming a device that is not present — the disk menu only
    offers devices that were probed, there is no way to type a path
  • an existing partition carrying a dev_path from another disk — same reason
  • an encryption type combined with LVM but with no recipe — encryption_menu.py:316
    filters the swap partitions out, and the combinations the menu does offer are the two
    the code handles
  • encrypted swap — same filter, not p.is_swap(), so the menu never offers it

All five new raise statements sit in parse_arg() / _setup_lvm_encrypted(), which is
the configuration-file path. Every one of them is a real gap in that path, and I still
think they are worth closing — but they are not something a user following the menus can
hit, and I presented them as if they were.

So the main line of attack for this project is the interactive path, and I do not have a
TUI-reachable case for this PR. It also carries the largest diff of the set (8 files) for
what is a configuration-only fix, which is not a good trade for your review time.

If you want the configuration path hardened, say so and I will bring it back as a single
focused change with the schema/unknown-key reporting free of the rest.

@xander-lin xander-lin closed this Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants