Verify packaged terminal UX in installed ISO acceptance
This commit is contained in:
@@ -308,6 +308,253 @@ def lifecycle_guest_script():
|
||||
'\n'.join(inspect.getsource(helper) for helper in helpers) + LIFECYCLE_BODY)
|
||||
|
||||
|
||||
def terminal_ux_iso():
|
||||
"""An empty ISO9660 filesystem, intentionally without an El Torito boot image."""
|
||||
def both(value, width):
|
||||
return value.to_bytes(width, 'little') + value.to_bytes(width, 'big')
|
||||
|
||||
def directory_record(identifier):
|
||||
record = bytearray(34)
|
||||
record[0] = 34
|
||||
record[2:10] = both(20, 4)
|
||||
record[10:18] = both(2048, 4)
|
||||
record[18:25] = bytes((126, 10, 13, 0, 0, 0, 0))
|
||||
record[25] = 2
|
||||
record[28:32] = both(1, 2)
|
||||
record[32] = 1
|
||||
record[33] = identifier
|
||||
return record
|
||||
|
||||
data = bytearray(21 * 2048)
|
||||
primary = bytearray(2048)
|
||||
primary[:7] = b'\x01CD001\x01'
|
||||
primary[8:40] = b'HYDRA OS'.ljust(32, b' ')
|
||||
primary[40:72] = b'HYDRA_UX_PROBE'.ljust(32, b' ')
|
||||
primary[80:88] = both(21, 4)
|
||||
primary[120:124] = both(1, 2)
|
||||
primary[124:128] = both(1, 2)
|
||||
primary[128:132] = both(2048, 2)
|
||||
primary[132:140] = both(10, 4)
|
||||
primary[140:144] = (18).to_bytes(4, 'little')
|
||||
primary[148:152] = (19).to_bytes(4, 'big')
|
||||
primary[156:190] = directory_record(0)
|
||||
primary[190:813] = b' ' * 623
|
||||
primary[813:830] = b'2026101300000000\x00'
|
||||
primary[830:847] = b'2026101300000000\x00'
|
||||
primary[847:881] = b'0000000000000000\x00' * 2
|
||||
primary[881] = 1
|
||||
data[16 * 2048:17 * 2048] = primary
|
||||
data[17 * 2048:17 * 2048 + 7] = b'\xffCD001\x01'
|
||||
for sector, order in ((18, 'little'), (19, 'big')):
|
||||
data[sector * 2048:sector * 2048 + 10] = (
|
||||
b'\x01\x00' + (20).to_bytes(4, order) + (1).to_bytes(2, order) + b'\x00\x00')
|
||||
data[20 * 2048:20 * 2048 + 68] = directory_record(0) + directory_record(1)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def verify_terminal_ux_branding(output, errors):
|
||||
rendered = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', output)
|
||||
if errors.strip() or re.search(r'\b(error|failed|unknown module)\b', rendered, re.I):
|
||||
raise SmokeError('Installed default Fastfetch emitted errors: ' + (errors or rendered)[-1500:])
|
||||
if not re.search(r'\bOS\s*:\s*Hydra OS\b', rendered):
|
||||
raise SmokeError('Installed default Fastfetch did not render the Hydra OS pretty name')
|
||||
if 'H Y D R A O S' not in rendered or rendered.count('(o >') != 3:
|
||||
raise SmokeError('Installed default Fastfetch did not render the three-headed Hydra logo')
|
||||
return rendered
|
||||
|
||||
|
||||
def terminal_ux_snapshot(context):
|
||||
"""Verify CLI-owned live process/QMP identity, excluding OS boot or audio claims."""
|
||||
try:
|
||||
if context['uid'] != os.getuid():
|
||||
raise SmokeError('Terminal UX process is not owned by the installed normal user')
|
||||
runtime = Path(context['runtime'])
|
||||
run = runtime / context['name']
|
||||
for directory in (runtime, run):
|
||||
info = directory.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != context['uid']
|
||||
or stat.S_IMODE(info.st_mode) != 0o700):
|
||||
raise SmokeError('Terminal UX runtime is not private and owned')
|
||||
if json.loads((run / 'identity.json').read_text()) != {
|
||||
'name': context['name'], 'uuid': context['uuid']}:
|
||||
raise SmokeError('Terminal UX metadata belongs to a different VM')
|
||||
pid = int((run / 'qemu.pid').read_text())
|
||||
process = Path('/proc') / str(pid)
|
||||
fields = (process / 'stat').read_text().rsplit(')', 1)[1].split()
|
||||
if process.stat().st_uid != context['uid'] or fields[0] == 'Z':
|
||||
raise SmokeError('Terminal UX VM process exited or changed owner')
|
||||
argv = (process / 'cmdline').read_bytes().split(b'\0')
|
||||
for option, expected in ((b'-name', context['name']), (b'-uuid', context['uuid'])):
|
||||
if argv[argv.index(option) + 1] != expected.encode():
|
||||
raise SmokeError('Terminal UX PID belongs to a different VM')
|
||||
lifecycle_socket(run / 'qmp.sock', connect_socket=False)
|
||||
lifecycle_socket(run / 'spice.sock')
|
||||
with QMPClient(run / 'qmp.sock', timeout=2) as client:
|
||||
if (client.command('query-name') != {'name': context['name']}
|
||||
or client.command('query-uuid') != {'UUID': context['uuid']}):
|
||||
raise SmokeError('Terminal UX QMP belongs to a different VM')
|
||||
if client.command('query-status').get('status') != 'running':
|
||||
raise SmokeError('Terminal UX VM is not running')
|
||||
if not client.command('query-kvm').get('enabled'):
|
||||
raise SmokeError('Terminal UX VM did not initialise nested KVM')
|
||||
return {'name': context['name'], 'uuid': context['uuid'], 'pid': pid,
|
||||
'process_start': fields[19], 'kvm': True}
|
||||
except (OSError, ValueError, KeyError, IndexError, QMPError) as exc:
|
||||
raise SmokeError('Terminal UX cannot establish owned live identity: ' + str(exc)) from exc
|
||||
|
||||
|
||||
TERMINAL_UX_BODY = r'''
|
||||
if os.getuid() == 0 or subprocess.check_output(['id', '-un'], text=True).strip() != 'hydratest':
|
||||
raise SmokeError('Terminal UX probe must run as the installed normal hydratest user')
|
||||
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
||||
raise SmokeError('Terminal UX needs installed-user nested KVM access')
|
||||
if not Path('/usr/bin/hydra').is_file():
|
||||
raise SmokeError('Installed product launcher /usr/bin/hydra is missing')
|
||||
fetch = subprocess.run(['fastfetch', '--pipe', 'false', '--show-errors'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if fetch.returncode:
|
||||
raise SmokeError('Installed default Fastfetch failed: ' + fetch.stderr[-1500:])
|
||||
fastfetch_output = verify_terminal_ux_branding(fetch.stdout, fetch.stderr)
|
||||
config_hash = hashlib.sha256(Path('/etc/fastfetch/config.jsonc').read_bytes()).hexdigest()
|
||||
logo_hash = hashlib.sha256(Path('/usr/share/hydra/fastfetch.txt').read_bytes()).hexdigest()
|
||||
base = Path(tempfile.mkdtemp(prefix='hux-', dir='/tmp'))
|
||||
name = 'ux-' + secrets.token_hex(4)
|
||||
context = {'name': name, 'uid': os.getuid(), 'config': str(base / 'config'),
|
||||
'state': str(base / 'state'), 'runtime': str(base / 'run')}
|
||||
context['uuid'] = str(uuid.uuid5(uuid.NAMESPACE_URL, str(base / 'state' / name / 'disk.qcow2')))
|
||||
environment = dict(os.environ, XDG_CACHE_HOME=str(base / 'cache'))
|
||||
environment.pop('HYDRA_SESSION_MANIFEST', None)
|
||||
command = ['/usr/bin/hydra', '--config', context['config'], '--state', context['state'],
|
||||
'--runtime', context['runtime']]
|
||||
|
||||
def cli(*arguments, input=None):
|
||||
result = subprocess.run(command + list(arguments), input=input, env=environment,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
raise SmokeError('Installed hydra ' + arguments[0] + ' failed: ' +
|
||||
(result.stdout + result.stderr)[-2000:])
|
||||
return result.stdout
|
||||
|
||||
def cli_json(*arguments):
|
||||
return json.loads(cli(*arguments))
|
||||
|
||||
fixture = base / 'fixture.iso'
|
||||
fixture.write_bytes(terminal_ux_iso())
|
||||
iso_hash = hashlib.sha256(fixture.read_bytes()).hexdigest()
|
||||
requests = []
|
||||
class ISOHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append(self.path)
|
||||
if self.path != '/fixture.iso':
|
||||
self.send_error(404)
|
||||
return
|
||||
payload = fixture.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/x-iso9660-image')
|
||||
self.send_header('Content-Length', str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), ISOHandler)
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
url = 'http://127.0.0.1:' + str(server.server_port) + '/fixture.iso'
|
||||
result = None
|
||||
try:
|
||||
if cli_json('list'):
|
||||
raise SmokeError('Terminal UX private configuration was not initially empty')
|
||||
empty_menu = cli('menu', input=':quit\n')
|
||||
if ('Hydra OS' not in empty_menu or 'No VM profiles' not in empty_menu
|
||||
or list(base.glob('run/*/qemu.pid')) or cli_json('list')):
|
||||
raise SmokeError('Installed empty menu was unreadable or started a VM')
|
||||
run_output = cli('run', url, '--name', name, '--memory', '512', '--cpus', '1',
|
||||
'--disk-size', '64M', '--sha256', iso_hash, '--no-view')
|
||||
if requests != ['/fixture.iso']:
|
||||
raise SmokeError('Installed hydra run did not download exactly its loopback ISO')
|
||||
before = terminal_ux_snapshot(context)
|
||||
profiles = cli_json('list')
|
||||
if len(profiles) != 1 or profiles[0]['name'] != name:
|
||||
raise SmokeError('Installed hydra run did not create exactly the requested private profile')
|
||||
cached_iso = Path(profiles[0]['iso'])
|
||||
cache = base / 'cache' / 'hydra' / 'iso'
|
||||
info = cached_iso.lstat()
|
||||
if (cached_iso.parent != cache or not stat.S_ISREG(info.st_mode)
|
||||
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600
|
||||
or hashlib.sha256(cached_iso.read_bytes()).hexdigest() != iso_hash
|
||||
or list(cache.glob('*.part'))):
|
||||
raise SmokeError('Installed hydra run cache is unsafe, incomplete or has a different checksum')
|
||||
profile = tomllib.loads((base / 'config' / (name + '.toml')).read_text())
|
||||
if (profile['memory'], profile['cpus'], profile['disk_size']) != (512, 1, '64M'):
|
||||
raise SmokeError('Installed hydra run lost its explicit allocations')
|
||||
downloaded = cli('download', url, '--sha256', iso_hash)
|
||||
if (Path(downloaded.splitlines()[-1]) != cached_iso or requests != ['/fixture.iso']
|
||||
or hashlib.sha256(cached_iso.read_bytes()).hexdigest() != iso_hash):
|
||||
raise SmokeError('Installed hydra download did not reuse the complete cached ISO')
|
||||
cli('open', name, '--no-view')
|
||||
after_open = terminal_ux_snapshot(context)
|
||||
if before != after_open:
|
||||
raise SmokeError('Installed hydra open restarted or replaced its running VM')
|
||||
menu = cli('menu', input=':quit\n')
|
||||
after_menu = terminal_ux_snapshot(context)
|
||||
if ('Hydra OS' not in menu or name not in menu or 'running' not in menu
|
||||
or '512 MiB' not in menu or '"status":' in menu or before != after_menu):
|
||||
raise SmokeError('Installed running menu was unreadable or changed VM lifetime')
|
||||
result = {'status': 'passed', 'uid': os.getuid(), 'normal_user': 'hydratest',
|
||||
'launcher': '/usr/bin/hydra', 'fixture_bytes': fixture.stat().st_size,
|
||||
'iso_sha256': iso_hash, 'cache_reused': True, 'http_requests': requests,
|
||||
'before': before, 'after_open': after_open, 'after_menu': after_menu,
|
||||
'memory_mib': profile['memory'], 'cpus': profile['cpus'],
|
||||
'disk_size': profile['disk_size'], 'network': profile['network'],
|
||||
'empty_menu': empty_menu, 'running_menu': menu, 'run_output': run_output,
|
||||
'download_output': downloaded, 'fastfetch_command': ['fastfetch', '--pipe', 'false', '--show-errors'],
|
||||
'fastfetch_default_config_sha256': config_hash, 'fastfetch_logo_sha256': logo_hash,
|
||||
'fastfetch_output': fastfetch_output,
|
||||
'scope': 'Packaged installed CLI, cache, branding and live nested QMP/KVM; nonboot ISO fixture, no nested OS boot or performance claim'}
|
||||
finally:
|
||||
try:
|
||||
status = cli_json('status', name).get('status')
|
||||
if status == 'running':
|
||||
# Never signal a saved PID: verify the exact private live identity
|
||||
# and let the product CLI perform its own identity-checked QMP quit.
|
||||
terminal_ux_snapshot(context)
|
||||
if cli_json('stop', name, '--force').get('status') != 'stopped':
|
||||
raise SmokeError('Owned terminal UX fixture VM did not stop')
|
||||
elif status != 'stopped':
|
||||
raise SmokeError('Refusing terminal UX cleanup of an unverified VM identity at ' + str(base))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join(timeout=5)
|
||||
if result is not None:
|
||||
result['cleanup'] = 'Only the verified private fixture VM stopped through hydra stop --force'
|
||||
print(json.dumps(result))
|
||||
shutil.rmtree(base)
|
||||
'''
|
||||
|
||||
|
||||
def terminal_ux_guest_script():
|
||||
helpers = (lifecycle_socket, terminal_ux_iso, verify_terminal_ux_branding, terminal_ux_snapshot)
|
||||
return ('import hashlib, http.server, json, os, re, secrets, shutil, socket, stat, subprocess, sys, tempfile, threading, tomllib, uuid\n'
|
||||
'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
'from hydra.qmp import QMPClient, QMPError\n'
|
||||
'class SmokeError(RuntimeError): pass\n' +
|
||||
'\n'.join(inspect.getsource(helper) for helper in helpers) + TERMINAL_UX_BODY)
|
||||
|
||||
|
||||
def installed_terminal_ux_probe(console):
|
||||
preparation = ("python3 - <<'HYDRA_TERMINAL_UX_SOURCE'\n" + terminal_ux_guest_script() +
|
||||
'\nHYDRA_TERMINAL_UX_SOURCE')
|
||||
output = console.command(preparation, 180)
|
||||
try:
|
||||
result = json.loads(output)
|
||||
except ValueError as exc:
|
||||
raise SmokeError('Installed terminal UX did not return framed JSON evidence') from exc
|
||||
if result.get('status') != 'passed':
|
||||
raise SmokeError('Installed terminal UX did not verify its packaged CLI')
|
||||
return output
|
||||
|
||||
|
||||
def installed_admin(console, password):
|
||||
console.buffer = b''
|
||||
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||
@@ -875,6 +1122,7 @@ def main(argv=None):
|
||||
if output.strip() != b'hydratest':
|
||||
raise SmokeError('Installed account identity mismatch')
|
||||
checkpoint('installed_password_login', output)
|
||||
checkpoint('installed_terminal_ux', installed_terminal_ux_probe(console))
|
||||
console.buffer = b''
|
||||
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||
console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
|
||||
|
||||
+3
-3
@@ -178,7 +178,7 @@ def main():
|
||||
env=viewer_env, stdout=vl, stderr=vl)
|
||||
processes.append(proc)
|
||||
log = root / f'{name}-{number}.log'
|
||||
wait_for(lambda: f'set_title("HydraOS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||
wait_for(lambda: f'set_title("Hydra OS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||
return proc, log
|
||||
|
||||
for name in names:
|
||||
@@ -220,8 +220,8 @@ def main():
|
||||
post_usb_switches = [switch(106, alog, blog), switch(105, blog, alog)]
|
||||
assert alpha.poll() is None and beta.poll() is None
|
||||
assert identities == {name: manager.paths(name).pid.read_text().strip() for name in names}, 'switch restarted a guest'
|
||||
assert alog.read_text().count('set_title("HydraOS: alpha")') == 1, 'switch reconnected alpha'
|
||||
assert blog.read_text().count('set_title("HydraOS: beta")') == 1, 'switch reconnected beta'
|
||||
assert alog.read_text().count('set_title("Hydra OS: alpha")') == 1, 'switch reconnected alpha'
|
||||
assert blog.read_text().count('set_title("Hydra OS: beta")') == 1, 'switch reconnected beta'
|
||||
with manager._connect(manager.paths('alpha')) as qmp:
|
||||
usb_after = qmp.command('human-monitor-command', {'command-line': 'info usb'})
|
||||
assert usb_after == usb_before, 'opening selector attached an unselected USB device'
|
||||
|
||||
Reference in New Issue
Block a user