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

183 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Writable DATA, managed descendants, sync/unmount and hot-removal in ARM VMs."""
import base64
import binascii
import hashlib
import io
import itertools
import json
from pathlib import Path
import shutil
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, LINUX_FILESYSTEM, digest
from vm_test import VM
work=Path(tempfile.mkdtemp(prefix='m7-vm.',dir=project/'out'))
for tool in ['mke2fs','e2fsck','debugfs']:
assert shutil.which(tool), 'Install the documented host e2fsprogs tools'
with (work/'filesystem-tools.log').open('wb') as log:
subprocess.run(['mke2fs','-V'],check=True,stdout=log,stderr=subprocess.STDOUT)
controller='qemu-xhci,id=xhci,addr=05.0'
normal=(project/'out/fds-system-cli.img').resolve()
def capture(vm,command):
# The kernel can write in the middle of a userspace line on ttyAMA0 during
# deliberate I/O faults. Execute once, then transfer the saved stdout with
# a checksum. A damaged transfer is retried without repeating the command
# or discarding any kernel diagnostics from the raw serial log.
vm.send('('+command+') >/tmp/m7-response; printf "%s" "$?" >/tmp/m7-status; printf "\\nM7_SAVED\\n"')
vm.expect(rb'^M7_SAVED\r?$')
for attempt in range(20):
vm.send('printf "\\nM7_BEGIN\\n"; base64 -w0 /tmp/m7-response; printf "\\n"; sha256sum /tmp/m7-response; cat /tmp/m7-status; printf "\\nM7_END\\n"')
frame=vm.expect(rb'^M7_BEGIN\r?\n(.*?)\r?\nM7_END\r?$').group(1).decode().replace('\r','')
try:
encoded,checksum,status=frame.split('\n')
payload=base64.b64decode(encoded,validate=True)
if hashlib.sha256(payload).hexdigest()!=checksum.split()[0]:continue
assert status=='0',(command,status,payload)
return payload.decode().strip()
except (ValueError,binascii.Error):
continue
raise AssertionError('Serial transfer repeatedly corrupted; inspect '+str(work/'data.log'))
def query(vm,command='fds --json bays'): return json.loads(capture(vm,command))
def shell(vm,command,marker):
vm.send(command+f' && printf "\\n{marker}\\n"')
vm.expect(('^'+marker+r'\r?$').encode())
def wait(vm,command,condition,timeout=40):
deadline=time.monotonic()+timeout
while True:
result=capture(vm,command)
if condition(result): return result
if time.monotonic()>deadline: raise AssertionError((command,result))
def bay(vm,n,state):
value=wait(vm,f'fds --json bay {n}',lambda s:json.loads(s)['bays'][0]['state']==state)
return json.loads(value)['bays'][0]
with VM(work,'probe',normal,extra=['-device',controller,'-device','usb-kbd,bus=xhci.0,port=3']) as vm:
vm.expect(rb'FDS> ')
report=query(vm,'fds --json topology')
hub=next(d['topology'] for d in report['unmapped'] if '03' in d['interfaces']).rsplit('/',1)[0]
shell(vm,'test "$(id -u)" = 1000 && touch "$HOME/no-data-check" && test ! -e /data/FDS','M7_EPHEMERAL_HOME')
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))
replacements={'usr/bin/fds-cartridged':(project/'out/fds-cartridged').read_bytes(),'usr/bin/fds':(project/'out/fds').read_bytes(),'etc/fds/bays.toml':config.encode(),'usr/libexec/fds/console-session':b'#!/bin/bash\nexec env HOME=/root bash --login\n'}
with tarfile.open(project/'out/rootfs-cli.tar') as source,tarfile.open(work/'fixture.tar','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()
for filename,data in {**replacements,'usr/libexec/fds/m7-writer':(project/'out/m7-writer').read_bytes()}.items():
info=tarfile.TarInfo(filename);info.size=len(data);info.mode=0o755 if filename.startswith('usr/') else 0o644
output.addfile(info,io.BytesIO(data))
(work/'system').mkdir()
with (work/'image.log').open('wb') as log:
subprocess.run([str(project/'image/build-system-cartridge'),'--rootfs',str(work/'fixture.tar'),'--output-directory',str(work/'system')],check=True,stdout=log,stderr=subprocess.STDOUT)
system=work/'system/system.img'
def data_image(name):
root=work/(name+'-files');(root/'FDS').mkdir(parents=True)
(root/'FDS/CARTRIDGE.TOML').write_text(f'format=1\n[cartridge]\nid="fds.data.{name}"\nname="DATA {name.upper()}"\nclass="data"\nversion="1"\n[media]\nwritable=true\n')
fs=work/(name+'.ext4')
with fs.open('xb') as f:f.truncate(256*1024*1024)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-E','root_owner=1000:1000,lazy_itable_init=0,lazy_journal_init=0','-d',str(root),str(fs)],check=True)
disk=work/(name+'.img');layout=gpt(disk,[('FDS_DATA',LINUX_FILESYSTEM,fs)])
return disk,layout
media={name:data_image(name) for name in ['primary','secondary','fault','restart']}
sequence=itertools.count()
with VM(work,'data',system,extra=['-device',controller]) as vm:
vm.expect(rb'FDS# ')
def add(name,port):
node=f'data{next(sequence)}'
vm.qmp('blockdev-add',{'driver':'raw','node-name':node,'file':{'driver':'file','filename':str(media[name][0])}})
vm.qmp('device_add',{'driver':'usb-storage','id':f'bay{port}','drive':node,'bus':'xhci.0','port':str(port)})
return node
def remove(port):vm.qmp('device_del',{'id':f'bay{port}'});bay(vm,port,'empty')
add('primary',2);assert bay(vm,2,'mounted_read_write')['mount']=='/data'
shell(vm,"s6-setuidgid fds /bin/bash -c 'printf persistent > /data/user-file; printf temporary > /home/fds/ephemeral-file'",'M7_USER_WRITES')
shell(vm,'cd /data; if fds eject 2; then false; else true; fi; cd /','M7_UNMANAGED_BUSY')
assert bay(vm,2,'mounted_read_write')['mount']=='/data'
started=query(vm,'s6-setuidgid fds fds --json run 2 -- /usr/libexec/fds/m7-writer')
assert started['started_pid']>1
wait(vm,'cat /data/progress 2>/dev/null || printf 0',lambda s:s.isdigit() and int(s)>=128)
status=capture(vm,'cat /data/process-status')
assert 'Uid:\t1000\t1000\t1000\t1000' in status and 'CapEff:\t0000000000000000' in status and 'NoNewPrivs:\t1' in status,status
assert query(vm,'fds --json bay 2')['bays'][0]['consumers']>=2
shell(vm,'mkdir /run/m7-extra; mount --bind /data /run/m7-extra; if fds eject 2; then false; else true; fi; umount /run/m7-extra','M7_EXTRA_MOUNT_BUSY')
assert query(vm,'fds --json bay 2')['bays'][0]['consumers']>=2
shell(vm,'s6-setuidgid fds fds eject 2','M7_SYNCED_SAFE')
assert bay(vm,2,'safe')['consumers']==0
shell(vm,'test ! -e /data/FDS && test "$(cat /home/fds/ephemeral-file)" = temporary','M7_HOME_INDEPENDENT')
shell(vm,'s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged','M7_SAFE_RESTART')
bay(vm,2,'safe');remove(2)
# Reinsertion reads persisted data, then eject cleanly for independent fsck.
add('primary',2);bay(vm,2,'mounted_read_write')
assert capture(vm,'cat /data/user-file')=='persistent'
shell(vm,'fds eject 2','M7_REINSERT_SAFE');remove(2)
print('PASS: sustained writes, UID/capability drop, descendant tracking, TERM escalation, sync/unmount, busy refusal and persistent data',flush=True)
# Stop the service while two DATA devices enumerate, then inspect one snapshot.
shell(vm,'s6-rc -l /run/s6-rc -d change cartridged','M7_DAEMON_STOPPED')
add('primary',2);add('secondary',4)
wait(vm,"lsblk -nr -o PARTLABEL | grep -c '^FDS_DATA$' || test \"$?\" = 1",lambda s:s=='2')
shell(vm,'s6-rc -l /run/s6-rc -u change cartridged','M7_DAEMON_STARTED')
bay(vm,2,'mounted_read_only');bay(vm,4,'mounted_read_only')
shell(vm,'test ! -e /data/FDS && fds data use 4','M7_EXPLICIT_DATA')
bay(vm,4,'mounted_read_write')
shell(vm,'if fds data use 2; then false; else true; fi','M7_NO_DATA_REPLACEMENT')
shell(vm,'fds eject 2 && fds eject 4','M7_BOTH_SAFE');remove(2);remove(4)
print('PASS: multiple DATA candidates require selection and cannot replace an active DATA session',flush=True)
# A crash loses the original syncfs error cursor. A root-owned activation
# record must prevent the same insertion becoming writable or SAFE again.
add('restart',2);bay(vm,2,'mounted_read_write')
shell(vm,"s6-setuidgid fds /bin/bash -c 'printf before-crash > /data/crash-file'",'M10_BEFORE_CRASH')
shell(vm,'s6-svc -O /run/service/cartridged && s6-svc -k /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','M10_CRASH_RESTART')
quarantined=bay(vm,2,'error')
assert 'interrupted' in quarantined['detail'] and quarantined['mount']=='/run/fds/media/02',quarantined
shell(vm,'if fds data use 2; then false; else true; fi; if fds eject 2; then false; else true; fi; test ! -e /run/fds/ejected/02','M10_QUARANTINED')
assert capture(vm,'cat /run/fds/media/02/crash-file')=='before-crash'
shell(vm,'s6-svc -d /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','M10_FAULT_RESTART')
assert 'interrupted' in bay(vm,2,'error')['detail']
remove(2)
print('PASS: interrupted DATA stays read-only and never SAFE across repeated daemon restarts',flush=True)
# A physical-style pull during dirty writes is a fault, never a successful eject.
# Preserve kernel I/O diagnostics separately from the serial JSON framing.
add('fault',2);bay(vm,2,'mounted_read_write')
query(vm,'fds --json run 2 -- /usr/libexec/fds/m7-writer')
wait(vm,'cat /data/progress 2>/dev/null || printf 0',lambda s:s.isdigit() and int(s)>=16)
remove(2)
shell(vm,'if fds eject 2; then false; else true; fi','M7_REMOVED_NOT_SAFE')
shell(vm,"grep -q 'populated 0' /sys/fs/cgroup/fds/bay02/cgroup.events && test ! -e /data/FDS",'M7_REMOVAL_CLEANUP')
diagnostics=capture(vm,'dmesg')
(work/'surprise-removal-kernel.log').write_text(diagnostics+'\n')
assert 'error -5' in diagnostics or 'I/O error' in diagnostics, 'Fault test did not observe a kernel storage error'
print('PASS: surprise removal stops managed descendants and does not report SAFE',flush=True)
# Verify unmounted ext4 using independent filesystem tools, without a host mount.
for name in ['primary','secondary']:
disk,layout=media[name];part=layout['partitions'][0];output=work/(name+'-after.ext4')
with disk.open('rb') as source,output.open('wb') as target:
source.seek(part['start']*512);remaining=part['payload_bytes']
while remaining:
data=source.read(min(1024*1024,remaining));assert data;target.write(data);remaining-=len(data)
with (work/(name+'-fsck.log')).open('wb') as log:
subprocess.run(['e2fsck','-fn',str(output)],check=True,stdout=log,stderr=subprocess.STDOUT)
if name=='primary':
extracted=work/'stress-after.bin'
subprocess.run(['debugfs','-R',f'dump /stress.bin {extracted}',str(output)],check=True,stdout=subprocess.DEVNULL)
expected=hashlib.sha256(bytes(range(256))*(64*1024*1024//256)).hexdigest()
assert extracted.stat().st_size==64*1024*1024 and digest(extracted)==expected
print('PASS: both safely ejected DATA filesystems pass e2fsck; 64 MiB sustained-write payload matches exactly')
link=project/'out/m7-vm-latest';temporary=link.with_suffix('.next');temporary.symlink_to(work.name);temporary.replace(link)
print(f'PASS: M7 writable DATA integration: {work}')
print('SKIP: physical storage power-loss behavior and Pi eject/shutdown latency')