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
+12
View File
@@ -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']())
+8
View File
@@ -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))
+93
View File
@@ -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())
+143
View File
@@ -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.
+10 -1
View File
@@ -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
+609
View File
@@ -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())
+8
View File
@@ -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())
+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')