update docs

This commit is contained in:
2026-09-22 13:23:34 +08:00
parent 99bc3d15c5
commit 8a4788fca8
126 changed files with 7198 additions and 2425 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Physical acceptance records
Physical tests are deferred until the Pi and its attached hardware are available.
Follow [the complete procedure](../../docs/stress-testing.md), including bay
Follow [the complete procedure](../../docs/developer/stress-testing.md), including bay
calibration, repeatable device populations, explicit SAFE handling and separate
external timing measurements.
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Exercise destructive cleanup only in small, disposable Git repositories."""
import fcntl
import contextlib
import importlib.machinery
import importlib.util
import io
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
PROJECT = Path(__file__).resolve().parents[2]
SCRIPT = PROJECT / 'tools/clean-builds'
loader = importlib.machinery.SourceFileLoader('clean_builds', str(SCRIPT))
spec = importlib.util.spec_from_loader(loader.name, loader)
clean = importlib.util.module_from_spec(spec)
loader.exec_module(clean)
class CleanupTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='fds-clean-test.')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name) / 'project'
(self.root / 'tools').mkdir(parents=True)
(self.root / 'out/manifests').mkdir(parents=True)
shutil.copyfile(SCRIPT, self.root / 'tools/clean-builds')
subprocess.run(['git', 'init', '-q', self.root], check=True)
def file(self, name, content='fixture'):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path
def run_cli(self, *args):
return subprocess.run([sys.executable, self.root / 'tools/clean-builds', *args],
cwd=self.root, text=True, capture_output=True, timeout=30)
def selected(self):
return {p.relative_to(self.root).as_posix() for p in clean.candidates(self.root)[0]}
def test_cleanup_and_repeat_preserve_published_and_user_files(self):
old = self.file('out/rootfs-build.OLD123/root/data')
self.file('target/debug/program')
keep = [
self.file('out/rootfs-build.NEW123/rootfs.tar'),
self.file('out/fds-os-0.1.0/release.img'),
self.file('out/inputs-m12-v5/lock.json'),
self.file('out/rebuild-m12-v5-a/out/image.img'),
self.file('out/my-emulator/data.qcow2'),
self.file('out/emulator/session.json'),
self.file('out/my-cartridge.img'),
self.file('out/cache/rootfs/package.xbps'),
self.file('out/logs/build.log'),
self.file('out/manifests/acceptance.json'),
self.file('out/.gitkeep'),
self.file('.host/xbps/tool'),
self.file('vendor/void-packages/hostdir/download'),
]
(self.root / 'out/rootfs-cli.tar').symlink_to('rootfs-build.NEW123/rootfs.tar')
preview = self.run_cli('--dry-run')
self.assertEqual(preview.returncode, 0, preview.stderr)
self.assertTrue(old.exists())
self.assertFalse((self.root / 'out/.clean.lock').exists())
result = self.run_cli()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(old.exists())
self.assertFalse((self.root / 'target').exists())
self.assertTrue(all(p.read_text() == 'fixture' for p in keep))
self.assertEqual((self.root / 'out/rootfs-cli.tar').read_text(), 'fixture')
repeat = self.run_cli()
self.assertEqual(repeat.returncode, 0, repeat.stderr)
self.assertIn('removed 0 disposable directories', repeat.stdout)
def test_text_pointers_latest_fixture_markers_and_transitive_links(self):
self.file('out/emu-test.old000/normal/data.qcow2')
self.file('out/emu-test.new000/normal/data.qcow2')
self.file('out/workstation-emulator-current.txt', str(self.root / 'out/emu-test.new000') + '\n')
self.file('out/dasung-s6.old000/compiled/a')
self.file('out/dasung-s6.new000/compiled/a')
self.file('out/manifests/dasung-s6-database.txt', str(self.root / 'out/dasung-s6.new000/compiled'))
self.file('out/workstation-images.new000/acceptance.json')
self.file('out/workstation-images-current.txt', 'out/workstation-images.new000\n')
self.file('out/m9-images.old000/root/program.img')
self.file('out/m9-images.new000/root/program.img')
os.utime(self.root / 'out/m9-images.old000', ns=(1, 1))
self.file('out/m8-vm.kept00/.fds-keep', '')
self.file('out/m8-vm.other0/disk.img')
(self.root / 'out/m8-vm.kept00/dependency').symlink_to('../m8-vm.other0')
self.assertEqual(self.selected(), {
'out/emu-test.old000', 'out/dasung-s6.old000', 'out/m9-images.old000'})
def test_symlinks_do_not_delete_external_content(self):
outside = Path(self.temporary.name) / 'outside'
outside.mkdir()
(outside / 'data').write_text('precious')
(self.root / 'target').symlink_to(outside)
(self.root / 'out/m8-vm.abcdef').symlink_to(outside)
work = self.file('out/m8-vm.ghijkl/data').parent
(work / 'external').symlink_to(outside)
result = self.run_cli()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(work.exists())
self.assertTrue((self.root / 'target').is_symlink())
self.assertEqual((outside / 'data').read_text(), 'precious')
shutil.rmtree(self.root / 'out')
(self.root / 'out').symlink_to(outside)
self.assertNotEqual(self.run_cli().returncode, 0)
self.assertEqual((outside / 'data').read_text(), 'precious')
def test_tracked_files_are_kept(self):
self.file('out/m2-vm.abcdef/notes')
subprocess.run(['git', '-C', self.root, 'add', 'out/m2-vm.abcdef/notes'], check=True)
self.assertEqual(self.selected(), set())
def test_directory_permissions_and_hardlinks(self):
kept = self.file('out/kept-file')
kept.chmod(0o444)
root = self.root / 'out/rootfs-build.abcdef/root'
root.mkdir(parents=True)
os.link(kept, root / 'file')
root.chmod(0o555)
result = self.run_cli()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(root.exists())
self.assertEqual(kept.stat().st_mode & 0o777, 0o444)
self.assertEqual(kept.read_text(), 'fixture')
def test_held_build_lock_rejects_without_deletion(self):
old = self.file('out/m2-vm.abcdef/image')
with (self.root / 'out/.rootfs.lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
result = self.run_cli()
self.assertNotEqual(result.returncode, 0)
self.assertIn('locked', result.stderr)
self.assertTrue(old.exists())
def test_live_process_rejects_without_deletion(self):
old = self.file('out/m2-vm.abcdef/image')
child = subprocess.Popen(
[sys.executable, '-c', 'import sys; print("ready", flush=True); sys.stdin.read()'],
cwd=old.parent, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
try:
self.assertEqual(child.stdout.readline().strip(), 'ready')
result = self.run_cli()
self.assertNotEqual(result.returncode, 0)
self.assertIn('is using', result.stderr)
self.assertTrue(old.exists())
finally:
child.communicate(timeout=10)
def test_mount_rejection_including_escaped_spaces(self):
mount = self.root / 'out/m2-vm.abcdef/a directory'
line = '1 0 0:1 / ' + str(mount).replace(' ', r'\040') + ' rw - tmpfs tmpfs rw\n'
with self.assertRaisesRegex(ValueError, 'mounted path'):
clean.check_mounts([mount.parent], line)
clean.check_mounts([self.root / 'target'], line)
def test_newly_published_output_aborts_before_any_deletion(self):
old = self.file('out/system-build.abcdef/system.img')
def publish(_paths):
(self.root / 'out/fds-system-cli.img').symlink_to('system-build.abcdef/system.img')
return 0
with patch.object(clean, 'footprint', side_effect=publish), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, 'selection changed'):
clean.clean(self.root, False)
self.assertTrue(old.exists())
if __name__ == '__main__':
unittest.main(verbosity=2)
+4
View File
@@ -250,5 +250,9 @@ with VM(work,'writeback-error',system,extra=['-device',controller]) as vm:
vm.qmp('device_del',{'id':'bay2'});bay(vm,2,'empty')
vm.send('s6-setuidgid fds fds poweroff');finished(vm)
print('PASS: attached-device writeback EIO is retained across restarts and refuses SAFE and shutdown',flush=True)
link=project/'out/m10-vm-latest'
temporary=link.with_suffix('.next')
temporary.symlink_to(work.name)
temporary.replace(link)
print(f'PASS: M10 ordered shutdown evidence: {work}')
print('SKIP: physical Pi poweroff/reboot, flash-controller durability, battery behavior and sub-second hardware timing targets')
+55 -5
View File
@@ -53,7 +53,7 @@ def desktop(vm, enabled):
return p['desktop']==('windowmaker' if enabled else 'cli') and (p['ready_ns'] is not None if enabled else True)
return json.loads(wait(vm,'fds --json profiles',condition))['profiles']
def image(name, replacements):
replacements={**replacements, **{f'usr/bin/{binary}':(project/'out'/binary).read_bytes() for binary in ['fds','fds-cartridged','fds-profile']}}
replacements={**replacements, **{f'usr/bin/{binary}':(project/'out'/binary).read_bytes() for binary in ['fds','fds-program','fds-control','fds-cartridged','fds-profile']}}
with tarfile.open(project/'out/rootfs-development.tar') as source, tarfile.open(work/(name+'.tar'),'w',format=tarfile.PAX_FORMAT) as output:
assert source.extractfile('usr/share/fds/image-profile').read().strip()==b'development', 'Build PROFILE=development before this test'
seen=set()
@@ -86,10 +86,13 @@ with VM(work,'probe',probe,extra=['-device',controller,'-device','usb-kbd,bus=xh
print(capture(vm,'cat /run/log/xserver/current /run/log/desktop/current /run/log/cartridged/current'),flush=True)
raise
shell(vm,'test ! -d /home/fds/.cache/fontconfig','M8_PREBUILT_FONT_CACHE')
shell(vm,'cmp /etc/WindowMaker/WindowMaker /usr/share/fds/eink/WindowMaker && cmp /etc/WindowMaker/WMRootMenu /usr/share/fds/eink/WMRootMenu && grep -q "FDS Control" /etc/WindowMaker/WMRootMenu','FDS_GLOBAL_RETRO_DEFAULTS')
(work/'activation.json').write_text(json.dumps(p,indent=2)+'\n')
assert p['ready_ns']>=p['activation_ns']>0
shell(vm,'export DISPLAY=:0 XAUTHORITY=/run/fds/x11/authority; s6-setuidgid fds xdpyinfo >/tmp/display-info; ! grep -q "COMPOSITE" /tmp/display-info','M8_X11_AUTHENTICATED')
shell(vm,'if env XAUTHORITY=/dev/null xdpyinfo >/dev/null 2>&1; then false; else true; fi','M8_X11_REJECTS_NO_COOKIE')
panel=wait(vm,'s6-setuidgid fds xdotool search --onlyvisible --name "^FDS Control$"',lambda s:s.isdigit())
shell(vm,'test "$(ps -o uid= -C fds-control | xargs)" = 1000','FDS_CONTROL_UNPRIVILEGED')
window=wait(vm,'s6-setuidgid fds xdotool search --onlyvisible --name "^FDS Terminal$"',lambda s:s.isdigit())
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {window} type --clearmodifiers "printf M8_INPUT_WORKED > /home/fds/desktop-input"; s6-setuidgid fds xdotool key --clearmodifiers Return','M8_X11_INPUT_SENT')
wait(vm,'cat /home/fds/desktop-input 2>/dev/null',lambda s:s=='M8_INPUT_WORKED')
@@ -145,12 +148,58 @@ with VM(work,'cartridges',fixture,extra=['-device',controller,'-netdev','user,id
add(env,2);bay(vm,2,'mounted_read_only');desktop(vm,True);remove(2);desktop(vm,False)
add(program,4);entry=bay(vm,4,'mounted_read_only');assert entry['mount']=='/run/fds/apps/fds.program.test'
shell(vm,'test ! -e /home/fds/program-identity && ! /run/fds/apps/fds.program.test/app/bin/check','M8_NO_AUTORUN')
started=query(vm,'s6-setuidgid fds fds --json run 4 -- check');assert started['started_pid']>1
shell(vm,'fds profile activate windowmaker','FDS_CONTROL_START_DESKTOP')
desktop(vm,True)
shell(vm,'export DISPLAY=:0 XAUTHORITY=/run/fds/x11/authority','FDS_CONTROL_DISPLAY')
panel=wait(vm,'s6-setuidgid fds xdotool search --onlyvisible --name "^FDS Control$"',lambda s:s.isdigit())
wait(vm,'s6-setuidgid fds xdotool search --onlyvisible --name "^FDS Terminal$"',lambda s:s.isdigit())
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel} windowraise {panel} mousemove --window {panel} 70 218 click 1','FDS_CONTROL_SELECT_BAY')
def panel_state():
output=capture(vm,f's6-setuidgid fds xprop -id {panel} _FDS_CONTROL_STATE')
return json.loads(json.loads(output.split(' = ',1)[1]))
deadline=time.monotonic()+30
while True:
snapshot=panel_state()
if snapshot['bay']==4 and snapshot['commands']>0 and not snapshot['busy']: break
assert time.monotonic()<deadline,snapshot
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel} windowraise {panel} mousemove --window {panel} 420 518 click 1','FDS_CONTROL_RUN_CLICK')
wait(vm,'cat /home/fds/program-identity 2>/dev/null',lambda s:'uid=1000(fds)' in s)
assert query(vm,'fds --json bay 4')['bays'][0]['consumers']>0
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel}','FDS_CONTROL_SHOW_PANEL')
shell(vm,'s6-setuidgid fds xwd -root -silent -out /home/fds/control.xwd','FDS_CONTROL_SCREENSHOT')
(work/'legacy-control.xwd').write_bytes(gzip.decompress(base64.b64decode(capture(vm,'gzip -c /home/fds/control.xwd | base64 -w0'))))
wait(vm,'cat /home/fds/program-identity 2>/dev/null',lambda s:'uid=1000(fds)' in s)
assert '/run/fds/apps/fds.program.test/app/lib' in capture(vm,'cat /home/fds/program-paths')
shell(vm,'if fds run 4 -- escape; then false; else true; fi','M8_PROGRAM_ESCAPE_REJECTED')
shell(vm,'if fds run 4 -- ../check; then false; else true; fi; fds eject 4','M8_PROGRAM_SAFE')
shell(vm,'if fds run 4 -- ../check; then false; else true; fi','M8_PROGRAM_PATH_REJECTED')
deadline=time.monotonic()+30
while panel_state()['busy']:
assert time.monotonic()<deadline
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel} windowraise {panel} mousemove --window {panel} 612 518 click 1','FDS_CONTROL_EJECT_CLICK')
assert bay(vm,4,'safe')['consumers']==0;remove(4)
# Launch a format-2, Void-built command through the real panel as well.
built=Path((project/'out/workstation-images-current.txt').read_text().strip())
assert json.loads((built/'acceptance.json').read_text())['status']=='passed'
add(built/'software.img',4);bay(vm,4,'mounted_read_only')
deadline=time.monotonic()+30
while True:
snapshot=panel_state()
if snapshot['commands']>=2 and not snapshot['busy']: break
assert time.monotonic()<deadline,snapshot
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel} windowraise {panel} mousemove --window {panel} 420 518 click 1','FDS_CONTROL_RUN_VOID')
hello_window=wait(vm,'s6-setuidgid fds xdotool search --onlyvisible --name "^demo.hello:hello$"',lambda s:s.isdigit())
shell(vm,f's6-setuidgid fds xdotool windowsize {hello_window} 650 360 windowmove {hello_window} 930 70 windowactivate --sync {panel}','FDS_CONTROL_VOID_RESULT')
shell(vm,'s6-setuidgid fds xwd -root -silent -out /home/fds/control.xwd','FDS_CONTROL_VOID_SCREENSHOT')
(work/'control.xwd').write_bytes(gzip.decompress(base64.b64decode(capture(vm,'gzip -c /home/fds/control.xwd | base64 -w0'))))
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel} windowraise {panel} key r','FDS_CONTROL_KEYBOARD_RESCAN')
deadline=time.monotonic()+30
while True:
snapshot=panel_state()
if not snapshot['busy'] and snapshot['status']=='Cartridge inventory refreshed.': break
assert time.monotonic()<deadline,snapshot
shell(vm,f's6-setuidgid fds xdotool windowactivate --sync {panel} windowraise {panel} mousemove --window {panel} 612 518 click 1','FDS_CONTROL_EJECT_VOID')
assert bay(vm,4,'safe')['consumers']==0;remove(4)
shell(vm,'fds profile deactivate && ! pgrep -x fds-control','FDS_CONTROL_SESSION_STOPPED')
shell(vm,'modprobe cdc_ether','M8_NETWORK_DRIVER')
vm.qmp('device_add',{'driver':'usb-net','id':'ethernet','netdev':'net','bus':'xhci.0','port':'3'})
wait(vm,'fds --json profiles',lambda s:len(json.loads(s)['profiles']['network'])==1)
@@ -173,7 +222,7 @@ with VM(work,'no-dhcp-server',fixture,extra=['-device',controller,'-netdev','hub
shell(vm,'fds network off','M8_NO_DHCP_STOPPED')
print('PASS: console and service controls work with Ethernet present and no DHCP server',flush=True)
# Convert this test's raw X11 screenshot to a portable PNG without external tools.
data=(work/'desktop.xwd').read_bytes();header=struct.unpack('>25I',data[:100]);size,version,fmt,depth,width,height,xoff,order,unit,bitorder,pad,bpp,stride,visual,rm,gm,bm,*_=header
data=(work/'control.xwd').read_bytes();header=struct.unpack('>25I',data[:100]);size,version,fmt,depth,width,height,xoff,order,unit,bitorder,pad,bpp,stride,visual,rm,gm,bm,*_=header
assert version==7 and fmt==2 and bpp==32 and (rm,gm,bm)==(0xff0000,0xff00,0xff)
offset=size+header[19]*12
raw=bytearray()
@@ -183,7 +232,8 @@ for y in range(height):
pixel=int.from_bytes(data[offset+y*stride+x*4:offset+y*stride+x*4+4], 'little' if order==0 else 'big')
raw.extend(((pixel>>16)&255,(pixel>>8)&255,pixel&255))
def chunk(kind,body):return struct.pack('>I',len(body))+kind+body+struct.pack('>I',zlib.crc32(kind+body)&0xffffffff)
(work/'desktop.png').write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',width,height,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b''))
(work/'control.png').write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',width,height,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b''))
(work/'acceptance.json').write_text(json.dumps(dict(status='passed', native_arm_x11=True, panel_uid=1000, global_grayscale_defaults=True, legacy_and_void_programs_launched_by_mouse=True, keyboard_rescan=True, panel_safe_eject=True, desktop_stop_removes_panel=True, physical_display='not tested'),indent=2)+'\n')
link=project/'out/m8-vm-latest';temporary=link.with_suffix('.next');temporary.symlink_to(work.name);temporary.replace(link)
print(f'PASS: M8 ARM desktop and network verification: {work}')
print('SKIP: Pi DRM/VC4 output, physical E-Ink quality and monitor input-to-refresh latency')
+127 -17
View File
@@ -3,6 +3,8 @@
import argparse
import io
import json
import hashlib
import lzma
import os
import pty
import selectors
@@ -63,6 +65,73 @@ def clean(number):
assert f'/run/fds/software/{number:02}/' not in text
assert '/run/fds/apps/demo.workstation' not in text
def interactive_program_checks():
master, slave = pty.openpty()
original = termios.tcgetattr(slave)
console = subprocess.Popen([cli, '--session', str(session), 'console'], stdin=slave, stdout=slave, stderr=slave)
selector = selectors.DefaultSelector(); selector.register(master, selectors.EVENT_READ)
pending = bytearray()
def until(marker):
output = pending; deadline = time.monotonic() + 30
while marker not in output:
assert time.monotonic() < deadline, output
for _, _ in selector.select(max(0, deadline-time.monotonic())):
chunk = os.read(master, 8192); assert chunk
output.extend(chunk)
end = output.index(marker) + len(marker)
result = bytes(output[:end]); del output[:end]
log.write(repr(result) + '\n'); log.flush()
return result
try:
until(b'FDS> ')
os.write(master, b"stty -g >/tmp/program-tty-before; b01:demo.report:shell -c 'printf \"READY:%s\\n\" tty; read -r line; printf \"REPLY:%s\\n\" \"$line\"'\n")
until(b'READY:tty\r\n')
os.write(master, b'literal interactive input\n')
until(b'REPLY:literal interactive input\r\n')
until(b'FDS> ')
os.write(master, b"b01:demo.report:shell -c 'printf \"SIGNAL:%s\\n\" ready; exec tail -f /dev/null'; printf 'RETURN:%s\\n' \"$?\"\n")
until(b'SIGNAL:ready\r\n')
os.write(master, b'\x03')
until(b'RETURN:130\r\n')
until(b'FDS> ')
os.write(master, b"b01:demo.report:shell -c 'printf \"STOP:%s\\n\" ready; exec tail -f /dev/null'\n")
until(b'STOP:ready\r\n')
os.write(master, b'\x1a')
until(b'Stopped')
until(b'FDS> ')
os.write(master, b'fg\n')
until(b"exec tail -f /dev/null'\r\n")
os.write(master, b'\x03')
until(b'FDS> ')
os.write(master, b"test \"$(stty -g)\" = \"$(cat /tmp/program-tty-before)\" && printf 'RESTORE:%s\\n' passed\n")
until(b'RESTORE:passed\r\n')
os.write(master, b'\x1d')
assert console.wait(timeout=10) == 0
assert termios.tcgetattr(slave) == original
finally:
if console.poll() is None: console.kill(); console.wait()
selector.close(); os.close(master); os.close(slave)
# Compatibility fixture only: the public creator never emits archive payloads.
legacy_metadata=work/'legacy-metadata'; (legacy_metadata/'FDS').mkdir(parents=True)
legacy_payload=work/'legacy-payload'; (legacy_payload/'bundles').mkdir(parents=True)
legacy_program=b'#!/bin/sh\nprintf "Legacy reader: %s\\n" "$1"\n'
legacy_tar=io.BytesIO()
with tarfile.open(fileobj=legacy_tar,mode='w',format=tarfile.USTAR_FORMAT) as archive:
directory=tarfile.TarInfo('bin');directory.type=tarfile.DIRTYPE;directory.mode=0o755;archive.addfile(directory)
program=tarfile.TarInfo('bin/legacy');program.mode=0o755;program.size=len(legacy_program);archive.addfile(program,io.BytesIO(legacy_program))
legacy_bytes=lzma.compress(legacy_tar.getvalue(),format=lzma.FORMAT_XZ)
(legacy_payload/'bundles/legacy.reader.tar.xz').write_bytes(legacy_bytes)
(legacy_metadata/'FDS/CARTRIDGE.TOML').write_text('format=1\n[cartridge]\nid="legacy.fixture"\nname="Legacy reader fixture"\nclass="program"\nversion="1"\n[media]\nwritable=false\n')
(legacy_metadata/'FDS/SOFTWARE.TOML').write_text(f'format=1\n[[software]]\nid="legacy.reader"\nname="Legacy reader"\nversion="1"\narchitecture="any"\npartition=2\narchive_bytes={len(legacy_bytes)}\nunpacked_bytes={len(legacy_program)}\nentries=2\nsha256="{hashlib.sha256(legacy_bytes).hexdigest()}"\n[software.commands]\nlegacy="bin/legacy"\n')
legacy_parts=[]
for name,tree in [('FDS_METADATA',legacy_metadata),('FDS_PAYLOAD02',legacy_payload)]:
filesystem=work/(name+'.erofs')
subprocess.run([str(project/'tools/in-image-tools'),'mkfs.erofs','--quiet','-T','0',str(filesystem),str(tree)],check=True,stdout=log,stderr=log)
legacy_parts.append((name,LINUX_FILESYSTEM,filesystem))
legacy=work/'legacy.img';gpt(legacy,legacy_parts)
subprocess.run([str(project/'out/workstation/fds-cartridge'),'--image-tool-runner',str(project/'tools/in-image-tools'),'inspect',str(legacy)],check=True,stdout=log,stderr=log)
# A workstation-created disposable DATA fixture, never a host block device.
data_root = work / 'data-root'
(data_root / 'FDS').mkdir(parents=True)
@@ -118,32 +187,59 @@ try:
if number in [1, 6, 12]:
guest('fds', 'run', number, '--', 'demo.hello:hello')
guest('fds', 'run', number, '--', 'demo.report:report')
guest('touch', f'/run/fds/software/{number:02}/cache-demo.hello/unexpected', ok=False)
guest('touch', f'/run/fds/software/{number:02}/payload02/programs/demo.hello/unexpected', ok=False)
guest('fds', 'run', number, '--', '../escape', ok=False)
guest('fds', 'run', number, '--', 'demo.hello:missing', ok=False)
assert guest('hello', 'literal $(id); and spaces').strip() == 'Hello from an FDS AArch64 software cartridge: literal $(id); and spaces'
assert guest(f'b{number:02}:demo.report:shell', '-c', 'id -u').strip() == '1000'
assert guest('sh', '-c', f'printf "pipe input" | b{number:02}:demo.report:shell -c "cat"') == 'pipe input'
assert guest('sh', '-c', f'b{number:02}:demo.report:shell -c "exit 37"; printf "%s" "$?"') == '37'
assert guest('sh', '-c', f'cd /tmp; b{number:02}:demo.report:shell -c "pwd"').strip() == '/tmp'
assert '/cache-' not in mounts()
assert state['commands']
if number == 1:
interactive_program_checks()
if number == 6:
guest('fds', 'run', number, '--', 'demo.report:report', 'hold')
assert query('bay', number)['bays'][0]['consumers'] > 0
guest('sh', '-c', 'report hold >/tmp/direct-hold.log 2>&1 & echo $! >/tmp/direct-launcher')
deadline = time.monotonic() + 20
while query('bay', number)['bays'][0]['consumers'] < 2:
assert time.monotonic() < deadline
assert query('bay', number)['bays'][0]['consumers'] >= 2
invoke('unplug', number)
else:
invoke('eject', number)
state = wait_bay(number, 'empty')
assert state['consumers'] == 0
clean(number)
if number == 6:
deadline = time.monotonic() + 20
while guest('sh', '-c', 'test ! -e /proc/$(cat /tmp/direct-launcher); printf "%s" "$?"').strip() != '0':
assert time.monotonic() < deadline
assert not guest('sh', '-c', 'command -v hello || true').strip()
# Two different cartridges export the same names. Lowest bay wins, and
# fully qualified commands remain available before and after an eject.
invoke('insert', 8, software); wait_bay(8, 'mounted_read_only')
invoke('insert', 2, builder / 'collision.img'); wait_bay(2, 'mounted_read_only')
assert '/02/' in guest('where', 'FDS_APP')
assert '/08/' in guest('b08:demo.report:where', 'FDS_APP')
invoke('eject', 2); wait_bay(2, 'empty')
assert '/08/' in guest('where', 'FDS_APP')
invoke('eject', 8); wait_bay(8, 'empty')
remaining = json.loads(invoke('status'))
assert remaining['state']['cartridges'] == {}
assert len(remaining['block_nodes']) == 2, remaining['block_nodes']
output = guest('cat', '/run/log/cartridged/current')
assert 'FDS cartridge AArch64 hello' in output
assert 'FDS cartridge script report' in output
assert 'Hello from an FDS AArch64 software cartridge' in output
assert 'FDS cartridge system report' in output
assert 'uid=1000(fds)' in output
(work / 'program-output.log').write_text(output)
invoke('insert', 3, builder / 'archive-corrupt.img')
wait_bay(3, 'mounted_read_only')
failure = guest('fds', 'run', 3, '--', 'demo.hello:hello', ok=False)
assert 'digest' in failure.lower() or 'sha' in failure.lower(), failure
assert '/run/fds/software/03/cache-' not in mounts()
invoke('eject', 3)
invoke('insert', 3, builder / 'program-corrupt.img')
failed = wait_bay(3, 'error')
assert 'digest' in failed['detail'].lower() or 'hash' in failed['detail'].lower(), failed
assert '/run/fds/software/03/' not in mounts()
invoke('unplug', 3)
wait_bay(3, 'empty')
invoke('insert', 4, builder / 'catalogue-mapping.img')
wait_bay(4, 'error')
@@ -162,7 +258,13 @@ try:
assert digest(data) == original_data
assert overlay.is_file()
assert len(json.loads(invoke('status'))['block_nodes']) == 2
# Keep a verified software cache mounted across native shutdown.
invoke('insert', 7, legacy); wait_bay(7, 'mounted_read_only')
assert guest('legacy', 'compatibility').strip() == 'Legacy reader: compatibility'
assert '/run/fds/software/07/cache-legacy.reader' in mounts()
guest('fds', 'run', 7, '--', 'legacy.reader:legacy', 'background')
invoke('eject', 7); wait_bay(7, 'empty'); clean(7)
assert not guest('sh', '-c', 'command -v legacy || true').strip()
# Keep a directly mounted software tree active across native shutdown.
invoke('insert', 12, software)
wait_bay(12, 'mounted_read_only')
guest('fds', 'run', 12, '--', 'demo.report:report', 'hold')
@@ -193,15 +295,22 @@ try:
assert guest('id', '-u').strip() == '0'
invoke('insert', 1, software)
wait_bay(1, 'mounted_read_only')
guest('fds', 'run', 1, '--', 'demo.report:report', 'hold')
assert '/run/fds/software/01/cache-demo.report' in mounts()
guest('sh', '-c', 'cd /home/fds; /run/fds/bin/report hold >/tmp/restart-direct.log 2>&1 &')
deadline = time.monotonic() + 20
while query('bay', 1)['bays'][0]['consumers'] == 0:
assert time.monotonic() < deadline
assert '/run/fds/software/01/payload03' in mounts()
assert '/cache-' not in mounts()
guest('s6-rc', '-l', '/run/s6-rc', '-d', 'change', 'cartridged')
guest('s6-rc', '-l', '/run/s6-rc', '-u', 'change', 'cartridged')
wait_bay(1, 'mounted_read_only')
assert query('bay', 1)['bays'][0]['consumers'] == 0
assert '/run/fds/software/01/cache-' not in mounts()
guest('fds', 'run', 1, '--', 'demo.hello:hello')
# Extra payload aliases must prevent SAFE; cache is genuinely read-only.
assert 'Hello from' in guest('sh', '-c', 'cd /home/fds; /run/fds/bin/hello')
guest('mkdir', '-m', '700', '/tmp/private-cwd')
assert 'Permission denied' in guest('sh', '-c', 'cd /tmp/private-cwd; /run/fds/bin/hello', ok=False)
# Extra payload aliases must prevent SAFE; program trees are read-only.
guest('mkdir', '/run/extra-payload')
guest('mount', '--bind', '/run/fds/software/01/payload02', '/run/extra-payload')
invoke('eject', 1, ok=False)
@@ -216,10 +325,11 @@ finally:
assert digest(software) == original_software and digest(data) == original_data
record = dict(status='passed', ordinary_guest_uid=1000, public_emulator_cli=True,
all_twelve_usb_bays=True, interactive_console_detach_and_terminal_restore=True, both_payload_partitions_executed=True,
shared_partition_executed=True, readonly_cache=True,
shared_partition_executed=True, readonly_program_trees_without_extraction=True, legacy_archive_host_and_guest_reader=True,
direct_path_arguments_pipes_exit_status_cwd=True, interactive_program_io_signals_and_restore=True, command_collision_fallback=True,
safe_eject=True, forced_removal_stops_consumer=True,
corrupt_archive_and_catalogue_rejected=True, data_overlay_preserves_source=True,
restart_cleans_cache_and_consumers=True, extra_mount_blocks_safe=True,
corrupt_program_and_catalogue_rejected=True, data_overlay_preserves_source=True,
restart_cleans_mounts_commands_and_consumers=True, extra_mount_blocks_safe=True,
native_shutdown_with_active_software=True, physical_pi='not tested')
(work / 'acceptance.json').write_text(json.dumps(record, indent=2) + '\n')
(project / 'out/workstation-emulator-current.txt').write_text(str(work) + '\n')
+45 -48
View File
@@ -14,12 +14,19 @@ from image_formats import gpt, LINUX_FILESYSTEM
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cli', type=Path, required=True)
parser.add_argument('--image-tool-runner', type=Path)
parser.add_argument('--cc', nargs='+', default=['aarch64-linux-gnu-gcc'])
parser.add_argument('--void-packages', type=Path, default=project / 'vendor/void-packages')
parser.add_argument('--xbps-tool-runner', type=Path)
parser.add_argument('--xbps-bin', type=Path)
args = parser.parse_args()
work = Path(tempfile.mkdtemp(prefix='workstation-images.', dir=project / 'out'))
cli = [str(args.cli.resolve())]
if args.image_tool_runner:
cli += ['--image-tool-runner', str(args.image_tool_runner)]
cli += ['--void-packages', str(args.void_packages.resolve())]
if args.xbps_tool_runner:
cli += ['--xbps-tool-runner', str(args.xbps_tool_runner.resolve())]
if args.xbps_bin:
cli += ['--xbps-bin', str(args.xbps_bin.resolve())]
log = (work / 'commands.log').open('w')
@@ -36,39 +43,32 @@ def digest(path):
return hashlib.file_digest(stream, 'sha256').hexdigest()
(work / 'hello-root/bin').mkdir(parents=True)
(work / 'report-root/bin').mkdir(parents=True)
(work / 'hello.c').write_text('#include <stdio.h>\nint main(void) { puts("FDS cartridge AArch64 hello"); return 0; }\n')
script = work / 'report-root/bin/report'
script.write_text('#!/bin/sh\nprintf "FDS cartridge script report\\n"\nid\n[ "${1-}" != hold ] || exec tail -f /dev/null\n')
script.chmod(0o755)
recipe = f'''format=1
id="demo.hello"
name="AArch64 hello"
version="1.0"
architecture="aarch64"
root="hello-root"
[commands]
hello="bin/hello"
[build]
directory={json.dumps(str(project))}
command={json.dumps([*args.cc, '-O2', str(work / 'hello.c'), '-o', str(work / 'hello-root/bin/hello')])}
'''
(work / 'hello.toml').write_text(recipe)
(work / 'report.toml').write_text('format=1\nid="demo.report"\nname="Script report"\nversion="1.0"\narchitecture="any"\nroot="report-root"\n[commands]\nreport="bin/report"\n')
invoke(['software', 'build', work / 'hello.toml', work / 'hello-bundle'])
invoke(['software', 'pack', work / 'report.toml', work / 'report-bundle'])
# Architecture-independent bundles cannot hide actual ELF executables.
(work / 'wrong-architecture.toml').write_text(recipe.replace('architecture="aarch64"', 'architecture="any"'))
invoke(['software', 'pack', work / 'wrong-architecture.toml', work / 'rejected-architecture'], False)
assert not (work / 'rejected-architecture').exists()
# Source links outside the software root must never become bundle contents.
(work / 'report-root/bin/escape').symlink_to('/etc/passwd')
invoke(['software', 'pack', work / 'report.toml', work / 'rejected-symlink'], False)
assert not (work / 'rejected-symlink').exists()
(work / 'report-root/bin/escape').unlink()
invoke(['software', 'pack', work / 'report.toml', work / 'report-bundle'], False)
(work / 'cartridge.toml').write_text('format=1\nid="demo.workstation"\nname="Workstation software"\nversion="1.0"\n[[payload]]\nbundles=["hello-bundle"]\n[[payload]]\nbundles=["report-bundle"]\n')
for name in ['hello', 'report']:
recipe = project / f'examples/software/{name}/software.toml'
if name == 'report':
text = recipe.read_text().replace('[commands]', '[commands]\nshell="usr/bin/bash"\nwhere="usr/bin/printenv"')
text = text.replace('template = "void"', 'template = ' + json.dumps(str(recipe.parent / 'void')))
recipe = work / 'report-source.toml'; recipe.write_text(text)
invoke(['software', 'build', recipe, work / f'{name}-installed'])
inspected = json.loads(invoke(['software', 'inspect', work / f'{name}-installed']))
assert inspected['installed'] and inspected['packages']
assert f'fds-demo-{name}-1.0_1' in inspected['packages']
assert any(p.startswith('glibc-') for p in inspected['packages'])
assert (work / f'{name}-installed/root/usr/bin/{name}').is_file()
assert not list((work / f'{name}-installed').glob('*.tar.xz'))
# The actual installed trees, including command links, are integrity checked.
program = work / 'hello-installed/root/usr/bin/hello'
original_program = program.read_bytes()
bad_elf = bytearray(original_program); bad_elf[18:20] = bytes([62, 0])
program.write_bytes(bad_elf)
invoke(['software', 'inspect', work / 'hello-installed'], False)
program.write_bytes(original_program)
escape = work / 'report-installed/root/usr/bin/escape'
escape.symlink_to('/etc/passwd')
invoke(['software', 'inspect', work / 'report-installed'], False)
escape.unlink()
invoke(['software', 'build', project / 'examples/software/hello/software.toml', work / 'hello-installed'], False)
(work / 'cartridge.toml').write_text('format=2\nid="demo.workstation"\nname="Workstation software"\nversion="1.0"\n[[payload]]\nsources=["hello-installed"]\n[[payload]]\nsources=["report-installed"]\n')
image = work / 'software.img'
original = json.loads(invoke(['create', work / 'cartridge.toml', image]))
assert len(original['image']['partitions']) == 3
@@ -78,18 +78,15 @@ assert [p['name'] for p in observed['partitions']] == ['FDS_METADATA', 'FDS_PAYL
invoke(['create', work / 'cartridge.toml', work / 'repeat.img'])
assert digest(image) == digest(work / 'repeat.img')
invoke(['create', work / 'cartridge.toml', image], False)
shared = (work / 'cartridge.toml').read_text().replace('bundles=["hello-bundle"]\n[[payload]]\nbundles=["report-bundle"]', 'bundles=["hello-bundle","report-bundle"]')
(work / 'collision.toml').write_text((work / 'cartridge.toml').read_text().replace('demo.workstation', 'demo.second'))
invoke(['create', work / 'collision.toml', work / 'collision.img'])
shared = (work / 'cartridge.toml').read_text().replace('sources=["hello-installed"]\n[[payload]]\nsources=["report-installed"]', 'sources=["hello-installed","report-installed"]')
(work / 'shared.toml').write_text(shared)
grouped = json.loads(invoke(['create', work / 'shared.toml', work / 'shared.img']))
assert len(grouped['image']['partitions']) == 2
assert [s['partition'] for s in grouped['catalogue']['software']] == [2, 2]
for name in ['software.img', 'shared.img']:
subprocess.run(['sfdisk', '--verify', str(work / name)], check=True, stdout=log, stderr=log)
for directory in ['hello-bundle', 'report-bundle']:
archive = next((work / directory).glob('*.tar.xz'))
subprocess.run(['xz', '--test', str(archive)], check=True)
subprocess.run(['tar', '-tJf', str(archive)], check=True, stdout=log)
size = image.stat().st_size
for label, extra in [('exact', 0), ('larger', 8 * 1024 * 1024)]:
target = work / (label + '.target')
@@ -135,14 +132,14 @@ for part in parts:
target.write(source.read(part['bytes']))
filesystems.append(filesystem)
tools = [str(args.image_tool_runner.resolve())] if args.image_tool_runner else []
for case in ['archive-corrupt', 'catalogue-mapping']:
number = 2 if case == 'archive-corrupt' else 1
for case in ['program-corrupt', 'catalogue-mapping']:
number = 2 if case == 'program-corrupt' else 1
tree = work / (case + '-tree')
subprocess.run([*tools, 'fsck.erofs', '--extract=' + str(tree), str(filesystems[number-1])], check=True, stdout=log, stderr=log)
if number == 2:
archive = next((tree / 'bundles').glob('*.tar.xz'))
content = bytearray(archive.read_bytes()); content[len(content)//2] ^= 1
archive.write_bytes(content)
program = tree / 'programs/demo.hello/usr/bin/hello'
content = bytearray(program.read_bytes()); content[len(content)//2] ^= 1
program.write_bytes(content)
else:
metadata = tree / 'FDS/SOFTWARE.TOML'
metadata.write_text(metadata.read_text().replace('partition = 2', 'partition = 4'))
@@ -153,11 +150,11 @@ for case in ['archive-corrupt', 'catalogue-mapping']:
gpt(malformed, [(part['name'], LINUX_FILESYSTEM, filesystem) for part, filesystem in zip(parts, selected)])
invoke(['inspect', malformed], False)
record = dict(status='passed', work=str(work), cli_sha256=digest(args.cli.resolve()), compiled_aarch64_software=True,
script_bundle=True, elf_in_any_bundle_and_escaping_source_symlink_rejected=True, shared_and_separate_payload_partitions=True,
repeat_image_identical=True, independent_gpt_xz_tar_checks=True,
void_source_packages=True, installed_runtime_dependencies=True, wrong_elf_and_escaping_symlink_rejected=True, shared_and_separate_payload_partitions=True,
repeat_image_identical=True, direct_installed_erofs_programs=True,
exact_and_larger_target_readback=True, wrong_confirmation_unchanged=True,
changed_source_unchanged_target=True, stale_target_preview_rejected=True,
corrupted_gpt_rejected=True, corrupt_archive_and_catalogue_rejected=True, physical_usb_write='not performed')
corrupted_gpt_rejected=True, corrupt_program_and_catalogue_rejected=True, physical_usb_write='not performed')
(work / 'acceptance.json').write_text(json.dumps(record, indent=2) + '\n')
(project / 'out/workstation-images-current.txt').write_text(str(work) + '\n')
print('PASS: workstation software/image/write acceptance:', work)