"""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 LIVE_REMOVABLE. Store all other live-only configuration in live-mode itself; ordinary installed configuration must already be present in the payload. """ from __future__ import annotations import argparse from dataclasses import dataclass import getpass import fcntl import json import os from pathlib import Path, PurePosixPath import re import shlex import shutil import stat import struct 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', '/etc/systemd/system/serial-getty@*.service.d/90-hydra-live.conf', '/etc/systemd/system/greetd.service.d/90-hydra-live.conf', '/etc/sudoers.d/90-hydra-live', '/etc/greetd/live.toml', '/etc/NetworkManager/system-connections/90-hydra-live.nmconnection', ) DEVICE = re.compile(r'/dev/[A-Za-z0-9_.+:/-]+\Z') KERNEL = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+-]*\Z') KNOWN_TYPES = {'disk', 'part', 'loop', 'rom', 'crypt', 'lvm', 'mpath', 'dm', 'raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'raid10', 'md', 'zram'} class InstallError(RuntimeError): """A failed safety check or operation; never suppress this in execution.""" @dataclass class Request: 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: """Checked argv-only commands. Secret-bearing commands redact all output.""" def run(self, argv, *, input=None, sensitive=False): try: result = subprocess.run(list(argv), input=input, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, check=False) except OSError as exc: raise InstallError(f'Cannot run {argv[0]}: {exc.strerror}') from exc if result.returncode: detail = '' if sensitive else ': ' + result.stderr.strip()[:2000] raise InstallError(f'Command failed ({result.returncode}): {shlex.join(argv)}{detail}') return '' if sensitive else result.stdout class Topology: def __init__(self): self.nodes = {} self.edges = {} self.parents = {} self.protected = set() @classmethod def from_json(cls, raw): try: data = json.loads(raw) except (ValueError, TypeError) as exc: raise InstallError('Malformed lsblk JSON') from exc if not isinstance(data, dict) or not isinstance(data.get('blockdevices'), list) or not data['blockdevices']: raise InstallError('lsblk must return a nonempty blockdevices array') result = cls() parents = [] def visit(node, ancestor=None, trail=()): if not isinstance(node, dict): raise InstallError('Malformed lsblk node') path, kind = node.get('path'), node.get('type') if not isinstance(path, str) or not DEVICE.fullmatch(path) or '..' in PurePosixPath(path).parts: raise InstallError('Invalid lsblk device path') if path in trail or kind not in KNOWN_TYPES: raise InstallError('Cyclic or unknown lsblk topology') mounts = node.get('mountpoints') size, identity = node.get('size'), node.get('maj:min') if not isinstance(mounts, list) or any(m is not None and not isinstance(m, str) for m in mounts): raise InstallError('Malformed lsblk mountpoints') if isinstance(size, bool) or not isinstance(size, int) or size < 0: raise InstallError('Invalid lsblk size') empty_optical = (kind == 'rom' and not any(mounts) and not node.get('children') and node.get('pkname') is None and ancestor is None) if size == 0 and not empty_optical: 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') 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()) result.parents.setdefault(path, set()) if any(m for m in mounts): result.protected.add(path) pkname = node.get('pkname') if pkname is not None: if not isinstance(pkname, str) or not pkname: raise InstallError('Invalid lsblk parent') parent = pkname if pkname.startswith('/dev/') else '/dev/' + pkname if not DEVICE.fullmatch(parent): raise InstallError('Invalid lsblk parent path') parents.append((path, parent)) if ancestor: parents.append((path, ancestor)) children = node.get('children', []) if not isinstance(children, list): raise InstallError('Malformed lsblk children') for child in children: visit(child, path, trail + (path,)) for node in data['blockdevices']: visit(node) identities = {} for path, node in result.nodes.items(): identity = node['maj:min'] if identity in identities and identities[identity] != path: raise InstallError('Conflicting block-device identities') identities[identity] = path directed = {path: set() for path in result.nodes} for child, parent in parents: result.link(child, parent) directed[child].add(parent) visiting, visited = set(), set() def check_cycle(path): if path in visiting: raise InstallError('Cyclic block-device parent references') if path in visited: return visiting.add(path) for parent in directed[path]: check_cycle(parent) visiting.remove(path) visited.add(path) for path in directed: check_cycle(path) for path, node in result.nodes.items(): if node['size'] == 0 and result.edges[path]: raise InstallError('Empty optical drive has block-device dependencies') # An undirected child edge does not establish backing ancestry. # Each layer must identify its own parent, even when another # apparently unrelated disk is the requested installation target. if node['type'] not in {'disk', 'rom', 'loop', 'zram'} and not directed[path]: raise InstallError(f"Device has no known backing parent: {path} ({node['type']})") return result def link(self, child, parent): if child == parent or child not in self.nodes or parent not in self.nodes: raise InstallError('Unresolved or cyclic block-device parent') # Backing links discovered after lsblk must obey the same directed # ancestry rule. An undirected shared disk is not itself a cycle. seen, pending = set(), [parent] while pending: current = pending.pop() if current == child: raise InstallError('Cyclic block-device backing ancestry') if current not in seen: seen.add(current) pending.extend(self.parents[current] - seen) self.parents[child].add(parent) self.edges[child].add(parent) self.edges[parent].add(child) def protect(self, path): if path not in self.nodes: raise InstallError(f'Cannot resolve mounted/live source: {path}') if self.nodes[path]['size'] == 0: raise InstallError(f'Empty optical drive unexpectedly backs mounted/live storage: {path}') self.protected.add(path) def component(self, path): seen, pending = set(), [path] while pending: current = pending.pop() if current not in seen: seen.add(current) pending.extend(self.edges[current] - seen) return seen class SystemProbe: """Read-only Linux topology probes, including loop backing files.""" def __init__(self, runner=None): self.runner = runner or Runner() def resolve(self, path): return os.path.realpath(path) def physical_disk(self, path): try: info = os.stat(path) if not stat.S_ISBLK(info.st_mode): return False device = Path(f'/sys/dev/block/{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}') if not device.exists() or (device / 'partition').exists(): return False # Reject loop, device mapper, md, ram and other virtual targets. return '/virtual/' not in str(device.resolve()) and (device / 'device').exists() except OSError: return False def snapshot(self, payload): raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output', '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) def canonicalize(node): if not isinstance(node, dict): raise ValueError() if not isinstance(node.get('path'), str) or not DEVICE.fullmatch(node['path']) or '..' in PurePosixPath(node['path']).parts: raise ValueError() node['path'] = self.resolve(node['path']) if isinstance(node.get('pkname'), str): parent = node['pkname'] node['pkname'] = self.resolve(parent if parent.startswith('/dev/') else '/dev/' + parent) children = node.get('children', []) if not isinstance(children, list): raise ValueError() for child in children: canonicalize(child) for node in decoded['blockdevices']: canonicalize(node) topo = Topology.from_json(json.dumps(decoded)) except (ValueError, TypeError, KeyError) as exc: raise InstallError('Malformed lsblk JSON') from exc mounts = self._mounts(self.runner.run(['findmnt', '--json', '--output', 'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])) for mount in mounts: source = self._block_source(mount, topo) if source: topo.protect(source) if not Path(payload).is_file(): raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}') source = self._source_for_path(self.resolve(payload), topo) if not source: # tmpfs/overlay payload location cannot establish live-medium ancestry. raise InstallError('Cannot identify the payload backing block device; use --payload on the mounted live medium') topo.protect(source) # Loops can hide further loops, including ancestors of a mounted # partition or device mapper. Resolve protected components to a fixed # point, independent of lsblk ordering and payload discovery order. inspected = set() while True: protected_devices = set() for path in topo.protected: protected_devices.update(topo.component(path)) pending = [path for path in topo.nodes if path in protected_devices and topo.nodes[path]['type'] == 'loop' and path not in inspected] if not pending: break 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 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 @staticmethod def _loop_backing_identity(path, expected_identity): # LOOP_GET_STATUS64 reports the backing inode's filesystem device, # independent of names which dracut switch_root may have relocated. # lo_rdevice is nonzero when the backing inode is itself a block device. try: descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC) try: info = os.fstat(descriptor) identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}' if not stat.S_ISBLK(info.st_mode) or identity != expected_identity: raise InstallError(f'Mounted loop device identity changed: {path}') status = fcntl.ioctl(descriptor, 0x4C05, bytes(232)) if not isinstance(status, bytes) or len(status) != 232: raise InstallError(f'Malformed loop status: {path}') filesystem_device, _, block_device = struct.unpack_from('=QQQ', status) finally: os.close(descriptor) except (OSError, struct.error) as exc: raise InstallError(f'Cannot inspect mounted loop backing identity: {path}') from exc device = block_device or filesystem_device return f'{os.major(device)}:{os.minor(device)}' @staticmethod def _mounts(raw): try: data = json.loads(raw) roots = data['filesystems'] if not isinstance(roots, list) or not roots: raise ValueError() result = [] def visit(item): if not isinstance(item, dict) or any(not isinstance(item.get(k), str) for k in ('source', 'target', 'fstype', 'maj:min')): raise ValueError() result.append(item) children = item.get('children', []) if not isinstance(children, list): raise ValueError() for child in children: visit(child) for root in roots: visit(root) return result except (ValueError, TypeError, KeyError) as exc: raise InstallError('Malformed findmnt topology') from exc def _block_source(self, mount, topo): 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'] matches = [p for p, n in topo.nodes.items() if n['maj:min'] == identity] if source.startswith('/dev/'): path = self.resolve(source) if path in topo.nodes: if topo.nodes[path]['maj:min'] != identity: raise InstallError('Inconsistent mounted block identity') topo.protect(path) return path if not matches: raise InstallError(f'Cannot resolve mounted/live source: {source}') if matches: topo.protect(matches[0]) return matches[0] if identity.split(':', 1)[0] != '0' and mount['fstype'] not in {'zfs', 'nfs', 'nfs4', 'cifs', '9p', 'virtiofs'}: 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']) mounts = self._mounts(raw) if len(mounts) != 1: raise InstallError('Ambiguous backing filesystem') return self._block_source(mounts[0], topo) 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): raise InstallError('Invalid timezone; use an IANA name such as Europe/Moscow') if not Path(request.payload).is_absolute() or any(ord(c) < 32 for c in request.payload): 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}') if request.execute and request.confirm_erase != target: raise InstallError(f'Execution requires --confirm-erase {target} exactly') topo = probe.snapshot(request.payload) if target not in topo.nodes or topo.nodes[target]['type'] != 'disk': raise InstallError('Target is not a known whole disk') if topo.nodes[target]['size'] < 4 * 1024 ** 3: raise InstallError('Target disk is smaller than the minimum 4 GiB') 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 def partitions(target): suffix = 'p' if target[-1].isdigit() else '' return target + suffix + '1', target + suffix + '2' def partition_commands(target, root_fs='ext4'): esp, root = partitions(target) return [ ['parted', '--script', target, 'mklabel', 'gpt'], ['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'ESP', 'fat32', '1MiB', '1025MiB'], ['parted', '--script', target, 'set', '1', 'esp', 'on'], ['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'], ['partprobe', target], ['udevadm', 'settle'], ['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp], storage.format_root(root, root_fs), ] def grub_command(root, preserve=False): return ['chroot', str(root), 'grub-install', '--target=x86_64-efi', '--efi-directory=/boot/efi', '--bootloader-id=HydraOS', *([] if preserve else ['--removable']), '--no-nvram'] def render_plan(request, target, root): 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], ['mount', '--rbind', '/dev', str(Path(root) / 'dev')], ['mount', '--make-rslave', str(Path(root) / 'dev')], ['mount', '-t', 'proc', 'proc', str(Path(root) / 'proc')], ['mount', '--rbind', '/sys', str(Path(root) / 'sys')], ['mount', '--make-rslave', str(Path(root) / 'sys')], ['chroot', str(root), 'locale-gen'], ['chroot', str(root), 'useradd', '--create-home', '--groups', 'wheel,kvm,video,render,audio', '--shell', '/bin/bash', request.user], ['chroot', str(root), 'chpasswd'], ['chroot', str(root), 'passwd', '--lock', 'root'], ['chroot', str(root), 'systemd-machine-id-setup'], ['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live', '/boot/initramfs-.img', ''], 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}.', 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.', 'Ensure wheel,kvm,video,render,audio groups exist before creating the installed account.', '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) def target_path(root, relative, *, symlinks=False): """Resolve a path inside a target, interpreting absolute symlinks as chroot paths.""" root = Path(root).resolve() parts = list(PurePosixPath(relative).parts) if parts and parts[0] == '/': parts.pop(0) resolved, hops = [], 0 while parts: part = parts.pop(0) if part in ('', '.'): continue if part == '..': if not resolved: raise InstallError('Target path escapes root') resolved.pop() continue candidate = root.joinpath(*resolved, part) if candidate.is_symlink(): if not symlinks: raise InstallError(f'Refusing symlink traversal in target: {relative}') hops += 1 if hops > 40: raise InstallError('Target symlink cycle') link = PurePosixPath(os.readlink(candidate)) extra = list(link.parts) if link.is_absolute(): resolved = [] extra.pop(0) parts = extra + parts else: resolved.append(part) return root.joinpath(*resolved) def remove_live_files(root): directory = target_path(root, 'etc/hydra/live-mode') if not directory.is_dir(): raise InstallError('Payload missing /etc/hydra/live-mode contract') manifest = target_path(root, 'etc/hydra/live-mode/remove-on-install.json') marker = target_path(root, 'etc/hydra/live-mode/active') if not manifest.is_file() or not marker.is_file(): raise InstallError('Payload live-mode marker/manifest missing') try: paths = json.loads(manifest.read_text()) except (OSError, ValueError) as exc: raise InstallError('Invalid live-mode removal manifest') from exc if not isinstance(paths, list): raise InstallError('Live removal manifest must be a JSON array') removals = [] for path in paths: if not isinstance(path, str) or not path.startswith('/') or '..' in PurePosixPath(path).parts or str(PurePosixPath(path)) != path: raise InstallError('Invalid path in live removal manifest') if not any(re.fullmatch(re.escape(pattern).replace(r'\*', r'[A-Za-z0-9_.-]+'), path) for pattern in LIVE_REMOVABLE): raise InstallError(f'Live manifest path is not allowlisted: {path}') leaf = PurePosixPath(path) parent = target_path(root, str(leaf.parent)) candidate = parent / leaf.name if candidate.exists() and not candidate.is_file() and not candidate.is_symlink(): raise InstallError('Live manifest may only remove files/symlinks') removals.append(candidate) for path in removals: path.unlink(missing_ok=True) # shutil.rmtree never follows directory symlinks inside this directory. shutil.rmtree(directory) def remove_live_account(root, run): """Remove the payload's fixed local live identity, before binding live /proc.""" passwd = target_path(root, 'etc/passwd') try: rows = [row.split(':') for row in passwd.read_text().splitlines() if row.split(':', 1)[0] == 'hydra-live'] except OSError as exc: raise InstallError('Payload missing readable /etc/passwd') from exc if len(rows) > 1: raise InstallError('Duplicate live account in payload') if rows: row = rows[0] if len(row) != 7 or not row[2].isdigit() or int(row[2]) < 1000 or row[5] != '/home/hydra-live': raise InstallError('Live account must have a nonroot UID and exactly /home/hydra-live') home = target_path(root, 'home/hydra-live') if home.exists() and not home.is_dir(): raise InstallError('Live home must be an ordinary directory') if rows: # /proc is not mounted yet, so the live session's identical UID cannot # make userdel mistake a running live process for a target process. run(['chroot', str(root), 'userdel', 'hydra-live']) if home.exists(): shutil.rmtree(home) for spool in ('var/mail/hydra-live', 'var/spool/mail/hydra-live'): parent = target_path(root, str(PurePosixPath(spool).parent), symlinks=True) leaf = parent / 'hydra-live' if leaf.is_file() or leaf.is_symlink(): leaf.unlink() def select_kernel(root, requested): modules = target_path(root, '/lib/modules', symlinks=True) if not modules.is_dir(): raise InstallError('Payload has no installed kernel modules') versions = sorted(p.name for p in modules.iterdir() if not p.is_symlink() and p.is_dir() and KERNEL.fullmatch(p.name)) if requested: if requested not in versions: raise InstallError(f'Requested kernel not installed: {requested}') version = requested elif len(versions) == 1: version = versions[0] else: raise InstallError('Select --kernel explicitly when payload has zero or multiple kernels: ' + ', '.join(versions)) if not target_path(root, '/boot/vmlinuz-' + version, symlinks=True).is_file(): raise InstallError(f'Payload missing /boot/vmlinuz-{version}') return version def write_config(root, relative, value, mode=0o644): path = target_path(root, relative) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(value) path.chmod(mode) def install(request, runner, probe, root, password, log: TextIO): """Execute into an empty mount directory; inject a runner for disk-free tests.""" if not request.execute: 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') 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') mounted = [] error = None journal = [] def emit(message): journal.append(message) print(message, file=log, flush=True) def run(argv, **kw): emit('+ ' + shlex.join(argv)) return runner.run(argv, **kw) def mount(argv, path, recursive=False): run(argv) mounted.append((str(path), recursive)) try: run(['unsquashfs', '-s', request.payload]) 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) 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) remove_live_account(root, run) if not target_path(root, '/usr/share/zoneinfo/' + request.timezone, symlinks=True).is_file(): raise InstallError('Timezone does not exist in payload: ' + request.timezone) for folder in ('boot/efi', 'dev', 'proc', 'sys'): target_path(root, folder).mkdir(parents=True, exist_ok=True) mount(['mount', esp, str(root / 'boot/efi')], root / 'boot/efi') root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip() 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') 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') localtime = target_path(root, 'etc/localtime', symlinks=True) # Replace even an absolute /etc/localtime symlink without touching the host. localtime_link = target_path(root, 'etc') / 'localtime' if localtime_link.is_symlink(): localtime_link.unlink() elif localtime.exists(): localtime.unlink() (root / 'etc/localtime').symlink_to('/usr/share/zoneinfo/' + request.timezone) 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' + ('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')]) mount(['mount', '-t', 'proc', 'proc', str(root / 'proc')], root / 'proc') mount(['mount', '--rbind', '/sys', str(root / 'sys')], root / 'sys', True) run(['mount', '--make-rslave', str(root / 'sys')]) def chroot(*args, **kw): return run(['chroot', str(root), *args], **kw) chroot('locale-gen') for group in ('wheel', 'kvm', 'video', 'render', 'audio'): chroot('groupadd', '--force', '--system', group) chroot('useradd', '--create-home', '--groups', 'wheel,kvm,video,render,audio', '--shell', '/bin/bash', request.user) chroot('chpasswd', input=f'{request.user}:{password}\n', sensitive=True) password = None chroot('passwd', '--lock', 'root') chroot('systemd-machine-id-setup') chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live', '/boot/initramfs-' + kernel + '.img', kernel) 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 emit(f'Installation stopped: {exc}. Target data retained for diagnosis.') finally: password = None if any(path == str(root) for path, _ in mounted): try: write_config(root, 'var/log/hydra-install.log', '\n'.join(journal) + '\n', 0o600) except (InstallError, OSError) as exc: emit(f'Unable to retain diagnostic in target: {exc}') cleanup_errors = [] for path, recursive in reversed(mounted): try: run(['umount', *(['--recursive'] if recursive else []), path]) except (InstallError, OSError) as exc: cleanup_errors.append(str(exc)) emit(f'cleanup failed: {exc}') if cleanup_errors and error is None: 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) def preflight(request, runner): if any(Path(marker).exists() for marker in ('/.dockerenv', '/run/.containerenv', '/run/systemd/container')): raise InstallError('Refusing disk installation in a container; boot the live ISO in a disposable VM') if os.geteuid() != 0: raise InstallError('Execution requires root inside the live environment; no automatic sudo') if not sys.stdin.isatty(): 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') 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]) def prompt_password(): try: # Only the prompt output stream is needed here; getpass opens its own # raw controlling-TTY input. Buffered r+ requires seekability. with open('/dev/tty', 'w') as tty: if not tty.isatty(): raise InstallError('Password entry requires a controlling TTY') with warnings.catch_warnings(): warnings.simplefilter('error', getpass.GetPassWarning) password = getpass.getpass('Installed account password: ', stream=tty) if password != getpass.getpass('Repeat password: ', stream=tty): raise InstallError('Passwords differ') return password except (OSError, getpass.GetPassWarning) as exc: raise InstallError('Cannot securely read password from the controlling TTY') from exc def main(argv=None): 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') parser.add_argument('--confirm-erase', help='must exactly match the resolved /dev target with --execute') parser.add_argument('--timezone', default='Europe/Moscow') parser.add_argument('--kernel', help='required if payload contains multiple kernel module versions') args = parser.parse_args(argv) runner = Runner() try: 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'])) print('Disks (no target selected; mounted disks are ineligible):') for path, node in sorted(topo.nodes.items()): if node['type'] == 'disk': state = 'in use' if topo.component(path) & topo.protected else 'unmounted; requires full guard check' print(f' {path} {node["size"] / 1024**3:.1f} GiB {state}') print('Choose --disk DEVICE --user USER to inspect the dry-run plan.') return 0 if not args.user: raise InstallError('--user is required with --disk') request = Request(**vars(args)) probe = SystemProbe(runner) target = validate_request(request, probe) print(render_plan(request, target, '/mnt/hydra-target')) if not request.execute: return 0 preflight(request, runner) password = prompt_password() state = Path(tempfile.mkdtemp(prefix='hydra-install-', dir='/run')) state.chmod(0o700) root = state / 'target' root.mkdir() logfile = state / 'installer.log' print(f'Diagnostic log: {logfile}') with logfile.open('w') as log: log.write(render_plan(request, target, str(root)) + '\n') install(request, runner, probe, root, password, log) password = None print(f'Installation completed. Diagnostic log: {logfile}') return 0 except (InstallError, OSError, KeyboardInterrupt) as exc: print(f'hydra-install: {exc}', file=sys.stderr) return 1 if __name__ == '__main__': raise SystemExit(main())