"""Offline, live-ISO-only Btrfs base checkpoints. Never runs installed executables.""" from __future__ import annotations import argparse import contextlib import ctypes from datetime import datetime, timezone import fcntl import hashlib import json import os from pathlib import Path import re import shutil import stat import subprocess import sys import tempfile import uuid class RecoveryError(RuntimeError): pass MARKER_KEYS = {'format', 'install_uuid', 'filesystem', 'root_fs_uuid', 'root_partuuid', 'esp_uuid', 'esp_partuuid', 'owned_efi_path', 'root_subvolume'} ID_RE = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z') def require(condition, message): if not condition: raise RecoveryError(message) def valid_uuid(value): return isinstance(value, str) and bool(ID_RE.fullmatch(value.lower())) def validate_marker(value): require(isinstance(value, dict) and set(value) == MARKER_KEYS, 'Invalid storage marker fields') require(type(value['format']) is int and value['format'] == 1, 'Unsupported storage marker format') require(all(valid_uuid(value[k]) for k in ('install_uuid', 'root_fs_uuid', 'root_partuuid', 'esp_partuuid')), 'Invalid storage UUID') require(isinstance(value['esp_uuid'], str) and re.fullmatch(r'[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}', value['esp_uuid']), 'Invalid FAT filesystem UUID') require(value['filesystem'] == 'btrfs' and value['root_subvolume'] == '@root' and value['owned_efi_path'] == 'EFI/HydraOS', 'Unsupported storage layout') return value def safe_path(base, relative, *, missing=False): """Refuse links/special files at every component; caller owns private mount.""" base = Path(base) require(not base.is_symlink() and base.is_dir(), 'Unsafe base directory') parts = Path(relative).parts require(parts and not Path(relative).is_absolute() and all(p not in ('.', '..') for p in parts), 'Unsafe path') path = base for part in parts: path = path / part try: mode = path.lstat().st_mode except FileNotFoundError: require(missing, 'Missing path: ' + str(path)) continue require(stat.S_ISDIR(mode) or stat.S_ISREG(mode), 'Link or special file refused: ' + str(path)) return path def read_json(path): path = Path(path) info = path.lstat() require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_uid == os.geteuid() and not info.st_mode & 0o022, 'Metadata must be a trusted regular file: ' + str(path)) require(info.st_size <= 16 * 1024 * 1024, 'Metadata too large') def unique(pairs): out = {} for key, value in pairs: require(key not in out, 'Duplicate JSON field') out[key] = value return out try: return json.loads(path.read_text(), object_pairs_hook=unique) except (ValueError, UnicodeError) as exc: raise RecoveryError('Malformed metadata: ' + str(path)) from exc def fsync_dir(path): fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: os.fsync(fd) finally: os.close(fd) def durable_json(path, value): path = Path(path) fd, name = tempfile.mkstemp(prefix='.metadata-', dir=path.parent) try: with os.fdopen(fd, 'w') as stream: json.dump(value, stream, sort_keys=True, indent=2) stream.write('\n'); stream.flush(); os.fsync(stream.fileno()) os.replace(name, path) fsync_dir(path.parent) finally: if os.path.exists(name): os.unlink(name) def digest(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() def file_hash(path): with Path(path).open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def tree_manifest(root): """FAT-compatible names, regular files/directories, exact backup metadata.""" root = Path(root) require(root.is_dir() and not root.is_symlink(), 'Owned EFI tree must be a directory') entries = [] def walk(directory): folded = set() for path in sorted(directory.iterdir()): name = path.name require(re.fullmatch(r'[A-Za-z0-9_+., -]+', name) and name not in ('.', '..') and not name.endswith((' ', '.')), 'Unsafe EFI name') require(name.casefold() not in folded, 'Ambiguous FAT filename') folded.add(name.casefold()) info = path.lstat() require(stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode), 'Unsafe EFI file type') require(not stat.S_ISREG(info.st_mode) or info.st_nlink == 1, 'EFI hardlink refused') entry = dict(path=path.relative_to(root).as_posix(), kind='dir' if path.is_dir() else 'file', mode=stat.S_IMODE(info.st_mode), mtime_ns=info.st_mtime_ns) if path.is_file(): entry.update(size=info.st_size, sha256=file_hash(path)) entries.append(entry) if path.is_dir(): walk(path) walk(root) return entries def sync_tree(root): for path in Path(root).rglob('*'): if path.is_file(): with path.open('rb') as stream: os.fsync(stream.fileno()) for directory, _, _ in os.walk(root, topdown=False): fsync_dir(directory) class Btrfs: def run(self, argv): try: return subprocess.run(argv, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=dict(os.environ, LC_ALL='C')).stdout except subprocess.CalledProcessError as exc: detail = (exc.stderr or '').strip()[:2000] raise RecoveryError(f'{argv[0]} failed ({exc.returncode}): {detail}') from exc def info(self, path): output = self.run(['btrfs', 'subvolume', 'show', str(path)]) fields = dict(line.strip().split(':', 1) for line in output.splitlines() if ':' in line) value = fields.get('UUID', '').strip() parent = fields.get('Parent UUID', '').strip() require(valid_uuid(value), 'Not a Btrfs subvolume: ' + str(path)) ro = self.run(['btrfs', 'property', 'get', '-ts', str(path), 'ro']).strip() require(ro in ('ro=true', 'ro=false'), 'Cannot establish snapshot readonly state') return dict(uuid=value, parent_uuid=None if parent == '-' else parent, readonly=ro == 'ro=true') def nested(self, path): return self.run(['btrfs', 'subvolume', 'list', '-o', str(path)]).splitlines() def snapshot(self, source, dest, readonly): self.run(['btrfs', 'subvolume', 'snapshot', *(['-r'] if readonly else []), str(source), str(dest)]) return self.info(dest) def sync(self, path): fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: libc = ctypes.CDLL(None, use_errno=True) if libc.syncfs(fd) != 0: raise OSError(ctypes.get_errno(), 'syncfs failed') finally: os.close(fd) class RecoverySession: """Mounted engine; CLI supplies verified identities and private offline mounts.""" def __init__(self, top, esp, backend, identities): self.top, self.esp, self.backend = Path(top), Path(esp), backend self.identities = dict(identities) self.snapshots = safe_path(self.top, '@snapshots') self.journal = self.snapshots / 'restore.json' for name in ('@snapshots', '@home'): path = safe_path(self.top, name) require(not self.backend.info(path)['readonly'], 'Layout subvolume is readonly') def marker(self, root): marker = validate_marker(read_json(safe_path(root, 'etc/hydra/storage.json'))) for key, expected in self.identities.items(): require(marker.get(key) == expected, 'Storage identity mismatch: ' + key) require(not self.backend.nested(root), 'Nested subvolumes under root are unsupported') return marker def current_root(self): root = safe_path(self.top, '@root') require(not self.backend.info(root)['readonly'], 'Installed root is readonly') return root, self.marker(root) def efi_root(self, *, missing=False): # EFI itself must exist. Only the owned directory may be recreated. safe_path(self.esp, 'EFI') root = safe_path(self.esp, 'EFI/HydraOS', missing=missing) for parent, expected in ((self.esp, 'EFI'), (self.esp / 'EFI', 'HydraOS')): require(not any(p.name.casefold() == expected.casefold() and p.name != expected for p in parent.iterdir()), 'Case alias of owned EFI directory refused') names = [p.name.casefold() for p in parent.iterdir()] require(len(names) == len(set(names)), 'Ambiguous EFI directory names') return root def checkpoint(self, name, *, dry_run=False): safe_path(self.snapshots, 'restore.json', missing=True) require(not self.journal.exists(), 'Resume pending restore before checkpointing') require(isinstance(name, str) and 1 <= len(name) <= 100 and all(ord(c) >= 32 for c in name), 'Invalid checkpoint name') root, storage = self.current_root() efi = self.efi_root() manifest = tree_manifest(efi) source = self.backend.info(root) if dry_run: return dict(operation='checkpoint', execute=False, name=name, storage=storage) ident = str(uuid.uuid4()) directory = self.snapshots / ident directory.mkdir(mode=0o700) fsync_dir(self.snapshots) snapshot = self.backend.snapshot(root, directory / 'root', True) require(snapshot['readonly'] and snapshot['parent_uuid'] == source['uuid'], 'Invalid new snapshot') shutil.copytree(efi, directory / 'efi', copy_function=shutil.copy2) require(tree_manifest(directory / 'efi') == manifest, 'EFI changed during checkpoint') sync_tree(directory / 'efi') self.backend.sync(self.top) descriptor = dict(format=1, id=ident, name=name, created=datetime.now(timezone.utc).isoformat(), storage=storage, source_root_uuid=source['uuid'], snapshot_uuid=snapshot['uuid'], efi_manifest=manifest) durable_json(directory / 'checkpoint.json', descriptor) self.backend.sync(self.top) return descriptor def descriptor(self, ident): require(valid_uuid(ident), 'Invalid checkpoint ID') directory = safe_path(self.snapshots, ident) require({p.name for p in directory.iterdir()} == {'root', 'efi', 'checkpoint.json'}, 'Unexpected checkpoint files') value = read_json(safe_path(directory, 'checkpoint.json')) require(isinstance(value, dict) and set(value) == {'format', 'id', 'name', 'created', 'storage', 'source_root_uuid', 'snapshot_uuid', 'efi_manifest'}, 'Invalid checkpoint fields') require(type(value['format']) is int and value['format'] == 1 and value['id'] == ident, 'Unsupported checkpoint descriptor') require(isinstance(value['name'], str) and isinstance(value['created'], str) and valid_uuid(value['source_root_uuid']) and valid_uuid(value['snapshot_uuid']), 'Invalid checkpoint metadata') validate_marker(value['storage']) snapshot = safe_path(directory, 'root') info = self.backend.info(snapshot) require(info == dict(uuid=value['snapshot_uuid'], parent_uuid=value['source_root_uuid'], readonly=True), 'Checkpoint snapshot identity or readonly state changed') require(self.marker(snapshot) == value['storage'], 'Checkpoint storage identity changed') require(tree_manifest(safe_path(directory, 'efi')) == value['efi_manifest'], 'EFI backup metadata/hash mismatch') return value def list(self): result = [] for directory in sorted(self.snapshots.iterdir()): if valid_uuid(directory.name) and not directory.is_symlink() and (directory / 'checkpoint.json').exists(): result.append(self.descriptor(directory.name)) return result def save_journal(self, value, phase): value['phase'] = phase durable_json(self.journal, value) self.backend.sync(self.top) def restore(self, ident, *, dry_run=False): safe_path(self.snapshots, 'restore.json', missing=True) descriptor = self.descriptor(ident) # ALL backup validation precedes any write. source = self.snapshots / ident / 'root' owned = self.efi_root(missing=True) if owned.exists(): tree_manifest(owned) if self.journal.exists(): tx = read_json(self.journal) require(isinstance(tx, dict) and set(tx) == {'format', 'id', 'checkpoint', 'checkpoint_digest', 'storage', 'old_uuid', 'new_uuid', 'prepared_root', 'failed_root', 'phase'}, 'Invalid restore journal') require(type(tx['format']) is int and tx['format'] == 1 and valid_uuid(tx['id']) and tx['checkpoint'] == ident and tx['checkpoint_digest'] == digest(descriptor) and tx['storage'] == descriptor['storage'] and valid_uuid(tx['old_uuid']) and (tx['new_uuid'] is None or valid_uuid(tx['new_uuid'])) and tx['phase'] in ('preparing', 'prepared', 'root-retained', 'root-installed', 'efi-restored') and tx['prepared_root'] == '@restore-' + tx['id'] and tx['failed_root'] == '@failed-' + tx['id'], 'Restore journal does not match requested checkpoint') else: root, marker = self.current_root() require(marker == descriptor['storage'], 'Current root belongs to a different installation') require(not any(p.name.startswith('@restore-') for p in self.top.iterdir()), 'Unexplained prepared root') txid = str(uuid.uuid4()) tx = dict(format=1, id=txid, checkpoint=ident, checkpoint_digest=digest(descriptor), storage=marker, old_uuid=self.backend.info(root)['uuid'], new_uuid=None, prepared_root='@restore-' + txid, failed_root='@failed-' + txid, phase='preparing') prepared = safe_path(self.top, tx['prepared_root'], missing=True) failed = safe_path(self.top, tx['failed_root'], missing=True) root = safe_path(self.top, '@root', missing=True) def identity(path): if not path.exists(): return None info = self.backend.info(path) require(not info['readonly'] and self.marker(path) == tx['storage'], 'Unexpected root identity or layout') return info actual_root, actual_prepared, actual_failed = identity(root), identity(prepared), identity(failed) # Enumerate the only three legitimate rename arrangements. No phase-based guessing. if tx['new_uuid'] is None: require(tx['phase'] == 'preparing' and actual_root and actual_root['uuid'] == tx['old_uuid'] and actual_failed is None, 'Unexplained initial restore state') if actual_prepared is None: if dry_run: return dict(operation='restore', execute=False, checkpoint=ident, storage=descriptor['storage']) if not self.journal.exists(): self.save_journal(tx, 'preparing') actual_prepared = self.backend.snapshot(source, prepared, False) fsync_dir(self.top); self.backend.sync(self.top) require(actual_prepared['parent_uuid'] == descriptor['snapshot_uuid'] and not actual_prepared['readonly'], 'Prepared snapshot source mismatch') tx['new_uuid'] = actual_prepared['uuid'] if not dry_run: self.save_journal(tx, 'prepared') new_uuid, old_uuid = tx['new_uuid'], tx['old_uuid'] state = (actual_root['uuid'] if actual_root else None, actual_prepared['uuid'] if actual_prepared else None, actual_failed['uuid'] if actual_failed else None) require(state in ((old_uuid, new_uuid, None), (None, new_uuid, old_uuid), (new_uuid, None, old_uuid)), 'Unexplained root rename state; preserve all roots and inspect offline') active_new = actual_prepared if actual_prepared else actual_root require(active_new['parent_uuid'] == descriptor['snapshot_uuid'], 'Prepared root parent UUID mismatch') if dry_run: return dict(operation='restore', execute=False, checkpoint=ident, storage=descriptor['storage']) if state == (old_uuid, new_uuid, None): os.rename(root, failed); fsync_dir(self.top); self.backend.sync(self.top) self.save_journal(tx, 'root-retained') state = (None, new_uuid, old_uuid) if state == (None, new_uuid, old_uuid): os.rename(prepared, root); fsync_dir(self.top); self.backend.sync(self.top) self.save_journal(tx, 'root-installed') self.restore_efi(self.snapshots / ident / 'efi', owned) self.backend.sync(self.esp) self.save_journal(tx, 'efi-restored') # Archive rather than discard the identity record and retained root path. os.replace(self.journal, self.snapshots / ('restored-' + tx['id'] + '.json')) fsync_dir(self.snapshots); self.backend.sync(self.top) return dict(checkpoint=ident, failed_root=tx['failed_root'], restored_root_uuid=new_uuid) def restore_efi(self, source, dest): # Backup and existing destination have already passed complete path validation. # Repeating this copy is safe after interruption at any individual FAT write. if dest.exists(): for child in list(dest.iterdir()): if child.is_dir(): shutil.rmtree(child) else: child.unlink() else: dest.mkdir() shutil.copytree(source, dest, dirs_exist_ok=True, copy_function=shutil.copy2) sync_tree(dest); fsync_dir(dest.parent) expected = [(e['path'], e.get('sha256')) for e in tree_manifest(source)] actual = [(e['path'], e.get('sha256')) for e in tree_manifest(dest)] require(actual == expected, 'Restored EFI verification failed; rerun the same restore') def flatten(nodes): result = {} def visit(node): require(isinstance(node, dict) and isinstance(node.get('path'), str), 'Malformed device topology') path = node['path'] require(re.fullmatch(r'/dev/[A-Za-z0-9_./-]+', path) and '..' not in Path(path).parts, 'Invalid device path') require(path not in result or result[path] == node, 'Conflicting device topology') result[path] = node require(isinstance(node.get('children', []), list), 'Malformed device children') for child in node.get('children', []): visit(child) for node in nodes: visit(node) return result def resolve_partition(nodes, key, value, filesystem): devices = flatten(nodes) matches = [n for n in devices.values() if str(n.get(key, '')).lower() == value.lower()] require(len(matches) == 1, 'Missing or ambiguous ' + key + ': ' + value) selected = matches[0] require(selected.get('type') == 'part' and selected.get('fstype') == filesystem and not selected.get('children'), 'Target must be an ordinary partition of type ' + filesystem) require(isinstance(selected.get('mountpoints'), list) and not any(selected['mountpoints']), 'Target is mounted or active swap') for field in ('uuid', 'partuuid'): identity = selected.get(field) require(isinstance(identity, str) and identity and sum(str(n.get(field, '')).lower() == identity.lower() for n in devices.values()) == 1, 'Missing or duplicate target ' + field) parent = selected.get('pkname', '') if not parent.startswith('/dev/'): parent = '/dev/' + parent require(parent in devices and devices[parent].get('type') == 'disk', 'Target parent must be an ordinary disk') require(not any(devices[parent].get('mountpoints', [])), 'Parent disk is mounted') require(re.fullmatch(r'\d+:\d+', selected.get('maj:min', '')), 'Missing target block identity') return selected def validate_live(base=Path('/'), cmdline=None, mountinfo=None): base = Path(base) path = safe_path(base, 'etc/hydra/live-mode/active') info = path.lstat() require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and info.st_nlink == 1 and not info.st_mode & 0o022, 'Untrusted live marker') for directory in (path.parent, path.parent.parent, path.parent.parent.parent): info = directory.stat() require(info.st_uid == os.geteuid() and not info.st_mode & 0o022, 'Untrusted live marker directory') if cmdline is None: cmdline = Path('/proc/cmdline').read_text() if mountinfo is None: mountinfo = Path('/proc/self/mountinfo').read_text() words = cmdline.split() require('rd.live.image' in words and any(w.startswith('root=live:') for w in words), 'Recovery requires a Hydra live boot') mounts = [line.split() for line in mountinfo.splitlines()] require(any(len(parts) > 8 and parts[4] == '/' and '-' in parts and parts[parts.index('-') + 1] == 'overlay' for parts in mounts) and any(' - squashfs ' in line for line in mountinfo.splitlines()), 'Live root must use overlay and squashfs') class DeviceProbe: def __init__(self, backend, *, proc=Path('/proc'), sysroot=Path('/sys')): self.backend, self.proc, self.sysroot = backend, Path(proc), Path(sysroot) def nodes(self): data = json.loads(self.backend.run(['lsblk', '--json', '--paths', '--output', 'PATH,TYPE,PKNAME,MAJ:MIN,FSTYPE,UUID,PARTUUID,MOUNTPOINTS'])) require(isinstance(data, dict) and isinstance(data.get('blockdevices'), list), 'Malformed lsblk result') nodes = data['blockdevices'] # Probe signatures directly; do not trust stale udev UUID caches. for node in flatten(nodes).values(): result = subprocess.run(['blkid', '-p', '-o', 'export', node['path']], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) require(result.returncode in (0, 2), 'Ambiguous or unreadable device signatures') fields = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line) node.update(uuid=fields.get('UUID'), partuuid=fields.get('PART_ENTRY_UUID'), fstype=fields.get('TYPE')) return nodes def check(self, node): path = Path(node['path']) info = path.stat() require(stat.S_ISBLK(info.st_mode) and f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}' == node['maj:min'], 'Device identity changed') sysnode = self.sysroot / 'dev/block' / node['maj:min'] require((sysnode / 'partition').is_file() and not any((sysnode / 'holders').iterdir()), 'Target has holders or is not a partition') parent = sysnode.resolve().parent require(not any((parent / 'holders').iterdir()), 'Parent disk has active holders') for line in (self.proc / 'self/mountinfo').read_text().splitlines(): fields = line.split(); separator = fields.index('-') source = fields[separator + 2] require(fields[2] != node['maj:min'], 'Target is mounted') if source.startswith('/dev/'): try: mounted = Path(source).stat() except FileNotFoundError: raise RecoveryError('Cannot resolve mounted block source') require(mounted.st_rdev != info.st_rdev, 'Target backs a mounted filesystem') for line in (self.proc / 'swaps').read_text().splitlines()[1:]: source = Path(line.split()[0]) swap = source.stat() require((swap.st_rdev if stat.S_ISBLK(swap.st_mode) else swap.st_dev) != info.st_rdev, 'Target has active swap') @contextlib.contextmanager def operation_lock(): fd = os.open('/run/hydra-system.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) try: info = os.fstat(fd) require(stat.S_ISREG(info.st_mode) and info.st_uid == 0 and info.st_nlink == 1 and not info.st_mode & 0o077, 'Untrusted operation lock') try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: raise RecoveryError('Another hydra-system operation is running') yield finally: os.close(fd) def private_namespace(backend): libc = ctypes.CDLL(None, use_errno=True) if libc.unshare(0x00020000) != 0: raise OSError(ctypes.get_errno(), 'Cannot create private mount namespace') backend.run(['mount', '--make-rprivate', '/']) @contextlib.contextmanager def mounted(backend, device, filesystem, writable=False): directory = Path(tempfile.mkdtemp(prefix='hydra-system-', dir='/run')) did_mount = False try: options = ['rw' if writable else 'ro', 'nosuid', 'nodev', 'noexec'] if filesystem == 'btrfs': options.append('subvolid=5') if not writable: options.append('rescue=nologreplay') backend.run(['mount', '-t', filesystem, '-o', ','.join(options), device, str(directory)]) did_mount = True if filesystem == 'btrfs': show = backend.run(['btrfs', 'filesystem', 'show', '--raw', str(directory)]) require(re.search(r'Total devices\s+1\b', show) and len(re.findall(r'\bdevid\s+\d+', show)) == 1, 'Recovery requires a single-device Btrfs filesystem') yield directory finally: if did_mount: # A failed unmount deliberately leaves the directory intact; never recurse into a mount. backend.run(['umount', str(directory)]) directory.rmdir() def mounted_storage(top): if (top / '@root').exists(): return validate_marker(read_json(safe_path(top, '@root/etc/hydra/storage.json'))) journal = read_json(safe_path(top, '@snapshots/restore.json')) require(isinstance(journal, dict) and isinstance(journal.get('storage'), dict), 'Missing root without valid recovery journal') return validate_marker(journal['storage']) def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest='command', required=True) for name in ('checkpoint', 'list', 'restore'): command = commands.add_parser(name) command.add_argument('--root-uuid', required=True) if name != 'list': command.add_argument('--execute', action='store_true') command.add_argument('--confirm-root-uuid') if name == 'checkpoint': command.add_argument('--esp-partuuid', required=True) command.add_argument('--name', required=True) if name == 'restore': command.add_argument('--checkpoint', required=True) args = parser.parse_args(argv) try: require(valid_uuid(args.root_uuid), 'Invalid root filesystem UUID') execute = getattr(args, 'execute', False) if execute: require(args.confirm_root_uuid == args.root_uuid, 'Execution requires --confirm-root-uuid matching --root-uuid') require(os.geteuid() == 0, 'Run from the Hydra live ISO as root; no automatic sudo') validate_live() backend = Btrfs(); probe = DeviceProbe(backend) with operation_lock(): nodes = probe.nodes() root = resolve_partition(nodes, 'uuid', args.root_uuid, 'btrfs'); probe.check(root) private_namespace(backend) # First inspect read-only, without log replay, before opening either filesystem writable. with mounted(backend, root['path'], 'btrfs') as top: marker = mounted_storage(top) require(marker['root_fs_uuid'] == args.root_uuid and marker['root_partuuid'] == root['partuuid'], 'Root ownership mismatch') if args.command == 'checkpoint': require(args.esp_partuuid == marker['esp_partuuid'], 'ESP differs from installation record') esp = resolve_partition(nodes, 'partuuid', marker['esp_partuuid'], 'vfat'); probe.check(esp) require(esp['uuid'] == marker['esp_uuid'], 'ESP filesystem identity mismatch') with mounted(backend, esp['path'], 'vfat') as efi: session = RecoverySession(top, efi, backend, marker) if args.command == 'list': result = session.list() elif args.command == 'restore': result = session.restore(args.checkpoint, dry_run=True) else: result = session.checkpoint(args.name, dry_run=True) if execute: # Re-resolve and check after releasing inspection mounts, immediately before writes. nodes = probe.nodes() current_root = resolve_partition(nodes, 'uuid', args.root_uuid, 'btrfs') current_esp = resolve_partition(nodes, 'partuuid', marker['esp_partuuid'], 'vfat') for before, after in ((root, current_root), (esp, current_esp)): require(all(before.get(k) == after.get(k) for k in ('path', 'uuid', 'partuuid', 'maj:min', 'fstype')), 'Target changed during planning') probe.check(after) with mounted(backend, root['path'], 'btrfs', True) as top: with mounted(backend, esp['path'], 'vfat', True) as efi: session = RecoverySession(top, efi, backend, marker) result = session.checkpoint(args.name) if args.command == 'checkpoint' else session.restore(args.checkpoint) print(json.dumps(result, indent=2, sort_keys=True)) return 0 except (RecoveryError, OSError, ValueError, subprocess.CalledProcessError) as exc: print('hydra-system: ' + str(exc), file=sys.stderr) return 1 if __name__ == '__main__': raise SystemExit(main())