From 0c02052a4e67e173a870d34d13faf84f77ae3ec5 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 05:33:27 +0300 Subject: [PATCH] Fix ISO tool checks and fail promptly on installer refusal --- dev/iso-smoke.py | 37 +++++++++++++++++++----- tests/test_iso_smoke.py | 64 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/dev/iso-smoke.py b/dev/iso-smoke.py index 97a36d6..5fe431b 100644 --- a/dev/iso-smoke.py +++ b/dev/iso-smoke.py @@ -423,6 +423,15 @@ def parse_frame(data, token): return int(end.group(1)), output +def installer_command(token): + # A literal control-byte return frame cannot be forged by terminal echo. + # Keep the installer on this controlling TTY for getpass, then mark every + # shell return, including an unexpected status zero without completion. + return ('sudo -n hydra-install --disk /dev/vda --user hydratest --execute ' + '--confirm-erase /dev/vda; rc=$?; ' + f"printf '\\036{token}:INSTALL_EXIT:%s\\037' \"$rc\"") + + class Console: def __init__(self, connection, logfile, passwords): self.sock = connection @@ -472,10 +481,17 @@ class Console: raise SmokeError('Console exceeded bounded capture size') return data - def expect(self, pattern, timeout): + def expect(self, pattern, timeout, failure_pattern=None): deadline = time.monotonic() + timeout while True: match = re.search(pattern, self.buffer) + failure = re.search(failure_pattern, self.buffer) if failure_pattern else None + if failure and (not match or failure.start() < match.start()): + evidence = failure.group() + for password in self.passwords: + evidence = evidence.replace(password, b'[REDACTED]') + raise SmokeError('Installer exited before expected console state: ' + + evidence.decode(errors='replace')) if match: self.buffer = self.buffer[match.end():] return match @@ -484,6 +500,11 @@ class Console: raise SmokeError('Timed out waiting for guest console state') self.read(min(remaining, 1)) + def expect_installer(self, pattern, timeout, token): + failure = (rb'hydra-install: [^\r\n]*[\r\n]|\x1e' + + re.escape(token.encode()) + rb':INSTALL_EXIT:[0-9]+\x1f') + return self.expect(pattern, timeout, failure_pattern=failure) + def command(self, command, timeout=60): token = secrets.token_hex(12) self.buffer = b'' @@ -579,7 +600,7 @@ test -f /etc/hydra/live-mode/active findmnt -n -o FSTYPE / | grep -qx overlay test -f /run/initramfs/live/LiveOS/squashfs.img sudo -n true -for tool in hydra hydra-install qemu-system-x86_64 qemu-img virt-viewer foot labwc; do command -v "$tool"; done +for tool in hydra hydra-install qemu-system-x86_64 qemu-img remote-viewer foot labwc; do command -v "$tool"; done for service in NetworkManager hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done ''' @@ -627,7 +648,7 @@ ip -4 route show default | grep '^default ' nft list table ip hydra_nat nmcli -t -f STATE general | grep -E '^connected' getent ahostsv4 gentoo.org -for tool in hydra hydra-install qemu-system-x86_64 qemu-img virt-viewer foot labwc; do command -v "$tool"; done +for tool in hydra hydra-install qemu-system-x86_64 qemu-img remote-viewer foot labwc; do command -v "$tool"; done hydra --help qemu-system-x86_64 --version qemu-img --version @@ -789,12 +810,14 @@ def main(argv=None): console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout) checkpoint('live_boot', console.command(LIVE_CHECK, 90)) console.buffer = b'' - console.send('sudo -n hydra-install --disk /dev/vda --user hydratest --execute --confirm-erase /dev/vda\n') - console.expect(rb'Installed account password: ', 120) + installer_token = secrets.token_hex(12) + console.send(installer_command(installer_token) + '\n') + console.expect_installer(rb'Installed account password: ', 120, installer_token) console.send(password + '\n') - console.expect(rb'Repeat password: ', 30) + console.expect_installer(rb'Repeat password: ', 30, installer_token) console.send(password + '\n') - console.expect(rb'Installation completed\. Diagnostic log: [^\r\n]+', args.install_timeout) + console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+', + args.install_timeout, installer_token) checkpoint('real_installer', b'Installer completed via guest controlling TTY') console.command("sudo -n mkdir -p /mnt/hydra-validation && sudo -n mount /dev/vda2 /mnt/hydra-validation && printf %s " + shlex.quote(sentinel) + " | sudo -n tee /mnt/hydra-validation/var/lib/hydra-acceptance-sentinel >/dev/null && sudo -n umount /mnt/hydra-validation", 60) diff --git a/tests/test_iso_smoke.py b/tests/test_iso_smoke.py index 82971cd..37499c7 100644 --- a/tests/test_iso_smoke.py +++ b/tests/test_iso_smoke.py @@ -101,6 +101,57 @@ class SmokeTests(unittest.TestCase): with self.assertRaises(self.mod.SmokeError): console.command('true', timeout=0.05) + def installer_console(self): + one, two = socket.socketpair() + self.addCleanup(two.close) + console = self.mod.Console(one, self.root / 'installer.log', ['test-secret']) + self.addCleanup(console.close) + return console, two + + def test_captured_installer_refusal_fails_before_password_timeout(self): + console, guest = self.installer_console() + guest.sendall(b'\x1b]3008;type=command\x1b\\hydra-install: Command failed (1): ' + b'findmnt --json --target /LiveOS/squashfs.img ' + b'--output SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS: \r\n') + with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*findmnt'): + console.expect_installer(rb'Installed account password: ', 0.05, 'testtoken') + + def test_installer_shell_return_without_completion_is_failure_even_zero(self): + for status in (0, 1, 127): + with self.subTest(status=status): + console, guest = self.installer_console() + guest.sendall(b'\x1etesttoken:INSTALL_EXIT:' + str(status).encode() + b'\x1f') + with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*INSTALL_EXIT'): + console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+', + 0.05, 'testtoken') + + def test_installer_echo_cannot_fake_exit_and_complete_prompt_still_required(self): + console, guest = self.installer_console() + guest.sendall(self.mod.installer_command('testtoken').encode() + b'\r\n' + b'Installed account password: ') + self.assertEqual(console.expect_installer(rb'Installed account password: ', + 0.05, 'testtoken').group(), b'Installed account password: ') + guest.sendall(b'Installation completed. Diagnostic') + with self.assertRaisesRegex(self.mod.SmokeError, 'Timed out'): + console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+', + 0.05, 'testtoken') + guest.sendall(b' log: /var/log/hydra-install.log\r\n') + self.assertIn(b'/var/log/hydra-install.log', console.expect_installer( + rb'Installation completed\. Diagnostic log: [^\r\n]+', 0.05, 'testtoken').group()) + + def test_installer_wrapper_marks_actual_shell_return_and_redacts_error(self): + console, guest = self.installer_console() + fake_sudo = self.root / 'sudo' + fake_sudo.write_text('#!/bin/sh\nprintf "hydra-install: test-secret refused\\n"\nexit 1\n') + fake_sudo.chmod(0o700) + result = subprocess.run(['/bin/sh', '-c', self.mod.installer_command('testtoken')], + env={'PATH': str(self.root)}, capture_output=True, check=True) + self.assertIn(b'\x1etesttoken:INSTALL_EXIT:1\x1f', result.stdout) + guest.sendall(result.stdout) + with self.assertRaisesRegex(self.mod.SmokeError, r'Installer.*\[REDACTED\] refused') as caught: + console.expect_installer(rb'Repeat password: ', 0.05, 'testtoken') + self.assertNotIn('test-secret', str(caught.exception)) + def test_frame_real_shell_roundtrip_does_not_accept_sentinel_in_echo(self): one, two = socket.socketpair() self.addCleanup(one.close) @@ -207,14 +258,19 @@ else: print('fixture tool') dispatcher.chmod(0o755) for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft', 'nmcli', 'sudo', 'hydra', 'hydra-install', - 'qemu-system-x86_64', 'qemu-img', 'virt-viewer', 'foot', 'labwc'): + 'qemu-system-x86_64', 'qemu-img', 'remote-viewer', 'foot', 'labwc'): path = commands / command if not path.exists(): path.symlink_to(dispatcher) probe = self.mod.LIVE_CHECK if live else self.mod.INSTALLED_CHECK for path in ('/etc/', '/usr/share', '/proc/cmdline', '/sys/firmware/efi', '/run/initramfs/live/LiveOS/squashfs.img'): probe = probe.replace(path, str(root) + path) - env = dict(os.environ, PATH=str(commands) + ':' + os.environ['PATH'], + import shutil + for command in ('python3', 'sh', 'grep', 'find', 'cut', 'readlink', 'base64'): + target = shutil.which(command) + self.assertIsNotNone(target) + (commands / command).symlink_to(target) if not (commands / command).exists() else None + env = dict(os.environ, PATH=str(commands), SMOKE_CASE=case, SMOKE_LIVE='1' if live else '0') result = subprocess.run(['/bin/sh', '-c', self.mod.frame_command(probe, 'probecheck')], env=env, capture_output=True, timeout=10) @@ -500,3 +556,7 @@ else: sys.exit(1) self.assertTrue(self.mod.no_open_logins(context)) with self.assertRaises(self.mod.SmokeError): self.mod.capture_logout_snapshot(context) + + def test_guest_tools_accept_gentoo_remote_viewer_without_virt_viewer(self): + self.assertEqual(self.probe_fixture('clean'), 0) + self.assertEqual(self.probe_fixture('clean', live=True), 0)