#!/usr/bin/env python3 """Confirmed writes by the ordinary FDS user to disposable virtual USB media.""" import hashlib 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 vm_test import VM from image_formats import digest, gpt, LINUX_FILESYSTEM work = Path(tempfile.mkdtemp(prefix='m9-vm.', dir=project/'out')) controller = 'qemu-xhci,id=xhci,addr=05.0' # A complete legacy PROGRAM compatibility image generated on the workstation. image_runs = sorted((project/'out').glob('m9-images.*'), key=lambda p:p.stat().st_mtime, reverse=True) program = next(p/'root/m9-test/program.img' for p in image_runs if (p/'program.json').is_file()) assert program.is_file(), 'Run make media-image-test first' # Bypass the native creator's manifest validation to exercise hostile input at # the worker boundary. The filesystem and GPT themselves are valid. malformed_root=work/'malformed-source' (malformed_root/'FDS').mkdir(parents=True) (malformed_root/'FDS/CARTRIDGE.TOML').write_text('format = 1\n[cartridge]\nname = "'+'x'*(60*1024)) malformed_fs=work/'malformed.erofs' subprocess.run([str(project/'tools/in-image-tools'),'mkfs.erofs','--quiet','-b','4096','-T','0','-L','FDS_PROGRAM',str(malformed_fs),str(malformed_root)],check=True) malformed_image=work/'malformed.img' gpt(malformed_image,[('FDS_PROGRAM',LINUX_FILESYSTEM,malformed_fs)]) def fixture(name, hub=None): replacements = {'usr/libexec/fds/console-session': b'#!/bin/bash\n# Isolated test image only.\nexec env HOME=/root bash --login\n'} replacements.update({f'usr/bin/{name}':(project/'out'/name).read_bytes() for name in ['fds','fds-burn','fds-cartridged','fds-profile','fds-inspect','fds-eject']}) if hub: replacements['etc/fds/bays.toml'] = f'[front]\nhub = "{hub}"\n[front.ports]\n1 = 1\n2 = 2\n3 = 3\n'.encode() extra = {'usr/share/fds/m9-program.img': program.read_bytes(), 'usr/share/fds/m9-malformed.img': malformed_image.read_bytes()} archive = work/f'{name}.tar' with tarfile.open(project/'out/rootfs-cli.tar') as src, tarfile.open(archive,'w',format=tarfile.PAX_FORMAT) as out: assert src.getmember('usr/bin/fds-burn'), 'Build the M9 rootfs first' seen=set() for member in src: if member.name in replacements: seen.add(member.name);continue out.addfile(member,src.extractfile(member) if member.isfile() else None) assert seen==replacements.keys() for path,data in {**replacements,**extra}.items(): member=tarfile.TarInfo(path);member.size=len(data) member.mode=0o755 if path.endswith('console-session') or path.startswith('usr/bin/') else 0o644 out.addfile(member,io.BytesIO(data)) destination=work/name;destination.mkdir() with (work/f'{name}.image.log').open('wb') as log: subprocess.run([str(project/'image/build-system-cartridge'),'--rootfs',str(archive),'--output-directory',str(destination)],check=True,stdout=log,stderr=subprocess.STDOUT) return destination/'system.img' def capture(vm, command): vm.send('printf "\\nM9_BEGIN\\n"; '+command+'; printf "\\nM9_END\\n"') return vm.expect(rb'^M9_BEGIN\r?\n(.*?)\r?\nM9_END\r?$',timeout=180).group(1).decode().strip() def user(vm,args,ok=True): command='s6-setuidgid fds fds --json '+shlex.join(args)+' 2>/home/fds/command.err; printf "\\nSTATUS:%s" "$?"' output=capture(vm,command) text,code=output.rsplit('STATUS:',1) assert (code.strip()=='0')==ok,(args,output,capture(vm,'cat /home/fds/command.err; tail -n 30 /run/log/cartridged/current')) return json.loads(text) if ok else capture(vm,'cat /home/fds/command.err') def wait_bay(vm,state,number=2): deadline=time.monotonic()+30 while True: value=user(vm,['bay',str(number)])['bays'][0] if value['state']==state:return value if time.monotonic()>deadline:raise AssertionError(value) def restart(vm,crash=False): stop='s6-svc -O /run/service/cartridged && s6-svc -k' if crash else 's6-svc -d' output=capture(vm,stop+' /run/service/cartridged && s6-svwait -d -t 15000 /run/service/cartridged && s6-svc -u /run/service/cartridged && s6-svwait -U -t 15000 /run/service/cartridged && printf RESTARTED') assert 'RESTARTED' in output,output sequence=itertools.count() def attach(vm,path=None,port=2): path=path or blank node=f'media{next(sequence)}' vm.qmp('blockdev-add',{'driver':'raw','node-name':node,'file':{'driver':'file','filename':str(path)}}) vm.qmp('device_add',{'driver':'usb-storage','id':'targetusb' if port==2 else 'sourceusb','drive':node,'bus':'xhci.0','port':str(port),'serial':'M9TARGET' if port==2 else 'M9SOURCE'}) blank=work/'cartridge.img' with blank.open('wb') as stream:stream.truncate(96*1024*1024) unchanged=digest(blank) probe=fixture('probe') with VM(work,'probe',probe,system_usb=True,extra=['-drive',f'file={blank},if=none,id=target,format=raw','-device','usb-storage,id=targetusb,drive=target,bus=xhci.0,port=2,serial=M9TARGET']) as vm: vm.expect(rb'FDS# ') topology=json.loads(capture(vm,'fds --json topology')) hub=next(d['topology'] for d in topology['unmapped'] if d.get('serial')=='M9TARGET').rsplit('/',1)[0] system=fixture('burn-system',hub) with VM(work,'burn',system,system_usb=True,extra=['-drive',f'file={blank},if=none,id=target,format=raw','-device','usb-storage,id=targetusb,drive=target,bus=xhci.0,port=2,serial=M9TARGET']) as vm: vm.expect(rb'FDS# ') capture(vm,'fds-boottrace mark console-ready; cd /home/fds') disk=user(vm,['inspect','BAY02']);assert disk['bytes']==blank.stat().st_size and disk['protected'] is None assert user(vm,['inspect','BAY01'])['protected'] user(vm,['burn','program','/usr/share/fds/m9-program.img','BAY01'],ok=False) user(vm,['burn','system','/usr/share/fds/m9-program.img','BAY02'],ok=False) assert digest(blank)==unchanged daemon_pid=capture(vm,'s6-svstat -o pid /run/service/cartridged') error=user(vm,['burn','program','/usr/share/fds/m9-malformed.img','BAY02'],ok=False) assert 'Invalid cartridge manifest' in error and len(error)<2200,error assert not any(ord(c)<32 for c in error),repr(error) assert capture(vm,'s6-svstat -o pid /run/service/cartridged')==daemon_pid assert len(user(vm,['bays'])['bays'])==12 and digest(blank)==unchanged print('PASS: oversized manifest diagnostics remain bounded; daemon stays responsive and target unchanged',flush=True) prepared=user(vm,['burn','program','/usr/share/fds/m9-program.img','BAY02']) assert prepared['phase']=='awaiting_confirmation' and prepared['image_sha256']==digest(program) user(vm,['burn','confirm',prepared['id'],'ERASE BAY02 wrong-operation'],ok=False) assert user(vm,['burn','status',prepared['id']])['phase']=='awaiting_confirmation' assert digest(blank)==unchanged,'Preview or rejected confirmation modified the disk' user(vm,['burn','cancel',prepared['id']],ok=False) assert digest(blank)==unchanged print('PASS: active SYSTEM protection, class validation, bound confirmation, preview and cancellation do not write',flush=True) capture(vm,'cp /usr/share/fds/m9-program.img /home/fds/mutable.img; chown fds:fds /home/fds/mutable.img') prepared=user(vm,['burn','program','/home/fds/mutable.img','BAY02']) capture(vm,"printf changed | dd of=/home/fds/mutable.img bs=1 seek=2097152 conv=notrunc status=none") error=user(vm,['burn','confirm',prepared['id'],prepared['confirmation']],ok=False) assert 'changed after confirmation' in error,error assert digest(blank)==unchanged print('PASS: source mutation after preview is rejected before the target is written',flush=True) prepared=user(vm,['burn','program','/usr/share/fds/m9-program.img','BAY02']) vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty') attach(vm) # The old worker holds the old insertion's descriptor; it must never follow # the new kernel disk in the same physical port. deadline=time.monotonic()+30 while True: observed=capture(vm,'s6-setuidgid fds fds --json inspect BAY02 2>/home/fds/enumeration.err') if observed.startswith('{') and json.loads(observed)['diskseq']!=disk['diskseq']:break assert time.monotonic()1 user(vm,['eject','BAY02']) print('PASS: workstation-only PROGRAM creation and explicit legacy application launch',flush=True) prepared=user(vm,['format','data','BAY02','--label','M9 DATA','--size-mib','32']) assert prepared['phase']=='awaiting_confirmation' # Leave an actual write running while querying the ordinary control endpoint. command='s6-setuidgid fds fds --json burn confirm '+shlex.join([prepared['id'],prepared['confirmation']])+' >/home/fds/write-result.json 2>/home/fds/write-result.err &' capture(vm,'('+command+')') assert len(user(vm,['bays'])['bays'])==12 result=user(vm,['burn','wait',prepared['id']]);assert result['phase']=='complete' vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty') attach(vm) cartridge=wait_bay(vm,'mounted_read_write');assert cartridge['manifest']['cartridge']['name']=='M9 DATA' assert 'WROTE_DATA' in capture(vm,"s6-setuidgid fds /bin/bash -c 'printf persistent > /data/test-write' && printf WROTE_DATA") user(vm,['eject','BAY02']);wait_bay(vm,'safe') vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty') # QEMU flushes the backend on safe removal; retain DATA for an independent host fsck. saved=work/'verified-data.img' import shutil shutil.copyfile(blank,saved) attach(vm) wait_bay(vm,'mounted_read_write');user(vm,['eject','BAY02']) print('PASS: on-target DATA formatting, responsive status during writing, unprivileged use and safe eject',flush=True) # Source DATA remains protected while an image descriptor is held in the # writer's private mount namespace, even though the destination is another bay. vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty') attach(vm,port=3);wait_bay(vm,'mounted_read_write',3) other=work/'other-target.img' with other.open('wb') as stream:stream.truncate(96*1024*1024) attach(vm,other) deadline=time.monotonic()+30 while True: observed=capture(vm,'fds --json inspect BAY02 2>/home/fds/enumeration.err') if observed.startswith('{'):break assert time.monotonic()