diff --git a/README.md b/README.md index a1eaa84..e925e87 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# HydraOS +# Hydra OS Gentoo-based host for concurrent virtual desktops. Its default session shows one VM fullscreen and switches between guests with keyboard shortcuts. Other host @@ -31,9 +31,37 @@ Build outputs, VM disks, runtime state and credentials stay outside Git. ## First use Follow the [installation guide](docs/install.md) to install from the live ISO. -The initial empty graphical session opens a recovery terminal. Enter `:shell` -for an ordinary host shell; `sudo nmtui` configures host network connections. -Guest operating systems are supplied by you: +The session opens a terminal VM menu. Choose a machine by number or name; +Enter opens the remembered selection, or the only machine. Opening a stopped +machine starts it; opening a running one reconnects its screen. Merely opening +the menu does not start every VM. `:shell` opens an ordinary host shell and +`:network` opens NetworkManager's connection menu. The existing VM disks and +command-line controls remain available. + +From a Hydra shell, a direct installer URL can become a running VM in one +command. Replace the example URL with an actual ISO download link: + +```sh +hydra run 'https://example.org/distribution.iso' --name work --memory 4096 --cpus 2 +hydra open work +``` + +`run` also accepts a local ISO path. It refuses to replace an existing VM name; +use `open` for subsequent starts. Add `--no-view` for a headless launch. A +download-page URL returning HTML is rejected. Downloads show progress, can be +cancelled with Ctrl+C, and become reusable cache entries only after validation. +Supply the publisher's SHA-256 when available: + +```sh +hydra download 'https://example.org/distribution.iso' --sha256 PUBLISHER_SHA256 +``` + +The cache lives under `$XDG_CACHE_HOME/hydra/iso` (normally +`~/.cache/hydra/iso`). Without a publisher checksum, ISO format validation +does not establish publisher authenticity. New launches through this workflow +check available RAM with a 768 MiB host reserve; this cannot guarantee against +later memory pressure. In a nested test, leave RAM available on the outer host +too. The original lower-level commands remain useful for manual control: ```sh hydra add work --iso /absolute/path/ubuntu.iso --memory 8192 --cpus 4 @@ -43,7 +71,7 @@ hydra start experiments exit ``` -Back at `hydra recovery>`, enter `:refresh` to show the newly created guests. +Back in the terminal menu, select the new guest to open its screen. Guests remain running while you switch, reconnect viewers or leave the host graphical session. Shut a guest down from inside its OS, or request an orderly shutdown with `hydra shutdown NAME`. Check `hydra status NAME` before detaching @@ -52,14 +80,14 @@ its installer with `hydra media NAME --detach` and starting it again. | Shortcut | Action | | --- | --- | | Ctrl+Alt+Shift+Left / Right | Switch guest workspaces | -| Ctrl+Alt+Shift+Escape | Open local recovery | +| Ctrl+Alt+Shift+Escape | Open the terminal VM menu | | Ctrl+Alt+Shift+R | Release viewer input capture | | Ctrl+Alt+Shift+U | Choose a USB device to redirect | | Brightness keys | Adjust host backlight | New guests share `hydrabr0` (`10.203.0.0/24`) and have distinct persistent MAC addresses. Services can communicate between guests without per-port forwarding; -access from your LAN requires a router route through the HydraOS host. Each +access from your LAN requires a router route through the Hydra OS host. Each guest controls its own services and firewall. Host Internet access is separate from this local guest network. @@ -69,6 +97,11 @@ Other host desktops can use `hydra start NAME` and `hydra view NAME --windowed` without entering the minimal labwc session. Ordinary Gentoo administration and Portage remain available through the recovery shell. +Fastfetch displays the **Hydra OS** system summary and its custom terminal +logo. It is included in the new image; the first published ISO predates this +interface update. See [terminal usage](docs/terminal-usage.md) for networking, +installation media and common recovery steps. + ## Build the ISO Follow the [isolated Gentoo build instructions](image/README.md). Outputs are diff --git a/dev/guest-update.py b/dev/guest-update.py new file mode 100644 index 0000000..ab3cd3a --- /dev/null +++ b/dev/guest-update.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Build a self-contained, backed-up runtime update for an installed Hydra guest. + +This updates explicitly listed program/branding files, never guest disks, +profiles, credentials, networking or the kernel. It is not a package manager. +""" +import argparse +import datetime +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile + + +class UpdateError(ValueError): + pass + + +EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', + 'etc/fastfetch/config.jsonc', 'usr/share/hydra/fastfetch.txt'} +LIMIT = 16 * 1024 * 1024 + + +def allowed(name): + return (name in EXACT or + re.fullmatch(r'usr/lib/hydra/hydra/[a-z_]+\.py', name) is not None) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def build_bundle(source, output): + source, output = Path(source), Path(output) + files = {} + for module in sorted((source / 'hydra').glob('*.py')): + files['usr/lib/hydra/hydra/' + module.name] = (module.read_bytes(), 0o644) + launcher = source / 'bin/hydra' + if not launcher.is_file() or 'usr/lib/hydra/hydra/cli.py' not in files: + raise UpdateError('Source is missing the Hydra runtime') + files['usr/lib/hydra/bin/hydra'] = (launcher.read_bytes(), 0o755) + for name in EXACT - {'usr/lib/hydra/bin/hydra'}: + path = source / 'image/rootfs' / name + if path.is_file(): + files[name] = (path.read_bytes(), 0o644) + commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], + text=True, capture_output=True, timeout=10) + manifest = {'format': 1, 'product': 'Hydra OS', + 'source_commit': commit.stdout.strip() if commit.returncode == 0 else None, + 'files': {name: {'sha256': digest(data), 'mode': mode} + for name, (data, mode) in sorted(files.items())}} + # Exclusive creation preserves previous artifacts, even on repeated builds. + output.parent.mkdir(parents=True, exist_ok=True) + try: + with output.open('xb') as stream: + stream.write(b'#!/usr/bin/env python3\n') + with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as bundle: + bundle.writestr('__main__.py', Path(__file__).read_bytes()) + bundle.writestr('manifest.json', json.dumps(manifest, indent=2)) + for name, (data, _) in sorted(files.items()): + if not allowed(name): + raise UpdateError('Unrecognized payload path: ' + name) + bundle.writestr('payload/' + name, data) + except BaseException: + # Do not remove a pre-existing output when exclusive creation failed. + if 'stream' in locals(): + output.unlink(missing_ok=True) + raise + output.chmod(0o700) + return manifest + + +def checked_path(root, name): + relative = PurePosixPath(name) + if relative.is_absolute() or '..' in relative.parts: + raise UpdateError('Unsafe update path') + current = root + for part in relative.parts: + current = current / part + if current.is_symlink(): + # /etc/os-release is the one intentional replacement of a known + # vendor link. os.replace replaces the link, never its target. + if (name == 'etc/os-release' and current == root / name and + str(current.readlink()) in {'../usr/lib/os-release', '/usr/lib/os-release'}): + checked_path(root, 'usr/lib/os-release') + return current + raise UpdateError('Symlink update destination refused: ' + str(current)) + if current.exists() and not current.is_file(): + raise UpdateError('Update destination is not a regular file: ' + str(current)) + return current + + +def atomic_write(path, data, mode): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.hydra-update-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), mode) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def apply_bundle(bundle, root=Path('/'), *, execute=False): + root = Path(root).absolute() + if root.is_symlink() or root.resolve() != root: + raise UpdateError('Symlink target root refused') + marker = checked_path(root, 'usr/share/hydra/build-manifest.json') + if not marker.is_file() or not (root / 'usr/lib/hydra/hydra').is_dir(): + raise UpdateError('Target is not an existing Hydra installation') + payload = {} + with zipfile.ZipFile(bundle) as archive: + names = archive.namelist() + if len(names) != len(set(names)) or sum(i.file_size for i in archive.infolist()) > LIMIT: + raise UpdateError('Invalid or oversized update bundle') + manifest = json.loads(archive.read('manifest.json')) + if manifest.get('format') != 1 or manifest.get('product') != 'Hydra OS' or not isinstance(manifest.get('files'), dict): + raise UpdateError('Invalid Hydra update manifest') + for name, entry in manifest['files'].items(): + if not allowed(name) or entry.get('mode') not in {0o644, 0o755}: + raise UpdateError('Unrecognized update file: ' + name) + data = archive.read('payload/' + name) + if digest(data) != entry.get('sha256'): + raise UpdateError('Payload checksum mismatch: ' + name) + checked_path(root, name) + payload[name] = (data, entry['mode']) + if 'usr/lib/hydra/hydra/cli.py' not in payload: + raise UpdateError('Update has no Hydra CLI') + release = checked_path(root, 'etc/os-release') + vendor = checked_path(root, 'usr/lib/os-release') + current_release = vendor if release.is_symlink() else release + original = current_release.read_text() if current_release.is_file() else '' + retained = [line for line in original.splitlines() + if not re.match(r'^(NAME|PRETTY_NAME|ID|ID_LIKE)=', line)] + retained += ['NAME="Hydra OS"', 'PRETTY_NAME="Hydra OS"', + 'ID=hydra-os', 'ID_LIKE=gentoo'] + payload['etc/os-release'] = (('\n'.join(retained) + '\n').encode(), 0o644) + receipt_name = 'usr/share/hydra/runtime-update.json' + checked_path(root, receipt_name) + payload[receipt_name] = ((json.dumps(manifest, indent=2) + '\n').encode(), 0o644) + result = {'applied': False, 'source_commit': manifest.get('source_commit'), + 'files': sorted(payload), 'backup': None} + if not execute: + return result + if root == Path('/') and os.geteuid() != 0: + raise UpdateError('Apply inside the Hydra guest with sudo') + backup_base = root / 'var/lib/hydra/updates' + # Validate ancestors without creating anything before all payload checks. + checked_path(root, 'var/lib/hydra/updates/.probe') + backup_base.mkdir(parents=True, exist_ok=True, mode=0o700) + stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ-') + backup = Path(tempfile.mkdtemp(prefix=stamp, dir=backup_base)) + records = {} + for name in payload: + dest = checked_path(root, name) + if dest.exists() or dest.is_symlink(): + saved = backup / name + saved.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(dest, saved, follow_symlinks=False) + records[name] = {'existed': True, 'mode': dest.lstat().st_mode & 0o777} + if dest.is_symlink(): + records[name]['symlink'] = str(dest.readlink()) + else: + records[name]['sha256'] = digest(dest.read_bytes()) + else: + records[name] = {'existed': False} + (backup / 'backup.json').write_text(json.dumps(records, indent=2) + '\n') + changed = [] + try: + for name, (data, mode) in payload.items(): + atomic_write(checked_path(root, name), data, mode) + changed.append(name) + except BaseException: + for name in reversed(changed): + dest = checked_path(root, name) + original = records[name] + if 'symlink' in original: + dest.unlink() + dest.symlink_to(original['symlink']) + elif original['existed']: + atomic_write(dest, (backup / name).read_bytes(), original['mode']) + else: + dest.unlink() + raise + result.update(applied=True, backup=str(backup)) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, metavar='SOURCE') + parser.add_argument('--output', type=Path) + parser.add_argument('--root', type=Path, default=Path('/'), help='Installed Hydra root; staging roots are supported for verification') + parser.add_argument('--apply', action='store_true', help='Apply after verification and retain original files in a backup') + args = parser.parse_args() + try: + if args.build: + if not args.output or args.apply: + parser.error('--build requires --output and cannot be combined with --apply') + result = build_bundle(args.build, args.output) + else: + result = apply_bundle(Path(sys.argv[0]), args.root, execute=args.apply) + print(json.dumps(result, indent=2)) + if args.apply: + print('Runtime updated. Log out of the Hydra graphical session and log in again to reload session code; running VMs are unchanged.') + if not (args.root / 'usr/bin/fastfetch').exists(): + print('Fastfetch is not installed yet. Inside Hydra: sudo emerge --getbinpkg --noreplace app-misc/fastfetch') + return 0 + except (UpdateError, OSError, ValueError, KeyError, zipfile.BadZipFile) as exc: + print('Hydra OS update: ' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/install.md b/docs/install.md index 3abb05f..c16dcc1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ -# Install HydraOS +# Install Hydra OS -HydraOS targets x86_64 UEFI systems. Disable Secure Boot for this first image. +Hydra OS targets x86_64 UEFI systems. Disable Secure Boot for this first image. Boot the ISO and open the recovery terminal. The live session uses a locked `hydra-live` account with local sudo access; changes in the live filesystem are volatile. The image provides no default SSH login or universal installed password. @@ -70,7 +70,7 @@ shuts down, and launches a second QEMU with the ISO absent. It verifies password login, disk root, removed live overrides, services, bridge/NAT configuration, DNS, recovery tools, and a routed new TCP connection from a synthetic LAN to a synthetic VM endpoint. Network namespaces and veth interfaces exist only inside -the disposable HydraOS guest. When guest KVM is available, it launches a further +the disposable Hydra OS guest. When guest KVM is available, it launches a further fresh-disk QEMU as the installed account to exercise KVM, the bridge helper, and private QMP; this checks VM launch, not a nested operating system boot. It also logs the installed account out and back in, checking that the same VM, @@ -88,7 +88,7 @@ acceptance. ## Checks requiring the laptop Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before -using HydraOS daily, boot the live ISO and observe Wi-Fi, touchpad, audio, +using Hydra OS daily, boot the live ISO and observe Wi-Fi, touchpad, audio, brightness, suspend/resume, USB device selection, input capture, and actual display refresh. Physical installation and these checks remain pending until explicitly performed and recorded. diff --git a/docs/terminal-usage.md b/docs/terminal-usage.md new file mode 100644 index 0000000..9be345f --- /dev/null +++ b/docs/terminal-usage.md @@ -0,0 +1,126 @@ +# Using Hydra OS from the terminal + +Hydra OS keeps the host small. Virtual machines have independent disks and +processes; closing a screen or the host menu does not power them off. + +## Open a machine + +The initial session shows the VM menu. Enter a number or a VM name. Enter alone +uses the last selection when it still exists, or the only configured VM. A +stopped VM starts; an already running one reconnects. Ctrl+Alt+Shift+Escape +opens the menu while working inside a VM, and Ctrl+Alt+Shift+Left/Right switch +between VM workspaces. + +An ordinary terminal or SSH session can use: + +```sh +hydra open work +hydra start work +hydra status work +hydra shutdown work +``` + +`open` requires a graphical display unless `--no-view` is supplied. `start` +and `shutdown` are the existing explicit lifecycle commands. Shutdown requests +an orderly guest poweroff; it does not promise instantaneous completion. + +## Download and run an installer + +Use a direct `.iso` download URL supplied by the distribution: + +```sh +hydra run 'https://example.org/distribution.iso' --name work --memory 4096 --cpus 2 +``` + +Replace the example URL. The command downloads a validated image, creates a +new profile, starts that VM and opens its screen. Existing names are refused +before downloads or disk changes; use `hydra open work` next time. The same +command accepts a local ISO path. Use `--no-view` to run without a viewer. + +To download without creating a machine: + +```sh +hydra download 'https://example.org/distribution.iso' +``` + +Add `--sha256` followed by the publisher's 64 hexadecimal digits to check the +download. Format validation alone checks that the file looks like an ISO, +not who published it. Validated files are cached under +`~/.cache/hydra/iso` (or `$XDG_CACHE_HOME/hydra/iso`). Ctrl+C cancels an active +download without publishing its partial file. HTTP is available for a local +test transfer; prefer the publisher's HTTPS endpoint for Internet downloads. + +The guest OS installer still performs installation inside its virtual disk. +After it finishes, shut the guest down, detach the installer and reopen it: + +```sh +hydra shutdown work +hydra status work +# Wait until status is stopped: +hydra media work --detach +hydra open work +``` + +This avoids accidentally booting the live ISO again. No guest disk is recreated. + +## Network and host controls + +The menu offers host shell and network actions. `sudo nmtui` controls the +physical host's Wi-Fi/Ethernet connections. Guests see virtual Ethernet and +receive Internet access through Hydra; they do not each select the Wi-Fi SSID. +When testing Hydra inside another VM, physical Wi-Fi remains controlled by the +outer host (currently NixOS). + +The guest bridge uses `10.203.0.1/24`. A guest can SSH to `10.203.0.1` after +SSH service and authentication have been configured on Hydra. An SSH login is +not enabled automatically. Networking between Hydra and its guests does not +require Internet access. VPN clients can run in individual guests; a shared +host VPN requires routing guest traffic through that VPN explicitly. + +## If something fails + +- Download returns HTML: copy the actual ISO link, not its web download page. +- A checksum mismatch: do not boot that download; verify the source/checksum. +- A missing ISO: attach a valid image, or detach installation media after the + guest was installed and stopped. +- Insufficient RAM: stop an unused VM or choose a smaller guest allocation. + The launch check reserves 768 MiB on Hydra. A nested Hydra cannot see memory + pressure on the outer NixOS host; leave headroom there too. +- A viewer closes: reopen the VM. Closing a viewer does not erase its disk or + stop its running workloads. +- An unknown VM state: inspect the reported error/QEMU log. Hydra will not + blindly start a duplicate or signal a process whose identity it cannot verify. + +Run `fastfetch` for the host summary and Hydra OS logo. Advanced administration +remains ordinary Gentoo administration from the host shell. + +## Update an existing test installation + +The first ISO predates this interface. A runtime bundle updates the Hydra +program and branding files while retaining the original image manifest and +backing up replaced files under `/var/lib/hydra/updates`. It leaves VM profiles, +disks, the kernel and network configuration in place. To build one from a +trusted checkout: + +```sh +python3 dev/guest-update.py --build . --output /tmp/hydra-os-update.pyz +``` + +Copy the resulting file into Hydra, then inspect and apply it **inside Hydra**: + +```sh +python3 hydra-os-update.pyz +sudo python3 hydra-os-update.pyz --apply +``` + +The first command only prints the file inventory. After applying, log out of +Hydra's graphical session and log in again so the long-running session process +loads the new code. Reopening a terminal alone cannot reload that process. +Running VMs remain independent of the graphical session. + +This bundle contains the Fastfetch configuration and logo, but no executable +packages. If the old installation lacks Fastfetch, install it inside Hydra: + +```sh +sudo emerge --getbinpkg --noreplace app-misc/fastfetch +``` diff --git a/image/README.md b/image/README.md index cf2a926..4e1a91e 100644 --- a/image/README.md +++ b/image/README.md @@ -1,4 +1,4 @@ -HydraOS builds start from the pinned amd64 systemd stage3 and pinned Gentoo +Hydra OS builds start from the pinned amd64 systemd stage3 and pinned Gentoo repository in `sources.json`. Run the builder as the normal user with Python 3, Podman and GnuPG available; temporary tool environments are sufficient. It refuses host root and work/cache paths outside the primary project checkout. diff --git a/tests/test_guest_update.py b/tests/test_guest_update.py new file mode 100644 index 0000000..810626c --- /dev/null +++ b/tests/test_guest_update.py @@ -0,0 +1,145 @@ +"""An installed-guest update must be bounded, inspectable and reversible.""" +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + + +class GuestUpdateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.script = Path(__file__).resolve().parents[1] / 'dev/guest-update.py' + + def setUp(self): + self.assertTrue(self.script.is_file(), 'guest update helper must exist') + spec = importlib.util.spec_from_file_location('guest_update', self.script) + self.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.mod) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name) + self.source = self.base / 'source' + for name, value in {'hydra/__init__.py': '', 'hydra/cli.py': 'NEW = True\n', + 'bin/hydra': '#!/usr/bin/python3\n', + 'image/rootfs/etc/motd': 'Hydra OS\n', + 'image/rootfs/usr/share/hydra/fastfetch.txt': 'hydra logo\n'}.items(): + dest = self.source / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(value) + self.root = self.base / 'guest' + marker = self.root / 'usr/share/hydra/build-manifest.json' + marker.parent.mkdir(parents=True) + marker.write_text('{"original": true}\n') + self.old = self.root / 'usr/lib/hydra/hydra/cli.py' + self.old.parent.mkdir(parents=True) + self.old.write_text('OLD = True\n') + self.bundle = self.base / 'update.pyz' + + def build(self): + return self.mod.build_bundle(self.source, self.bundle) + + def test_dry_run_preserves_installed_files_and_user_disk(self): + self.build() + disk = self.root / 'home/user/.local/state/hydra/work/disk.qcow2' + disk.parent.mkdir(parents=True) + disk.write_bytes(b'user disk') + result = self.mod.apply_bundle(self.bundle, self.root) + self.assertFalse(result['applied']) + self.assertEqual(self.old.read_text(), 'OLD = True\n') + self.assertEqual(disk.read_bytes(), b'user disk') + self.assertFalse((self.root / 'var/lib/hydra/updates').exists()) + + def test_apply_saves_backup_and_preserves_original_image_evidence(self): + self.build() + result = self.mod.apply_bundle(self.bundle, self.root, execute=True) + self.assertTrue(result['applied']) + self.assertEqual(self.old.read_text(), 'NEW = True\n') + backup = Path(result['backup']) + self.assertEqual((backup / 'usr/lib/hydra/hydra/cli.py').read_text(), 'OLD = True\n') + self.assertEqual(json.loads((self.root / 'usr/share/hydra/build-manifest.json').read_text()), {'original': True}) + self.assertTrue((self.root / 'usr/share/hydra/runtime-update.json').is_file()) + self.assertEqual((self.root / 'usr/share/hydra/fastfetch.txt').read_text(), 'hydra logo\n') + + def test_branding_preserves_vendor_release_and_backs_up_etc_symlink(self): + vendor = self.root / 'usr/lib/os-release' + vendor.parent.mkdir(parents=True, exist_ok=True) + vendor.write_text('NAME="Gentoo"\nID=gentoo\nVERSION_ID=2.18\n') + release = self.root / 'etc/os-release' + release.parent.mkdir(parents=True) + release.symlink_to('../usr/lib/os-release') + self.build() + result = self.mod.apply_bundle(self.bundle, self.root, execute=True) + self.assertFalse(release.is_symlink()) + self.assertIn('PRETTY_NAME="Hydra OS"', release.read_text()) + self.assertIn('ID_LIKE=gentoo', release.read_text()) + self.assertIn('VERSION_ID=2.18', release.read_text()) + self.assertEqual(vendor.read_text(), 'NAME="Gentoo"\nID=gentoo\nVERSION_ID=2.18\n') + saved = Path(result['backup']) / 'etc/os-release' + self.assertTrue(saved.is_symlink()) + self.assertEqual(str(saved.readlink()), '../usr/lib/os-release') + + def test_non_hydra_root_is_refused_before_changes(self): + self.build() + (self.root / 'usr/share/hydra/build-manifest.json').unlink() + with self.assertRaisesRegex(self.mod.UpdateError, 'Hydra'): + self.mod.apply_bundle(self.bundle, self.root, execute=True) + self.assertEqual(self.old.read_text(), 'OLD = True\n') + + def test_modified_payload_is_refused_before_changes(self): + self.build() + other = self.base / 'tampered.pyz' + with zipfile.ZipFile(self.bundle) as src, zipfile.ZipFile(other, 'w') as dst: + for name in src.namelist(): + data = b'CORRUPTED\n' if name == 'payload/usr/lib/hydra/hydra/cli.py' else src.read(name) + dst.writestr(name, data) + with self.assertRaisesRegex(self.mod.UpdateError, 'checksum'): + self.mod.apply_bundle(other, self.root, execute=True) + self.assertEqual(self.old.read_text(), 'OLD = True\n') + + def test_symlink_destination_cannot_redirect_privileged_update(self): + self.build() + outside = self.base / 'outside' + outside.write_text('KEEP\n') + self.old.unlink() + self.old.symlink_to(outside) + with self.assertRaisesRegex(self.mod.UpdateError, '[Ss]ymlink'): + self.mod.apply_bundle(self.bundle, self.root, execute=True) + self.assertEqual(outside.read_text(), 'KEEP\n') + + def test_bundle_never_overwrites_an_existing_output(self): + self.bundle.write_bytes(b'keep this artifact') + with self.assertRaises((FileExistsError, self.mod.UpdateError)): + self.build() + self.assertEqual(self.bundle.read_bytes(), b'keep this artifact') + + def test_self_contained_bundle_applies_without_source_checkout(self): + self.build() + result = subprocess.run([sys.executable, str(self.bundle), '--root', str(self.root), '--apply'], + capture_output=True, text=True, timeout=10, cwd=self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.old.read_text(), 'NEW = True\n') + self.assertIn('Runtime updated', result.stdout) + + def test_write_failure_rolls_back_files_already_replaced(self): + self.build() + actual_write = self.mod.atomic_write + calls = 0 + + def fail_once(*args): + nonlocal calls + calls += 1 + if calls == 3: + raise OSError('injected full disk') + return actual_write(*args) + + with patch.object(self.mod, 'atomic_write', side_effect=fail_once): + with self.assertRaisesRegex(OSError, 'full disk'): + self.mod.apply_bundle(self.bundle, self.root, execute=True) + self.assertEqual(self.old.read_text(), 'OLD = True\n') + self.assertFalse((self.root / 'etc/motd').exists()) + self.assertFalse((self.root / 'usr/lib/hydra/bin/hydra').exists())