Support safe installation into existing GPT partitions and Btrfs layouts

This commit is contained in:
Codex
2026-10-13 21:41:23 +03:00
parent ae0d9cf08d
commit 653388f0d7
6 changed files with 798 additions and 44 deletions
+64 -4
View File
@@ -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.
+182
View File
@@ -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)
+228 -37
View File
@@ -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-<random>')
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-<kernel>.img', '<kernel>'],
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']))
+2 -2
View File
@@ -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"
+3
View File
@@ -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
+319 -1
View File
@@ -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):