576 lines
31 KiB
Python
576 lines
31 KiB
Python
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
|
|
from urllib.error import HTTPError
|
|
def empty_releases(url, limit):
|
|
if url == module.RELEASES:
|
|
return b'[]'
|
|
if url == 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/manifest.json':
|
|
raise HTTPError(url, 404, 'Not Found', {}, None)
|
|
raise AssertionError('Unexpected discovery URL: ' + url)
|
|
with patch.object(module, 'fetch', side_effect=empty_releases):
|
|
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.assertFalse(any('/raw/branch/' in url for url in requested))
|
|
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')
|
|
|
|
def test_fresh_recovery_reconciles_public_status_after_terminal_journal_before_publish(self):
|
|
self.package()
|
|
updater = self.updater()
|
|
from unittest.mock import patch
|
|
original_publish = updater.publish
|
|
def interrupted_publish():
|
|
if not updater.pending():
|
|
raise OSError('power lost after terminal journal before public status')
|
|
return original_publish()
|
|
for operation in ('apply', 'rollback'):
|
|
with self.subTest(operation=operation):
|
|
with patch.object(updater, 'publish', side_effect=interrupted_publish):
|
|
with self.assertRaisesRegex(OSError, 'terminal journal'):
|
|
if operation == 'apply':
|
|
updater.apply(self.artifacts)
|
|
else:
|
|
updater.rollback(updater.state()['current']['transaction'])
|
|
authoritative = updater.status()
|
|
self.assertEqual(authoritative['pending'], [])
|
|
stale = json.loads(updater.public_status.read_text())
|
|
self.assertNotEqual(stale, authoritative)
|
|
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(json.loads(result.stdout), [])
|
|
self.assertEqual(json.loads(updater.public_status.read_text()), authoritative)
|
|
self.assertEqual(self.target.read_bytes(), b'new runtime\n' if operation == 'apply' else b'old runtime\n')
|
|
updater.base.chmod(0)
|
|
try:
|
|
self.assertEqual(updater.status(), authoritative)
|
|
finally:
|
|
updater.base.chmod(0o700)
|
|
|
|
|
|
def test_signed_repository_fallback_applies_and_preserves_replay_protection(self):
|
|
self.package()
|
|
module = engine()
|
|
from unittest.mock import patch
|
|
from contextlib import redirect_stdout, redirect_stderr
|
|
import io
|
|
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
|
requested = []
|
|
def fetch(url, limit):
|
|
requested.append(url)
|
|
if url == module.RELEASES:
|
|
return b'[]'
|
|
if url not in {base + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}:
|
|
raise AssertionError('Unexpected fallback URL: ' + url)
|
|
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
|
with patch.object(module, 'fetch', side_effect=fetch):
|
|
found = module.discover(self.pub)
|
|
self.assertIsNotNone(found)
|
|
self.assertEqual(found[0]['version'], '1.2.3')
|
|
self.assertEqual(found[3], base + 'runtime.zip')
|
|
self.assertNotIn(base + 'runtime.zip', requested)
|
|
with redirect_stdout(io.StringIO()) as output:
|
|
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
|
|
self.assertEqual(code, 0)
|
|
self.assertTrue(json.loads(output.getvalue())['applied'])
|
|
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
|
with redirect_stderr(io.StringIO()) as error:
|
|
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
|
|
self.assertEqual(code, 1)
|
|
self.assertIn('replay', error.getvalue())
|
|
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
|
|
|
def test_repository_fallback_refuses_tamper_missing_signature_and_server_errors(self):
|
|
self.package()
|
|
module = engine()
|
|
from unittest.mock import patch
|
|
from urllib.error import HTTPError
|
|
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
|
for failure in ('signature', 'missing_signature', 'server_error'):
|
|
with self.subTest(failure=failure):
|
|
requested = []
|
|
def fetch(url, limit):
|
|
requested.append(url)
|
|
if url == module.RELEASES:
|
|
return b'[]'
|
|
if url == base + 'manifest.json':
|
|
if failure == 'server_error':
|
|
raise HTTPError(url, 503, 'Unavailable', {}, None)
|
|
return (self.artifacts / 'manifest.json').read_bytes()
|
|
if url == base + 'manifest.sig':
|
|
if failure == 'missing_signature':
|
|
raise HTTPError(url, 404, 'Not Found', {}, None)
|
|
return b'x' * 64
|
|
raise AssertionError('Unsigned archive must not be fetched')
|
|
with patch.object(module, 'fetch', side_effect=fetch):
|
|
with self.assertRaises((ValueError, HTTPError)):
|
|
module.discover(self.pub)
|
|
self.assertNotIn(base + 'runtime.zip', requested)
|
|
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
|
|
|
def test_native_release_errors_do_not_fall_back_to_repository_assets(self):
|
|
self.package()
|
|
module = engine()
|
|
from unittest.mock import patch
|
|
from urllib.error import HTTPError
|
|
assets = [{'name': name, 'browser_download_url': 'https://git.shanaty.ru/attachments/' + name}
|
|
for name in ('manifest.json', 'manifest.sig', 'runtime.zip')]
|
|
for failure in ('tag', 'incomplete', 'signature', 'api_unavailable'):
|
|
with self.subTest(failure=failure):
|
|
def fetch(url, limit):
|
|
if '/raw/branch/' in url:
|
|
raise AssertionError('API failures must not activate fallback')
|
|
if url == module.RELEASES:
|
|
if failure == 'api_unavailable':
|
|
raise HTTPError(url, 503, 'Unavailable', {}, None)
|
|
return json.dumps([{'tag_name': 'wrong' if failure == 'tag' else '1.2.3',
|
|
'assets': assets[:1] if failure == 'incomplete' else assets}]).encode()
|
|
if failure == 'signature' and url.endswith('manifest.sig'):
|
|
return b'x' * 64
|
|
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
|
with patch.object(module, 'fetch', side_effect=fetch):
|
|
with self.assertRaises((ValueError, HTTPError)):
|
|
module.discover(self.pub)
|
|
|
|
def test_repository_fallback_downloads_use_existing_size_and_redirect_checks(self):
|
|
module = engine()
|
|
from unittest.mock import patch
|
|
import io
|
|
import urllib.request
|
|
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
|
def oversized(request, timeout):
|
|
if request.full_url == module.RELEASES:
|
|
return io.BytesIO(b'[]')
|
|
return io.BytesIO(b'x' * (module.MANIFEST_LIMIT + 1))
|
|
with patch('urllib.request.OpenerDirector.open', side_effect=oversized):
|
|
with self.assertRaisesRegex(ValueError, 'size'):
|
|
module.discover(self.pub)
|
|
request = urllib.request.Request(base + 'manifest.json')
|
|
for destination in ('http://git.shanaty.ru/manifest.json', 'https://other.example/manifest.json'):
|
|
with self.subTest(destination=destination), self.assertRaises(ValueError):
|
|
module.SafeRedirect().redirect_request(request, None, 302, '', {}, destination)
|