From 00aa0229ccdb07bb8be2e59808e8d1059d7fcefc Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 21:52:38 +0300 Subject: [PATCH] Add read-only laptop doctor and trusted image recovery entrypoints --- docs/hardware/asus-fx517zc.md | 58 +++++++++-- hydra/cli.py | 5 + hydra/doctor.py | 186 ++++++++++++++++++++++++++++++++++ hydra/image.py | 29 +++++- tests/test_cli.py | 23 +++++ tests/test_doctor.py | 121 ++++++++++++++++++++++ tests/test_image.py | 60 +++++++++++ 7 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 hydra/doctor.py create mode 100644 tests/test_doctor.py diff --git a/docs/hardware/asus-fx517zc.md b/docs/hardware/asus-fx517zc.md index 0c546b8..026b426 100644 --- a/docs/hardware/asus-fx517zc.md +++ b/docs/hardware/asus-fx517zc.md @@ -1,11 +1,11 @@ # ASUS TUF Dash F15 FX517ZC -Read-only inventory 2026-10-13; no serial numbers or machine identifiers. +Initial read-only inventory on 2026-10-13; no serials or machine identifiers. | Component | Hardware | Driver / requirement | | --- | --- | --- | | CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x | -| RAM | 32 GB nominal | Four build jobs initially; avoid disrupting existing VMs | +| RAM | 32 GB nominal | Preserve memory headroom for existing VMs | | Display | Intel Alder Lake-P UHD 8086:46a3 | i915, internal eDP, intel_backlight | | Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately | | Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware | @@ -16,7 +16,53 @@ Read-only inventory 2026-10-13; no serial numbers or machine identifiers. | Thunderbolt | Intel 8086:463e | thunderbolt | | Laptop controls | ASUS | asus_wmi, asus_nb_wmi | -UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci, -thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz; -actual guest frame delivery must be measured. About290GiB build storage free at -inventory. Initial RAM headroom about10GiB with existing sessions running. +Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage. +Target physical refresh is 144 Hz; actual guest frame delivery requires measurement. + +## Read-only doctor and live functional checks + +Run `hydra doctor` from the Hydra live session, or `hydra doctor --json` to save +structured observations. The command reads procfs/sysfs and checks service status; +it does not connect networks, load modules, change brightness, play sound, mount +storage, suspend, or start services. Root is not required. Missing tools, absent +hardware, access failures and service timeouts are reported without aborting. + +Reports omit storage serials, PCI addresses, interface names, MAC/IP addresses, +SSID, usernames, hostname and machine IDs. CPU facts include architecture, logical +processor count and virtualization flags. Device facts include counts, capacities, +allowlisted driver names and connector resolution modes. Unknown driver names are +reported as `other`. Presence and status do not establish physical functionality. + +Reads are limited to 64 KiB per file, directory scans to 64 entries, connector +modes to 32 lines, and seven service queries to one second each. Counts may be +partial on systems exceeding these limits. Service output is discarded; exit +status alone determines active/inactive/unavailable/unknown. User audio service +queries refer to the user running the command. A root invocation can therefore +observe a different user audio manager than the live desktop user. + +The following source-checkout smoke was observed on the running NixOS host on +2026-10-13. It involved no changes to host settings or hardware. It is separate +from Hydra ISO validation and does not certify the new image's laptop drivers. + +| Check | Doctor detects | Source-checkout host observation | Live functional check still needed | +| --- | --- | --- | --- | +| CPU / RAM | Architecture, logical CPUs, VM flags, memory totals | x86_64, 16 logical CPUs, `vmx`, 31,715 MiB RAM | Boot the Hydra image and compare | +| Firmware | UEFI presence, Secure Boot variable | UEFI present, Secure Boot disabled | Confirm live boot from USB | +| KVM | Device access and loaded KVM modules | `/dev/kvm` accessible, `kvm` and `kvm_intel` present | Launch a disposable VM from Hydra | +| Storage | Whole-device count/capacity; no serials or identities | Visibility observed; no target selected | Identify any future installation target separately | +| Graphics | PCI driver bindings and connector resolution modes | `i915`, `nvidia`; connected 1920×1080 mode | Select 144 Hz and verify actual refresh rate | +| Input / brightness | Event devices, keyboard capabilities, backlight presence | 23 event devices, three keyboard-capable devices, one backlight | Test keyboard, touchpad and both brightness keys | +| Wi-Fi / Ethernet | Device counts, wireless presence, driver bindings | One wireless device; `iwlwifi` and `e1000e` | Connect Wi-Fi and test connectivity; test Ethernet if used | +| Audio | Card presence and user audio service status | Three cards; PipeWire, WirePlumber and PipeWire Pulse active | Test speakers, headphones and microphone recording | +| Desktop / guest networking | Hydra and desktop service status | NetworkManager active; Hydra services absent on NixOS | Observe required services in the Hydra live session | +| Suspend / USB | No functional probe | Not exercised | Suspend/resume, then retest graphics/network/audio; attach USB devices | + +Run the manual checks before deciding the image is ready for daily laptop use. +Read-only diagnostics and disposable VM acceptance cannot replace these checks. +Keep any report you share within its default structured output: unrelated logs, +network commands and screenshots can contain private identifiers. + +Installation remains an explicit operator action; this document selects no disk +or partition. Signed runtime rollback restores Hydra runtime files only. Offline +Btrfs root/owned-EFI recovery has separate requirements and acceptance evidence +in [system-recovery.md](../system-recovery.md). diff --git a/hydra/cli.py b/hydra/cli.py index 0fc6dfc..df00e65 100644 --- a/hydra/cli.py +++ b/hydra/cli.py @@ -21,6 +21,8 @@ def parser() -> argparse.ArgumentParser: help='Distribution preferences for newly created profiles') commands = cli.add_subparsers(dest='command') commands.add_parser('menu', help='Open the terminal VM menu') + doctor = commands.add_parser('doctor', help='Read-only laptop hardware observations') + doctor.add_argument('--json', action='store_true', help='Print structured observations') update = commands.add_parser('update', help='Signed runtime updates and rollback') update.add_argument('update_args', nargs=argparse.REMAINDER) download = commands.add_parser('download', help='Download and validate a direct ISO URL') @@ -95,6 +97,9 @@ def main(argv: list[str] | None = None) -> int: cli = parser() args = cli.parse_args(argv) try: + if args.command == 'doctor': + from .doctor import doctor_main + return doctor_main(['--json'] if args.json else []) if args.command == 'update': checkout = Path(__file__).resolve().parents[1] if checkout == Path('/usr/lib/hydra'): diff --git a/hydra/doctor.py b/hydra/doctor.py new file mode 100644 index 0000000..29eb37a --- /dev/null +++ b/hydra/doctor.py @@ -0,0 +1,186 @@ +"""Bounded, read-only hardware observations; presence is not a functional test.""" + +import argparse +import itertools +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess + +READ_LIMIT = 65536 +DEVICE_LIMIT = 64 +TOOLS = ('systemctl', 'nmcli', 'ip', 'iw', 'rfkill', 'wpctl', 'pactl', + 'aplay', 'brightnessctl', 'wlr-randr', 'qemu-system-x86_64', 'btrfs', + 'openssl', 'efibootmgr', 'lsblk', 'lspci') +DRIVERS = frozenset(('i915', 'xe', 'nouveau', 'nvidia', 'amdgpu', 'radeon', + 'virtio_gpu', 'bochs', 'simpledrm', 'iwlwifi', 'ath9k', + 'ath10k_pci', 'ath11k_pci', 'mt7921e', 'mt7922', 'rtw89_pci', + 'r8169', 'e1000e', 'igc', 'virtio_net', 'snd_hda_intel')) +MODULES = ('kvm', 'kvm_intel', 'kvm_amd', 'i915', 'xe', 'nouveau', 'nvidia', + 'amdgpu', 'iwlwifi', 'mt7921e', 'snd_hda_intel', 'snd_sof_pci_intel_tgl') +MANUAL_CHECKS = [ + 'Wi-Fi connection: connect to your access point and test connectivity.', + 'Brightness keys: check both keys and the visible panel brightness.', + '144 Hz: select the panel mode and verify the actual refresh rate.', + 'Audio: play sound through speakers/headphones and record microphone input.', + 'Suspend/resume: test wake-up, graphics, network and audio after suspend.', + 'USB: attach your devices and verify input/storage without erasing data.', +] + + +def _read(path, limit=READ_LIMIT): + try: + with path.open('rb') as stream: + return stream.read(limit).decode('ascii', errors='replace') + except OSError: + return '' + + +def _entries(path): + try: + # Do not materialize an unbounded directory or emit its identifying names. + with os.scandir(path) as entries: + return [Path(entry.path) for entry in itertools.islice(entries, DEVICE_LIMIT)] + except OSError: + return [] + + +def _number(path, *, base=10): + text = _read(path, 128).strip() + try: + value = int(text, base) + return value if 0 <= value <= 2**64 - 1 else None + except ValueError: + return None + + +def _driver(path): + try: + name = (path / 'driver').resolve(strict=True).name + return name if name in DRIVERS else 'other' + except OSError: + return 'unknown' + + +def _services(find_tool, run): + systemctl = find_tool('systemctl') + result = {} + for unit, user in (('NetworkManager', False), ('greetd', False), + ('hydra-network', False), ('hydra-dnsmasq', False), + ('pipewire', True), ('wireplumber', True), ('pipewire-pulse', True)): + key = unit + (':user' if user else ':system') + if not systemctl: + result[key] = 'unavailable' + continue + command = [systemctl] + (['--user'] if user else []) + ['is-active', '--quiet', unit + '.service'] + try: + status = run(command, timeout=1, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode + result[key] = {0: 'active', 3: 'inactive', 4: 'unavailable'}.get(status, 'unknown') + except subprocess.TimeoutExpired: + result[key] = 'timeout' + except OSError: + result[key] = 'unavailable' + return result + + +def doctor_report(*, proc_root=Path('/proc'), sys_root=Path('/sys'), dev_root=Path('/dev'), + find_tool=shutil.which, run=subprocess.run): + """Return JSON-safe facts without identifiers or hardware-changing commands. + + Each file read is bounded to 64 KiB, each device class to 64 entries and + the seven read-only service queries to one second each. Missing access + produces unknown/absent facts, never a claim of functional success. + """ + proc_root, sys_root, dev_root = Path(proc_root), Path(sys_root), Path(dev_root) + cpu = _read(proc_root / 'cpuinfo') + flags = set(re.findall(r'\b(?:vmx|svm)\b', cpu)) + memory = {} + meminfo = _read(proc_root / 'meminfo') + for field, key in (('MemTotal', 'total_mib'), ('MemAvailable', 'available_mib')): + match = re.search(r'^' + field + r':\s+([0-9]{1,16})\s+kB$', meminfo, re.M) + memory[key] = int(match[1]) // 1024 if match else None + efi = sys_root / 'firmware/efi' + secure_boot = None + variable = efi / 'efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c' + try: + with variable.open('rb') as stream: + value = stream.read(6) + if len(value) == 5 and value[4] in (0, 1): + secure_boot = bool(value[4]) + except OSError: + pass + storage = [] + for path in _entries(sys_root / 'class/block'): + if (path / 'partition').exists() or re.match(r'^(loop|ram|zram)', path.name): + continue + sectors = _number(path / 'size') + storage.append({'size_bytes': sectors * 512 if sectors is not None else None, + 'removable': {0: False, 1: True}.get(_number(path / 'removable'))}) + graphics_drivers = set() + for path in _entries(sys_root / 'bus/pci/devices'): + pci_class = _number(path / 'class', base=16) + if pci_class is not None and pci_class >> 16 == 3: + graphics_drivers.add(_driver(path)) + connectors = [] + for path in _entries(sys_root / 'class/drm'): + if not (path / 'status').exists(): + continue + status = _read(path / 'status', 64).strip() + modes = [line for line in _read(path / 'modes').splitlines()[:32] + if re.fullmatch(r'[0-9]{1,5}x[0-9]{1,5}', line)] + connectors.append({'status': status if status in ('connected', 'disconnected', 'unknown') else 'unknown', + 'modes': modes}) + network = _entries(sys_root / 'class/net') + inputs = [p for p in _entries(sys_root / 'class/input') if re.fullmatch(r'event[0-9]+', p.name)] + keyboards = 0 + for path in inputs: + value = _read(path / 'device/capabilities/key', 256).strip().replace(' ', '') + if re.fullmatch(r'[0-9a-fA-F]{1,256}', value): + bits = int(value, 16) + keyboards += bool(bits & (1 << 30) and bits & (1 << 44)) + return { + 'format': 1, + 'scope': 'Read-only observations; physical functionality requires manual tests.', + 'cpu': {'architecture': platform.machine(), 'logical_processors': len(re.findall(r'^processor\s*:', cpu, re.M)) or None, + 'virtualization_flags': sorted(flags)}, + 'memory': memory, + 'firmware': {'uefi': efi.is_dir(), 'secure_boot': secure_boot}, + 'kvm': {'device_present': (dev_root / 'kvm').exists(), + 'accessible': os.access(dev_root / 'kvm', os.R_OK | os.W_OK), + 'modules': [name for name in ('kvm', 'kvm_intel', 'kvm_amd') if (sys_root / 'module' / name).is_dir()]}, + 'storage': {'devices': storage}, + 'graphics': {'drivers': sorted(graphics_drivers), 'connectors': connectors, + 'refresh_rate_tested': False}, + 'input': {'event_devices': len(inputs), 'keyboards': keyboards, + 'backlight_devices': len(_entries(sys_root / 'class/backlight'))}, + 'network': {'devices': len(network), 'wireless_devices': sum((p / 'wireless').is_dir() for p in network), + 'drivers': sorted({_driver(p / 'device') for p in network if (p / 'device').exists()})}, + 'audio': {'cards': sum(bool(re.fullmatch(r'card[0-9]+', p.name)) for p in _entries(sys_root / 'class/sound'))}, + 'modules': [name for name in MODULES if (sys_root / 'module' / name).is_dir()], + 'tools': {name: bool(find_tool(name)) for name in TOOLS}, + 'services': _services(find_tool, run), + 'manual_checks': list(MANUAL_CHECKS), + } + + +def doctor_main(argv=None): + parser = argparse.ArgumentParser(prog='hydra doctor', description=__doc__) + parser.add_argument('--json', action='store_true', help='Print structured observations') + options = parser.parse_args(argv) + report = doctor_report() + if options.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print('Hydra laptop doctor — read-only observations') + print(report['scope']) + for key, value in report.items(): + if key not in ('format', 'scope', 'manual_checks'): + print(f'{key}: {json.dumps(value, sort_keys=True)}') + print('\nManual checks:') + for check in report['manual_checks']: + print('- ' + check) + return 0 diff --git a/hydra/image.py b/hydra/image.py index e09b468..973074c 100644 --- a/hydra/image.py +++ b/hydra/image.py @@ -269,8 +269,10 @@ def brand_os_release(root): def configure_rootfs(root, source): root, source = Path(root), Path(source) # Refuse incomplete integrations before changing the exported copy. - for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py'): - if not (source / name).is_file(): + for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py', + 'bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py', + 'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'): + if (source / name).is_symlink() or not (source / name).is_file(): raise ImageError(f'Required committed runtime/installer asset is missing: {name}') version = kernel_version(root) kernel = kernel_image(root) @@ -287,11 +289,28 @@ def configure_rootfs(root, source): for module in sorted((source / 'hydra').glob('*.py')): shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name)) # Session helpers derive this checkout-compatible launcher from __file__. - for name in ('hydra', 'hydra-install'): + for name in ('hydra', 'hydra-install', 'hydra-system'): write(root, 'usr/lib/hydra/bin/' + name, (source / 'bin' / name).read_text(), 0o755) - for name, module in (('hydra', 'cli'), ('hydra-install', 'installer')): + # The updater bootstrap and engine are independently installed trusted files. + # They must retain their isolated runpy launchers, outside runtime imports. + for destination, asset, mode in ( + ('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644), + ('usr/bin/hydra-update', 'bin/hydra-update', 0o755), + ('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755), + ('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644), + ): + remove(root, destination) + path = write(root, destination, (source / asset).read_text(), mode) + if os.geteuid() == 0: # Assembly runs as root; source fixtures remain unprivileged. + os.chown(path, 0, 0) + engine_directory = root_path(root, 'usr/lib/hydra-update') + engine_directory.chmod(0o755) + if os.geteuid() == 0: + os.chown(engine_directory, 0, 0) + for name, module in (('hydra', 'cli'), ('hydra-install', 'installer'), + ('hydra-system', 'system_recovery')): write(root, 'usr/bin/' + name, - '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n' + '#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n' f'from hydra.{module} import main\nraise SystemExit(main())\n', 0o755) write(root, 'usr/bin/hydra-session', '#!/bin/sh\nexec /usr/bin/hydra session "$@"\n', 0o755) write(root, 'usr/bin/hydra-network', '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n' diff --git a/tests/test_cli.py b/tests/test_cli.py index 22d8d90..58415f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,6 +21,29 @@ class CLITests(unittest.TestCase): def run_cli(self, *args): return subprocess.run(self.base + list(args), capture_output=True, text=True, timeout=5) + def test_doctor_json_and_text_do_not_create_runtime_state(self): + from unittest.mock import patch + from hydra import cli + import contextlib + import io + with patch('hydra.doctor.doctor_report', return_value={'format': 1, 'scope': 'observations', + 'manual_checks': ['Wi-Fi connection', 'suspend']}): + output = io.StringIO() + with contextlib.redirect_stdout(output): + try: + status = cli.main(self.base[2:] + ['doctor', '--json']) + except SystemExit as error: + status = error.code + self.assertEqual(status, 0) + self.assertEqual(json.loads(output.getvalue())['format'], 1) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(cli.main(self.base[2:] + ['doctor']), 0) + self.assertIn('Manual checks', output.getvalue()) + self.assertFalse((self.root / 'state').exists()) + self.assertFalse((self.root / 'config').exists()) + self.assertFalse((self.root / 'run').exists()) + def test_defaults_affect_new_profiles_only_and_explicit_options_win(self): self.defaults.write_text('network="bridge"\nbridge="hydrabr0"\nwidth=1920\nheight=1080\nrefresh_millihz=144000\n') added = self.run_cli('add', 'first', '--width', '1600', '--network', 'user', '--refresh-millihz', '0') diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..6f8e4d1 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,121 @@ +import contextlib +import io +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +try: + from hydra import doctor +except ImportError: + doctor = None + + +class DoctorTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(doctor, 'read-only doctor module is missing') + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + for name in ('proc', 'sys', 'dev'): + (self.root / name).mkdir() + + def file(self, name, value=''): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value) + return path + + def report(self, **kwargs): + return doctor.doctor_report(proc_root=self.root / 'proc', sys_root=self.root / 'sys', + dev_root=self.root / 'dev', find_tool=lambda _: None, **kwargs) + + def test_absent_hardware_is_reported_without_false_functional_claims(self): + result = self.report() + self.assertEqual(result['firmware']['uefi'], False) + self.assertIsNone(result['firmware']['secure_boot']) + self.assertEqual(result['storage']['devices'], []) + self.assertFalse(result['kvm']['device_present']) + self.assertTrue(all(not value for value in result['tools'].values())) + self.assertIn('Wi-Fi connection', ' '.join(result['manual_checks'])) + self.assertIn('144 Hz', ' '.join(result['manual_checks'])) + json.dumps(result) + + def test_fake_hardware_reports_presence_and_driver_without_identifiers(self): + self.file('proc/cpuinfo', 'processor : 0\nflags : vmx sse\nSerial : PRIVATE_SERIAL\n') + self.file('proc/meminfo', 'MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n') + secure = self.file('sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c') + secure.write_bytes(b'\x07\0\0\0\x01') + self.file('dev/kvm') + self.file('sys/module/kvm_intel/refcnt', '0') + self.file('sys/class/block/nvme0n1/size', '2097152') + self.file('sys/class/block/nvme0n1/device/serial', 'PRIVATE_SERIAL') + self.file('sys/class/block/nvme0n1p1/partition', '1') + self.file('sys/class/drm/card0-eDP-1/status', 'connected') + self.file('sys/class/drm/card0-eDP-1/modes', '1920x1080\nPRIVATE_USER\n') + self.file('sys/bus/pci/devices/0000:00:02.0/class', '0x030000') + driver = self.root / 'sys/bus/pci/drivers/i915' + driver.mkdir(parents=True) + (self.root / 'sys/bus/pci/devices/0000:00:02.0/driver').symlink_to(driver) + self.file('sys/class/net/wlan0/wireless/present') + self.file('sys/class/net/wlan0/address', 'aa:bb:cc:dd:ee:ff') + self.file('sys/class/net/wlan0/ssid', 'PRIVATE_SSID') + self.file('sys/class/sound/card0/id', 'PRIVATE_USER') + self.file('sys/class/input/event0/device/name', 'PRIVATE_USER') + self.file('sys/class/input/event0/device/capabilities/key', hex((1 << 30) | (1 << 44))[2:]) + result = self.report() + self.assertEqual(result['cpu']['virtualization_flags'], ['vmx']) + self.assertEqual(result['memory']['total_mib'], 8192) + self.assertEqual(result['firmware']['secure_boot'], True) + self.assertEqual(result['storage']['devices'][0]['size_bytes'], 1073741824) + self.assertEqual(result['graphics']['drivers'], ['i915']) + self.assertEqual(result['network']['wireless_devices'], 1) + self.assertEqual(result['audio']['cards'], 1) + self.assertEqual(result['input']['keyboards'], 1) + encoded = json.dumps(result) + for secret in ('PRIVATE_SERIAL', 'PRIVATE_USER', 'PRIVATE_SSID', 'aa:bb:cc:dd:ee:ff'): + self.assertNotIn(secret, encoded) + + def test_probes_timeout_independently_and_discard_untrusted_output(self): + calls = [] + def run(command, **kwargs): + calls.append((command, kwargs)) + if len(calls) == 1: + raise subprocess.TimeoutExpired(command, kwargs['timeout'], output='PRIVATE_SSID') + return subprocess.CompletedProcess(command, 5, 'PRIVATE_USER 192.0.2.1 aa:bb:cc:dd:ee:ff', '') + result = doctor.doctor_report(proc_root=self.root / 'proc', sys_root=self.root / 'sys', + dev_root=self.root / 'dev', find_tool=lambda name: '/usr/bin/' + name, + run=run) + self.assertIn('timeout', result['services'].values()) + self.assertIn('unknown', result['services'].values()) + self.assertNotIn('PRIVATE', json.dumps(result)) + self.assertLessEqual(len(calls), 8) + for argv, options in calls: + self.assertEqual(argv[0], '/usr/bin/systemctl') + self.assertIn('is-active', argv) + self.assertLessEqual(options['timeout'], 1) + self.assertFalse(options.get('shell', False)) + + def test_reads_are_bounded_and_invalid_numbers_are_unknown(self): + self.file('proc/meminfo', 'MemTotal: invalid kB\n' + 'secret' * 100000) + self.file('sys/class/block/sda/size', '-1') + self.file('sys/class/drm/card0-HDMI-A-1/status', 'private text') + result = self.report() + self.assertIsNone(result['memory']['total_mib']) + self.assertIsNone(result['storage']['devices'][0]['size_bytes']) + self.assertEqual(result['graphics']['connectors'][0]['status'], 'unknown') + self.assertLess(len(json.dumps(result)), 10000) + + def test_text_and_json_outputs_share_report_and_manual_limits(self): + with patch.object(doctor, 'doctor_report', return_value=self.report()): + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(doctor.doctor_main(['--json']), 0) + self.assertEqual(json.loads(output.getvalue())['firmware']['uefi'], False) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(doctor.doctor_main([]), 0) + self.assertIn('Manual checks', output.getvalue()) + self.assertIn('suspend', output.getvalue()) diff --git a/tests/test_image.py b/tests/test_image.py index ca7abf6..22714a8 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -35,6 +35,66 @@ class ImageTests(unittest.TestCase): self.file('boot/vmlinuz-' + version, 'kernel') return version + def test_installs_independent_updater_and_base_recovery_assets(self): + self.kernel() + source = Path(__file__).resolve().parents[1] + image.configure_rootfs(self.root, source) + for relative, asset, mode in ( + ('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644), + ('usr/bin/hydra-update', 'bin/hydra-update', 0o755), + ('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755), + ('usr/lib/hydra/bin/hydra-system', 'bin/hydra-system', 0o755), + ('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644), + ): + with self.subTest(relative=relative): + path = self.root / relative + self.assertTrue(path.is_file(), relative) + self.assertEqual(path.read_bytes(), (source / asset).read_bytes()) + self.assertEqual(path.stat().st_mode & 0o777, mode) + wrapper = self.root / 'usr/bin/hydra-system' + self.assertTrue(wrapper.is_file(), 'base recovery launcher is missing') + self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755) + self.assertEqual(wrapper.read_text(), + '#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n' + 'from hydra.system_recovery import main\nraise SystemExit(main())\n') + self.assertEqual((self.root / 'usr/lib/hydra/hydra/doctor.py').read_bytes(), + (source / 'hydra/doctor.py').read_bytes()) + + def test_root_assembly_sets_trusted_asset_ownership(self): + from unittest.mock import patch + self.kernel() + with patch('hydra.image.os.geteuid', return_value=0), patch('hydra.image.os.chown') as chown: + image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) + for relative in ('usr/lib/hydra-update/engine.py', 'usr/lib/hydra-update', + 'usr/bin/hydra-update', 'usr/bin/hydra-update-recover', 'etc/hydra/update.pub'): + chown.assert_any_call(self.root / relative, 0, 0) + + def test_missing_trusted_asset_refuses_configuration_before_mutation(self): + import shutil + source = Path(__file__).resolve().parents[1] + fixture = Path(self.tmp.name) / 'source' + fixture.mkdir() + for directory in ('bin', 'hydra', 'image'): + shutil.copytree(source / directory, fixture / directory, + ignore=shutil.ignore_patterns('__pycache__')) + for asset in ('bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py', + 'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'): + with self.subTest(asset=asset): + self.root = Path(self.tmp.name) / ('root-' + asset.replace('/', '-')) + self.root.mkdir() + self.kernel('6.18.54-gentoo-dist-bin') + original = self.file('etc/machine-id', 'preserved-builder-id') + missing = fixture / asset + saved = missing.read_bytes() + missing.unlink() + try: + with self.assertRaisesRegex(image.ImageError, 'missing'): + image.configure_rootfs(self.root, fixture) + self.assertEqual(original.read_text(), 'preserved-builder-id') + self.assertFalse((self.root / 'usr').exists()) + finally: + missing.write_bytes(saved) + def test_kernel_from_payload_not_running_host(self): self.kernel() self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist')