240 lines
14 KiB
Python
240 lines
14 KiB
Python
#!/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()<deadline,observed
|
|
error=user(vm,['burn','confirm',prepared['id'],prepared['confirmation']],ok=False)
|
|
assert 'identity changed' in error,error
|
|
assert digest(blank)==unchanged
|
|
print('PASS: replacement in the same bay invalidates the old confirmation without writing',flush=True)
|
|
|
|
prepared=user(vm,['burn','program','/usr/share/fds/m9-program.img','BAY02'])
|
|
result=user(vm,['burn','confirm',prepared['id'],prepared['confirmation']])
|
|
assert result['phase']=='complete'
|
|
assert wait_bay(vm,'safe')['mount'] is None
|
|
restart(vm);assert wait_bay(vm,'safe')['mount'] is None
|
|
# Removing and reinserting the same bytes is a new kernel diskseq. It can be mounted normally.
|
|
vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty')
|
|
attach(vm)
|
|
cartridge=wait_bay(vm,'mounted_read_only');assert cartridge['manifest']['cartridge']['class']=='program'
|
|
assert user(vm,['inspect','BAY02'])['diskseq']!=disk['diskseq']
|
|
user(vm,['burn','program','/usr/share/fds/m9-program.img','BAY02'],ok=False)
|
|
user(vm,['eject','BAY02']);wait_bay(vm,'safe')
|
|
print('PASS: verified PROGRAM write, larger-disk backup GPT, SAFE across restart, reinsertion and mounted-target refusal',flush=True)
|
|
|
|
capture(vm,'mkdir -p /home/fds/app/bin; cp /usr/bin/fds /home/fds/app/bin/report; chown -R fds:fds /home/fds/app')
|
|
error=user(vm,['format','program','/home/fds/app','BAY02','--label','M9 TOOLS','--id','fds.m9tools'],ok=False)
|
|
assert 'workstation' in error.lower(),error
|
|
# Existing PROGRAM images remain launchable; newly built software images
|
|
# are covered by the native workstation/emulator integration suite.
|
|
vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty');attach(vm)
|
|
cartridge=wait_bay(vm,'mounted_read_only');assert cartridge['manifest']['cartridge']['id']=='fds.test.program'
|
|
assert user(vm,['run','2','--','fds','info'])['started_pid']>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()<deadline
|
|
capture(vm,'s6-setuidgid fds cp /usr/share/fds/m9-program.img /data/source.img')
|
|
prepared=user(vm,['burn','program','/data/source.img','BAY02'])
|
|
assert 'media operation is active' in user(vm,['eject','3'],ok=False)
|
|
assert user(vm,['bay','3'])['bays'][0]['mount']=='/data'
|
|
user(vm,['burn','cancel',prepared['id']],ok=False)
|
|
user(vm,['eject','3']);wait_bay(vm,'safe',3)
|
|
vm.qmp('device_del',{'id':'sourceusb'});wait_bay(vm,'empty',3)
|
|
vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty')
|
|
attach(vm);wait_bay(vm,'mounted_read_write');user(vm,['eject','2'])
|
|
print('PASS: a source DATA cartridge cannot be ejected while its image is held by the writer',flush=True)
|
|
|
|
prepared=user(vm,['format','environment','BAY02','--profile','cli','--label','CONSOLE ENVIRONMENT'])
|
|
assert user(vm,['burn','confirm',prepared['id'],prepared['confirmation']])['phase']=='complete'
|
|
vm.qmp('device_del',{'id':'targetusb'});wait_bay(vm,'empty')
|
|
attach(vm)
|
|
cartridge=wait_bay(vm,'mounted_read_only');assert cartridge['manifest']['activation']['profile']=='cli'
|
|
user(vm,['eject','BAY02'])
|
|
prepared=user(vm,['burn','program','/usr/share/fds/m9-program.img','BAY02'])
|
|
restart(vm,crash=True)
|
|
assert 'restarted' in user(vm,['burn','status',prepared['id']],ok=False)
|
|
assert wait_bay(vm,'failed')['mount'] is None
|
|
print('PASS: on-target ENVIRONMENT formatting and fail-closed interrupted-operation restart',flush=True)
|
|
|
|
layout=json.loads(subprocess.check_output(['sfdisk','--json',str(saved)]))['partitiontable']['partitions'][0]
|
|
assert layout['name']=='FDS_DATA'
|
|
payload=work/'verified-data.ext4'
|
|
with saved.open('rb') as src,payload.open('wb') as dst:
|
|
src.seek(layout['start']*512);remaining=layout['size']*512
|
|
while remaining:
|
|
data=src.read(min(1024*1024,remaining));assert data;dst.write(data);remaining-=len(data)
|
|
subprocess.run(['e2fsck','-fn',str(payload)],check=True,stdout=subprocess.DEVNULL)
|
|
assert subprocess.check_output(['debugfs','-R','cat /test-write',str(payload)],stderr=subprocess.DEVNULL)==b'persistent'
|
|
print(f'PASS: M9 confirmed-write integration: {work}')
|
|
print('SKIP: physical media, power-loss behavior and Pi write throughput')
|