Files
2026-09-21 22:29:23 +08:00

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')