Add signed passive runtime updates and independent crash recovery
This commit is contained in:
Executable
+12
@@ -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']())
|
||||
Executable
+8
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user