158 lines
9.5 KiB
Python
Executable File
158 lines
9.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Exercise real USB hotplug, mounts and the root service in isolated ARM VMs."""
|
|
import io
|
|
import itertools
|
|
import json
|
|
from pathlib import Path
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
|
|
project = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(project/'tools'))
|
|
from image_formats import gpt, digest, LINUX_FILESYSTEM
|
|
from vm_test import VM
|
|
|
|
work = Path(tempfile.mkdtemp(prefix='m6-vm.', dir=project/'out'))
|
|
normal = (project/'out/fds-system-cli.img').resolve()
|
|
controller = 'qemu-xhci,id=xhci,addr=05.0'
|
|
keyboard = 'usb-kbd,id=keyboard,bus=xhci.0,port=3'
|
|
|
|
def query(vm, command='fds --json bays'):
|
|
vm.send('printf "\\nM6_BEGIN\\n"; '+command+'; printf "\\nM6_END\\n"')
|
|
return json.loads(vm.expect(rb'^M6_BEGIN\r?\n(\{.*?\})(?:\r?\n)+M6_END\r?$').group(1))
|
|
|
|
def wait_bay(vm, bay, state, timeout=30):
|
|
deadline = time.monotonic()+timeout
|
|
while True:
|
|
value = query(vm, f'fds --json bay {bay}')['bays'][0]
|
|
if value['state'] == state: return value
|
|
if time.monotonic() >= deadline: raise AssertionError((bay, state, value))
|
|
# Each query is a completed IPC/serial exchange, not a time-based delay.
|
|
|
|
def shell(vm, command, marker):
|
|
vm.send(command+f' && printf "\\n{marker}\\n"')
|
|
vm.expect(('^'+marker+r'\r?$').encode())
|
|
|
|
def image(name, replacements, additions=None):
|
|
path = work/(name+'.tar')
|
|
with tarfile.open(project/'out/rootfs-cli.tar') as source, tarfile.open(path, 'w', format=tarfile.PAX_FORMAT) as output:
|
|
seen=set()
|
|
for member in source:
|
|
if member.name in replacements: seen.add(member.name); continue
|
|
output.addfile(member, source.extractfile(member) if member.isfile() else None)
|
|
assert seen == replacements.keys(), (seen, replacements.keys())
|
|
for filename, data in {**replacements, **(additions or {})}.items():
|
|
info = tarfile.TarInfo(filename); info.size=len(data); info.mode=0o755 if filename.startswith(('usr/','etc/s6-linux-init/')) else 0o644
|
|
output.addfile(info, io.BytesIO(data))
|
|
destination=work/name; destination.mkdir()
|
|
with (work/(name+'-image.log')).open('wb') as log:
|
|
subprocess.run([str(project/'image/build-system-cartridge'), '--rootfs', str(path), '--output-directory', str(destination)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
|
return destination/'system.img'
|
|
|
|
# Discover the actual virtual controller identity, not a guessed /dev name.
|
|
with VM(work, 'probe', normal, extra=['-device', controller, '-device', keyboard]) as vm:
|
|
vm.expect(rb'FDS> ')
|
|
report=query(vm, 'fds --json topology')
|
|
assert len(report['bays']) == 12 and all(b['state']=='unconfigured' for b in report['bays'])
|
|
device=next(d for d in report['unmapped'] if d['class']=='00' and '03' in d['interfaces'])
|
|
hub=device['topology'].rsplit('/',1)[0]
|
|
assert hub.endswith(':usb2'), device
|
|
(work/'topology.json').write_text(json.dumps(report,indent=2)+'\n')
|
|
|
|
config=''
|
|
for name, identity in [('usb2',hub), ('usb3',hub.replace(':usb2',':usb3'))]:
|
|
config+=f'[{name}]\nhub={json.dumps(identity)}\n[{name}.ports]\n'+''.join(f'{n}={n}\n' for n in range(1,13))
|
|
catalog=f'[[device]]\nname="VM KEYBOARD"\nvendor="{device["vendor"]}"\nproduct="{device["product"]}"\nclass="03"\n'
|
|
fixture=image('mapped', {
|
|
'usr/bin/fds-cartridged':(project/'out/fds-cartridged').read_bytes(),
|
|
'etc/fds/bays.toml':config.encode(), 'etc/fds/hardware-catalog.toml':catalog.encode(),
|
|
'usr/libexec/fds/console-session':b'#!/bin/bash\n# Isolated test image only.\nexec env HOME=/root bash --login\n',
|
|
}, {'usr/libexec/fds/m6-client':(project/'out/m6-client').read_bytes()})
|
|
|
|
# Small declarative media. None of these images is a host block device.
|
|
def cartridge(name, manifest, label='FDS_ENVIRONMENT', symlink=False):
|
|
archive=work/(name+'.tar')
|
|
with tarfile.open(archive,'w') as output:
|
|
d=tarfile.TarInfo('FDS');d.type=tarfile.DIRTYPE;d.mode=0o755;output.addfile(d)
|
|
m=tarfile.TarInfo('FDS/CARTRIDGE.TOML');m.mode=0o644
|
|
if symlink: m.type=tarfile.SYMTYPE;m.linkname='/etc/passwd';output.addfile(m)
|
|
else: m.size=len(manifest);output.addfile(m,io.BytesIO(manifest))
|
|
erofs=work/(name+'.erofs')
|
|
subprocess.run([str(project/'tools/in-image-tools'),'mkfs.erofs','-T','0','--tar=f',str(erofs),str(archive)],check=True,stdout=subprocess.DEVNULL)
|
|
disk=work/(name+'.img');gpt(disk,[(label,LINUX_FILESYSTEM,erofs)]);return disk
|
|
manifest=(project/'tests/fixtures/manifests/windowmaker.toml').read_bytes()
|
|
media={
|
|
'environment':cartridge('environment',manifest),
|
|
'wrongclass':cartridge('wrongclass',manifest.replace(b'class = "environment"',b'class = "system"').split(b'[activation]')[0]),
|
|
'symlink':cartridge('symlink',b'',symlink=True),
|
|
}
|
|
# Distinct FDS partitions must not cause first-match mounting.
|
|
media['ambiguous']=work/'ambiguous.img'
|
|
gpt(media['ambiguous'], [('FDS_ENVIRONMENT',LINUX_FILESYSTEM,work/'environment.erofs'),('FDS_PROGRAM',LINUX_FILESYSTEM,work/'environment.erofs')])
|
|
before={str(p):digest(p) for p in [normal,fixture,*media.values()]}
|
|
extra=['-device',keyboard]
|
|
insertion=itertools.count()
|
|
with VM(work, 'hotplug', fixture, system_usb=True, extra=extra) as vm:
|
|
vm.expect(rb'FDS# ')
|
|
assert wait_bay(vm,1,'protected')['manifest']['cartridge']['class']=='system'
|
|
assert wait_bay(vm,3,'hardware')['name']=='VM KEYBOARD'
|
|
shell(vm,'if fds eject 1; then false; else true; fi','M6_ROOT_PROTECTED')
|
|
report=query(vm,'s6-setuidgid fds fds --json bays');assert len(report['bays'])==12
|
|
shell(vm,'/usr/libexec/fds/m6-client slow','M6_SLOW_OK')
|
|
shell(vm,'/usr/libexec/fds/m6-client oversize','M6_OVERSIZE_OK')
|
|
invalid=query(vm,"/usr/libexec/fds/m6-client '{\"command\":\"eject\",\"bay\":0}'")
|
|
assert invalid['error']=='Invalid control request'
|
|
shell(vm,'chmod 0666 /run/fds/control.sock; if setpriv --reuid=65534 --regid=65534 --clear-groups /usr/libexec/fds/m6-client \'{"command":"bays"}\'; then false; else chmod 0660 /run/fds/control.sock; fi','M6_PEER_REJECTED')
|
|
def add(name,port=2):
|
|
node=f'media{next(insertion)}'
|
|
vm.qmp('blockdev-add',{'driver':'raw','node-name':node,'read-only':True,'file':{'driver':'file','filename':str(media[name])}})
|
|
vm.qmp('device_add',{'driver':'usb-storage','id':'inserted','drive':node,'bus':'xhci.0','port':str(port)})
|
|
def remove(port=2): vm.qmp('device_del',{'id':'inserted'});wait_bay(vm,port,'empty')
|
|
add('environment'); mounted=wait_bay(vm,2,'mounted_read_only')
|
|
assert mounted['manifest']['cartridge']['id']=='fds.windowmaker'
|
|
shell(vm,'test -r /run/fds/media/02/FDS/CARTRIDGE.TOML && ! touch /run/fds/media/02/unexpected','M6_READ_ONLY')
|
|
shell(vm,'mkdir /run/m6-extra; mount --bind /run/fds/media/02 /run/m6-extra; if fds eject 2; then false; else true; fi','M6_EXTRA_MOUNT_REJECTED')
|
|
shell(vm,'umount /run/m6-extra','M6_EXTRA_MOUNT_CLOSED')
|
|
# An open working directory must prevent SAFE, with no lazy unmount fallback.
|
|
shell(vm,'cd /run/fds/media/02; if fds eject 2; then false; else true; fi','M6_BUSY_REJECTED')
|
|
assert query(vm,'fds --json bay 2')['bays'][0]['state']=='mounted_read_only'
|
|
shell(vm,'cd /; fds eject 2','M6_EJECT_OK')
|
|
assert wait_bay(vm,2,'safe')['mount'] is None
|
|
shell(vm,'test ! -e /run/fds/media/02/FDS/CARTRIDGE.TOML','M6_UNMOUNTED')
|
|
shell(vm,'s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged','M6_SAFE_RESTARTED')
|
|
wait_bay(vm,2,'safe')
|
|
remove()
|
|
# Reinsert in another bay: identity follows the port, not sdX ordering.
|
|
add('environment',4);wait_bay(vm,4,'mounted_read_only');remove(4)
|
|
for name in ['wrongclass','symlink']:
|
|
add(name);assert wait_bay(vm,2,'error')['mount'] is None;remove()
|
|
add('ambiguous');wait_bay(vm,2,'ambiguous');remove()
|
|
add('environment');wait_bay(vm,2,'mounted_read_only')
|
|
shell(vm,'s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged','M6_RESTARTED')
|
|
wait_bay(vm,2,'mounted_read_only');remove()
|
|
vm.qmp('device_del',{'id':'keyboard'});wait_bay(vm,3,'empty')
|
|
print('PASS: active SYSTEM protection, hardware catalog, unprivileged IPC, bounded clients, hotplug, read-only mounts, busy eject, safe eject, removal and restart',flush=True)
|
|
# Hold the entire cartridge service before readiness. Getty must still work.
|
|
stage2='etc/s6-linux-init/current/scripts/rc.init'
|
|
with tarfile.open(project/'out/rootfs-cli.tar') as archive:
|
|
original_stage2=archive.extractfile(stage2).read()
|
|
gated=image('gated', {
|
|
stage2: ('#!/bin/bash\nset -euo pipefail\nmkfifo -m 0666 /run/m6-gate\nexec /'+stage2+'.real "$@"\n').encode(),
|
|
'usr/bin/fds-cartridged': b'#!/bin/bash\nprintf "M6_DAEMON_WAIT\\n" >/dev/console\nIFS= read -r release </run/m6-gate\nexec /usr/libexec/fds/m6-daemon-real "$@"\n',
|
|
}, {stage2+'.real': original_stage2, 'usr/libexec/fds/m6-daemon-real':(project/'out/fds-cartridged').read_bytes()})
|
|
with VM(work,'blocked-cartridged',gated) as vm:
|
|
vm.expect(rb'FDS> ')
|
|
if b'M6_DAEMON_WAIT' not in vm.data: vm.expect(rb'M6_DAEMON_WAIT')
|
|
shell(vm, 'test "$(id -u)" = 1000 && test ! -e /run/fds/control.sock', 'M6_CONSOLE_INDEPENDENT')
|
|
shell(vm, 'printf "release\\n" >/run/m6-gate', 'M6_GATE_RELEASED')
|
|
print('PASS: ordinary-user console is usable while cartridged readiness is blocked',flush=True)
|
|
|
|
for path,checksum in before.items(): assert digest(path)==checksum, path
|
|
link=project/'out/m6-vm-latest';temporary=link.with_suffix('.next');temporary.symlink_to(work.name);temporary.replace(link)
|
|
print(f'PASS: M6 virtual USB integration: {work}')
|
|
print('SKIP: physical bay wiring/calibration and Pi USB electrical behavior')
|