From 98022dd3552631ab2b205abb980b9765e1576093 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 21:38:44 +0300 Subject: [PATCH] Add signed passive runtime updates and independent crash recovery --- bin/hydra-update | 12 + bin/hydra-update-recover | 8 + dev/release.py | 93 ++++++ docs/updates.md | 143 +++++++++ hydra/cli.py | 11 +- hydra/update_engine.py | 609 +++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 8 + tests/test_update.py | 431 +++++++++++++++++++++++++++ 8 files changed, 1314 insertions(+), 1 deletion(-) create mode 100755 bin/hydra-update create mode 100755 bin/hydra-update-recover create mode 100644 dev/release.py create mode 100644 docs/updates.md create mode 100644 hydra/update_engine.py create mode 100644 tests/test_update.py diff --git a/bin/hydra-update b/bin/hydra-update new file mode 100755 index 0000000..c3b52e8 --- /dev/null +++ b/bin/hydra-update @@ -0,0 +1,12 @@ +#!/usr/bin/python3 -I +"""Independent trusted updater launcher; does not import the mutable runtime.""" +from pathlib import Path +import runpy + +path = Path(__file__).resolve() +if path.parent == Path('/usr/bin'): + engine = Path('/usr/lib/hydra-update/engine.py') +else: + # Source checkout only. Installed launchers never fall back to mutable code. + engine = path.parent.parent / 'hydra/update_engine.py' +raise SystemExit(runpy.run_path(str(engine))['main']()) diff --git a/bin/hydra-update-recover b/bin/hydra-update-recover new file mode 100755 index 0000000..820e45d --- /dev/null +++ b/bin/hydra-update-recover @@ -0,0 +1,8 @@ +#!/usr/bin/python3 -I +"""Recover without importing any module from the mutable Hydra runtime.""" +from pathlib import Path +import runpy + +path = Path(__file__).resolve() +engine = Path('/usr/lib/hydra-update/engine.py') if path.parent == Path('/usr/bin') else path.parent.parent / 'hydra/update_engine.py' +raise SystemExit(runpy.run_path(str(engine))['main'](recovery=True)) diff --git a/dev/release.py b/dev/release.py new file mode 100644 index 0000000..99b7407 --- /dev/null +++ b/dev/release.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Build reproducible, passive Ed25519-signed runtime release artifacts.""" +import argparse +import json +import os +from pathlib import Path +import runpy +import shutil +import stat +import subprocess +import sys +import tempfile +import zipfile + +ENGINE = runpy.run_path(str(Path(__file__).resolve().parents[1] / 'hydra/update_engine.py')) + + +def build(source, output, private_key, version, sequence): + source, output, private_key = Path(source).resolve(), Path(output).absolute(), Path(private_key).absolute() + if private_key.is_symlink() or not private_key.is_file(): + raise ValueError('Private key must be an existing regular file outside Git') + info = private_key.stat() + if info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise ValueError('Private key must be user-owned and private (0600)') + git = subprocess.run(['git', '-C', str(private_key.parent), 'rev-parse', '--show-toplevel'], capture_output=True, text=True) + if private_key.is_relative_to(source) or git.returncode == 0: + raise ValueError('Private key must remain outside Git and the source/build context') + commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip() + files = {} + for path in sorted((source / 'hydra').glob('*.py')): + name = 'usr/lib/hydra/hydra/' + path.name + if ENGINE['allowed'](name): + if path.is_symlink(): + raise ValueError('Source payload symlink refused') + files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644) + files['usr/lib/hydra/bin/hydra'] = (ENGINE['read_bytes'](source / 'bin/hydra', ENGINE['FILE_LIMIT']), 0o755) + for name in sorted(ENGINE['EXACT'] - {'usr/lib/hydra/bin/hydra'}): + path = source / 'image/rootfs' / name + if path.exists(): + files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644) + # Construct privately, verify our complete artifacts, then publish a new directory. + with tempfile.TemporaryDirectory(prefix='hydra-release-build-') as temporary: + temporary = Path(temporary) + with zipfile.ZipFile(temporary / 'runtime.zip', 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, (data, mode) in sorted(files.items()): + info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | mode) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data, compresslevel=9) + data = ENGINE['read_bytes'](temporary / 'runtime.zip', ENGINE['LIMIT']) + manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable', + 'architecture': 'x86_64', 'version': version, 'sequence': sequence, 'source_commit': commit, + 'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': ENGINE['digest'](data)}, + 'files': {name: {'size': len(content), 'mode': mode, 'sha256': ENGINE['digest'](content)} + for name, (content, mode) in sorted(files.items())}} + raw = (json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n').encode() + (temporary / 'manifest.json').write_bytes(raw) + subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(private_key), '-in', str(temporary / 'manifest.json'), + '-out', str(temporary / 'manifest.sig')], check=True, capture_output=True, timeout=15) + subprocess.run(['openssl', 'pkey', '-in', str(private_key), '-pubout', '-out', str(temporary / 'public.pem')], + check=True, capture_output=True, timeout=15) + ENGINE['validate_manifest'](raw, (temporary / 'manifest.sig').read_bytes(), temporary / 'public.pem') + ENGINE['validate_archive'](data, manifest) + output.mkdir(mode=0o755) # Existing version directories are immutable. + try: + for name in ('manifest.json', 'manifest.sig', 'runtime.zip'): + ENGINE['atomic_write'](output / name, (temporary / name).read_bytes(), 0o644) + ENGINE['fsync_dir'](output.parent) + except BaseException: + shutil.rmtree(output) + raise + return manifest + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--private-key', type=Path, required=True) + parser.add_argument('--version', required=True) + parser.add_argument('--sequence', type=int, required=True) + args = parser.parse_args(argv) + try: + print(json.dumps(build(args.source, args.output, args.private_key, args.version, args.sequence), indent=2)) + return 0 + except (ValueError, OSError, subprocess.SubprocessError) as exc: + print('hydra-release: ' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..32f284b --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,143 @@ +# Signed Hydra runtime updates + +The new ISO includes an independent updater and an independently pinned Ed25519 +public key. Discovery uses the public Gitea repository at +`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS`; discovery is not a trust source. +An empty releases list is normal and reports no update. + +```sh +hydra update check +hydra update status +sudo hydra update apply +sudo hydra update rollback TRANSACTION +sudo hydra-update-recover +``` + +`check` and `status` run without sudo. Apply prints the transaction identifier. +After applying or rolling back, log out of the graphical session and log in again +to load the changed runtime. Existing guest disks and profiles remain unchanged. +If the normal `hydra` command is broken, use the independent `hydra-update` command +or `hydra-update-recover` directly. A prepared/interrupted transaction blocks the +next apply until recovery succeeds. Recovery may be repeated after interruption. + +Only the latest current transaction can be explicitly rolled back. Rollback first +checks that its installed files still match their recorded content, ownership and +permissions, and refuses local edits. Recovery restores the originals of an +interrupted transaction without requiring mixed runtime files to match the new +release. Corrupt or missing backups stop restoration before any destination changes; +keep the complete transaction directory for inspection. No automatic cleanup removes +failed transaction evidence. + +Runtime updates contain only Python runtime modules, `usr/lib/hydra/bin/hydra`, +`etc/motd`, the Fastfetch configuration and its text logo. They cannot contain +`update*.py`, the independent updater, release keys, `/home`, guest profiles/disks, +credentials, network configuration, services, Portage, the kernel or boot files. +Runtime rollback **does not roll back Gentoo or its kernel**. Whole-system recovery +is a separate facility with separate acceptance requirements. + +## Trust bootstrap and installation + +These files are installed by the trusted image build, outside the runtime payload: + +| Source | Installed path | Owner/mode | +| --- | --- | --- | +| `hydra/update_engine.py` | `/usr/lib/hydra-update/engine.py` | root:root 0644 | +| `bin/hydra-update` | `/usr/bin/hydra-update` | root:root 0755 | +| `bin/hydra-update-recover` | `/usr/bin/hydra-update-recover` | root:root 0755 | +| Independently supplied public key | `/etc/hydra/update.pub` | root:root 0644 | + +The launchers use isolated Python (`/usr/bin/python3 -I`) and never import the +mutable `hydra` package. Install Python and OpenSSL in the image. Protect all parent +directories with root ownership and no group/other write permissions. The engine +and its key cannot update themselves through this protocol; their maintenance is +an independently verified image/bootstrap operation. + +An old ISO without these files cannot safely bootstrap by executing a downloaded +zipapp. Reinstall from the newly verified ISO, or independently verify and install +the exact engine, launchers and public key from a trusted source as administrator. +Do not fetch the trust key from the same release discovery response being verified. +Verify its fingerprint through the independently supplied release information. + +Production mutation requires UID 0, x86_64, the Hydra build marker and Hydra's +`ID=hydra-os` OS release. Production always uses `/etc/hydra/update.pub`; a key +override is refused. A `--root DIRECTORY` support/testing argument targets an +explicit isolated root containing the build marker and existing payload parent +directories. Only these staging roots allow `--public-key FILE`. No environment +variable silently redirects an installed update to another root or key. + +## Release creation + +Keep the Ed25519 private key outside Git, the source/build context and the Nix +store, in a user-owned private file (0600). Never upload it as a release asset. +Provision this key through the release operator's separate key-management process. +The builder does not generate keys, publish assets or overwrite an existing output. + +```sh +python3 dev/release.py \ + --source /path/to/verified-checkout \ + --output /path/to/new-release-directory \ + --private-key /private/outside-git/release.pem \ + --version 1.0.0 --sequence 1 +``` + +The three release assets are `manifest.json`, `manifest.sig`, and `runtime.zip`. +Publish them under the immutable Gitea tag `1.0.0`, exactly matching the signed +version. Each new release needs a strictly increasing integer sequence; never +reuse a sequence or mutate assets at an existing version. Rollback retains the +highest accepted sequence independently of the current installed version. +The builder is deterministic for identical source, version, sequence, key and +compression implementation. It records Git HEAD; build from a reviewed clean +checkout so that commit identifies the bytes actually being released. + +The signature is Ed25519 over the exact bytes of `manifest.json`, using OpenSSL +`pkeyutl -sign -rawin -inkey PRIVATE -in manifest.json -out manifest.sig`. +Verification uses `pkeyutl -verify -pubin -rawin` with the pinned key. No downloaded +Python is executed. `runtime.zip` is passive file data with reproducible timestamps. + +Manifest format 1 has exact top-level fields `format`, `product` (`Hydra OS`), +`scope` (`runtime`), `channel` (`stable`), `architecture` (`x86_64`), `sequence`, +`version`, `source_commit`, `archive` and `files`. The archive entry contains +`name`, `size` and `sha256`; each allowed file has `size`, `sha256` and `mode` +(0644 or 0755). Duplicate keys, unknown fields, unsafe paths, links/special archive +members, mismatched hashes/sizes and unsupported formats are rejected. +The maximum manifest is 256 KiB, archive/expanded payload 16 MiB, individual file +4 MiB and file count 256. Downloads have a 15-second socket timeout and 60-second +per-artifact deadline; redirects remain on the fixed HTTPS origin. Discovery +checks up to the latest 20 published release records, skipping draft/prerelease +records. Publish runtime releases regularly enough that the current stable release +remains in that window. + +For offline/local verification and application: + +```sh +hydra update check --artifacts /trusted/local/release +sudo hydra update apply --artifacts /trusted/local/release +``` + +These use the same pinned key and validation as online releases. For developer +staging, add `--root /isolated/hydra-root --public-key /test/public.pem`. +The Python API is `Updater(root, public_key).apply(artifacts)`, `.status()`, +`.rollback(transaction)` and `.recover()`; staging roots may run as their owner. + +## Durability and limits + +A nonblocking `flock` serializes mutations. The private state directory is +`/var/lib/hydra-update` (0700). Each transaction saves original regular-file bytes, +hashes, sizes, permissions and ownership; symlink targets/ownership and previously +absent files are recorded explicitly. Unsupported destination types and unsafe +parent directories are refused. Payload parent directories must already exist. + +All destination paths and payload bytes are checked before creating update state. +All backups and a prepared journal are durable before the first runtime replacement. +Writes use adjacent temporary files, file fsync, atomic replacement and directory +fsync. Recovery is independent of the runtime package. The public read-only status +summary is `/usr/share/hydra/update-status.json`; private journals are authoritative, +and an abrupt loss of power may leave the summary briefly older than those journals. +Run recovery as administrator when an update was interrupted. + +This is a per-file recoverable transaction, not a simultaneous filesystem snapshot. +A running process can observe a mixture during apply. Do not launch new Hydra +runtime operations until apply/recovery finishes. Root administrators and processes +that can already modify protected root-owned directories are outside the updater's +concurrent-write threat model. Filesystem/device failures can require manual repair; +recovery retains all evidence and never promises recovery from lost backup storage. diff --git a/hydra/cli.py b/hydra/cli.py index 7b72f23..0fc6dfc 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') + 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') download.add_argument('url') download.add_argument('--sha256') @@ -92,8 +94,15 @@ def _download_progress(): def main(argv: list[str] | None = None) -> int: cli = parser() args = cli.parse_args(argv) - runtime = Runtime(args.config, args.state, args.runtime) try: + if args.command == 'update': + checkout = Path(__file__).resolve().parents[1] + if checkout == Path('/usr/lib/hydra'): + command = ['/usr/bin/hydra-update'] + else: + command = [sys.executable, '-I', str(checkout / 'bin/hydra-update')] + return subprocess.run(command + args.update_args, check=False).returncode + runtime = Runtime(args.config, args.state, args.runtime) if args.command is None and not sys.stdin.isatty(): cli.print_help() return 0 diff --git a/hydra/update_engine.py b/hydra/update_engine.py new file mode 100644 index 0000000..1de65ac --- /dev/null +++ b/hydra/update_engine.py @@ -0,0 +1,609 @@ +"""Trusted, self-contained runtime updater. Installed outside the runtime payload. + +Only Python's standard library and OpenSSL are used. Never import hydra here. +""" +import argparse +from contextlib import contextmanager +import fcntl +import hashlib +import io +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import urllib.parse +import urllib.request +import uuid +import zipfile + +LIMIT = 16 * 1024 * 1024 +MANIFEST_LIMIT = 256 * 1024 +FILE_LIMIT = 4 * 1024 * 1024 +MAX_FILES = 256 +ORIGIN = 'https://git.shanaty.ru' +RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20' +EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc', + 'usr/share/hydra/fastfetch.txt'} + + +class UpdateError(ValueError): + pass + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def allowed(name): + return isinstance(name, str) and (name in EXACT or + re.fullmatch(r'usr/lib/hydra/hydra/(?!update)[a-z_]+\.py', name) is not None) + + +def integer(value, minimum=0, maximum=LIMIT): + return type(value) is int and minimum <= value <= maximum + + +def sha(value): + return isinstance(value, str) and re.fullmatch('[0-9a-f]{64}', value) is not None + + +def parse_json(data): + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise UpdateError('Duplicate JSON key') + result[key] = value + return result + try: + return json.loads(data, object_pairs_hook=pairs, + parse_constant=lambda _: (_ for _ in ()).throw(UpdateError('Invalid JSON number'))) + except (UnicodeError, json.JSONDecodeError, RecursionError) as exc: + raise UpdateError('Invalid JSON') from exc + + +def read_bytes(path, limit): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > limit: + raise UpdateError('Oversized or nonregular file') + data = stream.read(limit + 1) + if len(data) > limit: + raise UpdateError('Oversized file') + return data + + +def validate_manifest(raw, signature, public_key): + if len(raw) > MANIFEST_LIMIT or len(signature) != 64: + raise UpdateError('Invalid signature or manifest size') + with tempfile.TemporaryDirectory(prefix='hydra-verify-') as directory: + directory = Path(directory) + (directory / 'manifest').write_bytes(raw) + (directory / 'signature').write_bytes(signature) + # Copy the key so a changing external file cannot race OpenSSL's read. + (directory / 'key').write_bytes(read_bytes(public_key, 4096)) + result = subprocess.run(['openssl', 'pkeyutl', '-verify', '-pubin', '-inkey', str(directory / 'key'), + '-rawin', '-in', str(directory / 'manifest'), '-sigfile', str(directory / 'signature')], + capture_output=True, timeout=15, env={'PATH': '/usr/bin:/bin'}) + if result.returncode: + raise UpdateError('Ed25519 signature verification failed') + manifest = parse_json(raw) + expected = {'format', 'product', 'scope', 'channel', 'architecture', 'sequence', 'version', 'source_commit', 'archive', 'files'} + if not isinstance(manifest, dict) or set(manifest) != expected: + raise UpdateError('Invalid manifest fields') + for key, value in {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable', 'architecture': 'x86_64'}.items(): + if type(manifest[key]) is not type(value) or manifest[key] != value: + raise UpdateError('Unsupported manifest ' + key) + if not integer(manifest['sequence'], 1, 2**63 - 1): + raise UpdateError('Invalid release sequence') + if not isinstance(manifest['version'], str) or not re.fullmatch(r'[0-9][A-Za-z0-9._-]{0,63}', manifest['version']): + raise UpdateError('Invalid release version') + if not isinstance(manifest['source_commit'], str) or not re.fullmatch('[0-9a-f]{40}', manifest['source_commit']): + raise UpdateError('Invalid source commit') + archive = manifest['archive'] + if (not isinstance(archive, dict) or set(archive) != {'name', 'size', 'sha256'} or + archive['name'] != 'runtime.zip' or not integer(archive['size'], 1) or not sha(archive['sha256'])): + raise UpdateError('Invalid archive metadata') + files = manifest['files'] + if not isinstance(files, dict) or not 1 <= len(files) <= MAX_FILES: + raise UpdateError('Invalid file count') + for name, entry in files.items(): + if (not allowed(name) or not isinstance(entry, dict) or set(entry) != {'size', 'sha256', 'mode'} or + not integer(entry['size'], 0, FILE_LIMIT) or not sha(entry['sha256']) or + type(entry['mode']) is not int or entry['mode'] not in (0o644, 0o755)): + raise UpdateError('Invalid or forbidden payload path/metadata') + if sum(entry['size'] for entry in files.values()) > LIMIT: + raise UpdateError('Oversized payload') + if 'usr/lib/hydra/hydra/cli.py' not in files: + raise UpdateError('Missing runtime CLI') + return manifest + + +def validate_archive(data, manifest): + metadata = manifest['archive'] + if len(data) != metadata['size'] or digest(data) != metadata['sha256']: + raise UpdateError('Archive checksum/size mismatch') + payload = {} + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + infos = archive.infolist() + names = [entry.filename for entry in infos] + if len(names) != len(set(names)) or set(names) != set(manifest['files']): + raise UpdateError('Duplicate or unlisted archive entry') + for info in infos: + entry = manifest['files'][info.filename] + mode = info.external_attr >> 16 + if (info.is_dir() or stat.S_IFMT(mode) != stat.S_IFREG or info.flag_bits & 1 or + info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED) or + info.file_size != entry['size']): + raise UpdateError('Invalid archive file type/size') + with archive.open(info) as stream: + content = stream.read(entry['size'] + 1) + if len(content) != entry['size'] or digest(content) != entry['sha256']: + raise UpdateError('Payload checksum/size mismatch') + payload[info.filename] = content + except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc: + raise UpdateError('Invalid archive') from exc + return payload + + +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 atomic_write(path, data, mode=0o600, uid=None, gid=None): + fd, temporary = tempfile.mkstemp(prefix='.hydra-update-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + if uid is not None: + os.fchown(stream.fileno(), uid, gid) + os.fchmod(stream.fileno(), mode) + os.fsync(stream.fileno()) + os.replace(temporary, path) + fsync_dir(path.parent) + finally: + Path(temporary).unlink(missing_ok=True) + + +def write_json(path, value, mode=0o600): + atomic_write(path, (json.dumps(value, sort_keys=True) + '\n').encode(), mode) + + +class Updater: + def __init__(self, root=Path('/'), public_key=None): + self.root = Path(root).absolute() + if self.root.resolve() != self.root or not self.root.is_dir(): + raise UpdateError('Unsafe target root') + self.owner = 0 if self.root == Path('/') else os.geteuid() + if self.root == Path('/') and public_key is not None: + raise UpdateError('Production trust key cannot be overridden') + self.public_key = Path(public_key) if public_key else self.root / 'etc/hydra/update.pub' + self.base = self.root / 'var/lib/hydra-update' + self.public_status = self.root / 'usr/share/hydra/update-status.json' + + def path(self, name, *, leaf_link=True): + parts = name.split('/') + if any(part in ('', '.', '..') for part in parts): + raise UpdateError('Unsafe path') + current = self.root + for index, part in enumerate(parts): + current = current / part + leaf = index == len(parts) - 1 + try: + info = current.lstat() + except FileNotFoundError: + if leaf: + return current + raise UpdateError('Missing destination parent: ' + name) + if info.st_uid != self.owner or (not stat.S_ISLNK(info.st_mode) and info.st_mode & 0o022): + raise UpdateError('Unsafe path ownership/permissions: ' + name) + if not leaf and not stat.S_ISDIR(info.st_mode): + raise UpdateError('Unsafe symlink or non-directory parent: ' + name) + if leaf and not (stat.S_ISREG(info.st_mode) or leaf_link and stat.S_ISLNK(info.st_mode)): + raise UpdateError('Unsafe destination type: ' + name) + return current + + def target(self): + self.path('usr/share/hydra/build-manifest.json', leaf_link=False) + if not (self.root / 'usr/share/hydra/build-manifest.json').is_file(): + raise UpdateError('Target is not a Hydra installation') + if self.root == Path('/'): + if os.geteuid() != 0: + raise UpdateError('Use sudo inside Hydra for update mutations') + if os.uname().machine != 'x86_64': + raise UpdateError('Unsupported architecture') + release = read_bytes(self.path('etc/os-release', leaf_link=False), 65536) + if not re.search(rb'^ID=(?:"hydra-os"|hydra-os)$', release, re.M): + raise UpdateError('Target is not Hydra OS') + self.path('etc/hydra/update.pub', leaf_link=False) + + def state(self): + if not self.base.exists(): + return {'highest_sequence': 0, 'current': None} + path = self.path('var/lib/hydra-update/state.json', leaf_link=False) + if not path.exists(): + return {'highest_sequence': 0, 'current': None} + result = parse_json(read_bytes(path, MANIFEST_LIMIT)) + if not isinstance(result, dict) or set(result) != {'highest_sequence', 'current'} or not integer(result['highest_sequence'], 0, 2**63 - 1): + raise UpdateError('Corrupt update state') + self.validate_current(result['current']) + return result + + @staticmethod + def validate_current(value): + if value is not None and (not isinstance(value, dict) or set(value) != {'transaction', 'sequence', 'version'} or + not re.fullmatch('[0-9a-f]{32}', str(value.get('transaction'))) or + not integer(value.get('sequence'), 1, 2**63 - 1) or not isinstance(value.get('version'), str)): + raise UpdateError('Corrupt current update state') + + def pending(self): + directory = self.base / 'transactions' + if not directory.exists(): + return [] + self.path('var/lib/hydra-update/transactions/.probe') + result = [] + for item in sorted(directory.iterdir()): + if not re.fullmatch('[0-9a-f]{32}', item.name): + raise UpdateError('Unexpected transaction directory') + path = self.path('var/lib/hydra-update/transactions/' + item.name + '/journal.json', leaf_link=False) + if not path.exists(): + # Backup preparation interrupted before any destination mutation. + continue + journal = parse_json(read_bytes(path, MANIFEST_LIMIT)) + if not isinstance(journal, dict) or journal.get('phase') not in ('prepared', 'committed', 'restoring', 'recovered', 'rolled_back'): + raise UpdateError('Corrupt journal') + if journal['phase'] in ('prepared', 'restoring'): + result.append(item.name) + return result + + def status(self): + try: + value = self.state() + return dict(value, pending=self.pending()) + except PermissionError: + value = parse_json(read_bytes(self.public_status, MANIFEST_LIMIT)) + return value + + def publish(self): + self.path('usr/share/hydra/update-status.json', leaf_link=False) + write_json(self.public_status, dict(self.state(), pending=self.pending()), 0o644) + + @contextmanager + def lock(self): + self.target() + self.path('var/lib/.probe') + if not self.base.exists(): + self.base.mkdir(mode=0o700) + fsync_dir(self.base.parent) + self.path('var/lib/hydra-update/.probe') + if stat.S_IMODE(self.base.stat().st_mode) != 0o700: + raise UpdateError('Update state must be private (0700)') + path = self.path('var/lib/hydra-update/lock', leaf_link=False) + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise UpdateError('Another update operation holds the lock') from exc + yield + finally: + os.close(fd) + + def local(self, directory): + directory = Path(directory) + raw = read_bytes(directory / 'manifest.json', MANIFEST_LIMIT) + signature = read_bytes(directory / 'manifest.sig', 64) + manifest = validate_manifest(raw, signature, self.public_key) + payload = validate_archive(read_bytes(directory / 'runtime.zip', LIMIT), manifest) + return manifest, payload + + def apply(self, directory): + manifest, payload = self.local(directory) + self.target() + # Preflight every destination before state creation or backups. + for name in payload: + self.path(name) + with self.lock(): + if self.pending(): + raise UpdateError('Recovery pending; run hydra-update-recover') + state = self.state() + if manifest['sequence'] <= state['highest_sequence']: + raise UpdateError('Release sequence rejected (replay)') + transaction = uuid.uuid4().hex + directory = self.base / 'transactions' + if not directory.exists(): + directory.mkdir(mode=0o700) + fsync_dir(self.base) + saved = directory / transaction + saved.mkdir(mode=0o700) + fsync_dir(directory) + (saved / 'backup').mkdir(mode=0o700) + fsync_dir(saved) + records = {} + for index, name in enumerate(sorted(payload)): + dest = self.path(name) + if not dest.exists() and not dest.is_symlink(): + records[name] = {'type': 'absent'} + continue + info = dest.lstat() + record = {'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid} + if stat.S_ISLNK(info.st_mode): + record.update(type='symlink', target=os.readlink(dest)) + else: + content = read_bytes(dest, FILE_LIMIT) + record.update(type='file', sha256=digest(content), size=len(content), backup=str(index)) + atomic_write(saved / 'backup' / str(index), content) + records[name] = record + journal = {'format': 1, 'transaction': transaction, 'phase': 'prepared', + 'previous': state['current'], 'release': {'transaction': transaction, 'sequence': manifest['sequence'], 'version': manifest['version']}, + 'files': manifest['files'], 'originals': records} + write_json(saved / 'journal.json', journal) + # Reserve the sequence before mutation; recovery never lowers this floor. + state['highest_sequence'] = manifest['sequence'] + write_json(self.base / 'state.json', state) + self.publish() + for name, content in sorted(payload.items()): + atomic_write(self.path(name), content, manifest['files'][name]['mode'], self.owner, 0 if self.owner == 0 else os.getegid()) + state['current'] = journal['release'] + write_json(self.base / 'state.json', state) + journal['phase'] = 'committed' + write_json(saved / 'journal.json', journal) + self.publish() + return dict(journal['release'], applied=True) + + def load_journal(self, transaction): + if not re.fullmatch('[0-9a-f]{32}', transaction): + raise UpdateError('Invalid transaction identifier') + prefix = 'var/lib/hydra-update/transactions/' + transaction + path = self.path(prefix + '/journal.json', leaf_link=False) + journal = parse_json(read_bytes(path, MANIFEST_LIMIT)) + if (not isinstance(journal, dict) or set(journal) != {'format', 'transaction', 'phase', 'previous', 'release', 'files', 'originals'} or + type(journal['format']) is not int or journal['format'] != 1 or journal['transaction'] != transaction or + journal['phase'] not in ('prepared', 'committed', 'restoring', 'recovered', 'rolled_back')): + raise UpdateError('Corrupt journal') + self.validate_current(journal['previous']) + self.validate_current(journal['release']) + if journal['release'] is None or journal['release']['transaction'] != transaction: + raise UpdateError('Corrupt journal release') + files, records = journal['files'], journal['originals'] + if not isinstance(files, dict) or not isinstance(records, dict) or set(files) != set(records) or not 1 <= len(files) <= MAX_FILES: + raise UpdateError('Corrupt journal records') + contents = {} + for name, record in records.items(): + if not allowed(name) or not isinstance(record, dict): + raise UpdateError('Unsafe backup path') + self.path(name) + entry = files[name] + if (not isinstance(entry, dict) or set(entry) != {'mode', 'size', 'sha256'} or + not integer(entry['mode']) or entry['mode'] not in (0o644, 0o755) or + not integer(entry['size'], 0, FILE_LIMIT) or not sha(entry['sha256'])): + raise UpdateError('Corrupt journal file') + kind = record.get('type') + if kind == 'absent' and set(record) == {'type'}: + continue + if kind not in ('file', 'symlink') or not integer(record.get('mode'), 0, 0o7777) or not integer(record.get('uid'), 0, 2**32-1) or not integer(record.get('gid'), 0, 2**32-1): + raise UpdateError('Corrupt backup metadata') + if kind == 'symlink': + if set(record) != {'type', 'mode', 'uid', 'gid', 'target'} or not isinstance(record['target'], str) or '\0' in record['target'] or not 1 <= len(record['target']) <= 4096: + raise UpdateError('Corrupt backup symlink') + else: + if (set(record) != {'type', 'mode', 'uid', 'gid', 'sha256', 'size', 'backup'} or + not isinstance(record['backup'], str) or not re.fullmatch('[0-9]{1,3}', record['backup']) or + not integer(record['size'], 0, FILE_LIMIT) or not sha(record['sha256'])): + raise UpdateError('Corrupt backup metadata') + content = read_bytes(self.path(prefix + '/backup/' + record['backup'], leaf_link=False), FILE_LIMIT) + if digest(content) != record['sha256'] or len(content) != record['size']: + raise UpdateError('Corrupt backup bytes') + contents[name] = content + return path, journal, contents + + def restore(self, transaction, explicit): + path, journal, contents = self.load_journal(transaction) + state = self.state() + if explicit: + if journal['phase'] != 'committed' or state['current'] != journal['release']: + raise UpdateError('Only the latest current transaction can be rolled back') + for name, entry in journal['files'].items(): + dest = self.path(name) + if not dest.exists() or dest.is_symlink(): + raise UpdateError('Runtime was locally modified') + info = dest.stat() + if (stat.S_IMODE(info.st_mode) != entry['mode'] or info.st_uid != self.owner or + info.st_gid != (0 if self.owner == 0 else os.getegid()) or digest(read_bytes(dest, FILE_LIMIT)) != entry['sha256']): + raise UpdateError('Runtime was locally modified') + journal['phase'] = 'restoring' + write_json(path, journal) + self.publish() + elif journal['phase'] not in ('prepared', 'restoring'): + raise UpdateError('Transaction does not require recovery') + for name, record in sorted(journal['originals'].items()): + dest = self.path(name) + if record['type'] == 'absent': + dest.unlink(missing_ok=True) + fsync_dir(dest.parent) + elif record['type'] == 'file': + atomic_write(dest, contents[name], record['mode'], record['uid'], record['gid']) + else: + temporary = dest.parent / ('.hydra-update-' + uuid.uuid4().hex) + try: + temporary.symlink_to(record['target']) + os.chown(temporary, record['uid'], record['gid'], follow_symlinks=False) + fsync_dir(dest.parent) + os.replace(temporary, dest) + fsync_dir(dest.parent) + finally: + temporary.unlink(missing_ok=True) + state['current'] = journal['previous'] + state['highest_sequence'] = max(state['highest_sequence'], journal['release']['sequence']) + write_json(self.base / 'state.json', state) + journal['phase'] = 'rolled_back' if explicit or journal['phase'] == 'restoring' else 'recovered' + write_json(path, journal) + self.publish() + return {'transaction': transaction, 'status': journal['phase']} + + def rollback(self, transaction): + with self.lock(): + if self.pending(): + raise UpdateError('Recovery pending') + return self.restore(transaction, True) + + def recover(self): + with self.lock(): + pending = self.pending() + if len(pending) > 1: + raise UpdateError('Multiple pending journals require manual inspection') + return [self.restore(transaction, False) for transaction in pending] + + +def checked_url(url): + if not isinstance(url, str) or len(url) > 2048 or any(ord(c) < 33 for c in url) or '\\' in url: + raise UpdateError('Malformed release URL') + parsed = urllib.parse.urlsplit(url) + decoded = urllib.parse.unquote(parsed.path) + if (parsed.scheme != 'https' or parsed.netloc != 'git.shanaty.ru' or parsed.fragment or + not parsed.path.startswith('/') or decoded.startswith('//') or + any(part in ('.', '..') for part in decoded.split('/')) or '\\' in decoded): + raise UpdateError('Release URL must remain on the fixed HTTPS origin') + return url + + +class SafeRedirect(urllib.request.HTTPRedirectHandler): + max_redirections = 3 + max_repeats = 1 + + def redirect_request(self, req, fp, code, msg, headers, newurl): + checked_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def fetch(url, limit): + import time + url = checked_url(url) + # Do not inherit arbitrary proxy configuration in a privileged updater. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), SafeRedirect()) + request = urllib.request.Request(url, headers={'Accept': 'application/octet-stream', 'User-Agent': 'Hydra-Update/1'}) + deadline = time.monotonic() + 60 + with opener.open(request, timeout=15) as response: + if hasattr(response, 'geturl'): + checked_url(response.geturl()) + chunks, total = [], 0 + reader = getattr(response, 'read1', response.read) + while True: + if time.monotonic() > deadline: + raise UpdateError('Release download deadline exceeded') + chunk = reader(min(65536, limit - total + 1)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > limit: + raise UpdateError('Release download exceeds size limit') + return b''.join(chunks) + + +def discover(public_key): + releases = parse_json(fetch(RELEASES, 1024 * 1024)) + if not isinstance(releases, list) or len(releases) > 20: + raise UpdateError('Invalid release discovery response') + candidates = [] + for release in releases: + if not isinstance(release, dict): + raise UpdateError('Invalid release record') + if release.get('draft') or release.get('prerelease'): + continue + assets = release.get('assets', []) + if not isinstance(assets, list) or len(assets) > 100: + raise UpdateError('Invalid release assets') + found = {} + for asset in assets: + if not isinstance(asset, dict): + raise UpdateError('Invalid release asset') + name = asset.get('name') + if name in ('manifest.json', 'manifest.sig', 'runtime.zip'): + if name in found: + raise UpdateError('Duplicate release asset') + found[name] = checked_url(asset.get('browser_download_url')) + if not found: + continue + if set(found) != {'manifest.json', 'manifest.sig', 'runtime.zip'}: + raise UpdateError('Incomplete runtime release') + raw = fetch(found['manifest.json'], MANIFEST_LIMIT) + signature = fetch(found['manifest.sig'], 64) + manifest = validate_manifest(raw, signature, public_key) + if release.get('tag_name') != manifest['version']: + raise UpdateError('Release tag does not match signed immutable version') + candidates.append((manifest, raw, signature, found['runtime.zip'])) + if not candidates: + return None + sequences = [item[0]['sequence'] for item in candidates] + if len(sequences) != len(set(sequences)): + raise UpdateError('Duplicate release sequence') + return max(candidates, key=lambda item: item[0]['sequence']) + + +def main(argv=None, *, recovery=False): + parser = argparse.ArgumentParser(description='Verify, apply and recover signed Hydra runtime updates') + if not recovery: + parser.add_argument('action', choices=('check', 'status', 'apply', 'rollback')) + parser.add_argument('transaction', nargs='?') + parser.add_argument('--root', type=Path, default=Path('/'), help='Explicit isolated staging root for support/testing') + parser.add_argument('--public-key', type=Path, help='Staging-only verification key; installed root always uses its pinned key') + parser.add_argument('--artifacts', type=Path, help='Trusted local directory containing manifest.json, manifest.sig and runtime.zip') + args = parser.parse_args(argv) + try: + if args.root.absolute() == Path('/') and args.public_key is not None: + raise UpdateError('Production trust key cannot be overridden') + updater = Updater(args.root, args.public_key) + action = 'recover' if recovery else args.action + if not recovery and (action == 'rollback') != (args.transaction is not None): + raise UpdateError('Only rollback requires a transaction identifier') + if action == 'status': + result = updater.status() + elif action == 'recover': + result = updater.recover() + elif action == 'rollback': + result = updater.rollback(args.transaction) + elif action == 'check': + if args.artifacts: + manifest, _ = updater.local(args.artifacts) + else: + found = discover(updater.public_key) + manifest = found[0] if found else None + state = updater.status() + result = dict(state, available=manifest if manifest and manifest['sequence'] > state['highest_sequence'] else None) + elif args.artifacts: + result = updater.apply(args.artifacts) + else: + found = discover(updater.public_key) + if found is None: + result = {'applied': False, 'reason': 'No signed runtime releases available'} + else: + manifest, raw, signature, url = found + data = fetch(url, manifest['archive']['size']) + validate_archive(data, manifest) + with tempfile.TemporaryDirectory(prefix='hydra-release-') as directory: + directory = Path(directory) + (directory / 'manifest.json').write_bytes(raw) + (directory / 'manifest.sig').write_bytes(signature) + (directory / 'runtime.zip').write_bytes(data) + result = updater.apply(directory) + print(json.dumps(result, indent=2)) + return 0 + except (OSError, ValueError, subprocess.SubprocessError, urllib.error.URLError) as exc: + print('hydra-update: ' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py index b5d6837..22d8d90 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -157,3 +157,11 @@ class CLITests(unittest.TestCase): profile = self.guided_defaults_profile([]) self.assertEqual((profile.network, profile.bridge, profile.width, profile.height, profile.refresh_millihz), ('bridge', 'testbr0', 1600, 900, 75000)) + + def test_update_status_dispatch_is_independent_of_vm_state(self): + result = self.run_cli('update', 'status', '--root', str(self.root)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {'highest_sequence': 0, 'current': None, 'pending': []}) + self.assertFalse((self.root / 'config').exists()) + self.assertFalse((self.root / 'state').exists()) + self.assertFalse((self.root / 'run').exists()) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..1620438 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,431 @@ +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import stat +import subprocess +import sys +import tempfile +import time +import unittest +import warnings +import zipfile + +ENGINE = Path(__file__).resolve().parents[1] / 'hydra/update_engine.py' + +def engine(): + spec = importlib.util.spec_from_file_location('update_engine', ENGINE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +class UpdateTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name) + self.root = self.base / 'root' + for name in ('usr/lib/hydra/hydra', 'usr/share/hydra', 'etc', 'var/lib', 'home/live/VMs'): + (self.root / name).mkdir(parents=True, exist_ok=True) + (self.root / 'usr/share/hydra/build-manifest.json').write_text('{}') + self.target = self.root / 'usr/lib/hydra/hydra/cli.py' + self.target.write_bytes(b'old runtime\n') + self.target.chmod(0o640) + self.vm = self.root / 'home/live/VMs/disk.qcow2' + self.vm.write_bytes(b'guest disk unchanged') + self.key = self.base / 'private.pem' + self.pub = self.base / 'public.pem' + subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(self.key)], check=True, capture_output=True) + subprocess.run(['openssl', 'pkey', '-in', str(self.key), '-pubout', '-out', str(self.pub)], check=True, capture_output=True) + self.artifacts = self.base / 'artifacts' + self.artifacts.mkdir() + + def package(self, *, files=None, sequence=1, entries=None, raw=None): + files = files or {'usr/lib/hydra/hydra/cli.py': b'new runtime\n', 'etc/motd': b'Welcome\n'} + with zipfile.ZipFile(self.artifacts / 'runtime.zip', 'w') as archive: + for name, data in (entries or list(files.items())): + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + archive.writestr(info, data) + data = (self.artifacts / 'runtime.zip').read_bytes() + manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable', + 'architecture': 'x86_64', 'sequence': sequence, 'version': '1.2.3', + 'source_commit': 'a' * 40, + 'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()}, + 'files': {name: {'size': len(content), 'sha256': hashlib.sha256(content).hexdigest(), 'mode': 0o644} + for name, content in files.items()}} + self.sign(raw if raw is not None else json.dumps(manifest).encode()) + return manifest + + def sign(self, raw): + (self.artifacts / 'manifest.json').write_bytes(raw) + subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(self.key), '-in', + str(self.artifacts / 'manifest.json'), '-out', str(self.artifacts / 'manifest.sig')], check=True, capture_output=True) + + def updater(self): + return engine().Updater(self.root, self.pub) + + def test_signed_apply_and_rollback_restore_bytes_mode_absence_and_keep_replay_floor(self): + self.package() + updater = self.updater() + result = updater.apply(self.artifacts) + self.assertEqual(self.target.read_bytes(), b'new runtime\n') + self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged') + updater.rollback(result['transaction']) + self.assertEqual(self.target.read_bytes(), b'old runtime\n') + self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o640) + self.assertFalse((self.root / 'etc/motd').exists()) + self.assertEqual(updater.status()['highest_sequence'], 1) + with self.assertRaisesRegex(ValueError, 'sequence|replay'): + updater.apply(self.artifacts) + + def test_tampered_signature_payload_duplicate_json_and_forbidden_paths_fail_before_state(self): + for kind in ('signature', 'payload', 'duplicate_json', 'forbidden', 'duplicate_zip', 'boolean_sequence'): + with self.subTest(kind=kind): + self.package() + if kind == 'signature': + (self.artifacts / 'manifest.json').write_bytes((self.artifacts / 'manifest.json').read_bytes() + b' ') + elif kind == 'payload': + with (self.artifacts / 'runtime.zip').open('ab') as stream: + stream.write(b'changed') + elif kind == 'duplicate_json': + raw = (self.artifacts / 'manifest.json').read_bytes().replace(b'"format": 1', b'"format": 1, "format": 1') + self.sign(raw) + elif kind == 'forbidden': + self.package(files={'home/live/VMs/disk.qcow2': b'evil'}) + elif kind == 'boolean_sequence': + self.package(sequence=True) + else: + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.package(entries=[('usr/lib/hydra/hydra/cli.py', b'new runtime\n')] * 2) + with self.assertRaises(ValueError): + self.updater().apply(self.artifacts) + self.assertEqual(self.target.read_bytes(), b'old runtime\n') + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + + def test_symlink_leaf_restored_but_unsafe_parent_refused(self): + self.package() + self.target.unlink() + self.target.symlink_to('/outside/missing') + updater = self.updater() + result = updater.apply(self.artifacts) + updater.rollback(result['transaction']) + self.assertEqual(os.readlink(self.target), '/outside/missing') + self.target.unlink() + self.target.parent.rmdir() + self.target.parent.symlink_to(self.base) + self.package(sequence=2) + with self.assertRaisesRegex(ValueError, 'symlink|parent'): + updater.apply(self.artifacts) + + def test_rollback_refuses_modified_runtime_and_corrupt_backup(self): + self.package() + updater = self.updater() + result = updater.apply(self.artifacts) + self.target.write_bytes(b'local edit') + with self.assertRaisesRegex(ValueError, 'modified'): + updater.rollback(result['transaction']) + self.target.write_bytes(b'new runtime\n') + backups = list((self.root / 'var/lib/hydra-update/transactions' / result['transaction'] / 'backup').iterdir()) + backups[0].write_bytes(b'corrupt') + with self.assertRaisesRegex(ValueError, 'backup'): + updater.rollback(result['transaction']) + self.assertEqual(self.target.read_bytes(), b'new runtime\n') + + def test_rejects_signed_special_archive_types_sizes_traversal_and_engine_updates(self): + for kind in ('symlink', 'fifo', 'oversize', 'traversal', 'engine'): + with self.subTest(kind=kind): + manifest = self.package() + if kind in ('traversal', 'engine'): + name = '../etc/passwd' if kind == 'traversal' else 'usr/lib/hydra/hydra/update_engine.py' + self.package(files={name: b'bad'}) + elif kind == 'oversize': + manifest['files']['usr/lib/hydra/hydra/cli.py']['size'] = 5 * 1024 * 1024 + self.sign(json.dumps(manifest).encode()) + else: + with zipfile.ZipFile(self.artifacts / 'runtime.zip', 'w') as archive: + for name, content in {'usr/lib/hydra/hydra/cli.py': b'new runtime\n', 'etc/motd': b'Welcome\n'}.items(): + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = ((stat.S_IFLNK if kind == 'symlink' else stat.S_IFIFO) | 0o644) << 16 + archive.writestr(info, content) + data = (self.artifacts / 'runtime.zip').read_bytes() + manifest['archive'].update(size=len(data), sha256=hashlib.sha256(data).hexdigest()) + self.sign(json.dumps(manifest).encode()) + with self.assertRaises(ValueError): + self.updater().apply(self.artifacts) + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + + def test_fresh_process_recovers_sigkill_during_apply_and_rollback_with_runtime_broken(self): + self.package() + for operation in ('apply', 'rollback'): + with self.subTest(operation=operation): + if operation == 'rollback': + self.package(sequence=2) + transaction = self.updater().apply(self.artifacts)['transaction'] + call = 'u.rollback(' + repr(transaction) + ')' + else: + call = 'u.apply(Path(' + repr(str(self.artifacts)) + '))' + ready = self.base / ('ready-' + operation) + script = '''import os, runpy, signal +from pathlib import Path +m = runpy.run_path(ENGINE) +u = m['Updater'](Path(ROOT), Path(KEY)) +original = m['atomic_write'] +def pause(path, *args, **kwargs): + original(path, *args, **kwargs) + if path == Path(TARGET): + Path(READY).touch() + os.kill(os.getpid(), signal.SIGSTOP) +u.apply.__func__.__globals__['atomic_write'] = pause +CALL +''' + for key, value in [('ENGINE', str(ENGINE)), ('ROOT', str(self.root)), ('KEY', str(self.pub)), ('TARGET', str(self.target)), ('READY', str(ready))]: + script = script.replace(key, repr(value)) + script = script.replace('CALL', call) + proc = subprocess.Popen([sys.executable, '-I', '-c', script], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 10 + while not ready.exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(ready.exists(), 'child failed to reach actual mutation') + proc.kill() + proc.communicate(timeout=5) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + self.assertEqual(len(self.updater().status()['pending']), 1) + with self.assertRaisesRegex(ValueError, 'pending'): + self.updater().apply(self.artifacts) + # No hydra imports: corrupt the normal runtime before standalone recovery. + self.target.write_bytes(b'this is invalid Python') + result = subprocess.run([sys.executable, '-I', str(ENGINE.parent.parent / 'bin/hydra-update-recover'), + '--root', str(self.root), '--public-key', str(self.pub)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.target.read_bytes(), b'old runtime\n') + self.assertEqual(self.updater().status()['pending'], []) + self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged') + self.assertEqual(self.updater().status()['highest_sequence'], 1 if operation == 'apply' else 2) + + def test_lock_in_separate_process_prevents_mutation(self): + self.package() + updater = self.updater() + with updater.lock(): + proc = subprocess.run([sys.executable, '-I', str(ENGINE), 'apply', '--root', str(self.root), + '--public-key', str(self.pub), '--artifacts', str(self.artifacts)], capture_output=True, text=True) + self.assertNotEqual(proc.returncode, 0) + self.assertIn('lock', proc.stderr) + self.assertEqual(self.target.read_bytes(), b'old runtime\n') + + def test_only_current_transaction_can_rollback(self): + self.package() + first = self.updater().apply(self.artifacts)['transaction'] + self.package(sequence=2) + self.updater().apply(self.artifacts) + with self.assertRaisesRegex(ValueError, 'latest'): + self.updater().rollback(first) + + def test_transport_rejects_origin_changes_credentials_and_oversized_reads(self): + module = engine() + for url in ('http://git.shanaty.ru/file', 'https://evil.example/file', + 'https://git.shanaty.ru@evil.example/file', 'https://git.shanaty.ru:443/file', + 'https://git.shanaty.ru/../file', 'https://git.shanaty.ru/%2e%2e/file'): + with self.subTest(url=url), self.assertRaises(ValueError): + module.checked_url(url) + redirect = module.SafeRedirect() + request = __import__('urllib.request', fromlist=['Request']).Request('https://git.shanaty.ru/file') + with self.assertRaises(ValueError): + redirect.redirect_request(request, None, 302, '', {}, 'https://other.example/file') + import io + from unittest.mock import patch + with patch('urllib.request.OpenerDirector.open', return_value=io.BytesIO(b'x' * 100)): + with self.assertRaisesRegex(ValueError, 'size|large'): + module.fetch('https://git.shanaty.ru/file', 10) + + def test_empty_releases_and_verified_local_check_do_not_create_state(self): + module = engine() + from unittest.mock import patch + with patch.object(module, 'fetch', return_value=b'[]'): + self.assertIsNone(module.discover(self.pub)) + self.package() + result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root), + '--public-key', str(self.pub), '--artifacts', str(self.artifacts)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)['available']['sequence'], 1) + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + + def test_release_builder_is_reproducible_and_signs_passive_payload_only(self): + repo = ENGINE.parent.parent + output_a = self.base / 'release-a' + output_b = self.base / 'release-b' + self.key.chmod(0o600) + command = [sys.executable, str(repo / 'dev/release.py'), '--source', str(repo), + '--private-key', str(self.key), '--version', '1.0.0', '--sequence', '10'] + for output in (output_a, output_b): + result = subprocess.run(command + ['--output', str(output)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for name in ('manifest.json', 'manifest.sig', 'runtime.zip'): + self.assertEqual((output_a / name).read_bytes(), (output_b / name).read_bytes()) + module = engine() + manifest, payload = self.updater().local(output_a) + self.assertEqual(manifest['sequence'], 10) + self.assertIn('usr/lib/hydra/hydra/cli.py', payload) + self.assertFalse(any('/update' in name for name in payload)) + self.assertNotIn('__main__.py', zipfile.ZipFile(output_a / 'runtime.zip').namelist()) + result = subprocess.run(command + ['--output', str(output_a)], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.key.chmod(0o644) + result = subprocess.run(command + ['--output', str(self.base / 'unsafe-key')], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.base / 'unsafe-key').exists()) + + def test_production_api_cannot_override_pinned_key(self): + with self.assertRaisesRegex(ValueError, 'key|trust'): + engine().Updater(Path('/'), self.pub) + + def test_corrupt_backup_during_pending_recovery_keeps_journal_and_runtime(self): + self.package() + updater = self.updater() + from unittest.mock import patch + globals_ = updater.apply.__func__.__globals__ + original = globals_['atomic_write'] + def fail(path, *args, **kwargs): + if path == self.target: + raise OSError('simulated full filesystem') + return original(path, *args, **kwargs) + with patch.dict(globals_, atomic_write=fail): + with self.assertRaises(OSError): + updater.apply(self.artifacts) + transaction = updater.status()['pending'][0] + journal = self.root / 'var/lib/hydra-update/transactions' / transaction / 'journal.json' + saved = journal.read_bytes() + backup = next((journal.parent / 'backup').iterdir()) + backup.write_bytes(b'broken backup') + with self.assertRaisesRegex(ValueError, 'backup'): + updater.recover() + self.assertEqual(journal.read_bytes(), saved) + self.assertEqual(self.target.read_bytes(), b'old runtime\n') + self.assertEqual(updater.status()['pending'], [transaction]) + + def test_unsafe_state_directory_and_backup_symlinks_are_rejected(self): + self.package() + state = self.root / 'var/lib/hydra-update' + outside = self.base / 'outside' + outside.mkdir() + state.symlink_to(outside) + with self.assertRaises(ValueError): + self.updater().apply(self.artifacts) + self.assertEqual(list(outside.iterdir()), []) + state.unlink() + result = self.updater().apply(self.artifacts) + backup = next((state / 'transactions' / result['transaction'] / 'backup').iterdir()) + backup.unlink() + backup.symlink_to(self.vm) + with self.assertRaises(ValueError): + self.updater().rollback(result['transaction']) + self.assertEqual(self.target.read_bytes(), b'new runtime\n') + + def test_discovery_verifies_manifest_before_archive_and_binds_release_tag(self): + module = engine() + self.package() + from unittest.mock import patch + urls = {name: 'https://git.shanaty.ru/attachments/' + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')} + release = {'tag_name': '1.2.3', 'draft': False, 'prerelease': False, + 'assets': [{'name': name, 'browser_download_url': url} for name, url in urls.items()]} + requested = [] + def fetch(url, limit): + requested.append(url) + if url == module.RELEASES: + return json.dumps([release]).encode() + return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes() + with patch.object(module, 'fetch', side_effect=fetch): + found = module.discover(self.pub) + self.assertEqual(found[0]['version'], '1.2.3') + self.assertNotIn(urls['runtime.zip'], requested) + release['tag_name'] = 'latest' + with self.assertRaisesRegex(ValueError, 'tag'): + module.discover(self.pub) + release['tag_name'] = '1.2.3' + (self.artifacts / 'manifest.json').write_bytes(b'bad manifest') + with self.assertRaisesRegex(ValueError, 'signature'): + module.discover(self.pub) + self.assertNotIn(urls['runtime.zip'], requested) + + def test_missing_or_unwritable_parent_and_special_destination_rejected_before_state(self): + self.package() + self.target.unlink() + os.mkfifo(self.target) + with self.assertRaises(ValueError): + self.updater().apply(self.artifacts) + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + self.target.unlink() + self.target.parent.chmod(0o777) + with self.assertRaises(ValueError): + self.updater().apply(self.artifacts) + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + + def test_signature_wrong_key_and_truncated_archive_fail_without_changes(self): + self.package() + other = self.base / 'other.pem' + otherpub = self.base / 'other.pub' + subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(other)], check=True, capture_output=True) + subprocess.run(['openssl', 'pkey', '-in', str(other), '-pubout', '-out', str(otherpub)], check=True, capture_output=True) + with self.assertRaisesRegex(ValueError, 'signature'): + engine().Updater(self.root, otherpub).apply(self.artifacts) + data = (self.artifacts / 'runtime.zip').read_bytes() + (self.artifacts / 'runtime.zip').write_bytes(data[:20]) + with self.assertRaises(ValueError): + self.updater().apply(self.artifacts) + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + + def test_status_remains_readable_when_private_state_cannot_be_read(self): + self.package() + updater = self.updater() + applied = updater.apply(self.artifacts) + state = self.root / 'var/lib/hydra-update' + state.chmod(0) + try: + result = updater.status() + self.assertEqual(result['current']['transaction'], applied['transaction']) + self.assertEqual(result['highest_sequence'], 1) + self.assertEqual(result['pending'], []) + finally: + state.chmod(0o700) + + def test_recovery_rejects_corrupt_journal_paths_before_touching_runtime(self): + self.package() + updater = self.updater() + transaction = updater.apply(self.artifacts)['transaction'] + path = self.root / 'var/lib/hydra-update/transactions' / transaction / 'journal.json' + journal = json.loads(path.read_text()) + journal['phase'] = 'prepared' + journal['originals']['home/live/VMs/disk.qcow2'] = {'type': 'absent'} + journal['files']['home/live/VMs/disk.qcow2'] = {'mode': 0o644, 'size': 1, 'sha256': 'a' * 64} + path.write_text(json.dumps(journal)) + with self.assertRaisesRegex(ValueError, 'backup path'): + updater.recover() + self.assertEqual(self.target.read_bytes(), b'new runtime\n') + self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged') + + def test_prepared_journal_without_state_is_recoverable_and_reserves_sequence(self): + self.package() + updater = self.updater() + from unittest.mock import patch + globals_ = updater.apply.__func__.__globals__ + original = globals_['write_json'] + def crash(path, *args, **kwargs): + if path == self.root / 'var/lib/hydra-update/state.json': + raise OSError('power lost after durable journal before ledger') + return original(path, *args, **kwargs) + with patch.dict(globals_, write_json=crash): + with self.assertRaises(OSError): + updater.apply(self.artifacts) + self.assertEqual(len(updater.status()['pending']), 1) + updater.recover() + self.assertEqual(updater.status()['highest_sequence'], 1) + self.assertEqual(self.target.read_bytes(), b'old runtime\n')