191 lines
8.3 KiB
Python
191 lines
8.3 KiB
Python
"""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')
|
|
# fstab resolves the ESP by filesystem UUID. Copies on any surviving
|
|
# device are ambiguous even when their GPT partition identities differ.
|
|
# Root's old filesystem is replaced before fstab is written.
|
|
copies = [path for path, node in topo.nodes.items()
|
|
if path not in {root, esp}
|
|
and (node.get('uuid') or '').lower() == esp_node['uuid'].lower()]
|
|
if copies:
|
|
fail('ESP filesystem UUID is ambiguous on surviving devices: ' + ', '.join(sorted(copies)))
|
|
# 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)
|