diff --git a/docs/install.md b/docs/install.md index e1d545b..10d7079 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,7 +6,67 @@ Boot the ISO and enter `:shell` in the terminal VM menu. The live session uses a volatile. The image provides no default SSH login or universal installed password. `hydra-install` lists disks without selecting one. Inspect the devices, then ask -for a plan using the whole disk you intend to erase: +for a plan using either the explicitly chosen existing partitions or a whole disk. +The commands below use placeholders; the installer never chooses devices automatically. + +## Preserve existing partitions + +Choose a root partition whose contents you intend to replace and an existing FAT +GPT EFI System Partition (ESP). Inspect their identities and sizes with `lsblk`. +Both selected partitions must be unmounted; unrelated mounted siblings are allowed. + +```sh +sudo hydra-install --root-partition /dev/CHOSEN_ROOT \ + --esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs +``` + +This dry-run describes the operation. To execute the same choice: + +```sh +sudo hydra-install --root-partition /dev/CHOSEN_ROOT \ + --esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs \ + --execute --confirm-format /dev/CHOSEN_ROOT +``` + +`--confirm-format` must name the canonical resolved root path exactly, even if an +input path is an alias. This mode formats **only the selected root partition**. +It never recreates GPT, changes partition boundaries, or formats the ESP. Before +formatting, it checks uncompressed payload capacity, mounts the ESP read-only, +refuses any existing `EFI/HydraOS` collision, and requires at least 64 MiB free. +It fingerprints foreign ESP files and verifies their hashes after installation. + +GRUB writes `EFI/HydraOS/grubx64.efi` using `--no-nvram` without `--removable`. +Existing EFI loaders, `EFI/BOOT/BOOTX64.EFI`, and firmware BootOrder are preserved. +By default, select that Hydra EFI file manually in your firmware boot interface. +`--register-boot-entry` opts into `efibootmgr --create-only`: it creates a new +HydraOS entry without adding it to BootOrder. Firmware support for selecting EFI +files and managing entries varies. + +Only plain, nonoverlapping GPT partitions on physical disks are supported. Root +and ESP may reside on different disks. Device mapper, encrypted/LVM/RAID targets, +nested partition devices, missing identities/bounds, active holders, and ambiguous +backing information are rejected. Mounted Btrfs filesystems must report every +backing member; an incomplete or missing-device report is rejected. ZFS backing +anatomy is unsupported and causes a conservative refusal. The checks are +repeated just before formatting; do not change mounts or storage topology during +installation. They cannot replace your inspection and backups. + +`--root-fs ext4` is the default. Btrfs creates `@root`, `@home`, and `@snapshots`. +The kernel, `/boot`, and Portage database stay inside `@root`; `/home` (including +guest disks) uses `@home`, and `/.snapshots` uses `@snapshots`. fstab and boot +arguments use filesystem UUIDs and named subvolumes, without a fixed root +subvolume ID. The layout alone does not implement system rollback; full Gentoo +and kernel recovery requires separate bootable verification. + +An installed, root-owned mode-0600 `/etc/hydra/storage.json` records format 1, a +random install UUID, root/ESP filesystem UUIDs and GPT PARTUUIDs, filesystem type, +and owned EFI path `EFI/HydraOS`. Btrfs additionally records +`root_subvolume: "@root"`. Treat this identity record as installation metadata, +not an update payload. + +## Erase a whole disk + +Use this mode only when every partition on the explicitly chosen disk may be erased: ```sh hydra-install @@ -26,7 +86,7 @@ sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \ --execute --confirm-erase /dev/CHOSEN_DISK ``` -Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root, +Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root (or Btrfs with `--root-fs btrfs`), extracts the prepared payload, and prompts twice on the local terminal for your installed account password. Passwords have no command-line or configuration option. The installed root account stays locked. Defaults are Europe/Moscow and @@ -34,8 +94,8 @@ English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA zone present in the payload. The installer removes the live account, local password-free sudo rule, and -live autologin overrides, then builds a normal disk initramfs and fallback UEFI -loader. Wait for `Installation completed`, power off, remove the ISO, and boot +live autologin overrides, then builds a normal disk initramfs, its owned HydraOS UEFI loader, and a fallback +UEFI loader on the newly created ESP. Both installation modes keep os-prober disabled. Wait for `Installation completed`, power off, remove the ISO, and boot the installed disk. Log in with the account and password you supplied. On failure, read the printed live diagnostic path; when a target root was mounted, a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk. diff --git a/hydra/install_storage.py b/hydra/install_storage.py new file mode 100644 index 0000000..c35d5e3 --- /dev/null +++ b/hydra/install_storage.py @@ -0,0 +1,182 @@ +"""Read-only validation and explicit storage plans for the installer. + +Only plain GPT partitions on physical disks are supported. This module never +selects a target or runs a partition/format command while constructing a plan. +""" +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path, PurePosixPath +import re + +ESP_TYPE = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' +UUID = re.compile(r'[A-Fa-f0-9]+(?:-[A-Fa-f0-9]+)+\Z') +PARTUUID = re.compile(r'[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\Z') +ESP_RESERVE = 64 * 1024**2 +MIN_ROOT = 4 * 1024**3 + + +def fail(message): + from .installer import InstallError + raise InstallError(message) + + +def ancestors(topo, path): + seen, pending = set(), list(topo.parents[path]) + while pending: + current = pending.pop() + if current not in seen: + seen.add(current) + pending.extend(topo.parents[current] - seen) + return seen + + +def fingerprint(node): + return tuple(node.get(key) for key in + ('path', 'type', 'size', 'maj:min', 'start', 'partuuid', 'parttype', 'fstype', 'uuid')) + + +@dataclass(frozen=True) +class DiskPlan: + target: str + size: int + identity: tuple + + +@dataclass(frozen=True) +class PartitionPlan: + root: str + esp: str + root_parent: str + esp_parent: str + esp_number: int + root_size: int + root_partuuid: str + esp_partuuid: str + esp_uuid: str + identity: tuple + + +def partition_plan(request, probe, topo): + from .installer import DEVICE + paths = [] + for chosen in (request.root_partition, request.esp_partition): + if not isinstance(chosen, str) or not DEVICE.fullmatch(chosen) or '..' in PurePosixPath(chosen).parts: + fail('Choose explicit /dev root and ESP partitions') + canonical = probe.resolve(chosen) + if not DEVICE.fullmatch(canonical): + fail('Invalid canonical partition path') + paths.append(canonical) + root, esp = paths + if root == esp: + fail('Root and ESP must be distinct partitions') + if request.execute and request.confirm_format != root: + fail(f'Execution requires --confirm-format {root} exactly') + parents, numbers, identities = [], [], [] + for path in paths: + node = topo.nodes.get(path) + if not node or node['type'] != 'part' or len(topo.parents[path]) != 1: + fail('Target must be a plain partition with exactly one physical backing disk') + parent = next(iter(topo.parents[path])) + if topo.nodes[parent]['type'] != 'disk' or topo.parents[parent] or not probe.physical_disk(parent): + fail('Nested or virtual partition backing is unsupported') + if topo.edges[path] - {parent}: + fail('Selected partition has active holders or nested devices') + if any(source == path or source in ancestors(topo, path) or path in ancestors(topo, source) + for source in topo.protected): + fail(f'Selected partition backs mounted/root/live storage: {path}') + if not PARTUUID.fullmatch(node.get('partuuid') or ''): + fail('Missing or ambiguous GPT partition identity') + # Check every sibling extent; never infer bounds from partition names. + siblings = [p for p in topo.nodes if parent in topo.parents[p]] + extents = [] + for sibling in siblings: + child = topo.nodes[sibling] + start = child.get('start') + if child['type'] != 'part' or topo.parents[sibling] != {parent} or isinstance(start, bool) or not isinstance(start, int) or start <= 0: + fail('Ambiguous partition bounds or nested topology') + end = start * 512 + child['size'] + if child['size'] <= 0 or end > topo.nodes[parent]['size']: + fail('Partition extends beyond its backing disk') + extents.append((start*512, end, sibling)) + extents.sort() + if any(left[1] > right[0] for left, right in zip(extents, extents[1:])): + fail('Overlapping partition bounds') + # The partition number is provided by the kernel, not derived from an alias. + number = probe.partition_number(path, node) + if number <= 0: + fail('Missing kernel partition number') + probe.verify_identity(path, node) + probe.verify_identity(parent, topo.nodes[parent]) + parents.append(parent); numbers.append(number) + identities.append((fingerprint(node), fingerprint(topo.nodes[parent]), + tuple(sorted((sibling, fingerprint(topo.nodes[sibling])) for sibling in siblings)))) + if topo.nodes[root]['size'] < MIN_ROOT: + fail('Root partition is smaller than the minimum 4 GiB') + esp_node = topo.nodes[esp] + if esp_node.get('fstype') != 'vfat' or (esp_node.get('parttype') or '').lower() != ESP_TYPE: + fail('ESP must be an existing FAT GPT EFI System Partition') + if not UUID.fullmatch(esp_node.get('uuid') or ''): + fail('ESP filesystem UUID missing') + # Equal GPT IDs anywhere would make later PARTUUID recovery ambiguous. + ids = [n.get('partuuid', '').lower() for n in topo.nodes.values() if n.get('partuuid')] + if len(ids) != len(set(ids)): + fail('Duplicate GPT partition identities') + return PartitionPlan(root, esp, *parents, numbers[1], topo.nodes[root]['size'], + topo.nodes[root]['partuuid'], esp_node['partuuid'], esp_node['uuid'], tuple(identities)) + + +def format_root(root, filesystem): + return (['mkfs.btrfs', '-f', '-L', 'HYDRA_ROOT', root] if filesystem == 'btrfs' else + ['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root]) + + +def payload_capacity(listing): + """Sum uncompressed inode sizes from unsquashfs numeric long listing.""" + allocated, inodes = 0, 0 + for line in listing.splitlines(): + match = re.match(r'^[-dlcbps][rwxstST-]{9}\s+\d+/\d+\s+(\d+)\s+\d{4}-\d\d-\d\d\s+\d\d:\d\d\s+', line) + if match: + size = int(match[1]) + allocated += ((size + 4095) // 4096) * 4096 + 4096 + inodes += 1 + elif re.match(r'^[cb][rwxstST-]{9}\s+\d+/\d+\s+\d+,\s*\d+\s+\d{4}-\d\d-\d\d\s+\d\d:\d\d\s+', line): + allocated += 4096 + inodes += 1 + elif line and not line.startswith(('Parallel unsquashfs:', 'Number of processors', 'squashfs-root')): + fail('Cannot determine uncompressed payload capacity') + if not inodes: + fail('Cannot determine uncompressed payload capacity') + # Metadata, allocated blocks, generated initramfs, and initial user state. + return max(MIN_ROOT, allocated * 12 // 10 + 1024**3) + + +def foreign_efi_hashes(directory, *, ignore_owned=False): + """Fingerprint all foreign regular files; reject ambiguous/special paths.""" + root = Path(directory) + result = {} + for path in sorted(root.rglob('*')): + relative = path.relative_to(root).as_posix() + if ignore_owned and (relative.casefold() == 'efi/hydraos' or relative.casefold().startswith('efi/hydraos/')): + continue + if path.is_symlink() or not (path.is_file() or path.is_dir()): + fail('ESP contains unsupported special files') + if path.is_file(): + with path.open('rb') as source: + digest = hashlib.file_digest(source, 'sha256').hexdigest() + result[relative] = digest + return result + + +def inspect_esp(directory): + root = Path(directory) + for entry in root.iterdir(): + if entry.name.casefold() == 'efi': + if not entry.is_dir() or entry.is_symlink(): + fail('ESP EFI directory is not an ordinary directory') + if any(p.name.casefold() == 'hydraos' for p in entry.iterdir()): + fail('ESP already contains EFI/HydraOS; refusing loader collision') + info = os.statvfs(root) + if info.f_bavail * info.f_frsize < ESP_RESERVE: + fail('ESP needs at least 64 MiB free for the Hydra EFI loader') + return foreign_efi_hashes(root) diff --git a/hydra/installer.py b/hydra/installer.py index f399059..14f2b91 100644 --- a/hydra/installer.py +++ b/hydra/installer.py @@ -1,4 +1,4 @@ -"""Explicit whole-disk installer. Importing this module never mutates storage. +"""Explicit whole-disk and existing-partition installer. Importing this module never mutates storage. Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json must be regular files. The manifest is a JSON array of absolute paths matching @@ -23,8 +23,12 @@ import subprocess import sys import tempfile import warnings +import uuid + from typing import TextIO +from . import install_storage as storage + DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img' LIVE_REMOVABLE = ( '/etc/systemd/system/getty@*.service.d/90-hydra-live.conf', @@ -46,13 +50,18 @@ class InstallError(RuntimeError): @dataclass class Request: - disk: str + disk: str | None user: str payload: str = DEFAULT_PAYLOAD execute: bool = False confirm_erase: str | None = None timezone: str = 'Europe/Moscow' kernel: str | None = None + root_partition: str | None = None + esp_partition: str | None = None + confirm_format: str | None = None + root_fs: str = 'ext4' + register_boot_entry: bool = False class Runner: @@ -109,7 +118,13 @@ class Topology: raise InstallError('Zero-size device is not an unrelated empty optical drive') if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity): raise InstallError('Invalid lsblk device identity') - if path in result.nodes and any(result.nodes[path][key] != node[key] for key in ('type', 'size', 'maj:min', 'mountpoints')): + for key in ('partuuid', 'parttype', 'fstype', 'uuid'): + if node.get(key) is not None and not isinstance(node[key], str): + raise InstallError('Malformed lsblk storage metadata') + if node.get('start') is not None and (isinstance(node['start'], bool) or not isinstance(node['start'], int)): + raise InstallError('Malformed lsblk partition start') + if path in result.nodes and any(result.nodes[path].get(key) != node.get(key) for key in + ('type', 'size', 'maj:min', 'mountpoints', 'start', 'partuuid', 'parttype', 'fstype', 'uuid')): raise InstallError('Inconsistent duplicate lsblk node') result.nodes[path] = node result.edges.setdefault(path, set()) @@ -224,7 +239,7 @@ class SystemProbe: def snapshot(self, payload): raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output', - 'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']) + 'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN,START,PARTUUID,PARTTYPE,FSTYPE,UUID']) # lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N. try: decoded = json.loads(raw) @@ -275,11 +290,15 @@ class SystemProbe: for path in pending: identity = self._loop_backing_identity(path, topo.nodes[path]['maj:min']) sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity] - if len(sources) != 1: - raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})') - source = sources[0] - topo.link(path, source) - topo.protect(source) + if not sources: + btrfs_mounts = [m for m in mounts if m['fstype'] == 'btrfs' and m['maj:min'] == identity] + member_sets = [set(self._btrfs_members(m, topo)) for m in btrfs_mounts] + if not member_sets or any(members != member_sets[0] for members in member_sets[1:]): + raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})') + sources = sorted(member_sets[0]) + for source in sources: + topo.link(path, source) + topo.protect(source) inspected.add(path) return topo @@ -331,7 +350,9 @@ class SystemProbe: raise InstallError('Malformed findmnt topology') from exc def _block_source(self, mount, topo): - if mount['fstype'] in {'zfs', 'btrfs'}: + if mount['fstype'] == 'btrfs': + return self._btrfs_source(mount, topo) + if mount['fstype'] == 'zfs': raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype']) source = mount['source'].split('[', 1)[0] identity = mount['maj:min'] @@ -352,6 +373,49 @@ class SystemProbe: raise InstallError(f'Unknown mounted block source: {source}') return None + def _btrfs_source(self, mount, topo): + return self._btrfs_members(mount, topo)[0] + + def _btrfs_members(self, mount, topo): + if not Path(mount['target']).is_absolute() or any(ord(c) < 32 for c in mount['target']): + raise InstallError('Invalid mounted btrfs path') + raw = self.runner.run(['btrfs', 'filesystem', 'show', '--raw', mount['target']]) + counts = re.findall(r'Total devices (\d+) FS bytes used \d+', raw) + rows = re.findall(r'^\s*devid\s+(\d+)\s+size\s+\d+\s+used\s+\d+\s+path\s+(/dev/[^\s]+)\s*$', raw, re.MULTILINE) + if len(counts) != 1 or int(counts[0]) <= 0 or len(rows) != int(counts[0]) or len({row[0] for row in rows}) != len(rows): + raise InstallError('Cannot safely resolve all backing members of mounted btrfs') + paths = [self.resolve(row[1]) for row in rows] + if len(set(paths)) != len(paths) or any(path not in topo.nodes for path in paths): + raise InstallError('Unknown or duplicate mounted btrfs backing member') + source = mount['source'].split('[', 1)[0] + if source.startswith('/dev/') and self.resolve(source) in topo.nodes and self.resolve(source) not in paths: + raise InstallError('Mounted btrfs source is absent from backing members') + for path in paths: + topo.protect(path) + return paths + + def verify_identity(self, path, node): + try: + info = os.stat(path) + identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}' + if not stat.S_ISBLK(info.st_mode) or identity != node['maj:min']: + raise InstallError('Block device identity changed: ' + path) + sysfs = Path('/sys/dev/block') / identity + if int((sysfs / 'size').read_text()) * 512 != node['size']: + raise InstallError('Block device capacity changed: ' + path) + if list((sysfs / 'holders').iterdir()): + raise InstallError('Selected device has active holders: ' + path) + if node['type'] == 'part' and int((sysfs / 'start').read_text()) != node['start']: + raise InstallError('Partition bounds changed: ' + path) + except (OSError, ValueError) as exc: + raise InstallError('Cannot verify kernel block identity: ' + path) from exc + + def partition_number(self, path, node): + try: + return int((Path('/sys/dev/block') / node['maj:min'] / 'partition').read_text()) + except (OSError, ValueError) as exc: + raise InstallError('Cannot read kernel partition number: ' + path) from exc + def _source_for_path(self, path, topo): raw = self.runner.run(['findmnt', '--json', '--target', path, '--output', 'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS']) @@ -361,9 +425,17 @@ class SystemProbe: return self._block_source(mounts[0], topo) -def validate_request(request, probe): - if not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts: +def validate_request(request, probe, *, return_plan=False): + existing = request.root_partition is not None or request.esp_partition is not None + if request.root_fs not in {'ext4', 'btrfs'}: + raise InstallError('Root filesystem must be ext4 or btrfs') + if existing: + if request.disk is not None or request.confirm_erase is not None: + raise InstallError('Choose exactly one installation mode; partitions cannot use --disk/--confirm-erase') + elif not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts: raise InstallError('Choose an explicit /dev whole-disk device') + elif request.confirm_format is not None: + raise InstallError('--confirm-format is only valid for existing partitions') if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', request.user) or request.user in {'root', 'hydra-live', 'greetd'}: raise InstallError('Choose a valid nonreserved lowercase Unix username') if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone): @@ -372,6 +444,9 @@ def validate_request(request, probe): raise InstallError('Payload must be an absolute path without control characters') if request.kernel is not None and not KERNEL.fullmatch(request.kernel): raise InstallError('Invalid kernel version') + if existing: + plan = storage.partition_plan(request, probe, probe.snapshot(request.payload)) + return plan if return_plan else plan.root target = probe.resolve(request.disk) if not DEVICE.fullmatch(target) or not probe.physical_disk(target): raise InstallError(f'Target is not a physical whole block disk: {target}') @@ -385,6 +460,10 @@ def validate_request(request, probe): forbidden = topo.component(target) & topo.protected if forbidden: raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(forbidden))) + probe.verify_identity(target, topo.nodes[target]) + if return_plan: + return storage.DiskPlan(target, topo.nodes[target]['size'], + tuple(sorted((p, storage.fingerprint(topo.nodes[p])) for p in topo.component(target)))) return target @@ -393,7 +472,7 @@ def partitions(target): return target + suffix + '1', target + suffix + '2' -def partition_commands(target): +def partition_commands(target, root_fs='ext4'): esp, root = partitions(target) return [ ['parted', '--script', target, 'mklabel', 'gpt'], @@ -402,19 +481,37 @@ def partition_commands(target): ['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'], ['partprobe', target], ['udevadm', 'settle'], ['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp], - ['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root], + storage.format_root(root, root_fs), ] -def grub_command(root): +def grub_command(root, preserve=False): return ['chroot', str(root), 'grub-install', '--target=x86_64-efi', - '--efi-directory=/boot/efi', '--bootloader-id=HydraOS', '--removable', '--no-nvram'] + '--efi-directory=/boot/efi', '--bootloader-id=HydraOS', + *([] if preserve else ['--removable']), '--no-nvram'] def render_plan(request, target, root): - esp, part = partitions(target) - commands = partition_commands(target) + [ - ['mount', part, str(root)], ['unsquashfs', '-f', '-d', str(root), request.payload], + existing = request.root_partition is not None + esp, part = (request.esp_partition, target) if existing else partitions(target) + commands = [['unsquashfs', '-s', request.payload], ['unsquashfs', '-lln', request.payload]] + if existing: + temporary = str(Path(root).parent / 'esp-preflight-') + commands.extend([['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary], + ['umount', temporary]]) + commands.extend([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs)) + if request.root_fs == 'btrfs': + top = str(Path(root).parent / 'btrfs-top') + commands.append(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, top]) + commands.extend(['btrfs', 'subvolume', 'create', str(Path(top) / name)] + for name in ('@root', '@home', '@snapshots')) + commands.extend([['umount', top], ['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)], + ['mount', '-t', 'btrfs', '-o', 'subvol=@home', part, str(Path(root) / 'home')], + ['mount', '-t', 'btrfs', '-o', 'subvol=@snapshots', part, str(Path(root) / '.snapshots')]]) + else: + commands.append(['mount', part, str(root)]) + commands += [ + ['unsquashfs', '-f', '-d', str(root), request.payload], ['mount', esp, str(Path(root) / 'boot/efi')], ['blkid', '-s', 'UUID', '-o', 'value', part], ['blkid', '-s', 'UUID', '-o', 'value', esp], @@ -430,12 +527,18 @@ def render_plan(request, target, root): ['chroot', str(root), 'systemd-machine-id-setup'], ['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live', '/boot/initramfs-.img', ''], - grub_command(root), ['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'], + grub_command(root, preserve=True), + *([] if existing else [grub_command(root)]), + ['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'], ] lines = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.', - 'GPT: exactly 1 GiB FAT32 ESP, remaining space ext4 root.', + f'GPT: exactly 1 GiB FAT32 ESP, remaining space {request.root_fs} root.', f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.', 'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):'] + if existing: + lines[:2] = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: format ONLY {target} as {request.root_fs}.', + f'Preserve GPT, existing ESP {esp}, foreign EFI loaders, fallback loader and BootOrder.', + 'First inspect the ESP read-only for collision/free space and hash foreign files.'] lines.extend(' ' + shlex.join(c) for c in commands) lines.extend(['Write UUID fstab, en_US.UTF-8 and ru_RU.UTF-8, timezone, installed dracut/GRUB defaults.', 'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.', @@ -443,6 +546,11 @@ def render_plan(request, target, root): 'chpasswd receives the TTY-entered password on private stdin; root remains locked.', 'Unmount tracked sys/proc/dev/ESP/root in reverse order on success or failure.', f'To execute, add --execute --confirm-erase {target} (as root; no automatic sudo).']) + if existing: + lines[-1] = f'To execute, add --execute --confirm-format {target} (as root; no automatic sudo).' + lines.append('Default: select EFI/HydraOS/grubx64.efi in firmware manually; no firmware changes.') + if request.root_fs == 'btrfs': + lines.append('Btrfs: @root contains /boot and Portage; @home and @snapshots are separate subvolumes.') return '\n'.join(lines) @@ -572,7 +680,10 @@ def install(request, runner, probe, root, password, log: TextIO): raise InstallError('Installation requires --execute') if not isinstance(password, str) or not password or any(c in password for c in '\r\n\x00'): raise InstallError('Password must be nonempty and contain no newline or NUL') - target = validate_request(request, probe) + validated = validate_request(request, probe, return_plan=True) + existing = isinstance(validated, storage.PartitionPlan) + target = validated.root if existing else validated.target + esp, part = (validated.esp, validated.root) if existing else partitions(target) root = Path(root) if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()): raise InstallError('Target mount directory must be an empty absolute directory') @@ -594,13 +705,46 @@ def install(request, runner, probe, root, password, log: TextIO): try: run(['unsquashfs', '-s', request.payload]) - # Recheck after preflight, directly before the first destructive command. - if validate_request(request, probe) != target: - raise InstallError('Target device changed during validation') - for argv in partition_commands(target): + required = storage.payload_capacity(run(['unsquashfs', '-lln', request.payload])) + capacity = validated.root_size if existing else validated.size - 1025 * 1024**2 + if capacity < required: + raise InstallError('Root has insufficient capacity for the uncompressed payload and install reserve') + foreign = None + if existing: + # An independent temporary mount keeps the target root empty. + temporary = tempfile.mkdtemp(prefix='esp-preflight-', dir=root.parent) + mount(['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary], temporary) + try: + foreign = storage.inspect_esp(temporary) + finally: + # Never recurse through a directory that could still be mounted. + run(['umount', temporary]) + mounted.pop() + try: + Path(temporary).rmdir() + except OSError: + pass # Retain unexpected contents; never delete ESP files. + # Recheck the canonical paths, kernel identities, GPT IDs and bounds + # after read-only ESP inspection, immediately before formatting. + if validate_request(request, probe, return_plan=True) != validated: + raise InstallError('Target device identity or partition bounds changed during validation') + for argv in ([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs)): run(argv) - esp, part = partitions(target) - mount(['mount', part, str(root)], root) + if request.root_fs == 'btrfs': + top = root.parent / 'btrfs-top' + top.mkdir() + mount(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, str(top)], top) + for subvolume in ('@root', '@home', '@snapshots'): + run(['btrfs', 'subvolume', 'create', str(top / subvolume)]) + run(['umount', str(top)]) + mounted.pop() + top.rmdir() + mount(['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)], root) + for directory, subvolume in (('home', '@home'), ('.snapshots', '@snapshots')): + (root / directory).mkdir() + mount(['mount', '-t', 'btrfs', '-o', 'subvol=' + subvolume, part, str(root / directory)], root / directory) + else: + mount(['mount', part, str(root)], root) run(['unsquashfs', '-f', '-d', str(root), request.payload]) kernel = select_kernel(root, request.kernel) remove_live_files(root) @@ -614,7 +758,30 @@ def install(request, runner, probe, root, password, log: TextIO): esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).strip() if any(not re.fullmatch(r'[A-Fa-f0-9-]+', value) for value in (root_uuid, esp_uuid)): raise InstallError('Missing or invalid partition UUID') - write_config(root, 'etc/fstab', f'UUID={root_uuid} / ext4 defaults 0 1\nUUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n') + if request.root_fs == 'btrfs': + root_lines = (f'UUID={root_uuid} / btrfs defaults,subvol=@root 0 0\n' + f'UUID={root_uuid} /home btrfs defaults,subvol=@home 0 0\n' + f'UUID={root_uuid} /.snapshots btrfs defaults,subvol=@snapshots 0 0\n') + else: + root_lines = f'UUID={root_uuid} / ext4 defaults 0 1\n' + write_config(root, 'etc/fstab', root_lines + f'UUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n') + root_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', part]).strip() + esp_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', esp]).strip() + if not all(storage.PARTUUID.fullmatch(value) for value in (root_partuuid, esp_partuuid)): + raise InstallError('Missing or invalid GPT partition UUID for installed storage marker') + if existing: + before_root, before_esp = validated.root_partuuid, validated.esp_partuuid + if (root_partuuid.lower(), esp_partuuid.lower()) != (before_root.lower(), before_esp.lower()): + raise InstallError('GPT partition identity changed after formatting') + if esp_uuid.lower() != validated.esp_uuid.lower(): + raise InstallError('ESP filesystem identity changed after preflight') + marker = dict(format=1, install_uuid=str(uuid.uuid4()), filesystem=request.root_fs, + root_fs_uuid=root_uuid, root_partuuid=root_partuuid, + esp_uuid=esp_uuid, esp_partuuid=esp_partuuid, + owned_efi_path='EFI/HydraOS') + if request.root_fs == 'btrfs': + marker['root_subvolume'] = '@root' + write_config(root, 'etc/hydra/storage.json', json.dumps(marker, sort_keys=True) + '\n', 0o600) write_config(root, 'etc/locale.gen', 'en_US.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\n') write_config(root, 'etc/locale.conf', 'LANG=en_US.UTF-8\n') write_config(root, 'etc/timezone', request.timezone + '\n') @@ -629,9 +796,13 @@ def install(request, runner, probe, root, password, log: TextIO): write_config(root, 'etc/machine-id', '') dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id' dbus_machine_id.unlink(missing_ok=True) + boot_args = f'root=UUID={root_uuid}' + (' rootflags=subvol=@root' if request.root_fs == 'btrfs' else '') write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf', - 'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n# No root=live: or rd.live arguments on the installed disk.\n') - write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\nGRUB_CMDLINE_LINUX=""\nGRUB_TIMEOUT=3\n') + 'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n' + + ('filesystems+=" btrfs "\n' if request.root_fs == 'btrfs' else '') + + f'kernel_cmdline="{boot_args}"\n') + write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\n' + + f'GRUB_CMDLINE_LINUX="{boot_args}"\nGRUB_TIMEOUT=3\nGRUB_DISABLE_OS_PROBER=true\n') write_config(root, 'etc/sudoers.d/90-hydra-admin', '%wheel ALL=(ALL:ALL) ALL\n', 0o440) mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True) run(['mount', '--make-rslave', str(root / 'dev')]) @@ -650,8 +821,16 @@ def install(request, runner, probe, root, password, log: TextIO): chroot('systemd-machine-id-setup') chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live', '/boot/initramfs-' + kernel + '.img', kernel) - run(grub_command(root)) + run(grub_command(root, preserve=True)) + if not existing: + run(grub_command(root)) chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg') + if existing and storage.foreign_efi_hashes(root / 'boot/efi', ignore_owned=True) != foreign: + raise InstallError('Foreign ESP files changed; refusing successful installation') + if request.register_boot_entry: + parent, number = (validated.esp_parent, validated.esp_number) if existing else (target, 1) + run(['efibootmgr', '--create-only', '--disk', parent, '--part', str(number), + '--label', 'HydraOS', '--loader', r'\EFI\HydraOS\grubx64.efi']) run(['sync']) except (InstallError, OSError, KeyboardInterrupt) as exc: error = exc @@ -674,6 +853,8 @@ def install(request, runner, probe, root, password, log: TextIO): error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors)) if error: raise InstallError(str(error)) from error + if existing and not request.register_boot_entry: + print('Select EFI/HydraOS/grubx64.efi manually in firmware; BootOrder was not changed.', file=log, flush=True) print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True) @@ -686,8 +867,13 @@ def preflight(request, runner): raise InstallError('Execution requires a TTY for password entry') if not Path('/sys/firmware/efi').is_dir(): raise InstallError('Boot the live medium in UEFI mode before installation') - for command in ('lsblk', 'findmnt', 'parted', 'partprobe', 'udevadm', 'mkfs.vfat', - 'mkfs.ext4', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'): + commands = ['lsblk', 'findmnt', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'] + if request.root_partition is None: + commands.extend(['parted', 'partprobe', 'udevadm', 'mkfs.vfat']) + commands.extend(['btrfs', 'mkfs.btrfs'] if request.root_fs == 'btrfs' else ['mkfs.ext4']) + if request.register_boot_entry: + commands.append('efibootmgr') + for command in commands: if shutil.which(command) is None: raise InstallError('Required live installer tool missing: ' + command) runner.run(['unsquashfs', '-s', request.payload]) @@ -711,8 +897,13 @@ def prompt_password(): def main(argv=None): - parser = argparse.ArgumentParser(description='Hydra OS explicit whole-disk installer; dry-run by default') + parser = argparse.ArgumentParser(description='Hydra OS explicit disk or existing-partition installer; dry-run by default') parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected') + parser.add_argument('--root-partition', help='existing root partition; formats only this partition') + parser.add_argument('--esp-partition', help='existing FAT GPT ESP; never formatted in partition mode') + parser.add_argument('--confirm-format', help='must exactly match the resolved root partition with --execute') + parser.add_argument('--root-fs', choices=('ext4', 'btrfs'), default='ext4') + parser.add_argument('--register-boot-entry', action='store_true', help='opt in to create-only EFI entry, preserving BootOrder') parser.add_argument('--user', help='installed administrator username') parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload') parser.add_argument('--execute', action='store_true') @@ -722,8 +913,8 @@ def main(argv=None): args = parser.parse_args(argv) runner = Runner() try: - if not args.disk: - if args.execute or args.confirm_erase: + if not args.disk and not args.root_partition and not args.esp_partition: + if args.execute or args.confirm_erase or args.confirm_format or args.register_boot_entry: raise InstallError('--execute requires an explicit --disk and --user') topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output', 'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN'])) diff --git a/image/dracut.conf b/image/dracut.conf index 6b04673..a46b63a 100644 --- a/image/dracut.conf +++ b/image/dracut.conf @@ -1,6 +1,6 @@ hostonly="no" hostonly_cmdline="no" add_dracutmodules+=" dmsquash-live " -filesystems+=" squashfs overlay iso9660 vfat ext4 " -add_drivers+=" vmd nvme xhci_pci thunderbolt usb_storage uas sd_mod virtio_pci virtio_blk virtio_scsi virtio_net virtio_gpu i915 loop squashfs overlay sr_mod ahci " +filesystems+=" squashfs overlay iso9660 vfat ext4 btrfs " +add_drivers+=" vmd nvme xhci_pci thunderbolt usb_storage uas sd_mod virtio_pci virtio_blk virtio_scsi virtio_net virtio_gpu i915 loop squashfs overlay sr_mod ahci btrfs " compress="zstd" diff --git a/image/packages b/image/packages index 633afdc..db54181 100644 --- a/image/packages +++ b/image/packages @@ -22,6 +22,9 @@ sys-boot/grub sys-fs/squashfs-tools sys-fs/dosfstools sys-fs/e2fsprogs +sys-fs/btrfs-progs +sys-boot/efibootmgr +dev-libs/openssl sys-block/parted sys-apps/util-linux sys-fs/mtools diff --git a/tests/test_installer.py b/tests/test_installer.py index 2935c3d..14c7ecf 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -43,6 +43,12 @@ class FakeProbe: def physical_disk(self, path): return path == '/dev/vda' + def verify_identity(self, path, node): + return None + + def partition_number(self, path, node): + return {'/dev/vda1': 1, '/dev/vda2': 2, '/dev/vda3': 3}.get(path, 0) + class FakeRunner: def __init__(self, root, fail=None): @@ -68,11 +74,313 @@ class FakeRunner: (self.root / 'etc/systemd/system/getty@tty1.service.d/90-hydra-live.conf').touch() (self.root / 'etc/sudoers.d/90-hydra-live').touch() (self.root / 'boot/vmlinuz-6.12.1').touch() + if argv[0] == 'unsquashfs' and '-lln' in argv: + return 'drwxr-xr-x 0/0 0 2026-10-13 12:00 squashfs-root\n-rw-r--r-- 0/0 2048 2026-10-13 12:00 squashfs-root/file\n' if argv[0] == 'blkid': - return '11111111-2222-3333-4444-555555555555\n' + if 'PARTUUID' in argv: + number = '1' if argv[-1].endswith('1') else '2' + return 'aaaaaaaa-bbbb-cccc-dddd-' + number.zfill(12) + '\n' + return 'ABCD-1234\n' if argv[-1].endswith('1') else '11111111-2222-3333-4444-555555555555\n' return '' +def partition_topology(*, sibling_mounted=False): + esp = disk('/dev/vda1', kind='part') + root = disk('/dev/vda2', kind='part') + sibling = disk('/dev/vda3', kind='part', mounts=['/other'] if sibling_mounted else []) + for node, start, size, number in ((esp, 2048, 1024**3, 1), + (root, 2099200, 8*1024**3, 2), + (sibling, 18876416, 4*1024**3, 3)): + node.update(start=start, size=size, partuuid=f'aaaaaaaa-bbbb-cccc-dddd-{number:012d}', + parttype='c12a7328-f81f-11d2-ba4b-00a0c93ec93b' if number == 1 else + '0fc63daf-8483-4772-8e79-3d69d8477de4', + fstype='vfat' if number == 1 else 'ext4', uuid='ABCD-1234' if number == 1 else + f'11111111-2222-3333-4444-{number:012d}') + return topology(disk(children=[esp, root, sibling])) + + +def partition_request(**values): + return i.Request(None, 'emil', root_partition='/dev/vda2', esp_partition='/dev/vda1', + **values) + + +class PartitionGuardTests(unittest.TestCase): + def test_explicit_partitions_allow_unrelated_mounted_sibling(self): + self.assertEqual(i.validate_request(partition_request(), FakeProbe(partition_topology(sibling_mounted=True))), '/dev/vda2') + + def test_modes_and_confirmations_are_unambiguous(self): + for changes in ({'disk': '/dev/vda'}, {'esp_partition': None}, {'root_fs': 'xfs'}, + {'confirm_erase': '/dev/vda'}, {'confirm_format': '/dev/vda3', 'execute': True}): + request = partition_request() + for key, value in changes.items(): + setattr(request, key, value) + with self.subTest(changes=changes), self.assertRaises(i.InstallError): + i.validate_request(request, FakeProbe(partition_topology())) + self.assertEqual(i.validate_request(partition_request(execute=True, confirm_format='/dev/vda2'), + FakeProbe(partition_topology())), '/dev/vda2') + + def test_selected_mount_holder_alias_overlap_or_bad_metadata_refused(self): + for mutation in ('mount', 'holder', 'same', 'bounds', 'overlap', 'missing', 'not-fat', 'not-esp', 'nested'): + topo = partition_topology() + req = partition_request() + if mutation == 'mount': topo.protect('/dev/vda2') + if mutation == 'holder': + topo.nodes['/dev/holder'] = disk('/dev/holder', kind='crypt') + topo.edges['/dev/holder'] = set(); topo.parents['/dev/holder'] = set() + topo.link('/dev/holder', '/dev/vda2') + if mutation == 'same': req.esp_partition = req.root_partition + if mutation == 'bounds': topo.nodes['/dev/vda2']['size'] = 40*1024**3 + if mutation == 'overlap': topo.nodes['/dev/vda2']['start'] = 2048 + if mutation == 'missing': del topo.nodes['/dev/vda2']['partuuid'] + if mutation == 'not-fat': topo.nodes['/dev/vda1']['fstype'] = 'ext4' + if mutation == 'not-esp': topo.nodes['/dev/vda1']['parttype'] = '0fc63daf-8483-4772-8e79-3d69d8477de4' + if mutation == 'nested': topo.parents['/dev/vda2'].add('/dev/vda3') + with self.subTest(mutation=mutation), self.assertRaises(i.InstallError): + i.validate_request(req, FakeProbe(topo)) + + def test_malformed_optional_storage_metadata_is_rejected(self): + for field, value in (('partuuid', 123), ('uuid', []), ('parttype', {}), ('start', True)): + nodes = partition_topology().nodes + nodes['/dev/vda1'][field] = value + with self.subTest(field=field), self.assertRaises(i.InstallError): + topo = topology(nodes['/dev/vda']) + i.validate_request(partition_request(), FakeProbe(topo)) + + def test_btrfs_loop_backing_members_preserve_eligible_root(self): + nodes = list(partition_topology().nodes.values())[:1] + loop = disk('/dev/loop0', kind='loop', mounts=['/scratch']); loop['maj:min']='7:0' + media = disk('/dev/sr0', kind='rom', mounts=['/live']); media['maj:min']='11:0' + nodes.extend([loop,media]) + btrfs={'source':'/dev/vda3[/home]', 'target':'/other', 'fstype':'btrfs','maj:min':'0:37','options':'rw'} + scratch={'source':'/dev/loop0','target':'/scratch','fstype':'ext4','maj:min':'7:0','options':'rw'} + live={'source':'/dev/sr0','target':'/live','fstype':'iso9660','maj:min':'11:0','options':'ro'} + class ReadOnlyRunner: + def run(self, argv): + if argv[0]=='lsblk': return json.dumps({'blockdevices':nodes}) + if argv[0]=='btrfs': + return 'Label: none uuid: abc\n Total devices 1 FS bytes used 4096\n devid 1 size 999 used 1 path /dev/vda3\n' + return json.dumps({'filesystems':[live] if '--target' in argv else [btrfs,scratch,live]}) + probe=i.SystemProbe(ReadOnlyRunner()) + probe.physical_disk=lambda path: path=='/dev/vda' + probe.verify_identity=lambda path,node: None + probe.partition_number=lambda path,node: 1 if path.endswith('1') else 2 + with patch.object(Path,'is_file',return_value=True), patch.object(i.SystemProbe,'_loop_backing_identity',return_value='0:37'): + topo=probe.snapshot(i.DEFAULT_PAYLOAD) + probe.snapshot=lambda payload:topo + self.assertIn('/dev/vda3',topo.parents['/dev/loop0']) + self.assertEqual(i.validate_request(partition_request(),probe),'/dev/vda2') + + def test_partition_plan_formats_only_root_and_preserves_efi(self): + req = partition_request() + plan = i.render_plan(req, '/dev/vda2', '/mnt/target') + self.assertIn('mkfs.ext4', plan) + self.assertIn('--no-nvram', plan) + self.assertNotIn('--removable', plan) + self.assertNotIn('parted', plan) + self.assertNotIn('mkfs.vfat', plan) + self.assertIn('--confirm-format /dev/vda2', plan) + + def test_btrfs_dry_run_includes_real_mount_layout(self): + plan=i.render_plan(partition_request(root_fs='btrfs'),'/dev/vda2','/mnt/target') + self.assertIn('subvol=@root',plan) + self.assertIn('subvol=@home',plan) + self.assertIn('subvol=@snapshots',plan) + self.assertLess(plan.index('ro,nosuid,nodev,noexec'),plan.index('mkfs.btrfs')) + + def test_cli_accepts_partition_mode_without_disk(self): + output=io.StringIO() + with patch.object(i,'SystemProbe',return_value=FakeProbe(partition_topology())), contextlib.redirect_stdout(output): + self.assertEqual(i.main(['--root-partition','/dev/vda2','--esp-partition','/dev/vda1','--user','emil']),0) + self.assertIn('format ONLY /dev/vda2',output.getvalue()) + + def test_btrfs_mount_protects_all_members_without_protecting_siblings(self): + topo = partition_topology(sibling_mounted=True) + class ReadOnlyRunner: + def run(self, argv): + return ("Label: none uuid: 11111111-2222-3333-4444-555555555555\n" + "\tTotal devices 1 FS bytes used 4096\n" + "\tdevid 1 size 4294967296 used 4096 path /dev/vda3\n") + probe = i.SystemProbe(ReadOnlyRunner()) + probe._block_source({'source':'/dev/vda3[/home]', 'target':'/other', 'fstype':'btrfs', + 'maj:min':'0:37', 'options':'rw'}, topo) + self.assertIn('/dev/vda3', topo.protected) + self.assertEqual(i.validate_request(partition_request(), FakeProbe(topo)), '/dev/vda2') + + def test_multi_member_btrfs_mount_protects_selected_target(self): + class ReadOnlyRunner: + def run(self, argv): + return ('Label: none uuid: abc\n Total devices 2 FS bytes used 4096\n' + ' devid 1 size 999 used 1 path /dev/vda3\n' + ' devid 2 size 999 used 1 path /dev/vda2\n') + topo = partition_topology() + i.SystemProbe(ReadOnlyRunner())._block_source( + {'source':'/dev/vda3', 'target':'/other', 'fstype':'btrfs', 'maj:min':'0:37'}, topo) + with self.assertRaises(i.InstallError): + i.validate_request(partition_request(), FakeProbe(topo)) + + def test_conflicting_duplicate_partition_metadata_is_rejected(self): + node = disk('/dev/vda1', kind='part', pkname='/dev/vda') + other = dict(node, start=9999) + with self.assertRaises(i.InstallError): + topology(disk(children=[dict(node, start=2048)]), other) + + def test_btrfs_source_not_in_reported_members_is_rejected(self): + class ReadOnlyRunner: + def run(self, argv): + return 'Label: none uuid: abc\n Total devices 1 FS bytes used 4096\n devid 1 size 999 used 1 path /dev/vda3\n' + with self.assertRaises(i.InstallError): + i.SystemProbe(ReadOnlyRunner())._block_source( + {'source':'/dev/vda2', 'target':'/other', 'fstype':'btrfs', 'maj:min':'0:37'}, partition_topology()) + + def test_incomplete_btrfs_members_fail_closed(self): + class ReadOnlyRunner: + def run(self, argv): + return 'Label: none uuid: abc\n Total devices 2 FS bytes used 4096\n devid 1 size 999 used 1 path /dev/vda3\n' + with self.assertRaises(i.InstallError): + i.SystemProbe(ReadOnlyRunner())._block_source( + {'source':'/dev/vda3', 'target':'/other', 'fstype':'btrfs', 'maj:min':'0:37'}, partition_topology()) + + +class PartitionPipelineTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name)/'target'; self.root.mkdir() + self.probe = FakeProbe(partition_topology(sibling_mounted=True)) + self.request = partition_request(execute=True, confirm_format='/dev/vda2') + + def install(self, runner): + i.install(self.request, runner, self.probe, self.root, 'test-password', io.StringIO()) + + def test_existing_mode_formats_root_only_and_records_owned_layout(self): + runner = FakeRunner(self.root) + self.install(runner) + self.assertFalse(any(c[0] in ('parted', 'partprobe', 'mkfs.vfat') for c in runner.calls)) + self.assertEqual([c[-1] for c in runner.calls if c[0].startswith('mkfs.')], ['/dev/vda2']) + self.assertFalse(any('--removable' in c for c in runner.calls)) + self.assertFalse(any(c[0] == 'efibootmgr' for c in runner.calls)) + ro = next(n for n,c in enumerate(runner.calls) if c[0]=='mount' and 'ro,nosuid,nodev,noexec' in c) + format_at = next(n for n,c in enumerate(runner.calls) if c[0]=='mkfs.ext4') + self.assertLess(ro, format_at) + marker = json.loads((self.root/'etc/hydra/storage.json').read_text()) + self.assertEqual(marker['format'], 1); self.assertEqual(marker['filesystem'], 'ext4') + self.assertEqual(marker['owned_efi_path'], 'EFI/HydraOS') + self.assertEqual(stat.S_IMODE((self.root/'etc/hydra/storage.json').stat().st_mode), 0o600) + self.assertNotIn('root_subvolume', marker) + self.assertIn('GRUB_DISABLE_OS_PROBER=true', (self.root/'etc/default/grub').read_text()) + + def test_esp_collision_or_insufficient_space_prevents_formatting(self): + for problem in ('collision', 'space'): + runner = FakeRunner(self.root) + real_run = runner.run + def run(argv, **kw): + result = real_run(argv, **kw) + if argv[0]=='mount' and 'ro,nosuid,nodev,noexec' in argv and problem=='collision': + (Path(argv[-1])/'EFI/HydraOS').mkdir(parents=True) + return result + runner.run = run + context = patch('os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)) if problem=='space' else contextlib.nullcontext() + with self.subTest(problem=problem), context, self.assertRaises(i.InstallError): + self.install(runner) + self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls)) + + def test_esp_preflight_failure_never_recursively_removes_foreign_files(self): + runner=FakeRunner(self.root); original_run=runner.run; sentinels=[] + def run(argv, **kw): + result=original_run(argv, **kw) + if argv[0]=='mount' and 'ro,nosuid,nodev,noexec' in argv: + sentinel=Path(argv[-1])/'EFI/HydraOS/foreign' + sentinel.parent.mkdir(parents=True); sentinel.write_bytes(b'keep') + sentinels.append(sentinel) + return result + runner.run=run + with self.assertRaises(i.InstallError): self.install(runner) + self.assertEqual(sentinels[0].read_bytes(), b'keep') + self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls)) + + def test_identity_change_after_esp_preflight_prevents_formatting(self): + runner = FakeRunner(self.root) + original_snapshot = self.probe.snapshot + def snapshot(payload): + topo = original_snapshot(payload) + if self.probe.calls >= 2: topo.nodes['/dev/vda2']['maj:min'] = '252:9999' + return topo + self.probe.snapshot = snapshot + with self.assertRaises(i.InstallError): self.install(runner) + self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls)) + + def test_btrfs_root_uses_named_subvolumes_and_boot_arguments(self): + self.request.root_fs = 'btrfs' + runner = FakeRunner(self.root); self.install(runner) + self.assertIn(['mkfs.btrfs', '-f', '-L', 'HYDRA_ROOT', '/dev/vda2'], runner.calls) + subvolumes = [Path(c[-1]).name for c in runner.calls if c[:3]==['btrfs','subvolume','create']] + self.assertEqual(subvolumes, ['@root','@home','@snapshots']) + fstab=(self.root/'etc/fstab').read_text() + for name in ('@root','@home','@snapshots'): self.assertIn('subvol='+name, fstab) + self.assertNotIn('subvolid', fstab) + self.assertIn('rootflags=subvol=@root', (self.root/'etc/default/grub').read_text()) + self.assertIn('rootflags=subvol=@root', (self.root/'etc/dracut.conf.d/90-hydra-installed.conf').read_text()) + self.assertEqual(json.loads((self.root/'etc/hydra/storage.json').read_text())['root_subvolume'],'@root') + self.assertEqual(runner.calls[-1], ['umount', str(self.root)]) + + def test_foreign_efi_files_are_compared_after_loader_install(self): + runner=FakeRunner(self.root) + original_run=runner.run + def run(argv, **kw): + result=original_run(argv, **kw) + if argv[0]=='mount' and argv[-2]=='/dev/vda1': + foreign=Path(argv[-1])/'EFI/BOOT/BOOTX64.EFI' + foreign.parent.mkdir(parents=True, exist_ok=True); foreign.write_bytes(b'foreign-loader') + if 'grub-install' in argv: + (self.root/'boot/efi/EFI/BOOT/BOOTX64.EFI').write_bytes(b'changed-loader') + return result + runner.run=run + with self.assertRaisesRegex(i.InstallError, 'Foreign ESP files changed'): + self.install(runner) + + def test_payload_capacity_failure_occurs_before_root_format(self): + runner=FakeRunner(self.root); original_run=runner.run + def run(argv, **kw): + if '-lln' in argv: + return '-rw-r--r-- 0/0 10737418240 2026-10-13 12:00 squashfs-root/large\n' + return original_run(argv, **kw) + runner.run=run + with self.assertRaisesRegex(i.InstallError, 'capacity'): + self.install(runner) + self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls)) + + def test_btrfs_extraction_failure_cleans_home_snapshots_and_root(self): + self.request.root_fs='btrfs'; runner=FakeRunner(self.root,'unsquashfs-extract') + with self.assertRaises(i.InstallError): self.install(runner) + self.assertEqual(runner.calls[-3:], [['umount',str(self.root/'.snapshots')], + ['umount',str(self.root/'home')], ['umount',str(self.root)]]) + + def test_registration_creates_only_new_entry(self): + self.request.register_boot_entry=True + runner=FakeRunner(self.root); self.install(runner) + commands=[c for c in runner.calls if c[0]=='efibootmgr'] + self.assertEqual(len(commands),1) + self.assertIn('--create-only', commands[0]); self.assertNotIn('--bootorder',commands[0]) + self.assertIn('/dev/vda',commands[0]); self.assertIn('1',commands[0]) + + + +class PayloadCapacityTests(unittest.TestCase): + def test_capacity_reserves_blocks_for_many_small_inodes(self): + listing='-rw-r--r-- 0/0 1 2026-10-13 12:00 squashfs-root/tiny\n' * 500000 + self.assertGreater(i.storage.payload_capacity(listing), 4*1024**3) + + def test_payload_special_inodes_do_not_need_device_capacity(self): + listing=('drwxr-xr-x 0/0 39 2026-10-13 12:00 squashfs-root\n' + 'crw-rw-rw- 0/0 1, 3 2026-10-13 12:00 squashfs-root/dev/null\n' + 'brw-rw---- 0/0 8, 0 2026-10-13 12:00 squashfs-root/dev/disk\n') + self.assertEqual(i.storage.payload_capacity(listing),4*1024**3) + + def test_unrecognized_payload_listing_fails_closed(self): + for listing in ('', '-rw-r--r-- root/root unknown file', 'device entry cannot be measured'): + with self.subTest(listing=listing), self.assertRaises(i.InstallError): + i.storage.payload_capacity(listing) + + class GuardTests(unittest.TestCase): def setUp(self): self.assertIsNotNone(i, 'installer module must implement the guarded installer') @@ -182,6 +490,14 @@ class PipelineTests(unittest.TestCase): self.assertIn('6.12.1', dracut) self.assertEqual(runner.calls[-1], ['umount', str(self.root)]) + def test_whole_disk_installs_owned_loader_and_fallback(self): + runner=FakeRunner(self.root); self.install(runner) + boot_commands=[c for c in runner.calls if 'grub-install' in c] + self.assertEqual(len(boot_commands),2) + self.assertNotIn('--removable',boot_commands[0]) + self.assertIn('--removable',boot_commands[1]) + self.assertEqual(json.loads((self.root/'etc/hydra/storage.json').read_text())['owned_efi_path'], 'EFI/HydraOS') + def test_partition_format_and_extract_failures_abort_and_cleanup_only_mounted(self): for failing in ('parted', 'mkfs.vfat', 'mkfs.ext4', 'unsquashfs-extract'): with self.subTest(failing=failing): @@ -452,6 +768,7 @@ class RelocatedLiveLoopTests(unittest.TestCase): self.assertEqual(syscall.call_args.args, (91, 0x4C05, bytes(232))) self.assertFalse(any('/LiveOS/squashfs.img' in args for args in calls)) probe.snapshot = lambda payload: topo + probe.verify_identity = lambda path, node: None self.assertEqual(i.validate_request(i.Request('/dev/vda', 'hydratest'), probe), '/dev/vda') probe.physical_disk = lambda path: path == '/dev/sr0' with self.assertRaises(i.InstallError): @@ -522,6 +839,7 @@ class NestedLoopAncestryTests(unittest.TestCase): side_effect=lambda path, expected: identities[path]): topo = probe.snapshot(i.DEFAULT_PAYLOAD) probe.snapshot = lambda payload: topo + probe.verify_identity = lambda path, node: None return probe, topo def test_reverse_order_nested_loop_refuses_physical_backing(self):