Add signed passive runtime updates and independent crash recovery

This commit is contained in:
Codex
2026-10-13 21:38:44 +03:00
parent c43c0977f6
commit 98022dd355
8 changed files with 1314 additions and 1 deletions
+431
View File
@@ -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')