FDS/OS 1.0
This commit is contained in:
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exercise the imported daemon without opening a physical USB or display device.
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
need python3
|
||||
use_xbps
|
||||
package=out/packages/fds-dasungd-0.1.0_1.aarch64.xbps
|
||||
[[ -f $package ]] || die 'Run make dasung before make dasung-test'
|
||||
for script in tools/* tests/integration/*; do
|
||||
[[ -f $script && $(head -n 1 "$script") == '#!/usr/bin/env bash' ]] || continue
|
||||
bash -n "$script"
|
||||
done
|
||||
tools/in-void env LIBUSB_NO_PKG_CONFIG=1 LIBUDEV_NO_PKG_CONFIG=1 \
|
||||
cargo test --locked --offline --target x86_64-unknown-linux-gnu -p dasungd --features vendored
|
||||
tools/in-void env LIBUSB_NO_PKG_CONFIG=1 LIBUDEV_NO_PKG_CONFIG=1 \
|
||||
"$FDS_ROOT/tools/cargo-build" --locked --offline --release --target x86_64-unknown-linux-gnu -p dasungd --features vendored
|
||||
binary="$FDS_ROOT/target/x86_64-unknown-linux-gnu/release/dasungd"
|
||||
"$binary" --config packages/fds-dasungd/files/dasungd.toml check
|
||||
DASUNGD_BIN="$binary" python3 rust/dasungd/tests/pty_smoke.py
|
||||
scratch=$(mktemp -d "$FDS_ROOT/out/dasung-check.XXXXXX")
|
||||
trap 'rm -rf -- "$scratch"' EXIT
|
||||
tar -xf "$package" -C "$scratch"
|
||||
tools/verify-elf "$scratch/usr/bin/dasungd" aarch64 static
|
||||
cmp out/dasungd "$scratch/usr/bin/dasungd"
|
||||
cmp packages/fds-dasungd/files/dasungd.toml "$scratch/etc/dasungd.toml"
|
||||
cmp rust/dasungd/profiles/paperlike13k-37hz.edid "$scratch/usr/lib/firmware/edid/dasung-paperlike13k-37hz.bin"
|
||||
while IFS= read -r -d '' source; do
|
||||
cmp "$source" "s6/source/${source#"$scratch/etc/s6-rc/source/"}"
|
||||
done < <(find "$scratch/etc/s6-rc/source" -type f -print0)
|
||||
[[ $(XBPS_ARCH=aarch64 xbps-query -i --repository "$FDS_ROOT/out/packages" -p architecture fds-dasungd) == aarch64 ]]
|
||||
[[ -f $scratch/etc/s6-rc/source/boot/contents.d/dasungd ]] || die 'Dasung missing from base boot bundle'
|
||||
grep -qx fds-dasungd image/base-packages.list || die 'Dasung missing from base image package selection'
|
||||
if tar -tf "$package" | grep -Eq '/(systemd|runit)/'; then
|
||||
die 'Unexpected target init implementation in Dasung package'
|
||||
fi
|
||||
if XBPS_ARCH=aarch64 xbps-query -i --repository "$FDS_ROOT/out/packages" -x fds-dasungd | grep -Eq '^(musl|libusb|eudev-libudev|systemd|runit)(-|>|=|$)'; then
|
||||
die 'Unexpected runtime linkage or init dependency'
|
||||
fi
|
||||
tools/in-void s6-rc-compile "$scratch/compiled" "$scratch/etc/s6-rc/source"
|
||||
tests/integration/dasung-s6-check "$scratch" "$scratch/compiled"
|
||||
sha256sum -c out/manifests/dasung-artifacts.sha256
|
||||
printf 'PASS: packaged static daemon, exact profile, base selection and native s6 source\n'
|
||||
printf 'SKIP: Pi boot, physical Dasung picture, and cold-power recovery require hardware validation\n'
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the packaged s6 graph with a read-only root and no physical device access.
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
[[ $# == 2 ]] || die 'Usage: dasung-s6-check EXTRACTED_PACKAGE COMPILED_DATABASE'
|
||||
payload=$1
|
||||
compiled=$2
|
||||
scratch=$(mktemp -d "$FDS_ROOT/out/dasung-s6-run.XXXXXX")
|
||||
trap 'rm -rf -- "$scratch"' EXIT
|
||||
# Use copies, not hard links: adding the native test binary must not alter masterdir.
|
||||
cp -a --reflink=auto "$FDS_VOID/masterdir-x86_64/usr/bin" "$scratch/bin"
|
||||
cp "$FDS_ROOT/target/x86_64-unknown-linux-gnu/release/dasungd" "$scratch/bin/dasungd"
|
||||
mkdir -p "$scratch/root"/{usr,etc,run,tmp,dev,proc,sys,var}
|
||||
ln -s usr/bin "$scratch/root/bin"
|
||||
ln -s usr/bin "$scratch/root/sbin"
|
||||
ln -s usr/lib "$scratch/root/lib"
|
||||
ln -s usr/lib "$scratch/root/lib64"
|
||||
cat >"$scratch/run-test" <<'TEST'
|
||||
set -euo pipefail
|
||||
export PATH=/usr/bin:/bin
|
||||
mkdir -p /run/service
|
||||
mkfifo /run/scan-ready
|
||||
s6-svscan -d 3 /run/service 3>/run/scan-ready &
|
||||
scan=$!
|
||||
cleanup() {
|
||||
s6-svscanctl -t /run/service 2>/dev/null || true
|
||||
wait "$scan" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
read -r < /run/scan-ready
|
||||
s6-rc-init -t 3000 -d -c /tmp/compiled -l /run/s6-rc /run/service
|
||||
s6-rc -l /run/s6-rc -t 3000 -u change boot
|
||||
# Poll only the test assertion; target startup has no artificial waiting period.
|
||||
for attempt in {1..50}; do
|
||||
if dasungd status >/run/status.json 2>/dev/null; then break; fi
|
||||
sleep 0.02
|
||||
done
|
||||
grep -q '"connected": false' /run/status.json
|
||||
[[ $(stat -c %a /run/dasungd) == 750 ]]
|
||||
[[ $(stat -c %a /run/dasungd/control.sock) == 660 ]]
|
||||
s6-svstat /run/service/dasungd
|
||||
s6-svstat /run/service/dasungd-log
|
||||
# A real supervisor restart must restore the socket without requiring hardware.
|
||||
s6-rc -l /run/s6-rc -t 3000 -d change boot
|
||||
s6-rc -l /run/s6-rc -t 3000 -u change boot
|
||||
for attempt in {1..50}; do
|
||||
if dasungd status >/run/status.json 2>/dev/null; then break; fi
|
||||
sleep 0.02
|
||||
done
|
||||
grep -q '"connected": false' /run/status.json
|
||||
s6-rc -l /run/s6-rc -t 3000 -d change boot
|
||||
printf 'PASS: s6 boot, logs, socket permissions, restart and stop with no monitor and read-only root\n'
|
||||
TEST
|
||||
# No host /sys or USB nodes are exposed. Only /run and the private test copies
|
||||
# are writable. The PID namespace and outer timeout contain failed supervision.
|
||||
timeout 20 bwrap --unshare-user --uid 0 --gid 0 --unshare-pid --die-with-parent \
|
||||
--ro-bind "$scratch/root" / --ro-bind "$FDS_VOID/masterdir-x86_64/usr" /usr \
|
||||
--ro-bind "$scratch/bin" /usr/bin --tmpfs /etc \
|
||||
--ro-bind "$FDS_VOID/masterdir-x86_64/etc/passwd" /etc/passwd \
|
||||
--ro-bind "$FDS_VOID/masterdir-x86_64/etc/group" /etc/group \
|
||||
--ro-bind "$payload/etc/dasungd.toml" /etc/dasungd.toml \
|
||||
--dev /dev --proc /proc --tmpfs /run --tmpfs /tmp \
|
||||
--ro-bind "$compiled" /tmp/compiled --ro-bind "$scratch/run-test" /tmp/run-test \
|
||||
--chdir / /usr/bin/bash /tmp/run-test
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fail-closed checks for the build guardrails, without root or ARM hardware.
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
expect_failure() {
|
||||
local label=$1
|
||||
shift
|
||||
if "$@" >"$scratch/rejection.log" 2>&1; then
|
||||
die "Validation incorrectly accepted: $label"
|
||||
fi
|
||||
printf 'PASS: rejects %s\n' "$label"
|
||||
}
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf -- "$scratch"' EXIT
|
||||
for script in tools/* tests/integration/m0-checks; do
|
||||
[[ -f $script ]] || continue
|
||||
[[ $(head -n 1 "$script") == '#!/usr/bin/env bash' ]] || continue
|
||||
bash -n "$script"
|
||||
done
|
||||
tools/verify-elf target/aarch64-unknown-linux-musl/release/fds-smoketest aarch64 static
|
||||
expect_failure 'static musl executable as a glibc package' tools/verify-elf \
|
||||
target/aarch64-unknown-linux-musl/release/fds-smoketest aarch64 glibc
|
||||
expect_failure 'wrong target architecture' tools/verify-elf /bin/bash aarch64 static
|
||||
expect_failure 'dynamic host executable as static' tools/verify-elf /bin/bash x86_64 static
|
||||
expect_failure 'non-ELF input' tools/verify-elf README.md aarch64 static
|
||||
expect_failure 'missing artifact' tools/verify-elf "$scratch/missing" aarch64 static
|
||||
expect_failure 'unknown linkage mode' tools/verify-elf /bin/bash x86_64 unknown
|
||||
expect_failure 'invalid package path' tools/build-package ../hello
|
||||
expect_failure 'unsupported bootstrap option' tools/bootstrap-host --unknown
|
||||
|
||||
# Use a disposable minimal checkout to exercise pin and dirty-tree detection.
|
||||
mkdir -p "$scratch/repo/tools" "$scratch/repo/vendor/void-packages"
|
||||
cp tools/lib.sh tools/prepare-void "$scratch/repo/tools/"
|
||||
fake="$scratch/repo/vendor/void-packages"
|
||||
git -C "$fake" init -q
|
||||
printf '# fixture\n' >"$fake/xbps-src"
|
||||
mkdir -p "$fake/srcpkgs/fds-upstream" "$fake/etc" "$scratch/repo/config" "$scratch/repo/packages/fds-fixture"
|
||||
printf '# upstream\n' >"$fake/srcpkgs/fds-upstream/template"
|
||||
printf 'etc/conf\n' >"$fake/.gitignore"
|
||||
cp config/xbps-src.conf "$scratch/repo/config/"
|
||||
printf '# inert overlay fixture\n' >"$scratch/repo/packages/fds-fixture/template"
|
||||
git -C "$fake" add .
|
||||
git -C "$fake" -c user.name=FDS -c user.email=test@example.invalid commit -qm fixture
|
||||
git -C "$fake" rev-parse HEAD >"$scratch/repo/VOID_PACKAGES_COMMIT"
|
||||
bash -c 'source "$1"; check_void_pin' _ "$scratch/repo/tools/lib.sh"
|
||||
printf 'PASS: accepts exact clean Void commit\n'
|
||||
"$scratch/repo/tools/prepare-void"
|
||||
cmp "$scratch/repo/packages/fds-fixture/template" "$fake/srcpkgs/fds-fixture/template"
|
||||
"$scratch/repo/tools/prepare-void"
|
||||
printf 'PASS: overlay copied and repeat preparation is idempotent\n'
|
||||
printf '# changed\n' >>"$scratch/repo/packages/fds-fixture/template"
|
||||
expect_failure 'stale generated overlay' "$scratch/repo/tools/prepare-void"
|
||||
cp "$fake/srcpkgs/fds-fixture/template" "$scratch/repo/packages/fds-fixture/template"
|
||||
mkdir -p "$scratch/repo/packages/fds-upstream"
|
||||
printf '# collision\n' >"$scratch/repo/packages/fds-upstream/template"
|
||||
expect_failure 'overlay replacing upstream package' "$scratch/repo/tools/prepare-void"
|
||||
printf '# changed config\n' >>"$fake/etc/conf"
|
||||
expect_failure 'overwriting local xbps configuration' "$scratch/repo/tools/prepare-void"
|
||||
printf '\n# dirty\n' >>"$fake/xbps-src"
|
||||
expect_failure 'modified Void source' bash -c 'source "$1"; check_void_pin' _ "$scratch/repo/tools/lib.sh"
|
||||
printf '%040d\n' 0 >"$scratch/repo/VOID_PACKAGES_COMMIT"
|
||||
expect_failure 'wrong Void commit' bash -c 'source "$1"; check_void_pin' _ "$scratch/repo/tools/lib.sh"
|
||||
printf 'PASS: M0 guardrail checks complete\n'
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate the exported tar, then corrupt disposable copies to test rejection.
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
[[ -f out/rootfs-aarch64.tar ]] || die 'Run make rootfs PROFILE=cli first'
|
||||
scratch=$(mktemp -d "$FDS_ROOT/out/m1-checks.XXXXXX")
|
||||
trap 'rm -rf -- "$scratch"' EXIT
|
||||
build=$(dirname -- "$(realpath out/rootfs-aarch64.tar)")
|
||||
(
|
||||
cd "$build"
|
||||
sha256sum -c rootfs.sha256 packages.sha256
|
||||
)
|
||||
python3 - "$build" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
import tarfile
|
||||
build = pathlib.Path(sys.argv[1])
|
||||
metadata = json.loads((build / 'archive-metadata.json').read_text())
|
||||
with tarfile.open(build / 'rootfs-aarch64.tar') as archive:
|
||||
members = {member.name: member for member in archive}
|
||||
assert members['.'].isdir() and (members['.'].uid, members['.'].gid, members['.'].mode) == (0, 0, 0o755), 'Incorrect root directory metadata'
|
||||
assert members.keys() == metadata.keys(), 'Archive member mismatch'
|
||||
for name, member in members.items():
|
||||
assert not name.startswith('/') and '..' not in pathlib.PurePosixPath(name).parts
|
||||
assert not (member.ischr() or member.isblk()), name
|
||||
if member.isfifo():
|
||||
assert name.startswith('etc/s6-linux-init/current/run-image/'), name
|
||||
expected = metadata[name]
|
||||
assert (member.uid, member.gid, member.mode) == (expected['uid'], expected['gid'], int(expected['mode'], 8)), name
|
||||
for name in ('usr/bin/wall', 'usr/bin/write'):
|
||||
if name in members:
|
||||
assert (members[name].uid, members[name].gid, members[name].mode) == (0, 5, 0o2755), name
|
||||
assert (members['usr/lib/utempter/utempter'].uid, members['usr/lib/utempter/utempter'].gid, members['usr/lib/utempter/utempter'].mode) == (0, 14, 0o2711), 'Incorrect utempter group restoration'
|
||||
assert members['etc/shadow'].mode == 0o600
|
||||
assert members['tmp'].mode == 0o1777
|
||||
assert members['root'].mode == 0o700
|
||||
assert (members['usr/bin/xbps-uchroot'].uid, members['usr/bin/xbps-uchroot'].gid, members['usr/bin/xbps-uchroot'].mode) == (0, 101, 0o4750)
|
||||
assert members['bin'].linkname == 'usr/bin'
|
||||
capability = members['usr/bin/iputils-ping'].pax_headers['SCHILY.xattr.security.capability'].encode('utf-8', 'surrogateescape')
|
||||
assert len(capability) == 20 and struct.unpack_from('<I', capability)[0] >> 24 == 2
|
||||
assert struct.unpack_from('<I', capability, 4)[0] & (1 << 13), 'Missing CAP_NET_RAW'
|
||||
print('PASS: archive paths, ownership, modes and portable ping capability')
|
||||
PY
|
||||
mkdir "$scratch/capability-restore"
|
||||
# Exercise GNU tar's actual xattr restoration inside a private user namespace.
|
||||
# The resulting v3 xattr is namespaced to this host user, not host root.
|
||||
bwrap --unshare-user --uid 0 --gid 0 --cap-add CAP_SETFCAP \
|
||||
--ro-bind / / --bind "$scratch/capability-restore" "$scratch/capability-restore" \
|
||||
--chdir "$scratch/capability-restore" \
|
||||
tar --extract --file "$FDS_ROOT/out/rootfs-aarch64.tar" --numeric-owner \
|
||||
--same-permissions --xattrs --xattrs-include=security.capability usr/bin/iputils-ping
|
||||
python3 - "$scratch/capability-restore/usr/bin/iputils-ping" <<'PY'
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
cap = os.getxattr(sys.argv[1], 'security.capability')
|
||||
assert len(cap) == 24 and struct.unpack_from('<I', cap)[0] >> 24 == 3
|
||||
assert struct.unpack_from('<I', cap, 20)[0] == os.getuid()
|
||||
assert struct.unpack_from('<I', cap, 4)[0] & (1 << 13)
|
||||
print('PASS: GNU tar restores ping capability in a private user namespace')
|
||||
PY
|
||||
mkdir "$scratch/root"
|
||||
# Extract as the current user, preserving target modes for the audit. Capabilities
|
||||
# are verified above; this test does not grant capabilities to host-owned files.
|
||||
tar --extract --file out/rootfs-aarch64.tar --directory "$scratch/root" \
|
||||
--no-same-owner --same-permissions
|
||||
python3 tools/rootfs-audit "$scratch/root"
|
||||
tools/rootfs-runtime-check "$scratch/root"
|
||||
python3 - "$scratch/root" <<'PY'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import plistlib
|
||||
import shutil
|
||||
import sys
|
||||
sys.path.insert(0, 'tools')
|
||||
loader = importlib.machinery.SourceFileLoader('audit_module', 'tools/rootfs-audit')
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
database = root / 'var/db/xbps/pkgdb-0.38.plist'
|
||||
original = database.read_bytes()
|
||||
db = plistlib.loads(original)
|
||||
|
||||
def reject(label, needle):
|
||||
try:
|
||||
module.audit(root)
|
||||
except AssertionError as error:
|
||||
assert needle in str(error), (label, error)
|
||||
print('PASS: rejects ' + label)
|
||||
else:
|
||||
raise AssertionError('Incorrectly accepted: ' + label)
|
||||
|
||||
db['busybox'] = {'architecture': 'aarch64', 'state': 'installed'}
|
||||
database.write_bytes(plistlib.dumps(db))
|
||||
reject('forbidden package', 'Forbidden package')
|
||||
database.write_bytes(original)
|
||||
db = plistlib.loads(original)
|
||||
db['glibc']['state'] = 'unpacked'
|
||||
database.write_bytes(plistlib.dumps(db))
|
||||
reject('unfinished package configuration', 'Unconfigured package')
|
||||
database.write_bytes(original)
|
||||
db = plistlib.loads(original)
|
||||
db['glibc']['architecture'] = 'aarch64-musl'
|
||||
database.write_bytes(plistlib.dumps(db))
|
||||
reject('wrong package ABI', 'Wrong package ABI')
|
||||
database.write_bytes(original)
|
||||
binary = root / 'usr/bin/ls'
|
||||
backup = binary.read_bytes()
|
||||
binary.write_bytes(pathlib.Path('/usr/bin/ls').read_bytes())
|
||||
reject('host ELF contamination', 'Non-aarch64 ELF')
|
||||
binary.write_bytes(backup)
|
||||
marker = root / 'etc/s6-rc/source/boot/contents.d/dasungd'
|
||||
marker.unlink()
|
||||
reject('missing base Dasung service', 'Dasung missing')
|
||||
marker.touch()
|
||||
init = root / 'etc/s6-linux-init/current/bin/init'
|
||||
original_init = init.read_bytes()
|
||||
init.write_text('#!/bin/bash\nexec /usr/bin/s6-svscan /run/service\n')
|
||||
reject('shell PID 1 launcher', 'Init is not the native s6 launcher')
|
||||
init.write_bytes(original_init)
|
||||
boot_member = root / 'etc/s6-rc/source/boot/contents.d/getty'
|
||||
boot_member.unlink()
|
||||
reject('missing console boot member', 'Missing boot service: getty')
|
||||
boot_member.touch()
|
||||
PY
|
||||
if PROFILE=windowmaker tools/build-rootfs >"$scratch/rejection.log" 2>&1; then
|
||||
die 'Unimplemented profile was accepted'
|
||||
fi
|
||||
grep -q 'Supported profiles: cli, development' "$scratch/rejection.log"
|
||||
printf 'PASS: rejects unimplemented image profile\n'
|
||||
printf 'NOTE: run make init-test separately for native PID 1 and full ARM boot checks\n'
|
||||
printf 'SKIP: physical Pi/monitor tests and boot timings require hardware\n'
|
||||
printf 'PASS: exported-rootfs acceptance and failure-path checks\n'
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ordered native poweroff/reboot, busy refusal and persistent DATA quarantine."""
|
||||
import base64
|
||||
import binascii
|
||||
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 gpt, LINUX_FILESYSTEM, digest
|
||||
work=Path(tempfile.mkdtemp(prefix='m10-vm.',dir=project/'out'))
|
||||
controller='qemu-xhci,id=xhci,addr=05.0'
|
||||
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())
|
||||
|
||||
def capture(vm,command,ok=True):
|
||||
# Preserve diagnostics and return exactly the command's output despite
|
||||
# unrelated kernel messages on the same serial device. Never retry a command.
|
||||
vm.send('('+command+') >/tmp/m10-response 2>&1; printf "%s" "$?" >/tmp/m10-status; printf "\\nM10_SAVED\\n"')
|
||||
vm.expect(rb'^M10_SAVED\r?$',timeout=180)
|
||||
for attempt in range(20):
|
||||
vm.send('printf "\\nM10_BEGIN\\n"; base64 -w0 /tmp/m10-response; printf "\\n"; sha256sum /tmp/m10-response; cat /tmp/m10-status; printf "\\nM10_END\\n"')
|
||||
frame=vm.expect(rb'^M10_BEGIN\r?\n(.*?)\r?\nM10_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')==ok,(command,status,payload)
|
||||
return payload.decode().strip()
|
||||
except (ValueError,binascii.Error):continue
|
||||
raise AssertionError('Repeated serial transfer corruption')
|
||||
|
||||
def query(vm,args):
|
||||
return json.loads(capture(vm,'s6-setuidgid fds fds --json '+shlex.join(args)))
|
||||
def rejected(vm,args):
|
||||
return capture(vm,'s6-setuidgid fds fds '+shlex.join(args),ok=False)
|
||||
def wait(vm,command,condition,timeout=40):
|
||||
deadline=time.monotonic()+timeout
|
||||
while True:
|
||||
value=capture(vm,command)
|
||||
if condition(value):return value
|
||||
assert time.monotonic()<deadline,(command,value)
|
||||
def bay(vm,n,state):
|
||||
return json.loads(wait(vm,f'fds --json bay {n}',lambda s:json.loads(s)['bays'][0]['state']==state))['bays'][0]
|
||||
def boot(vm):
|
||||
vm.expect(rb'FDS# ')
|
||||
capture(vm,'fds-boottrace mark console-ready')
|
||||
|
||||
def fixture(name,config=None):
|
||||
replacements={'usr/libexec/fds/console-session':b'#!/bin/bash\n# Isolated test image only.\nexec env HOME=/root bash --login\n','etc/fds/xserver':b'xvfb\n'}
|
||||
if config:replacements['etc/fds/bays.toml']=config.encode()
|
||||
with tarfile.open(project/'out/rootfs-development.tar') as src,tarfile.open(work/f'{name}.tar','w',format=tarfile.PAX_FORMAT) as dst:
|
||||
assert src.extractfile('usr/share/fds/image-profile').read().strip()==b'development','Build PROFILE=development first'
|
||||
assert src.getmember('usr/bin/fds-power'),'Build the M10 rootfs first'
|
||||
seen=set()
|
||||
for member in src:
|
||||
if member.name in replacements:seen.add(member.name);continue
|
||||
dst.addfile(member,src.extractfile(member) if member.isfile() else None)
|
||||
assert seen==replacements.keys()
|
||||
extra={'usr/libexec/fds/m10-writer':(project/'out/m7-writer').read_bytes(),'usr/share/fds/m10-program.img':program.read_bytes()}
|
||||
for path,data in {**replacements,**extra}.items():
|
||||
member=tarfile.TarInfo(path);member.size=len(data);member.mode=0o755 if path.startswith('usr/libexec/') else 0o644
|
||||
dst.addfile(member,io.BytesIO(data))
|
||||
output=work/name;output.mkdir()
|
||||
with (work/f'{name}-image.log').open('wb') as log:
|
||||
subprocess.run([str(project/'image/build-system-cartridge'),'--rootfs',str(work/f'{name}.tar'),'--output-directory',str(output)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
return output/'system.img'
|
||||
|
||||
phases=['frozen','stopping_desktop','stopping_programs','stopping_network','syncing_and_unmounting_data','unmounting_cartridges','prepared','services_stopped']
|
||||
def finished(vm,reboot=False):
|
||||
report=json.loads(vm.expect(rb'^FDS_SHUTDOWN_FINAL (\{[^\r\n]*\})\r?\n',timeout=90).group(1))
|
||||
events=report['events'];start=max(i for i,e in enumerate(events) if e['phase']=='frozen')
|
||||
final=events[start:]
|
||||
assert [e['phase'] for e in final]==phases,report
|
||||
assert [e['at_ns'] for e in final]==sorted(e['at_ns'] for e in final)
|
||||
ending=vm.expect(rb'\[\s*([0-9]+\.[0-9]+)\] reboot: (Power down|Restarting system)',timeout=30)
|
||||
assert (ending.group(2)==b'Restarting system')==reboot
|
||||
assert vm.child.wait(timeout=20)==0,'QEMU did not exit after the guest power action'
|
||||
report['virtual_total_ms']=float(ending.group(1))*1000-final[0]['at_ns']/1e6
|
||||
assert report['virtual_total_ms']>=0
|
||||
(work/f'{vm.name}-shutdown.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(f'PASS: {vm.name}: ordered native {"reboot" if reboot else "poweroff"}; VM observation {report["virtual_total_ms"]:.3f} ms',flush=True)
|
||||
|
||||
probe=fixture('probe-system')
|
||||
with VM(work,'idle',probe,extra=['-device',controller,'-device','usb-kbd,bus=xhci.0,port=1']) as vm:
|
||||
boot(vm)
|
||||
topology=query(vm,['topology'])
|
||||
hub=next(d['topology'] for d in topology['unmapped'] if '03' in d['interfaces']).rsplit('/',1)[0]
|
||||
assert query(vm,['power','status'])['phase']=='idle'
|
||||
assert 'requires root' in capture(vm,'s6-setuidgid fds fds-power --shutdown-hook',ok=False)
|
||||
assert query(vm,['power','status'])['phase']=='idle'
|
||||
vm.send('s6-setuidgid fds fds poweroff')
|
||||
finished(vm)
|
||||
|
||||
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))
|
||||
system=fixture('mapped-system',config)
|
||||
|
||||
def data_image(name):
|
||||
root=work/f'{name}-files';(root/'FDS').mkdir(parents=True)
|
||||
(root/'FDS/CARTRIDGE.TOML').write_text(f'format=1\n[cartridge]\nid="fds.m10.{name}"\nname="M10 DATA"\nclass="data"\nversion="1"\n[media]\nwritable=true\n')
|
||||
if name=='ioerror':
|
||||
(root/'fault.bin').write_bytes(b'A'*(1024*1024))
|
||||
(root/'fault.bin').chmod(0o666)
|
||||
fs=work/f'{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/f'{name}.img';layout=gpt(disk,[('FDS_DATA',LINUX_FILESYSTEM,fs)])
|
||||
return disk,layout
|
||||
|
||||
sequence=itertools.count()
|
||||
def attach(vm,disk,port=2):
|
||||
node=f'disk{next(sequence)}'
|
||||
vm.qmp('blockdev-add',{'driver':'raw','node-name':node,'file':{'driver':'file','filename':str(disk)}})
|
||||
vm.qmp('device_add',{'driver':'usb-storage','id':f'bay{port}','drive':node,'bus':'xhci.0','port':str(port)})
|
||||
def restart(vm,crash=False):
|
||||
stop='s6-svc -O /run/service/cartridged && s6-svc -k' if crash else 's6-svc -d'
|
||||
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')
|
||||
def workload(vm):
|
||||
query(vm,['profile','activate','windowmaker'])
|
||||
capture(vm,'s6-svwait -U -t 25000 /run/service/desktop-session')
|
||||
assert query(vm,['profiles'])['profiles']['ready_ns']
|
||||
query(vm,['network','on'])
|
||||
assert query(vm,['profiles'])['profiles']['network']
|
||||
capture(vm,'rm -f /data/progress')
|
||||
assert query(vm,['run','2','--','/usr/libexec/fds/m10-writer'])['started_pid']>1
|
||||
wait(vm,'cat /data/progress 2>/dev/null || printf 0',lambda s:s.isdigit() and int(s)>=128)
|
||||
|
||||
data,layout=data_image('workload')
|
||||
with VM(work,'workload-reboot',system,extra=['-device',controller,'-netdev','user,id=net']) as vm:
|
||||
boot(vm);attach(vm,data);bay(vm,2,'mounted_read_write')
|
||||
capture(vm,'modprobe cdc_ether')
|
||||
vm.qmp('device_add',{'driver':'usb-net','id':'ethernet','netdev':'net','bus':'xhci.0','port':'3'})
|
||||
wait(vm,'fds --json profiles',lambda s:bool(json.loads(s)['profiles']['network']))
|
||||
workload(vm)
|
||||
error=capture(vm,'cd /data; s6-setuidgid fds fds poweroff',ok=False)
|
||||
assert 'busy' in error.lower(),error
|
||||
status=query(vm,['power','status']);assert status['phase']=='blocked' and not status['native_pending']
|
||||
capture(vm,"! pgrep -x Xvfb && ! pgrep -x wmaker && ! pgrep -x dhcpcd && grep -q 'populated 0' /sys/fs/cgroup/fds/bay02/cgroup.events && grep -q 'populated 0' /sys/fs/cgroup/fds/network/cgroup.events && pgrep -x dasungd")
|
||||
for args in [['run','2','--','/usr/bin/true'],['data','use','2'],['profile','activate','windowmaker'],['network','on'],['rescan']]:
|
||||
assert 'frozen' in rejected(vm,args),args
|
||||
assert query(vm,['power','resume'])['phase']=='idle'
|
||||
error=capture(vm,"s6-setuidgid fds /bin/bash -c 'exec 9>>/data/open-writer; fds poweroff'",ok=False)
|
||||
assert 'busy' in error.lower(),error
|
||||
assert query(vm,['power','resume'])['phase']=='idle'
|
||||
capture(vm,"s6-setuidgid fds /bin/bash -c 'printf persisted > /data/reboot-marker'")
|
||||
workload(vm)
|
||||
vm.send('s6-setuidgid fds fds reboot')
|
||||
finished(vm,reboot=True)
|
||||
print('PASS: busy working directory/open writer refuse shutdown; frozen operations, explicit resume and actual consumer exit verified',flush=True)
|
||||
|
||||
# Inspect the final unmounted filesystem independently; the guest's SAFE marker
|
||||
# is not accepted as a substitute for filesystem and payload verification.
|
||||
payload=work/'workload-after.ext4';part=layout['partitions'][0]
|
||||
with data.open('rb') as src,payload.open('wb') as dst:
|
||||
src.seek(part['start']*512);remaining=part['payload_bytes']
|
||||
while remaining:
|
||||
chunk=src.read(min(1024*1024,remaining));assert chunk;dst.write(chunk);remaining-=len(chunk)
|
||||
with (work/'workload-fsck.log').open('wb') as log:subprocess.run(['e2fsck','-fn',str(payload)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
written=work/'stress-after.bin'
|
||||
subprocess.run(['debugfs','-R',f'dump /stress.bin {written}',str(payload)],check=True,stdout=subprocess.DEVNULL)
|
||||
assert written.stat().st_size==64*1024*1024
|
||||
assert digest(written)==hashlib.sha256(bytes(range(256))*(64*1024*1024//256)).hexdigest()
|
||||
assert subprocess.check_output(['debugfs','-R','cat /reboot-marker',str(payload)],stderr=subprocess.DEVNULL)==b'persisted'
|
||||
print('PASS: reboot leaves clean ext4 and the exact 64 MiB payload plus ordinary-user files',flush=True)
|
||||
|
||||
with VM(work,'native-guard',system,extra=['-device',controller]) as vm:
|
||||
boot(vm);attach(vm,data);bay(vm,2,'mounted_read_write')
|
||||
assert capture(vm,'cat /data/reboot-marker')=='persisted'
|
||||
vm.send('cd /data; /usr/bin/poweroff; printf "\\nNATIVE_REQUESTED\\n"')
|
||||
vm.expect(rb'^NATIVE_REQUESTED\r?$')
|
||||
status=json.loads(wait(vm,'fds --json power status',lambda s:json.loads(s)['phase']=='blocked'))
|
||||
assert status['native_pending'] and 'busy' in status['error'].lower(),status
|
||||
assert 'cannot be cancelled' in rejected(vm,['power','resume'])
|
||||
vm.send('cd /; s6-setuidgid fds fds poweroff')
|
||||
finished(vm)
|
||||
print('PASS: direct native poweroff remains blocked until DATA can be safely unmounted',flush=True)
|
||||
|
||||
blank=work/'blank.img'
|
||||
with blank.open('wb') as f:f.truncate(96*1024*1024)
|
||||
unchanged=digest(blank)
|
||||
with VM(work,'burn-guard',system,extra=['-device',controller]) as vm:
|
||||
boot(vm);attach(vm,blank,4)
|
||||
bay(vm,4,'unrecognized_storage')
|
||||
wait(vm,'fds --json inspect BAY04 2>/tmp/m10-enumeration-error; true',lambda s:s.startswith('{') and json.loads(s)['bytes']==blank.stat().st_size)
|
||||
job=query(vm,['burn','program','/usr/share/fds/m10-program.img','BAY04'])
|
||||
assert job['phase']=='awaiting_confirmation'
|
||||
assert 'active media operation' in rejected(vm,['poweroff'])
|
||||
assert 'frozen' in rejected(vm,['burn','confirm',job['id'],job['confirmation']])
|
||||
rejected(vm,['burn','cancel',job['id']])
|
||||
wait(vm,'pgrep -x fds-burn || test "$?" = 1',lambda s:not s)
|
||||
assert query(vm,['bay','4'])['bays'][0]['state']!='safe'
|
||||
vm.send('s6-setuidgid fds fds poweroff');finished(vm)
|
||||
assert digest(blank)==unchanged
|
||||
print('PASS: active media preparation blocks shutdown; cancellation leaves the target unchanged and never SAFE',flush=True)
|
||||
|
||||
quarantine,_=data_image('quarantine')
|
||||
with VM(work,'quarantine',system,extra=['-device',controller]) as vm:
|
||||
boot(vm);attach(vm,quarantine);bay(vm,2,'mounted_read_write')
|
||||
capture(vm,"s6-setuidgid fds /bin/bash -c 'printf retained > /data/test'")
|
||||
restart(vm,crash=True)
|
||||
assert 'interrupted' in bay(vm,2,'error')['detail']
|
||||
assert 'Shutdown blocked' in rejected(vm,['poweroff'])
|
||||
restart(vm)
|
||||
assert query(vm,['power','status'])['phase']=='blocked'
|
||||
assert 'quarantined' in rejected(vm,['poweroff'])
|
||||
assert query(vm,['bay','2'])['bays'][0]['state']!='safe'
|
||||
vm.qmp('device_del',{'id':'bay2'});bay(vm,2,'empty')
|
||||
vm.send('s6-setuidgid fds fds poweroff');finished(vm)
|
||||
print('PASS: DATA quarantine and shutdown freeze survive daemon restart without a false SAFE result',flush=True)
|
||||
|
||||
# Inject real EIO into a known allocated file data block, without pulling
|
||||
# the device or damaging GPT/manifest metadata. Mount-time journal writes do not
|
||||
# overlap this block, so the failure occurs in an already active DATA session.
|
||||
fault_disk,fault_layout=data_image('ioerror')
|
||||
blocks=subprocess.check_output(['debugfs','-R','blocks /fault.bin',str(work/'ioerror.ext4')],stderr=subprocess.DEVNULL).split()
|
||||
fault_sector=fault_layout['partitions'][0]['start']+int(blocks[0])*8
|
||||
with VM(work,'writeback-error',system,extra=['-device',controller]) as vm:
|
||||
boot(vm)
|
||||
vm.qmp('blockdev-add',{'driver':'blkdebug','node-name':'faultdisk',
|
||||
'image':{'driver':'raw','file':{'driver':'file','filename':str(fault_disk)}},
|
||||
'inject-error':[{'event':'none','iotype':'write','errno':5,'sector':fault_sector,'once':False}]})
|
||||
vm.qmp('device_add',{'driver':'usb-storage','id':'bay2','drive':'faultdisk','bus':'xhci.0','port':'2'})
|
||||
bay(vm,2,'mounted_read_write')
|
||||
capture(vm,"s6-setuidgid fds /bin/bash -c 'printf changed | dd of=/data/fault.bin conv=notrunc status=none'")
|
||||
error=rejected(vm,['eject','2'])
|
||||
assert 'flush DATA filesystem' in error and 'os error 5' in error,error
|
||||
before=bay(vm,2,'error');assert before['mount']=='/data'
|
||||
kernel=capture(vm,'dmesg')
|
||||
(work/'writeback-kernel.log').write_text(kernel+'\n')
|
||||
assert 'I/O error' in kernel or 'error -5' in kernel,kernel
|
||||
restart(vm)
|
||||
after=bay(vm,2,'error')
|
||||
assert after['mount']=='/run/fds/media/02' and after['detail']==before['detail'],(before,after)
|
||||
assert 'Shutdown blocked' in rejected(vm,['poweroff'])
|
||||
restart(vm)
|
||||
assert 'os error 5' in rejected(vm,['poweroff'])
|
||||
capture(vm,'test ! -e /run/fds/ejected/02')
|
||||
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)
|
||||
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')
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real write, flush, readback, cancellation and worker-crash failure boundaries."""
|
||||
import io
|
||||
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 gpt, digest, LINUX_FILESYSTEM
|
||||
work=Path(tempfile.mkdtemp(prefix='m11-faults.',dir=project/'out'))
|
||||
inputs=[Path(__file__),project/'tools/vm_test.py',project/'out/rootfs-cli.tar',
|
||||
project/'out/kernel/boot/kernel_2712.img',project/'out/fds-initramfs.img']
|
||||
(work/'inputs.sha256').write_text(''.join(f'{digest(path)} {path.relative_to(project)}\n' for path in inputs))
|
||||
(work/'qemu-version.txt').write_bytes(subprocess.check_output([str(project/'tools/in-void'),'qemu-system-aarch64','--version']))
|
||||
controller='qemu-xhci,id=xhci,addr=05.0'
|
||||
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())
|
||||
# A valid padded PROGRAM filesystem makes the transfer long enough to observe
|
||||
# cancellation after real writes. Its contents are still independently checked.
|
||||
layout=json.loads(subprocess.check_output(['sfdisk','--json',str(program)]))['partitiontable']['partitions'][0]
|
||||
large_fs=work/'large.erofs'
|
||||
with program.open('rb') as src,large_fs.open('xb') as dst:
|
||||
src.seek(layout['start']*512);dst.write(src.read(layout['size']*512));dst.truncate(256*1024*1024)
|
||||
large=work/'large.img';gpt(large,[('FDS_PROGRAM',LINUX_FILESYSTEM,large_fs)])
|
||||
subprocess.run([str(project/'tools/in-image-tools'),'fsck.erofs',str(large_fs)],check=True)
|
||||
|
||||
def query(vm,args):return json.loads(vm.capture('s6-setuidgid fds fds --json '+shlex.join(args)))
|
||||
def wait(vm,read,predicate,timeout=90):
|
||||
deadline=time.monotonic()+timeout
|
||||
while True:
|
||||
value=read()
|
||||
if predicate(value):return value
|
||||
assert time.monotonic()<deadline,value
|
||||
|
||||
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'}
|
||||
if hub:
|
||||
replacements['etc/fds/bays.toml']=f'[usb]\nhub="{hub}"\n[usb.ports]\n2=2\n'.encode()
|
||||
additions={'usr/share/fds/m11-small.img':program.read_bytes(),'usr/share/fds/m11-large.img':large.read_bytes()}
|
||||
with tarfile.open(project/'out/rootfs-cli.tar') as src,tarfile.open(work/f'{name}.tar','w',format=tarfile.PAX_FORMAT) as dst:
|
||||
seen=set()
|
||||
for member in src:
|
||||
if member.name in replacements:seen.add(member.name);continue
|
||||
dst.addfile(member,src.extractfile(member) if member.isfile() else None)
|
||||
assert seen==replacements.keys()
|
||||
for path,data in {**replacements,**additions}.items():
|
||||
member=tarfile.TarInfo(path);member.size=len(data);member.mode=0o755 if path.endswith('console-session') else 0o644
|
||||
dst.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(work/f'{name}.tar'),'--output-directory',str(destination)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
return destination/'system.img'
|
||||
|
||||
probe=fixture('probe')
|
||||
with VM(work,'probe',probe,extra=['-device',controller,'-device','usb-kbd,bus=xhci.0,port=2']) as vm:
|
||||
vm.expect(rb'FDS# ')
|
||||
report=wait(vm,lambda:query(vm,['topology']),lambda r:bool(r['unmapped']))
|
||||
hub=next(d['topology'] for d in report['unmapped'] if '03' in d['interfaces']).rsplit('/',1)[0]
|
||||
# USB storage negotiates SuperSpeed and has a distinct root-protocol identity.
|
||||
system=fixture('mapped',hub.replace(':usb2',':usb3'))
|
||||
|
||||
def attach(vm,disk,rule=None,throttle=False):
|
||||
node={'driver':'raw','node-name':'raw','file':{'driver':'file','filename':str(disk)}}
|
||||
vm.qmp('blockdev-add',node)
|
||||
top='raw'
|
||||
if rule:
|
||||
vm.qmp('blockdev-add',{'driver':'blkdebug','node-name':'fault','image':top,'inject-error':[{'event':'none','errno':5,'once':False,**rule}]})
|
||||
top='fault'
|
||||
if throttle:
|
||||
vm.qmp('object-add',{'qom-type':'throttle-group','id':'limit','limits':{'bps-write':8*1024*1024}})
|
||||
vm.qmp('blockdev-add',{'driver':'throttle','node-name':'limited','throttle-group':'limit','file':top})
|
||||
top='limited'
|
||||
vm.qmp('device_add',{'driver':'usb-storage','id':'target','drive':top,'bus':'xhci.0','port':'2','serial':'M11-FAULT'})
|
||||
wait(vm,lambda:vm.capture('fds --json inspect BAY02 2>/tmp/m11-enumeration.err; true'),lambda s:s.startswith('{') and json.loads(s)['bytes']==disk.stat().st_size)
|
||||
|
||||
def job_record(vm):return json.loads(vm.capture('cat /run/fds/burn/02.json'))['job']
|
||||
def failed(vm,job):
|
||||
observed=wait(vm,lambda:job_record(vm),lambda j:j['phase']=='failed')
|
||||
assert observed['id']==job['id'] and observed['error'],observed
|
||||
assert query(vm,['bay','2'])['bays'][0]['state']!='safe'
|
||||
wait(vm,lambda:vm.capture('pgrep -x fds-burn || test "$?" = 1'),lambda s:not s)
|
||||
vm.capture('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')
|
||||
assert job_record(vm)['phase']=='failed'
|
||||
assert query(vm,['bay','2'])['bays'][0]['state']=='failed'
|
||||
vm.capture('test ! -e /run/fds/ejected/02')
|
||||
(work/f'{vm.name}-result.json').write_text(json.dumps(observed,indent=2)+'\n')
|
||||
(work/f'{vm.name}-kernel.log').write_text(vm.capture('dmesg')+'\n')
|
||||
return observed
|
||||
|
||||
# Errors affect sectors beyond the GPT checks, so preview remains non-destructive.
|
||||
for name,rule in [('write-error',{'iotype':'write','sector':2056}),
|
||||
('flush-error',{'iotype':'flush'}),
|
||||
('readback-error',{'iotype':'read','sector':2056})]:
|
||||
disk=work/f'{name}.img'
|
||||
with disk.open('xb') as f:f.truncate(256*1024*1024)
|
||||
before=digest(disk)
|
||||
with VM(work,name,system,extra=['-device',controller]) as vm:
|
||||
vm.expect(rb'FDS# ');attach(vm,disk,rule)
|
||||
job=query(vm,['burn','program','/usr/share/fds/m11-small.img','BAY02'])
|
||||
assert job['phase']=='awaiting_confirmation' and digest(disk)==before
|
||||
error=vm.capture('s6-setuidgid fds fds burn confirm '+shlex.join([job['id'],job['confirmation']]),ok=False)
|
||||
result=failed(vm,job)
|
||||
assert 'os error 5' in result['error'],(name,error,result)
|
||||
assert digest(disk)!=before,(name,'No write was observed')
|
||||
print(f'PASS: {name}: actual EIO after confirmation; failed across restart, never SAFE',flush=True)
|
||||
|
||||
for name in ['cancel-writing','kill-worker','kill-daemon']:
|
||||
disk=work/f'{name}.img'
|
||||
with disk.open('xb') as f:f.truncate(384*1024*1024)
|
||||
before=digest(disk)
|
||||
with VM(work,name,system,extra=['-device',controller]) as vm:
|
||||
vm.expect(rb'FDS# ');attach(vm,disk,throttle=True)
|
||||
job=query(vm,['burn','program','/usr/share/fds/m11-large.img','BAY02'])
|
||||
vm.capture('(s6-setuidgid fds fds burn confirm '+shlex.join([job['id'],job['confirmation']])+' >/tmp/m11-confirm.out 2>&1 & )')
|
||||
progress=wait(vm,lambda:job_record(vm),lambda j:j['phase']=='writing' and j['progress_bytes']>=64*1024*1024,timeout=180)
|
||||
assert progress['progress_bytes']<job['image_bytes'],progress
|
||||
observed=wait(vm,lambda:vm.qmp('query-blockstats'),
|
||||
lambda rows:any('/target/' in r.get('qdev','') and r['stats']['wr_bytes']>=64*1024*1024 for r in rows),timeout=180)
|
||||
progress=job_record(vm)
|
||||
assert progress['phase']=='writing' and progress['progress_bytes']<job['image_bytes'],progress
|
||||
(work/f'{name}-interruption.json').write_text(json.dumps({'job':progress,'blockstats':observed},indent=2)+'\n')
|
||||
if name=='cancel-writing':
|
||||
vm.capture('s6-setuidgid fds fds burn cancel '+job['id'],ok=False)
|
||||
elif name=='kill-worker':
|
||||
vm.capture('pkill -KILL -x fds-burn')
|
||||
else:
|
||||
vm.capture('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')
|
||||
result=failed(vm,job)
|
||||
assert 'cancelled' in result['error'] if name=='cancel-writing' else ('exited' in result['error'] or 'restarted' in result['error']),result
|
||||
assert len(query(vm,['bays'])['bays'])==12
|
||||
assert digest(disk)!=before,'Cancellation/crash occurred before any physical write'
|
||||
print(f'PASS: {name}: interrupted after at least 64 MiB, service remains usable, no false SAFE',flush=True)
|
||||
link=project/'out/m11-faults-latest';link.unlink(missing_ok=True);link.symlink_to(work.name)
|
||||
print(f'PASS: M11 media failure evidence: {work}',flush=True)
|
||||
print('SKIP: flash-controller behavior, physical removal, power cuts and Pi throughput')
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Twelve real virtual USB disks: boot, concurrent I/O, hotplug and native power."""
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import statistics
|
||||
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 gpt, digest, LINUX_FILESYSTEM
|
||||
work = Path(tempfile.mkdtemp(prefix='m11-vm.', dir=project/'out'))
|
||||
inputs=[Path(__file__),project/'tools/vm_test.py',project/'out/rootfs-cli.tar',
|
||||
project/'out/kernel/boot/kernel_2712.img',project/'out/fds-initramfs.img']
|
||||
(work/'inputs.sha256').write_text(''.join(f'{digest(path)} {path.relative_to(project)}\n' for path in inputs))
|
||||
(work/'qemu-version.txt').write_bytes(subprocess.check_output([str(project/'tools/in-void'),'qemu-system-aarch64','--version']))
|
||||
controller = 'qemu-xhci,id=xhci,addr=05.0,p2=12,p3=12'
|
||||
sequence = itertools.count()
|
||||
|
||||
def query(vm, args):
|
||||
return json.loads(vm.capture('fds --json '+shlex.join(args)))
|
||||
|
||||
def wait(vm, read, predicate, timeout=60):
|
||||
deadline = time.monotonic()+timeout
|
||||
while True:
|
||||
value = read()
|
||||
if predicate(value): return value
|
||||
assert time.monotonic()<deadline, value
|
||||
|
||||
def ready(vm, placement):
|
||||
def inventory():
|
||||
text=vm.capture('fds --json bays 2>/tmp/m11-bays.err; true')
|
||||
if text:return json.loads(text)
|
||||
error=vm.capture('cat /tmp/m11-bays.err')
|
||||
assert 'Cartridge service unavailable' in error,error
|
||||
return None
|
||||
report = wait(vm, inventory, lambda r:bool(r) and all(
|
||||
b['state']==('mounted_read_write' if placement.get(b['bay'])==1 else 'mounted_read_only' if b['bay'] in placement else 'empty')
|
||||
for b in r['bays']))
|
||||
assert len(report['bays'])==12
|
||||
for b in report['bays']:
|
||||
if b['bay'] not in placement: continue
|
||||
number=placement[b['bay']]
|
||||
assert b['manifest']['cartridge']['id']==f'fds.m11.{number:02}',b
|
||||
assert len(b['devices'])==1 and b['devices'][0]['serial']==f'M11-{number:02}',b
|
||||
assert b['devices'][0]['topology'].endswith('/'+str(b['bay'])),b
|
||||
return report
|
||||
|
||||
def finish(vm, reboot=False):
|
||||
vm.send('fds '+('reboot' if reboot else 'poweroff'))
|
||||
report=json.loads(vm.expect(rb'^FDS_SHUTDOWN_FINAL (\{[^\r\n]*\})\r?\n',timeout=90).group(1))
|
||||
assert [e['phase'] for e in report['events']]==[
|
||||
'frozen','stopping_desktop','stopping_programs','stopping_network',
|
||||
'syncing_and_unmounting_data','unmounting_cartridges','prepared','services_stopped'],report
|
||||
ending=vm.expect(rb'\[\s*([0-9]+\.[0-9]+)\] reboot: (Power down|Restarting system)')
|
||||
assert (ending.group(2)==b'Restarting system')==reboot
|
||||
assert vm.child.wait(timeout=20)==0
|
||||
report['virtual_total_ms']=float(ending.group(1))*1000-report['events'][0]['at_ns']/1e6
|
||||
(work/f'{vm.name}-shutdown.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
|
||||
# Calibrate from live descriptors; never infer bay identity from /dev/sdX.
|
||||
with VM(work,'probe',project/'out/fds-system-cli.img',extra=['-device',controller,'-device','usb-kbd,bus=xhci.0,port=12']) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
report=wait(vm,lambda:query(vm,['topology']),lambda r:any('03' in d['interfaces'] for d in r['unmapped']))
|
||||
device=next(d for d in report['unmapped'] if '03' in d['interfaces'])
|
||||
assert device['topology'].endswith('/12'),device
|
||||
hub=device['topology'].rsplit('/',1)[0]
|
||||
(work/'probe-topology.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
finish(vm)
|
||||
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))
|
||||
|
||||
def fixture(name, root_console=False, gated=False):
|
||||
replacements={'etc/fds/bays.toml':config.encode()}
|
||||
if root_console:
|
||||
replacements['usr/libexec/fds/console-session']=b'#!/bin/bash\n# Isolated test image only.\nexec env HOME=/root bash --login\n'
|
||||
additions={'usr/libexec/fds/m11-stress':(project/'out/m11-stress').read_bytes(),
|
||||
'usr/libexec/fds/m11-writer':(project/'out/m7-writer').read_bytes(),
|
||||
'usr/libexec/fds/m11-capture-hardware':(project/'tools/capture-hardware').read_bytes()}
|
||||
if gated:
|
||||
with tarfile.open(project/'out/rootfs-cli.tar') as original:
|
||||
additions['usr/libexec/fds/m11-daemon-real']=original.extractfile('usr/bin/fds-cartridged').read()
|
||||
replacements['usr/bin/fds-cartridged']=b'#!/bin/bash\nset -euo pipefail\nmkfifo -m 0666 /run/m11-gate\nprintf "M11_DAEMON_WAIT\\n" >/dev/console\nIFS= read -r release </run/m11-gate\nexec /usr/libexec/fds/m11-daemon-real "$@"\n'
|
||||
with tarfile.open(project/'out/rootfs-cli.tar') as src,tarfile.open(work/f'{name}.tar','w',format=tarfile.PAX_FORMAT) as dst:
|
||||
assert src.extractfile('usr/share/fds/image-profile').read().strip()==b'cli'
|
||||
seen=set()
|
||||
for member in src:
|
||||
if member.name in replacements:seen.add(member.name);continue
|
||||
dst.addfile(member,src.extractfile(member) if member.isfile() else None)
|
||||
assert seen==replacements.keys()
|
||||
for path,data in {**replacements,**additions}.items():
|
||||
member=tarfile.TarInfo(path);member.size=len(data);member.mode=0o755 if path.startswith('usr/') else 0o644
|
||||
dst.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(work/f'{name}.tar'),'--output-directory',str(destination)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
return destination/'system.img'
|
||||
|
||||
ordinary=fixture('ordinary')
|
||||
admin=fixture('admin',True)
|
||||
media={};layouts={};unchanged={}
|
||||
for n in range(1,13):
|
||||
root=work/f'media{n:02}-root';(root/'FDS').mkdir(parents=True)
|
||||
kind='data' if n==1 else 'program'
|
||||
(root/'FDS/CARTRIDGE.TOML').write_text(f'format=1\n[cartridge]\nid="fds.m11.{n:02}"\nname="M11 BAY {n:02}"\nclass="{kind}"\nversion="1"\n[media]\nwritable={"true" if n==1 else "false"}\n')
|
||||
fs=work/f'media{n:02}.{ "ext4" if n==1 else "erofs"}'
|
||||
if n==1:
|
||||
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)
|
||||
else:
|
||||
(root/'payload.bin').write_bytes(bytes([n])*(8*1024*1024))
|
||||
subprocess.run([str(project/'tools/in-image-tools'),'mkfs.erofs','--quiet','-b','4096','-T','0',str(fs),str(root)],check=True)
|
||||
disk=work/f'media{n:02}.img';layouts[n]=gpt(disk,[(f'FDS_{kind.upper()}',LINUX_FILESYSTEM,fs)])
|
||||
media[n]=disk
|
||||
if n!=1:unchanged[disk]=digest(disk)
|
||||
|
||||
identity={n:n for n in range(1,13)}
|
||||
def cold_args(placement):
|
||||
args=['-device',controller]
|
||||
for port,n in placement.items():
|
||||
args+=['-drive',f'file={media[n]},if=none,id=media{port},format=raw'+(',readonly=on' if n!=1 else ''),
|
||||
'-device',f'usb-storage,id=bay{port},drive=media{port},bus=xhci.0,port={port},serial=M11-{n:02}']
|
||||
return args
|
||||
|
||||
def insert(vm, placement):
|
||||
# Pause virtual CPUs only while changing the fixture. All insertions are
|
||||
# pending together when execution resumes; no time-based enumeration delay.
|
||||
vm.qmp('stop')
|
||||
for port,n in placement.items():
|
||||
node=f'hot{next(sequence)}'
|
||||
vm.qmp('blockdev-add',{'driver':'raw','node-name':node,'read-only':n!=1,'file':{'driver':'file','filename':str(media[n])}})
|
||||
vm.qmp('device_add',{'driver':'usb-storage','id':f'bay{port}','drive':node,'bus':'xhci.0','port':str(port),'serial':f'M11-{n:02}'})
|
||||
vm.qmp('cont')
|
||||
|
||||
def remove_all(vm):
|
||||
# DATA has already been safely ejected; read-only PROGRAM pulls may be hot.
|
||||
vm.qmp('stop')
|
||||
for port in range(1,13):vm.qmp('device_del',{'id':f'bay{port}'})
|
||||
vm.qmp('cont')
|
||||
ready(vm,{})
|
||||
|
||||
def kernel_check(vm, name):
|
||||
kernel=vm.capture('dmesg')
|
||||
(work/f'{name}-kernel.log').write_text(kernel+'\n')
|
||||
errors=[line for line in kernel.splitlines() if re.search(r'(?i)(I/O error|EXT4-fs error|Buffer I/O|reset .*USB device|device descriptor read.*error|BUG:|Call trace:|hung task|host controller not responding|HC died)',line)]
|
||||
assert not errors,errors
|
||||
|
||||
# Like-for-like real ordinary-user prompt measurements with the same controller.
|
||||
reports={'empty':[],'twelve':[]}
|
||||
for iteration in range(3):
|
||||
for name,placement in [('empty',{}),('twelve',identity)]:
|
||||
with VM(work,f'cold-{name}-{iteration}',ordinary,extra=cold_args(placement)) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
assert vm.capture('id -u')=='1000'
|
||||
trace=query(vm,['boot-profile']);reports[name].append(trace)
|
||||
(work/f'{vm.name}-boot.json').write_text(json.dumps(trace,indent=2)+'\n')
|
||||
(work/f'{vm.name}-bays.json').write_text(json.dumps(ready(vm,placement),indent=2)+'\n')
|
||||
finish(vm)
|
||||
print(f'PASS: cold boot pair {iteration+1}: empty and twelve USB disks, stable identities and native shutdown',flush=True)
|
||||
comparison={name:{'samples_ms':[r['durations_ns']['kernel-to-console']/1e6 for r in rows],
|
||||
'median_ms':statistics.median(r['durations_ns']['kernel-to-console']/1e6 for r in rows)} for name,rows in reports.items()}
|
||||
comparison['change_ms']=comparison['twelve']['median_ms']-comparison['empty']['median_ms']
|
||||
(work/'boot-comparison.json').write_text(json.dumps(comparison,indent=2)+'\n')
|
||||
print(f'OBSERVED: twelve-device VM median console change {comparison["change_ms"]:+.3f} ms; physical timing deferred',flush=True)
|
||||
|
||||
# A real readiness gate proves that full cartridge enumeration is not a
|
||||
# prerequisite of the ordinary user's prompt, even with all bays populated.
|
||||
gated=fixture('gated',gated=True)
|
||||
with VM(work,'blocked-cartridged-twelve',gated,extra=cold_args(identity)) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
if b'M11_DAEMON_WAIT' not in vm.data:vm.expect(rb'M11_DAEMON_WAIT')
|
||||
vm.capture('test "$(id -u)" = 1000 && test ! -e /run/fds/control.sock')
|
||||
(work/'gated-boot.json').write_text(json.dumps(query(vm,['boot-profile']),indent=2)+'\n')
|
||||
vm.capture('printf "release\\n" >/run/m11-gate')
|
||||
ready(vm,identity);finish(vm)
|
||||
print('PASS: ordinary-user prompt is independent of blocked cartridge readiness with twelve devices present',flush=True)
|
||||
|
||||
# The same packaged image and controller are compared under different device
|
||||
# loads. Keep the actual median sample reports and the required explanation.
|
||||
explanation=('Controlled twelve-device load adds concurrent kernel USB work and cartridge scanning to '
|
||||
'the same two-vCPU VM. Stage0 discovery and the service graph are unchanged; the blocked '
|
||||
'twelve-device cartridge test proves console independence. This is measured device-load '
|
||||
'overhead, not a Pi timing claim. All raw samples, including variability, are retained.')
|
||||
for name,rows in reports.items():
|
||||
median=sorted(rows,key=lambda r:r['durations_ns']['kernel-to-console'])[len(rows)//2]
|
||||
(work/f'{name}-median.json').write_text(json.dumps(median,indent=2)+'\n')
|
||||
with (work/'boot-review.log').open('wb') as log:
|
||||
subprocess.run([str(project/'tools/in-void'),'qemu-aarch64',str(project/'out/fds-boottrace'),'compare',
|
||||
str(work/'empty-median.json'),str(work/'twelve-median.json'),
|
||||
*(['--explain',explanation] if comparison['change_ms']>100 else [])],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
|
||||
with VM(work,'hotplug-io-reboot',admin,extra=['-device',controller]) as vm:
|
||||
vm.expect(rb'FDS# ')
|
||||
for iteration in range(3):
|
||||
placement={port:((port+iteration-1)%12)+1 for port in reversed(range(1,13))}
|
||||
insert(vm,placement)
|
||||
report=ready(vm,placement)
|
||||
(work/f'hot-{iteration}-bays.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
data_bay=next(port for port,n in placement.items() if n==1)
|
||||
# SAFE must not follow a port's previous occupant when devices rotate.
|
||||
assert all(b['state']!='safe' for b in report['bays'])
|
||||
query(vm,['eject',str(data_bay)])
|
||||
remove_all(vm)
|
||||
print(f'PASS: simultaneous hotplug cycle {iteration+1}; reverse insertion order and rotated cartridge identities',flush=True)
|
||||
insert(vm,identity);ready(vm,identity)
|
||||
before=vm.qmp('query-blockstats')
|
||||
vm.capture('echo 3 >/proc/sys/vm/drop_caches')
|
||||
paths=[f'/run/fds/apps/fds.m11.{n:02}/payload.bin' for n in range(2,13)]
|
||||
rows=[json.loads(line) for line in vm.capture('s6-setuidgid fds /usr/libexec/fds/m11-stress '+shlex.join(paths)).splitlines()]
|
||||
assert len(rows)==12 and {r['bay'] for r in rows}==set(range(1,13)),rows
|
||||
assert max(r['start_ns'] for r in rows)<min(r['end_ns'] for r in rows),'Workloads did not overlap'
|
||||
after=vm.qmp('query-blockstats')
|
||||
(work/'concurrent-io.json').write_text(json.dumps({'intervals':rows,'before':before,'after':after},indent=2)+'\n')
|
||||
def stats(rows):
|
||||
result={}
|
||||
for row in rows:
|
||||
match=re.search(r'(?:^|/)bay([0-9]+)(?:/|$)',row.get('qdev',''))
|
||||
if match:result[int(match.group(1))]=row['stats']
|
||||
assert len(result)==12,rows
|
||||
return result
|
||||
old,new=stats(before),stats(after)
|
||||
for port in range(1,13):
|
||||
key=port
|
||||
metric='wr_bytes' if port==1 else 'rd_bytes'
|
||||
minimum=64*1024*1024 if port==1 else 32*1024*1024
|
||||
assert new[key][metric]-old[key][metric]>=minimum,(port,metric,old[key],new[key])
|
||||
kernel_check(vm,'concurrent')
|
||||
vm.capture('/bin/bash /usr/libexec/fds/m11-capture-hardware /tmp/m11-capture && cd /tmp/m11-capture && sha256sum -c SHA256SUMS && test "$(stat -c %a .)" = 700')
|
||||
(work/'hardware-capture-status.tsv').write_text(vm.capture('cat /tmp/m11-capture/status.tsv')+'\n')
|
||||
print('PASS: all twelve I/O intervals overlap; eleven direct readers verify payloads while DATA writes and flushes 64 MiB',flush=True)
|
||||
query(vm,['run','1','--','/usr/libexec/fds/m11-writer'])
|
||||
wait(vm,lambda:vm.capture('cat /data/progress 2>/dev/null || printf 0'),lambda s:s.isdigit() and int(s)>=128)
|
||||
finish(vm,reboot=True)
|
||||
|
||||
# Verify DATA independently, then boot the exact disks in another device order.
|
||||
part=layouts[1]['partitions'][0];payload=work/'data-after.ext4'
|
||||
with media[1].open('rb') as src,payload.open('wb') as dst:
|
||||
src.seek(part['start']*512);left=part['payload_bytes']
|
||||
while left:
|
||||
chunk=src.read(min(left,1024*1024));assert chunk;dst.write(chunk);left-=len(chunk)
|
||||
with (work/'data-fsck.log').open('wb') as log:subprocess.run(['e2fsck','-fn',str(payload)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
written=work/'stress-after.bin'
|
||||
subprocess.run(['debugfs','-R',f'dump /stress.bin {written}',str(payload)],check=True,stdout=subprocess.DEVNULL)
|
||||
assert written.stat().st_size==64*1024*1024
|
||||
assert digest(written)==hashlib.sha256(bytes(range(256))*(64*1024*1024//256)).hexdigest()
|
||||
with VM(work,'reboot-followup',admin,extra=cold_args(dict(reversed(list(identity.items()))))) as vm:
|
||||
vm.expect(rb'FDS# ');ready(vm,identity)
|
||||
assert vm.capture('stat -c %s /data/stress.bin')==str(64*1024*1024)
|
||||
kernel_check(vm,'reboot-followup')
|
||||
finish(vm)
|
||||
for path,checksum in unchanged.items():assert digest(path)==checksum,path
|
||||
link=project/'out/m11-vm-latest';link.unlink(missing_ok=True);link.symlink_to(work.name)
|
||||
print(f'PASS: M11 twelve-device virtual stress: {work}',flush=True)
|
||||
print('SKIP: physical hub wiring, USB voltage/current/reset behavior, RP1, battery and Pi timing')
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preserve a newer wall clock through the actual native startup sequence."""
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
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 digest
|
||||
from vm_test import VM
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix='m12-clock.', dir=project / 'out'))
|
||||
rootfs = (project / 'out/rootfs-development.tar').resolve(strict=True)
|
||||
stage2 = 'etc/s6-linux-init/current/scripts/rc.init'
|
||||
future = 1_893_456_000 # 2030-01-01 UTC, set only inside the disposable guest.
|
||||
with tarfile.open(rootfs) as source, tarfile.open(work / 'clock.tar', 'w', format=tarfile.PAX_FORMAT) as output:
|
||||
assert source.extractfile('usr/share/fds/image-profile').read().strip() == b'development'
|
||||
epoch = int(source.extractfile('usr/share/fds/build-epoch').read())
|
||||
assert epoch < future
|
||||
found = False
|
||||
for member in source:
|
||||
if member.name == stage2:
|
||||
member.name += '.real'
|
||||
found = True
|
||||
output.addfile(member, source.extractfile(member) if member.isfile() else None)
|
||||
assert found
|
||||
# Preserve the actual stage 2 and boottrace binary. This fixture only sets
|
||||
# the guest kernel clock before the normal startup code adopts boot events.
|
||||
data = f'#!/bin/bash\nset -euo pipefail\ndate -u -s @{future} >/dev/null\nexec /{stage2}.real "$@"\n'.encode()
|
||||
member = tarfile.TarInfo(stage2)
|
||||
member.mode = 0o755
|
||||
member.size = len(data)
|
||||
output.addfile(member, io.BytesIO(data))
|
||||
(work / 'image').mkdir()
|
||||
with (work / 'image.log').open('wb') as log:
|
||||
subprocess.run([str(project / 'image/build-system-cartridge'), '--profile', 'development',
|
||||
'--rootfs', str(work / 'clock.tar'), '--output-directory', str(work / 'image')],
|
||||
check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
image = work / 'image/system.img'
|
||||
before = digest(image)
|
||||
with VM(work, 'retained-clock', image) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
assert vm.capture('id -u') == '1000'
|
||||
status = json.loads(vm.capture('cat /run/fds/clock.json'))
|
||||
assert status['source'] == 'retained_kernel_clock', status
|
||||
assert status['previous_unix_seconds'] >= future, status
|
||||
assert status['minimum_unix_seconds'] == status['previous_unix_seconds'], status
|
||||
assert int(vm.capture('date -u +%s')) >= future
|
||||
assert 'requires root' in vm.capture('fds-boottrace clock-floor', ok=False)
|
||||
report = json.loads(vm.capture('fds --json boot-profile'))
|
||||
assert report['clock'] == 'Linux CLOCK_BOOTTIME', report
|
||||
assert 0 < report['durations_ns']['kernel-to-console'] < 120_000_000_000, report
|
||||
(work / 'clock.json').write_text(json.dumps(status, indent=2) + '\n')
|
||||
(work / 'boot-profile.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
deadline = time.monotonic() + 60
|
||||
while vm.capture('test -S /run/fds/control.sock && echo ready || echo pending') != 'ready':
|
||||
assert time.monotonic() < deadline
|
||||
vm.send('fds poweroff')
|
||||
vm.expect(rb'reboot: Power down')
|
||||
assert vm.child.wait(timeout=20) == 0
|
||||
assert digest(image) == before
|
||||
link = project / 'out/m12-clock-latest.next'
|
||||
link.symlink_to(work.name)
|
||||
link.replace(project / 'out/m12-clock-latest')
|
||||
print(f'PASS: newer guest clock preserved, ordinary-user mutation rejected, monotonic boot measurement retained, native halt: {work}')
|
||||
print('NOTE: the test changes only a disposable VM clock; physical Pi RTC behavior remains deferred')
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile and debug real native programs from the immutable development SYSTEM."""
|
||||
import base64
|
||||
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 digest
|
||||
from vm_test import VM
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix='m12-development.', dir=project / 'out'))
|
||||
rootfs = (project / 'out/rootfs-development.tar').resolve(strict=True)
|
||||
image = (project / 'out/fds-system-development.img').resolve(strict=True)
|
||||
with tarfile.open(rootfs) as archive:
|
||||
assert archive.extractfile('usr/share/fds/image-profile').read().strip() == b'development'
|
||||
before = digest(image)
|
||||
(work / 'inputs.sha256').write_text(f'{digest(rootfs)} {rootfs}\n{before} {image}\n')
|
||||
with VM(work, 'toolchain', image) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
assert vm.capture('id -u') == '1000'
|
||||
vm.capture('test "$(date -u +%s)" -ge "$(cat /usr/share/fds/build-epoch)"')
|
||||
clock = json.loads(vm.capture('cat /run/fds/clock.json'))
|
||||
assert clock['source'] == 'image_floor' and clock['previous_unix_seconds'] < clock['image_epoch'], clock
|
||||
(work / 'clock.json').write_text(json.dumps(clock, indent=2) + '\n')
|
||||
vm.capture('fds-boottrace clock-floor', ok=False)
|
||||
vm.capture('fds-release --version')
|
||||
versions = vm.capture('for tool in gcc g++ make cmake meson ninja pkg-config rustc cargo git gdb strace vim; do "$tool" --version | head -n 1; done')
|
||||
(work / 'tool-versions.txt').write_text(versions + '\n')
|
||||
vm.capture('mkdir -p /tmp/development/c /tmp/development/rust/src')
|
||||
files = {
|
||||
'/tmp/development/c/main.c': '#include <stdio.h>\nint main(void) { puts("FDS C OK"); return 0; }\n',
|
||||
'/tmp/development/c/main.cpp': '#include <iostream>\nint main() { std::cout << "FDS C++ OK\\n"; }\n',
|
||||
'/tmp/development/c/CMakeLists.txt': 'cmake_minimum_required(VERSION 3.20)\nproject(fds_development C CXX)\nadd_executable(fds_c main.c)\nadd_executable(fds_cpp main.cpp)\n',
|
||||
'/tmp/development/c/meson.build': "project('fds-development', 'c')\nexecutable('fds_meson', 'main.c')\n",
|
||||
'/tmp/development/c/Makefile': 'fds_make: main.c\n\t$(CC) -g -O0 -o $@ $<\n',
|
||||
'/tmp/development/rust/Cargo.toml': '[package]\nname="fds-development-test"\nversion="0.1.0"\nedition="2024"\n',
|
||||
'/tmp/development/rust/src/main.rs': 'fn main() { println!("FDS RUST OK"); }\n',
|
||||
}
|
||||
for path, text in files.items():
|
||||
# Serial input passes through interactive Bash readline. Encode the
|
||||
# bytes so a Makefile tab cannot be consumed as tab completion.
|
||||
encoded = base64.b64encode(text.encode()).decode('ascii')
|
||||
vm.capture('printf %s ' + shlex.quote(encoded) + ' | base64 -d >' + shlex.quote(path))
|
||||
vm.capture('cmake -S /tmp/development/c -B /tmp/development/cmake -G Ninja && cmake --build /tmp/development/cmake', timeout=600)
|
||||
assert vm.capture('/tmp/development/cmake/fds_c') == 'FDS C OK'
|
||||
assert vm.capture('/tmp/development/cmake/fds_cpp') == 'FDS C++ OK'
|
||||
vm.capture('meson setup /tmp/development/meson /tmp/development/c && ninja -C /tmp/development/meson', timeout=600)
|
||||
assert vm.capture('/tmp/development/meson/fds_meson') == 'FDS C OK'
|
||||
vm.capture('make -C /tmp/development/c', timeout=180)
|
||||
assert vm.capture('/tmp/development/c/fds_make') == 'FDS C OK'
|
||||
vm.capture('cd /tmp/development/rust && cargo build --offline', timeout=600)
|
||||
assert vm.capture('/tmp/development/rust/target/debug/fds-development-test') == 'FDS RUST OK'
|
||||
debug = vm.capture("gdb -q -batch -ex 'break main' -ex run -ex bt -ex continue --args /tmp/development/c/fds_make", timeout=180)
|
||||
assert 'Breakpoint 1' in debug and 'main' in debug and 'FDS C OK' in debug, debug
|
||||
(work / 'gdb.txt').write_text(debug + '\n')
|
||||
vm.capture('strace -o /tmp/development/strace.txt -e trace=write /tmp/development/c/fds_make && grep -q "FDS C OK" /tmp/development/strace.txt')
|
||||
vm.capture('git -C /tmp/development init && git -C /tmp/development add c/main.c && git -C /tmp/development -c user.name=FDS -c user.email=test.invalid@example.invalid commit -m "Local development acceptance"')
|
||||
vm.capture("vim -Nu NONE -n -es /tmp/development/editor.txt -c 'call setline(1, \"FDS EDITOR OK\")' -c wq")
|
||||
assert vm.capture('cat /tmp/development/editor.txt') == 'FDS EDITOR OK'
|
||||
vm.capture('pkg-config --version')
|
||||
vm.capture('test "$(findmnt -nro FSTYPE /)" = erofs && findmnt -nro OPTIONS / | grep -qw ro')
|
||||
vm.capture('touch /etc/development-write-probe', ok=False)
|
||||
vm.capture('! pgrep -x Xorg && ! pgrep -x dhcpcd && ! pgrep -x sshd && ! pgrep -x dbus-daemon')
|
||||
boot = json.loads(vm.capture('fds --json boot-profile'))
|
||||
(work / 'boot-profile.json').write_text(json.dumps(boot, indent=2) + '\n')
|
||||
deadline = time.monotonic() + 60
|
||||
while vm.capture('test -S /run/fds/control.sock && echo ready || echo pending') != 'ready':
|
||||
assert time.monotonic() < deadline
|
||||
vm.send('fds poweroff')
|
||||
vm.expect(rb'reboot: Power down')
|
||||
assert vm.child.wait(timeout=20) == 0
|
||||
assert digest(image) == before
|
||||
link = project / 'out/m12-development-latest.next'
|
||||
link.symlink_to(work.name)
|
||||
link.replace(project / 'out/m12-development-latest')
|
||||
print(f'PASS: native C/C++/Rust compilation and execution, CMake/Meson/Ninja/Make, GDB/strace, Git/editor, read-only SYSTEM and native shutdown: {work}')
|
||||
print('SKIP: Pi compiler throughput and physical boot timing require hardware')
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real Pi 5 firmware configuration roundtrips without any hardware access."""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
project=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(project/'tools'))
|
||||
from eeprom_inputs import prepare,sha256
|
||||
cache,pin=prepare()
|
||||
work=Path(tempfile.mkdtemp(prefix='m12-eeprom.',dir=project/'out'))
|
||||
base=cache/'pieeprom.bin';before=sha256(base)
|
||||
# Use the pinned upstream parser to compare immutable firmware payloads as well
|
||||
# as configuration text, including both AB slots where present.
|
||||
loader=importlib.machinery.SourceFileLoader('upstream_eeprom',str(cache/'rpi-eeprom-config'))
|
||||
spec=importlib.util.spec_from_loader(loader.name,loader);upstream=importlib.util.module_from_spec(spec);loader.exec_module(upstream)
|
||||
original=upstream.BootloaderImage(str(base))
|
||||
protected={s.filename:original.get_file(s.filename) for s in original._sections if s.filename and s.filename not in ['bootconf.txt','bootconf.sig']}
|
||||
|
||||
def run(arguments,ok=True):
|
||||
result=subprocess.run([str(project/'tools/configure-pi-eeprom'),*map(str,arguments)],capture_output=True,text=True,env={**os.environ,'FDS_OFFLINE':'1'})
|
||||
assert (result.returncode==0)==ok,(arguments,result.stdout,result.stderr)
|
||||
return result
|
||||
|
||||
for profile in ['production','development']:
|
||||
output=work/profile
|
||||
run(['--profile',profile,'--output-directory',output])
|
||||
manifest=json.loads((output/'manifest.json').read_text())
|
||||
assert manifest['hardware_modified'] is False and manifest['custom_inputs_provided'] is False
|
||||
for name,digest in manifest['files'].items():assert sha256(output/name)==digest
|
||||
image=upstream.BootloaderImage(str(output/'configured.bin'))
|
||||
for name,data in protected.items():assert image.get_file(name)==data,(profile,name)
|
||||
assert 'NET_INSTALL_ENABLED=0' in image.get_file('bootconf.txt').decode()
|
||||
second=work/(profile+'-repeat');run(['--profile',profile,'--output-directory',second])
|
||||
assert sha256(output/'configured.bin')==sha256(second/'configured.bin')
|
||||
assert sha256(output/'rollback.bin')==sha256(second/'rollback.bin')
|
||||
run(['--profile',profile,'--output-directory',output],ok=False)
|
||||
print(f'PASS: {profile}: actual Pi 5 firmware roundtrip, immutable payloads, deterministic files and overwrite refusal',flush=True)
|
||||
|
||||
saved=work/'board.conf'
|
||||
saved.write_text('[all]\nBOOT_ORDER=0xf461\nCUSTOM_BOARD_SETTING=retained\n[gpio8=0]\nBOOT_ORDER=0xf7\nNET_INSTALL_ENABLED=1\nOTHER_SETTING=unchanged\n')
|
||||
output=work/'custom'
|
||||
run(['--current-config',saved,'--output-directory',output])
|
||||
changed=(output/'configured.conf').read_text()
|
||||
assert changed.count('BOOT_ORDER=')==1 and 'BOOT_ORDER=0xf6' in changed
|
||||
assert 'CUSTOM_BOARD_SETTING=retained' in changed and '[gpio8=0]\nOTHER_SETTING=unchanged' in changed
|
||||
rollback=upstream.BootloaderImage(str(output/'rollback.bin')).get_file('bootconf.txt').decode()
|
||||
assert rollback==saved.read_text(),'Rollback lost the original conditional settings'
|
||||
bad=work/'bad.bin';bad.write_bytes(b'not firmware')
|
||||
run(['--base-image',bad,'--output-directory',work/'bad'],ok=False)
|
||||
run(['--base-image','/dev/null','--output-directory',work/'device'],ok=False)
|
||||
large=work/'large.conf';large.write_bytes(b'x'*5000)
|
||||
run(['--current-config',large,'--output-directory',work/'large'],ok=False)
|
||||
assert sha256(base)==before
|
||||
(work/'evidence.json').write_text(json.dumps({'format':1,'upstream_commit':pin['commit'],'base_sha256':before,'hardware_modified':False},indent=2)+'\n')
|
||||
link=project/'out/m12-eeprom-latest';link.unlink(missing_ok=True);link.symlink_to(work.name)
|
||||
print(f'PASS: preserved custom settings, exact rollback, invalid-input rejection and unchanged firmware input: {work}')
|
||||
print('SKIP: EEPROM application, physical boot order, PMIC and Pi timing')
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the packaged machine settings service against disposable virtual NVMe."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project / 'tools'))
|
||||
from image_formats import digest, gpt, LINUX_FILESYSTEM
|
||||
from vm_test import VM
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix='m12-internal.', dir=project / 'out'))
|
||||
runner = str(project / 'tools/in-image-tools')
|
||||
controller = 'qemu-xhci,id=xhci,addr=05.0'
|
||||
placeholder = work / 'placeholder.bin'
|
||||
placeholder.write_bytes(bytes(4096))
|
||||
empty = work / 'empty.img'
|
||||
gpt(empty, [('FDS_TEST', LINUX_FILESYSTEM, placeholder)])
|
||||
|
||||
for name in ['build-a', 'build-b']:
|
||||
output = work / name
|
||||
output.mkdir()
|
||||
with (work / (name + '.log')).open('wb') as log:
|
||||
subprocess.run([str(project / 'image/build-internal'), '--output-directory', str(output)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
image = work / 'build-a/internal.img'
|
||||
layout = json.loads((work / 'build-a/layout.json').read_text())
|
||||
assert digest(image) == digest(work / 'build-b/internal.img'), 'Internal image construction is not reproducible'
|
||||
assert [p['name'] for p in layout['partitions']] == ['FDS_BOOT', 'FDS_RECOVERY', 'FDS_INTERNAL']
|
||||
assert [p['size'] * 512 for p in layout['partitions']] == [512 * 1024**2, 1024**3, 256 * 1024**2]
|
||||
assert not ((project / 'out/rootfs-cli.tar').resolve().parent / 'root/var/cache/ldconfig/aux-cache').exists()
|
||||
print('PASS: two complete internal GPT builds are byte-identical, with independent FAT/EROFS/ext4/GPT checks', flush=True)
|
||||
|
||||
# Every VM boots the packaged recovery EROFS. No injected runtime or bay-map
|
||||
# replacement is used; installation exercises the public persistent-settings API.
|
||||
def extra(path, node='internal', readonly=False):
|
||||
return ['-drive', f'file={path},if=none,id={node},format=raw' + (',readonly=on' if readonly else ''),
|
||||
'-device', f'nvme,drive={node},serial=FDS-{node.upper()}']
|
||||
|
||||
def wait(operation, condition, timeout=60):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
result = operation()
|
||||
if condition(result): return result
|
||||
assert time.monotonic() < deadline, result
|
||||
|
||||
def query(vm, args):
|
||||
return json.loads(vm.capture('fds --json ' + shlex.join(args)))
|
||||
|
||||
def enter(vm):
|
||||
vm.expect(rb'FDS_SYSTEM MEDIA NOT PRESENT')
|
||||
vm.send('recovery')
|
||||
vm.expect(rb'RECOVERY# ')
|
||||
assert vm.capture('id -u') == '0'
|
||||
assert vm.capture('stat -c %u:%g:%a /') == '0:0:755'
|
||||
wait(lambda: vm.capture('test -S /run/fds/control.sock && echo ready || echo pending'), lambda text: text == 'ready')
|
||||
return query(vm, ['machine', 'status'])
|
||||
|
||||
def finish(vm):
|
||||
vm.send('fds poweroff')
|
||||
vm.expect(rb'reboot: Power down')
|
||||
assert vm.child.wait(timeout=20) == 0
|
||||
|
||||
def unchanged_payloads(path):
|
||||
import hashlib
|
||||
with path.open('rb') as source:
|
||||
for part in layout['partitions'][:2]:
|
||||
source.seek(part['start'] * 512)
|
||||
checksum = hashlib.sha256()
|
||||
remaining = part['payload_bytes']
|
||||
while remaining:
|
||||
chunk = source.read(min(remaining, 1024 * 1024))
|
||||
assert chunk
|
||||
remaining -= len(chunk)
|
||||
checksum.update(chunk)
|
||||
assert checksum.hexdigest() == part['sha256']
|
||||
|
||||
def copy_image(name):
|
||||
destination = work / (name + '.img')
|
||||
subprocess.run(['cp', '--reflink=auto', '--sparse=always', str(image), str(destination)], check=True)
|
||||
return destination
|
||||
|
||||
machine = copy_image('machine')
|
||||
original = digest(machine)
|
||||
with VM(work, 'install-settings', empty, extra=[*extra(machine), '-device', controller, '-device', 'usb-kbd,bus=xhci.0,port=4']) as vm:
|
||||
status = enter(vm)
|
||||
assert status['source'] == 'internal_nvme' and status['name'] == 'FP-85', status
|
||||
assert digest(machine) == original, 'Loading settings changed the disk'
|
||||
vm.capture('test -z "$(findmnt -nr -o TARGET | grep /run/fds/machine/internal)"')
|
||||
vm.capture('s6-setuidgid fds fds machine store denied /etc/hostname', ok=False)
|
||||
vm.capture('s6-setuidgid fds fds machine --load', ok=False)
|
||||
devices = wait(lambda: query(vm, ['topology'])['unmapped'], lambda rows: any('03' in row['interfaces'] for row in rows))
|
||||
keyboard = next(row for row in devices if '03' in row['interfaces'])
|
||||
hub = keyboard['topology'].rsplit('/', 1)[0]
|
||||
config = f'[front]\nhub={json.dumps(hub)}\n[front.ports]\n4=4\n'
|
||||
catalog = f'[[device]]\nname="LAB KEYBOARD"\nvendor={json.dumps(keyboard["vendor"])}\nproduct={json.dumps(keyboard["product"])}\n'
|
||||
vm.capture('fds machine export /tmp/new-machine')
|
||||
vm.capture('fds machine export /tmp/new-machine', ok=False)
|
||||
for file, data in [('bays.toml', config), ('hardware-catalog.toml', catalog), ('machine.toml', 'format=1\nname="FP-85 LAB"\n')]:
|
||||
vm.capture('printf %s ' + shlex.quote(data) + ' >/tmp/new-machine/' + file)
|
||||
vm.capture('fds machine validate /tmp/new-machine && fds machine install /tmp/new-machine')
|
||||
assert query(vm, ['machine', 'status'])['name'] == 'FP-85'
|
||||
vm.capture('s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged')
|
||||
assert query(vm, ['topology'])['unmapped'], 'Machine settings changed underneath the current boot'
|
||||
vm.capture('fds --json boot-profile >/tmp/boot.json && fds machine store acceptance-boot.json /tmp/boot.json && fds machine fetch acceptance-boot.json /tmp/retrieved.json && cmp /tmp/boot.json /tmp/retrieved.json')
|
||||
vm.capture('fds machine store acceptance-boot.json /tmp/boot.json', ok=False)
|
||||
vm.capture('fds machine store ../escape /tmp/boot.json', ok=False)
|
||||
vm.capture('fds machine fetch acceptance-boot.json /tmp/retrieved.json', ok=False)
|
||||
vm.capture('truncate -s 16777217 /tmp/oversize && fds machine store oversize /tmp/oversize', ok=False)
|
||||
vm.capture('test -z "$(findmnt -nr -o TARGET | grep /run/fds/machine/internal)"')
|
||||
finish(vm)
|
||||
unchanged_payloads(machine)
|
||||
print('PASS: settings load without writes, root-only atomic installation, fixed boot snapshot, bounded diagnostics and exact retrieval; boot/recovery partitions remain unchanged', flush=True)
|
||||
|
||||
with VM(work, 'persistent-settings', empty, extra=[*extra(machine), '-device', controller, '-device', 'usb-kbd,bus=xhci.0,port=4']) as vm:
|
||||
status = enter(vm)
|
||||
assert status['source'] == 'internal_nvme' and status['name'] == 'FP-85 LAB', status
|
||||
bay = wait(lambda: query(vm, ['bay', '4'])['bays'][0], lambda row: row['state'] == 'hardware')
|
||||
assert bay['name'] == 'LAB KEYBOARD', bay
|
||||
vm.capture('fds machine fetch acceptance-boot.json /tmp/saved-boot.json')
|
||||
saved = json.loads(vm.capture('cat /tmp/saved-boot.json'))
|
||||
assert saved['boot_id'] != query(vm, ['boot-profile'])['boot_id']
|
||||
(work / 'persistent-status.json').write_text(json.dumps(status, indent=2) + '\n')
|
||||
finish(vm)
|
||||
print('PASS: independent reboot loads saved bay/catalog/name settings and retrieves the previous boot record', flush=True)
|
||||
|
||||
# Independent filesystem inspection after real guest writes.
|
||||
part = layout['partitions'][2]
|
||||
settings = work / 'after-guest.ext4'
|
||||
with machine.open('rb') as source, settings.open('wb') as output:
|
||||
source.seek(part['start'] * 512)
|
||||
remaining = part['payload_bytes']
|
||||
while remaining:
|
||||
chunk = source.read(min(remaining, 1024 * 1024))
|
||||
assert chunk
|
||||
output.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
with (work / 'after-guest-fsck.log').open('wb') as log:
|
||||
subprocess.run([runner, 'e2fsck', '-f', '-n', str(settings)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
previous = subprocess.check_output([runner, 'debugfs', '-R', 'cat /config/previous.json', str(settings)], stderr=subprocess.DEVNULL)
|
||||
assert json.loads(previous)['name'] == 'FP-85'
|
||||
|
||||
# Both recovery and settings are present on USB, but only recovery may be selected
|
||||
# by its GPT label. USB cannot supply persistent machine settings.
|
||||
with VM(work, 'usb-lookalike', image, system_usb=True) as vm:
|
||||
status = enter(vm)
|
||||
assert status['source'] == 'image_defaults' and 'No complete internal NVMe' in status['error'], status
|
||||
finish(vm)
|
||||
|
||||
# Boot the ordinary CLI SYSTEM with two eligible NVMes already present. Stage0
|
||||
# has one SYSTEM to choose; the machine settings loader must reject ambiguity.
|
||||
with VM(work, 'ambiguous-internal', project / 'out/fds-system-cli.img', extra=[*extra(image, 'first', True), *extra(image, 'second', True)]) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
assert vm.capture('id -u') == '1000'
|
||||
assert vm.capture('stat -c %u:%g:%a /') == '0:0:755'
|
||||
wait(lambda: vm.capture('test -S /run/fds/control.sock && echo ready || echo pending'), lambda text: text == 'ready')
|
||||
status = query(vm, ['machine', 'status'])
|
||||
assert status['source'] == 'image_defaults' and 'Multiple internal NVMe' in status['error'], status
|
||||
finish(vm)
|
||||
print('PASS: USB settings spoof and two eligible NVMes fall back with explicit diagnostics', flush=True)
|
||||
|
||||
# Corrupt configuration and an unclean ext4 are distinct failure cases. Build
|
||||
# fixtures by changing only the settings partition, retaining the packaged OS.
|
||||
def altered(name, commands):
|
||||
fs = work / (name + '.ext4')
|
||||
shutil.copyfile(work / 'build-a/internal.ext4', fs)
|
||||
script = work / (name + '.debugfs')
|
||||
script.write_text('\n'.join(commands) + '\n')
|
||||
subprocess.run([runner, 'debugfs', '-w', '-f', str(script), str(fs)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
disk = copy_image(name)
|
||||
with disk.open('r+b') as output, fs.open('rb') as source:
|
||||
output.seek(part['start'] * 512)
|
||||
shutil.copyfileobj(source, output)
|
||||
return disk
|
||||
bad_config = work / 'bad.json'
|
||||
bad_config.write_text('{"format":1,"command":"sh"}\n')
|
||||
fixtures = [('invalid-settings', altered('invalid-settings', ['rm /config/machine.json', f'write {bad_config} /config/machine.json', 'set_inode_field /config/machine.json uid 0', 'set_inode_field /config/machine.json gid 0']), 'Invalid machine settings'),
|
||||
('writable-settings', altered('writable-settings', ['set_inode_field /config/machine.json mode 0100666']), 'root-owned regular'),
|
||||
('unclean-settings', altered('unclean-settings', ['set_super_value state 0']), 'unclean'),
|
||||
('symlink-settings', altered('symlink-settings', ['rm /config/machine.json', 'symlink /config/machine.json /etc/passwd']), '(os error 40)')]
|
||||
for name, disk, expected in fixtures:
|
||||
before = digest(disk)
|
||||
with VM(work, name, empty, extra=extra(disk)) as vm:
|
||||
status = enter(vm)
|
||||
assert status['source'] == 'image_defaults' and expected in status['error'], status
|
||||
vm.capture('test -z "$(findmnt -nr -o TARGET | grep /run/fds/machine/internal)"')
|
||||
assert digest(disk) == before, 'Failure path wrote internal storage'
|
||||
finish(vm)
|
||||
print('PASS: invalid JSON, writable/symlink settings and unclean ext4 preserve bytes and leave the recovery console usable', flush=True)
|
||||
link = project / 'out/m12-internal-latest.next'
|
||||
link.symlink_to(work.name)
|
||||
link.replace(project / 'out/m12-internal-latest')
|
||||
print(f'PASS: internal storage software acceptance: {work}', flush=True)
|
||||
print('SKIP: Pi EEPROM/NVMe boot, real bay calibration, power-loss and flash durability')
|
||||
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Boot independent recovery, preserve media by default, repair and replace media."""
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
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 digest, gpt, LINUX_FILESYSTEM
|
||||
from vm_test import VM
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix='m12-recovery.', dir=project / 'out'))
|
||||
rootfs = (project / 'out/rootfs-recovery.tar').resolve(strict=True)
|
||||
recovery = (project / 'out/fds-recovery.img').resolve(strict=True)
|
||||
source_system = (project / 'out/fds-system-cli.img').resolve(strict=True)
|
||||
(work / 'inputs.sha256').write_text(''.join(f'{digest(p)} {p}\n' for p in [rootfs, recovery, source_system, project / 'out/fds-initramfs.img', project / 'out/kernel/boot/kernel_2712.img']))
|
||||
controller = 'qemu-xhci,id=xhci,addr=05.0'
|
||||
# Build two independent EROFS outputs and require identical payload bytes.
|
||||
for name in ['repeat-a', 'repeat-b']:
|
||||
output = work / name
|
||||
output.mkdir()
|
||||
with (work / (name + '.log')).open('wb') as log:
|
||||
subprocess.run([str(project / 'image/build-recovery'), '--rootfs', str(rootfs), '--output-directory', str(output)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
assert digest(work / 'repeat-a/recovery.erofs') == digest(recovery) == digest(work / 'repeat-b/recovery.erofs')
|
||||
base = work / 'recovery-base.img'
|
||||
gpt(base, [('FDS_RECOVERY', LINUX_FILESYSTEM, recovery)])
|
||||
|
||||
def query(vm, args):
|
||||
try:
|
||||
return json.loads(vm.capture('fds --json ' + shlex.join(args), timeout=600 if args[0] == 'burn' else 180))
|
||||
except AssertionError:
|
||||
(work / 'last-recovery-checker.log').write_text(vm.capture('cat /run/fds/recovery/*.log 2>/dev/null || true') + '\n')
|
||||
raise
|
||||
|
||||
def wait(operation, condition, timeout=60):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
result = operation()
|
||||
if condition(result):
|
||||
return result
|
||||
assert time.monotonic() < deadline, result
|
||||
|
||||
def bay(vm, n, state):
|
||||
try:
|
||||
return wait(lambda: query(vm, ['bay', str(n)])['bays'][0], lambda row: row['state'] == state)
|
||||
except AssertionError:
|
||||
(work / (vm.name + '-failure-state.log')).write_text(vm.capture('cat /run/fds/ejected/* /run/fds/data-sessions/* 2>/dev/null; cat /proc/self/mountinfo; fds inspect BAY02; tail -n 80 /run/log/cartridged/current; dmesg | tail -n 40') + '\n')
|
||||
raise
|
||||
|
||||
def enter(vm, damaged=False):
|
||||
vm.expect(rb'SYSTEM CANNOT BE USED' if damaged else rb'FDS_SYSTEM MEDIA NOT PRESENT')
|
||||
vm.send('recovery')
|
||||
vm.expect(rb'RECOVERY# ')
|
||||
assert vm.capture('id -u') == '0'
|
||||
assert vm.capture('cat /usr/share/fds/image-profile') == 'recovery'
|
||||
wait(lambda: vm.capture('test -S /run/fds/control.sock && echo ready || echo pending'), lambda value: value == 'ready')
|
||||
|
||||
def finish(vm):
|
||||
vm.send('fds poweroff')
|
||||
vm.expect(rb'reboot: Power down', timeout=120)
|
||||
assert vm.child.wait(timeout=20) == 0
|
||||
|
||||
with VM(work, 'without-system', base, extra=['-device', controller, '-device', 'usb-kbd,bus=xhci.0,port=4']) as vm:
|
||||
enter(vm)
|
||||
info = query(vm, ['info'])
|
||||
assert info['pid1'].endswith('s6-svscan') and info['target'] == 'aarch64 static-musl'
|
||||
vm.capture('test "$(findmnt -nr -o FSTYPE /)" = erofs && findmnt -nr -o OPTIONS / | grep -qw ro')
|
||||
vm.capture('touch /etc/recovery-write-probe', ok=False)
|
||||
vm.capture('pgrep -x dasungd && command -v bash ls mount e2fsck xbps-query fds-burn fds-inspect && test -s /usr/share/doc/fds/recovery.md')
|
||||
report = wait(lambda: query(vm, ['topology']), lambda row: any('03' in d['interfaces'] for d in row['unmapped']))
|
||||
hub = next(d['topology'] for d in report['unmapped'] if '03' in d['interfaces']).rsplit('/', 1)[0]
|
||||
vm.capture('bash /usr/share/fds/capture-hardware /tmp/recovery-capture && cd /tmp/recovery-capture && sha256sum -c SHA256SUMS')
|
||||
(work / 'capture-status.tsv').write_text(vm.capture('cat /tmp/recovery-capture/status.tsv') + '\n')
|
||||
(work / 'boot-without-system.json').write_text(json.dumps(query(vm, ['boot-profile']), indent=2) + '\n')
|
||||
finish(vm)
|
||||
print('PASS: separate reproducible recovery payload; missing-SYSTEM selection, read-only root, native s6, local root console, base Dasung and diagnostic capture', flush=True)
|
||||
|
||||
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))
|
||||
# Only the virtual bay calibration differs from the independently packaged root.
|
||||
with tarfile.open(rootfs) as source, tarfile.open(work / 'calibrated.tar', 'w', format=tarfile.PAX_FORMAT) as output:
|
||||
found = False
|
||||
for member in source:
|
||||
if member.name == 'etc/fds/bays.toml':
|
||||
found = True
|
||||
data = config.encode()
|
||||
member.size = len(data)
|
||||
output.addfile(member, io.BytesIO(data))
|
||||
else:
|
||||
output.addfile(member, source.extractfile(member) if member.isfile() else None)
|
||||
assert found
|
||||
(work / 'calibrated').mkdir()
|
||||
with (work / 'calibrated.log').open('wb') as log:
|
||||
subprocess.run([str(project / 'image/build-recovery'), '--rootfs', str(work / 'calibrated.tar'), '--output-directory', str(work / 'calibrated')], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
calibrated = work / 'calibrated.img'
|
||||
gpt(calibrated, [('FDS_RECOVERY', LINUX_FILESYSTEM, work / 'calibrated/recovery.erofs')])
|
||||
|
||||
bad = work / 'bad-system.erofs'
|
||||
bad.write_bytes(bytes(4096))
|
||||
bad_disk = work / 'bad-system.img'
|
||||
gpt(bad_disk, [('FDS_SYSTEM', LINUX_FILESYSTEM, bad)])
|
||||
with VM(work, 'damaged-system', base, extra=['-device', controller, '-drive', f'file={bad_disk},if=none,id=bad,format=raw,readonly=on', '-device', 'usb-storage,id=badusb,drive=bad,bus=xhci.0,port=2']) as vm:
|
||||
enter(vm, damaged=True)
|
||||
vm.capture('test "$(findmnt -nr -o FSTYPE /)" = erofs')
|
||||
finish(vm)
|
||||
print('PASS: unreadable SYSTEM does not prevent explicit independent recovery', flush=True)
|
||||
|
||||
def data_image(name, size=128, system=False, damaged=False):
|
||||
root = work / (name + '-files')
|
||||
(root / 'FDS').mkdir(parents=True)
|
||||
(root / 'FDS/CARTRIDGE.TOML').write_text(f'format=1\n[cartridge]\nid="fds.recovery.{name}"\nname="RECOVERY {name.upper()}"\nclass="data"\nversion="1"\n[media]\nwritable=true\n')
|
||||
(root / 'precious.txt').write_text('Keep this DATA payload unchanged.\n')
|
||||
if system:
|
||||
shutil.copyfile(source_system, root / 'replacement.img')
|
||||
fs = work / (name + '.ext4')
|
||||
with fs.open('xb') as stream:
|
||||
stream.truncate(size * 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)
|
||||
if damaged:
|
||||
with (work / (name + '-corrupt.log')).open('wb') as log:
|
||||
subprocess.run(['debugfs', '-w', '-R', 'set_inode_field /precious.txt links_count 3', str(fs)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
checked = subprocess.run(['e2fsck', '-f', '-n', str(fs)], stdout=log, stderr=subprocess.STDOUT)
|
||||
assert checked.returncode == 4, checked.returncode
|
||||
disk = work / (name + '.img')
|
||||
layout = gpt(disk, [('FDS_DATA', LINUX_FILESYSTEM, fs)])
|
||||
return disk, layout
|
||||
|
||||
fault, fault_layout = data_image('fault', damaged=True)
|
||||
source, _ = data_image('source', size=768, system=True)
|
||||
clean, _ = data_image('clean')
|
||||
environment_files = work / 'environment-files'
|
||||
(environment_files / 'FDS').mkdir(parents=True)
|
||||
(environment_files / 'FDS/CARTRIDGE.TOML').write_text('format=1\n[cartridge]\nid="fds.recovery.environment"\nname="RECOVERY ENVIRONMENT TEST"\nclass="environment"\nversion="1"\n[media]\nwritable=false\n[activation]\nprofile="windowmaker"\n')
|
||||
environment_fs = work / 'environment.erofs'
|
||||
subprocess.run([str(project / 'tools/in-image-tools'), 'mkfs.erofs', '--quiet', '-b', '4096', '-T', '0', str(environment_fs), str(environment_files)], check=True)
|
||||
environment = work / 'environment.img'
|
||||
gpt(environment, [('FDS_ENVIRONMENT', LINUX_FILESYSTEM, environment_fs)])
|
||||
original = {p: digest(p) for p in [fault, source, clean, environment]}
|
||||
|
||||
with VM(work, 'maintenance', calibrated, extra=['-device', controller, '-netdev', 'user,id=recoverynet,restrict=on']) as vm:
|
||||
enter(vm)
|
||||
def attach(path, node, port, readonly=False):
|
||||
vm.qmp('blockdev-add', {'driver': 'raw', 'node-name': node, 'read-only': readonly, 'file': {'driver': 'file', 'filename': str(path)}})
|
||||
vm.qmp('device_add', {'driver': 'usb-storage', 'id': node + 'usb', 'drive': node, 'bus': 'xhci.0', 'port': str(port), 'serial': 'RECOVERY-' + node.upper()})
|
||||
def remove(node, port):
|
||||
vm.qmp('device_del', {'id': node + 'usb'})
|
||||
bay(vm, port, 'empty')
|
||||
attach(fault, 'fault', 2)
|
||||
attach(environment, 'environment', 3, True)
|
||||
vm.qmp('device_add', {'driver': 'usb-net', 'id': 'networkusb', 'netdev': 'recoverynet', 'bus': 'xhci.0', 'port': '4'})
|
||||
bay(vm, 2, 'mounted_read_only')
|
||||
bay(vm, 3, 'mounted_read_only')
|
||||
bay(vm, 4, 'hardware')
|
||||
vm.capture('test ! -e /data/FDS && ! pgrep -x Xorg && ! pgrep -x dhcpcd')
|
||||
profiles = query(vm, ['profiles'])['profiles']
|
||||
assert profiles['desktop'] == 'cli' and profiles['network'] == []
|
||||
vm.capture('s6-setuidgid fds fds recovery check 2', ok=False)
|
||||
preview = query(vm, ['recovery', 'repair', '2'])
|
||||
assert preview['checked'] is False and preview['confirmation']
|
||||
vm.capture('fds recovery repair 2 --confirm wrong', ok=False)
|
||||
assert digest(fault) == original[fault]
|
||||
vm.capture('cd /run/fds/media/02 && fds recovery check 2', ok=False)
|
||||
assert bay(vm, 2, 'mounted_read_only')['mount'] == '/run/fds/media/02'
|
||||
result = vm.capture('fds recovery check 2', ok=False)
|
||||
assert 'e2fsck returned 4' in result, result
|
||||
assert digest(fault) == original[fault], 'Read-only check changed DATA'
|
||||
fault_state = bay(vm, 2, 'error')
|
||||
vm.capture('s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged')
|
||||
assert 'recovery' in bay(vm, 2, 'error')['detail'].lower()
|
||||
vm.capture('fds data use 2', ok=False)
|
||||
repair = query(vm, ['recovery', 'repair', '2', '--confirm', preview['confirmation']])
|
||||
assert repair['checked'] and repair['repaired']
|
||||
(work / 'repair-result.json').write_text(json.dumps(repair, indent=2) + '\n')
|
||||
(work / 'repair.log').write_text(vm.capture('cat ' + shlex.quote(repair['log'])) + '\n')
|
||||
bay(vm, 2, 'safe')
|
||||
remove('fault', 2)
|
||||
attach(fault, 'repaired', 2)
|
||||
bay(vm, 2, 'mounted_read_only')
|
||||
assert vm.capture('cat /run/fds/media/02/precious.txt') == 'Keep this DATA payload unchanged.'
|
||||
vm.capture('fds recovery repair 2 --confirm ' + shlex.quote(preview['confirmation']), ok=False)
|
||||
checked = query(vm, ['recovery', 'check', '2'])
|
||||
assert checked['checked'] and not checked['repaired']
|
||||
remove('repaired', 2)
|
||||
remove('environment', 3)
|
||||
vm.qmp('device_del', {'id': 'networkusb'})
|
||||
bay(vm, 4, 'empty')
|
||||
print('PASS: no automatic DATA/GUI/network activation; root-only recovery, busy and stale-confirmation rejection, unchanged read-only check, actual ext4 repair, restart quarantine and payload preservation', flush=True)
|
||||
|
||||
attach(clean, 'clean', 2)
|
||||
bay(vm, 2, 'mounted_read_only')
|
||||
checked = query(vm, ['recovery', 'check', '2'])
|
||||
assert checked['checked'] and digest(clean) == original[clean]
|
||||
remove('clean', 2)
|
||||
attach(source, 'source', 1, True)
|
||||
bay(vm, 1, 'mounted_read_only')
|
||||
target = work / 'replacement-target.img'
|
||||
with target.open('xb') as stream:
|
||||
stream.truncate(640 * 1024 * 1024)
|
||||
attach(target, 'target', 4)
|
||||
bay(vm, 4, 'unrecognized_storage')
|
||||
wait(lambda: vm.capture('fds --json inspect BAY04 2>/dev/null || printf pending'), lambda value: value.startswith('{'))
|
||||
job = query(vm, ['burn', 'system', '/run/fds/media/01/replacement.img', 'BAY04'])
|
||||
completed = query(vm, ['burn', 'confirm', job['id'], job['confirmation']])
|
||||
assert completed['phase'] == 'complete'
|
||||
(work / 'burn-result.json').write_text(json.dumps(completed, indent=2) + '\n')
|
||||
remove('target', 4)
|
||||
# Attach an actual SYSTEM only after recovery has booted, then inspect it.
|
||||
attach(target, 'new-system', 4, True)
|
||||
inspected = bay(vm, 4, 'mounted_read_only')
|
||||
assert inspected['manifest']['cartridge']['class'] == 'system'
|
||||
vm.capture('fds recovery repair 4', ok=False)
|
||||
vm.capture('fds eject 4')
|
||||
vm.capture('test -z \"$(losetup -l -n -O NAME)\"')
|
||||
finish(vm)
|
||||
print('PASS: clean DATA check leaves bytes unchanged; recovery writes and verifies replacement SYSTEM, inspects it, excludes it from DATA repair, and shuts down through native s6', flush=True)
|
||||
|
||||
# Kill the real supervised checker while storage reads are throttled. The
|
||||
# throttle is a fault-observation fixture, never a production readiness delay.
|
||||
interrupted, _ = data_image('interrupted')
|
||||
interrupted_before = digest(interrupted)
|
||||
with VM(work, 'checker-parent-death', calibrated, extra=['-device', controller]) as vm:
|
||||
enter(vm)
|
||||
vm.qmp('blockdev-add', {'driver': 'raw', 'node-name': 'interruptdisk', 'file': {'driver': 'file', 'filename': str(interrupted)}})
|
||||
vm.qmp('object-add', {'qom-type': 'throttle-group', 'id': 'recoverylimit', 'limits': {}})
|
||||
vm.qmp('blockdev-add', {'driver': 'throttle', 'node-name': 'interruptlimited', 'throttle-group': 'recoverylimit', 'file': 'interruptdisk'})
|
||||
vm.qmp('device_add', {'driver': 'usb-storage', 'id': 'interruptusb', 'drive': 'interruptlimited', 'bus': 'xhci.0', 'port': '2', 'serial': 'RECOVERY-INTERRUPT'})
|
||||
bay(vm, 2, 'mounted_read_only')
|
||||
vm.capture('echo 3 >/proc/sys/vm/drop_caches')
|
||||
vm.qmp('qom-set', {'path': '/objects/recoverylimit', 'property': 'limits', 'value': {'bps-read': 16384}})
|
||||
vm.capture('fds recovery check 2 >/tmp/interrupted-check.log 2>&1 & echo $! >/tmp/check-client.pid')
|
||||
checker = wait(lambda: vm.capture('pgrep -x e2fsck || true'), lambda value: value.isdigit(), timeout=180)
|
||||
daemon = vm.capture('pgrep -x fds-cartridged')
|
||||
vm.capture('kill -KILL ' + daemon)
|
||||
vm.qmp('qom-set', {'path': '/objects/recoverylimit', 'property': 'limits', 'value': {'bps-read': 0}})
|
||||
wait(lambda: vm.capture('pgrep -x e2fsck || true'), lambda value: not value)
|
||||
wait(lambda: vm.capture('test -S /run/fds/control.sock && pgrep -x fds-cartridged || true'), lambda value: value.isdigit() and value != daemon)
|
||||
wait(lambda: vm.capture('fds --json bay 2 2>/dev/null || printf pending'), lambda value: value.startswith('{'))
|
||||
state = bay(vm, 2, 'error')
|
||||
assert 'recovery' in state['detail'].lower(), state
|
||||
vm.capture('fds data use 2', ok=False)
|
||||
assert digest(interrupted) == interrupted_before
|
||||
# A successful explicit retry clears only this insertion's incomplete check.
|
||||
checked = query(vm, ['recovery', 'check', '2'])
|
||||
assert checked['checked'] and not checked['repaired']
|
||||
bay(vm, 2, 'safe')
|
||||
wait(lambda: vm.capture('losetup -l -n -O NAME'), lambda value: not value)
|
||||
disk = vm.capture("""lsblk -dnpo NAME,SERIAL | awk '$2=="RECOVERY-INTERRUPT" {print $1}'""")
|
||||
assert disk.startswith('/dev/') and len(disk.splitlines()) == 1
|
||||
# eudev's short-lived blkid probes can legitimately make BLKRRPART busy.
|
||||
# Retry that observed condition, never wait for a guessed time interval.
|
||||
def reread():
|
||||
result = vm.capture('blockdev --rereadpt ' + shlex.quote(disk) + ' >/tmp/reread-result 2>&1; status=$?; cat /tmp/reread-result; printf "STATUS:%s" "$status"')
|
||||
assert result == 'STATUS:0' or 'Device or resource busy' in result, result
|
||||
return result == 'STATUS:0'
|
||||
for iteration in range(20):
|
||||
wait(reread, bool, timeout=20)
|
||||
query(vm, ['rescan'])
|
||||
bay(vm, 2, 'safe')
|
||||
bay(vm, 2, 'safe')
|
||||
vm.capture('s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged')
|
||||
bay(vm, 2, 'safe')
|
||||
(work / 'checker-interruption.json').write_text(json.dumps({'checker_pid': checker, 'daemon_pid': daemon, 'quarantine': state, 'retry': checked}, indent=2) + '\n')
|
||||
vm.capture('test -z \"$(losetup -l -n -O NAME)\"')
|
||||
finish(vm)
|
||||
print('PASS: real checker interruption kills the child, retains quarantine across daemon restart, preserves DATA bytes and allows an explicit verified retry', flush=True)
|
||||
|
||||
# Filesystem validation outside the guest and a separate boot of the written OS.
|
||||
part = fault_layout['partitions'][0]
|
||||
verified = work / 'repaired.ext4'
|
||||
with fault.open('rb') as src, verified.open('wb') as dst:
|
||||
src.seek(part['start'] * 512)
|
||||
remaining = part['payload_bytes']
|
||||
while remaining:
|
||||
chunk = src.read(min(remaining, 1024 * 1024))
|
||||
assert chunk
|
||||
dst.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
with (work / 'independent-fsck.log').open('wb') as log:
|
||||
subprocess.run(['e2fsck', '-f', '-n', str(verified)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
for path in [source, clean, environment]:
|
||||
assert digest(path) == original[path], f'Unexpected media change: {path}'
|
||||
with VM(work, 'boot-replacement', target, system_usb=True) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
assert vm.capture('id -u') == '1000'
|
||||
assert query(vm, ['info'])['pid1'].endswith('s6-svscan')
|
||||
finish(vm)
|
||||
link = project / 'out/m12-recovery-latest.next'
|
||||
link.symlink_to(work.name)
|
||||
link.replace(project / 'out/m12-recovery-latest')
|
||||
print(f'PASS: independent fsck and ordinary-user boot of the SYSTEM written by recovery: {work}', flush=True)
|
||||
print('SKIP: physical Pi boot, display, power-loss and media-controller durability')
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise comparison and frozen-lock rejection contracts without claiming an OS rebuild."""
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
loader = importlib.machinery.SourceFileLoader('fds_frozen_fixture', str(project / 'tools/frozen-inputs'))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
frozen = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(frozen)
|
||||
work = Path(tempfile.mkdtemp(prefix='m12-release-contracts.', dir=project / 'out'))
|
||||
|
||||
|
||||
def invoke(*args, ok=True):
|
||||
result = subprocess.run(list(map(str, args)), capture_output=True, text=True, timeout=60)
|
||||
with (work / 'commands.log').open('a') as log:
|
||||
log.write(repr(args) + '\n' + result.stdout + result.stderr)
|
||||
assert (result.returncode == 0) == ok, (args, result.returncode, result.stdout, result.stderr)
|
||||
return result
|
||||
|
||||
|
||||
lock = {'format': 1, 'version': '0.1.0', 'void_commit': 'a' * 40, 'source_epoch': 123,
|
||||
'source_sha256': 'b' * 64, 'rust_toolchain': '1.98.0', 'host_prerequisites': {}, 'files': []}
|
||||
snapshot = work / 'snapshot'
|
||||
(snapshot / 'project').mkdir(parents=True)
|
||||
(snapshot / 'project/source.txt').write_bytes(b'bounded input fixture\n')
|
||||
(snapshot / 'temporary').mkdir()
|
||||
(snapshot / 'temporary').chmod(0o1777)
|
||||
(snapshot / 'project/executable').write_bytes(b'archive mode fixture\n')
|
||||
(snapshot / 'project/executable').chmod(0o775)
|
||||
(snapshot / 'source-link').symlink_to('project/source.txt')
|
||||
lock['files'] = frozen.inventory(snapshot)
|
||||
lock['source_sha256'] = frozen.source_digest(lock['files'])
|
||||
(snapshot / 'lock.json').write_text(json.dumps(lock))
|
||||
verify = project / 'tools/frozen-inputs'
|
||||
invoke(verify, 'verify', snapshot)
|
||||
for case in ('bytes', 'mode', 'extra', 'missing', 'link', 'metadata'):
|
||||
damaged = work / ('damaged-' + case)
|
||||
shutil.copytree(snapshot, damaged, symlinks=True)
|
||||
leaf = damaged / 'project/source.txt'
|
||||
if case == 'bytes': leaf.write_bytes(b'changed input fixture\n')
|
||||
elif case == 'mode': leaf.chmod(0o600)
|
||||
elif case == 'extra': (damaged / 'extra').mkdir()
|
||||
elif case == 'missing': leaf.unlink()
|
||||
elif case == 'link':
|
||||
(damaged / 'source-link').unlink()
|
||||
(damaged / 'source-link').symlink_to('/etc/passwd')
|
||||
elif case == 'metadata':
|
||||
wrong = dict(lock, rust_toolchain='../unexpected')
|
||||
(damaged / 'lock.json').write_text(json.dumps(wrong))
|
||||
invoke(verify, 'verify', damaged, ok=False)
|
||||
print('PASS: frozen input byte/mode/path/symlink and metadata tampering rejected', flush=True)
|
||||
|
||||
# Exercise the actual release archive writer and the documented extraction
|
||||
# recipe. Default tar extraction masks modes and strips sticky bits, which would
|
||||
# make an otherwise intact build-input archive fail its frozen lock immediately.
|
||||
sys.path.insert(0, str(project / 'tools'))
|
||||
archive_loader = importlib.machinery.SourceFileLoader('fds_assembly_fixture', str(project / 'tools/assemble-release'))
|
||||
archive_spec = importlib.util.spec_from_loader(archive_loader.name, archive_loader)
|
||||
assembly = importlib.util.module_from_spec(archive_spec)
|
||||
archive_loader.exec_module(assembly)
|
||||
for name in ('one', 'two'):
|
||||
assembly.packed_tree(snapshot, work / f'{name}.tar.zst', lock['source_epoch'])
|
||||
assert frozen.digest(work / 'one.tar.zst') == frozen.digest(work / 'two.tar.zst')
|
||||
unpacked = work / 'unpacked'
|
||||
unpacked.mkdir()
|
||||
invoke('tar', '--extract', '--zstd', '--same-permissions', '--no-same-owner',
|
||||
'--file', work / 'one.tar.zst', '--directory', unpacked)
|
||||
invoke(verify, 'verify', unpacked)
|
||||
print('PASS: deterministic release archive and complete mode/symlink-preserving extraction', flush=True)
|
||||
|
||||
first, second = work / 'first', work / 'second'
|
||||
paths = [f'out/{name}' for name in ('fds-system-cli.img', 'fds-system-development.img',
|
||||
'fds-recovery.img', 'fds-boot.img', 'fds-internal.img', 'rootfs-cli.tar',
|
||||
'rootfs-development.tar', 'rootfs-recovery.tar')]
|
||||
paths += ['out/initramfs/initramfs.cpio' + suffix for suffix in ('', '.gz', '.lz4', '.zst')]
|
||||
paths += ['out/kernel/boot/' + name for name in ('kernel_2712.img', 'bcm2712-rpi-5-b.dtb')]
|
||||
paths += ['out/eeprom-production-latest/' + name for name in ('configured.bin', 'rollback.bin', 'configured.conf', 'original.conf')]
|
||||
for name in ('base', 'base-files', 'init', 'cli', 'cartridged', 'dasungd', 'kernel', 'dhcpcd', 'eink'):
|
||||
paths.append(f'out/packages/fds-{name}-0.1.0_1.aarch64.xbps')
|
||||
for name in paths:
|
||||
path = first / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(hashlib.sha256(name.encode()).digest())
|
||||
(first / '.host/frozen').mkdir(parents=True)
|
||||
(first / '.host/frozen/lock.json').write_text(json.dumps(lock))
|
||||
shutil.copytree(first, second)
|
||||
compare = project / 'tools/compare-builds'
|
||||
invoke(compare, first, second, '--report', work / 'equal.json')
|
||||
result = json.loads((work / 'equal.json').read_text())
|
||||
assert result['status'] == 'passed' and len(result['artifacts']) == 27
|
||||
invoke(compare, first, first, '--report', work / 'same-tree.json', ok=False)
|
||||
invoke(compare, first, second, '--report', work / 'equal.json', ok=False)
|
||||
changed = second / 'out/fds-system-cli.img'
|
||||
changed.write_bytes(b'changed image fixture')
|
||||
invoke(compare, first, second, '--report', work / 'different.json', ok=False)
|
||||
result = json.loads((work / 'different.json').read_text())
|
||||
assert result['status'] == 'failed'
|
||||
assert [x['name'] for x in result['artifacts'] if not x['identical']] == ['fds-system-cli.img']
|
||||
changed.unlink()
|
||||
invoke(compare, first, second, '--report', work / 'missing.json', ok=False)
|
||||
assert not (work / 'missing.json').exists()
|
||||
shutil.copyfile(first / 'out/fds-system-cli.img', changed)
|
||||
(second / '.host/frozen/lock.json').write_text(json.dumps(dict(lock, source_epoch=456)))
|
||||
invoke(compare, first, second, '--report', work / 'wrong-inputs.json', ok=False)
|
||||
assert not (work / 'wrong-inputs.json').exists()
|
||||
print('PASS: complete comparison, changed/missing artifact, same-tree, existing-report and different-input rejection', flush=True)
|
||||
|
||||
# Even before a complete build exists, assembly must reject an existing output
|
||||
# and must not resolve a private-key symlink past the signer's no-follow policy.
|
||||
key = work / 'test.key'
|
||||
key.write_bytes(bytes(32))
|
||||
key.chmod(0o600)
|
||||
output = work / 'existing-output'
|
||||
output.mkdir()
|
||||
(output / 'marker').write_text('preserve')
|
||||
command = [project / 'tools/assemble-release', '--inputs', snapshot, '--comparison-build', second,
|
||||
'--output-directory', output, '--key', key]
|
||||
invoke(*command, ok=False)
|
||||
assert (output / 'marker').read_text() == 'preserve' and len(list(output.iterdir())) == 1
|
||||
linked = work / 'linked.key'
|
||||
linked.symlink_to(key)
|
||||
command[-1] = linked
|
||||
result = invoke(*command, ok=False)
|
||||
assert 'not a symlink' in result.stderr
|
||||
print(f'PASS: release output preservation and private-key symlink rejection: {work}')
|
||||
print('NOTE: these are small contract fixtures; actual source-to-image offline reproduction remains a separate gate')
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify release signatures with independent OpenSSL and native/static ARM tools."""
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host-only', action='store_true')
|
||||
args = parser.parse_args()
|
||||
work = Path(tempfile.mkdtemp(prefix='m12-signing.', dir=project / 'out'))
|
||||
host = project / 'target/x86_64-unknown-linux-gnu/release/fds-release'
|
||||
arm = project / 'target/aarch64-unknown-linux-musl/release/fds-release'
|
||||
commands = [('host', [str(host)])]
|
||||
if not args.host_only:
|
||||
commands.append(('arm', [str(project / 'tools/in-void'), '--isolated-network', 'qemu-aarch64', str(arm)]))
|
||||
log = (work / 'commands.log').open('w')
|
||||
|
||||
def invoke(prefix, arguments, ok=True):
|
||||
result = subprocess.run([*prefix, *map(str, arguments)], capture_output=True, text=True, timeout=60)
|
||||
log.write('COMMAND ' + repr([*prefix, *map(str, arguments)]) + '\n' + result.stdout + result.stderr)
|
||||
log.flush()
|
||||
assert (result.returncode == 0) == ok, (arguments, result.returncode, result.stdout, result.stderr)
|
||||
return result.stdout + result.stderr
|
||||
|
||||
def check_all(directory, key, ok=True):
|
||||
for label, command in commands:
|
||||
result = invoke(command, ['verify', directory, '--key', key], ok)
|
||||
if ok: assert 'VERIFIED FDS/OS 0.1.0' in result
|
||||
|
||||
key_prefix = work / 'test-key'
|
||||
invoke([str(host)], ['keygen', key_prefix])
|
||||
private, public = work / 'test-key.key', work / 'test-key.pub'
|
||||
assert private.stat().st_size == 32 and stat.S_IMODE(private.stat().st_mode) == 0o600
|
||||
invoke([str(host)], ['keygen', key_prefix], False)
|
||||
invoke([str(host)], ['public-key', private, work / 'exported.pub'])
|
||||
assert (work / 'exported.pub').read_bytes() == public.read_bytes()
|
||||
release = work / 'release'
|
||||
release.mkdir()
|
||||
payload = b'FDS signed artifact acceptance.\n' * 8192
|
||||
(release / 'image.img').write_bytes(payload)
|
||||
manifest = {'format': 1, 'version': '0.1.0', 'source_epoch': 1,
|
||||
'source_sha256': hashlib.sha256(b'fixture source').hexdigest(),
|
||||
'void_commit': (project / 'VOID_PACKAGES_COMMIT').read_text().strip(),
|
||||
'hardware_validation': 'deferred',
|
||||
'files': [{'name': 'image.img', 'bytes': len(payload), 'sha256': hashlib.sha256(payload).hexdigest()}]}
|
||||
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
invoke([str(host)], ['sign', release, '--key', private])
|
||||
check_all(release, public)
|
||||
invoke([str(host)], ['sign', release, '--key', private], False)
|
||||
|
||||
# Independent standard Ed25519 verification/signing over the exact domain prefix
|
||||
# and raw manifest bytes. Test-only DER files are private and never printed.
|
||||
message = work / 'message'
|
||||
message.write_bytes(b'FDS/OS release manifest v1\0' + (release / 'manifest.json').read_bytes())
|
||||
private_der = work / 'private.der'
|
||||
fd = os.open(private_der, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(bytes.fromhex('302e020100300506032b657004220420') + private.read_bytes())
|
||||
public_der = work / 'public.der'
|
||||
public_der.write_bytes(bytes.fromhex('302a300506032b6570032100') + bytes.fromhex(public.read_text().strip()))
|
||||
signature = work / 'signature.bin'
|
||||
signature.write_bytes(bytes.fromhex((release / 'manifest.sig').read_text().strip()))
|
||||
invoke(['openssl'], ['pkeyutl', '-verify', '-rawin', '-pubin', '-inkey', public_der, '-keyform', 'DER', '-in', message, '-sigfile', signature])
|
||||
invoke(['openssl'], ['pkeyutl', '-sign', '-rawin', '-inkey', private_der, '-keyform', 'DER', '-in', message, '-out', work / 'openssl.sig'])
|
||||
assert (work / 'openssl.sig').read_bytes() == signature.read_bytes()
|
||||
(release / 'manifest.sig').write_text((work / 'openssl.sig').read_bytes().hex() + '\n')
|
||||
check_all(release, public)
|
||||
print('PASS: actual key generation, no-overwrite policy, deterministic signatures and independent OpenSSL interoperability', flush=True)
|
||||
|
||||
other = work / 'other-key'
|
||||
invoke([str(host)], ['keygen', other])
|
||||
check_all(release, work / 'other-key.pub', False)
|
||||
weak = work / 'weak.pub'
|
||||
weak.write_text('01' + '00' * 31 + '\n')
|
||||
check_all(release, weak, False)
|
||||
private.chmod(0o644)
|
||||
invoke([str(host)], ['public-key', private, work / 'insecure.pub'], False)
|
||||
private.chmod(0o600)
|
||||
|
||||
for name in ['payload', 'size', 'missing', 'signature', 'manifest', 'symlink', 'fifo']:
|
||||
damaged = work / ('bad-' + name)
|
||||
shutil.copytree(release, damaged)
|
||||
target = damaged / 'image.img'
|
||||
if name == 'payload':
|
||||
data = bytearray(target.read_bytes()); data[4096] ^= 1; target.write_bytes(data)
|
||||
elif name == 'size':
|
||||
with target.open('ab') as stream: stream.write(b'X')
|
||||
elif name == 'missing': target.unlink()
|
||||
elif name == 'signature': (damaged / 'manifest.sig').write_text('00' * 64 + '\n')
|
||||
elif name == 'manifest':
|
||||
# A semantically identical JSON document still changes the signed bytes.
|
||||
with (damaged / 'manifest.json').open('a') as stream: stream.write('\n')
|
||||
elif name == 'symlink':
|
||||
target.unlink(); target.symlink_to(release / 'image.img')
|
||||
elif name == 'fifo':
|
||||
target.unlink(); os.mkfifo(target, 0o600)
|
||||
check_all(damaged, public, False)
|
||||
|
||||
for name in ['traversal', 'duplicate', 'unknown', 'wrong-version', 'unbounded']:
|
||||
bad = work / ('manifest-' + name)
|
||||
bad.mkdir()
|
||||
value = copy.deepcopy(manifest)
|
||||
if name == 'traversal': value['files'][0]['name'] = '../image.img'
|
||||
elif name == 'duplicate': value['files'].append(copy.deepcopy(value['files'][0]))
|
||||
elif name == 'unknown': value['execute'] = 'sh'
|
||||
elif name == 'wrong-version': value['version'] = '999'
|
||||
(bad / 'manifest.json').write_text(json.dumps(value) + (' ' * (1024 * 1024) if name == 'unbounded' else ''))
|
||||
(bad / 'manifest.sig').write_bytes((release / 'manifest.sig').read_bytes())
|
||||
check_all(bad, public, False)
|
||||
|
||||
# The ARM signer must produce the same bytes for the same manifest and key.
|
||||
if not args.host_only:
|
||||
counterpart = work / 'arm-signed'
|
||||
shutil.copytree(release, counterpart)
|
||||
(counterpart / 'manifest.sig').unlink()
|
||||
invoke(commands[1][1], ['sign', counterpart, '--key', private])
|
||||
assert (counterpart / 'manifest.sig').read_bytes() == (release / 'manifest.sig').read_bytes()
|
||||
check_all(counterpart, public)
|
||||
log.close()
|
||||
link = project / ('out/m12-signing-host-latest.next' if args.host_only else 'out/m12-signing-latest.next')
|
||||
link.symlink_to(work.name)
|
||||
link.replace(link.with_suffix(''))
|
||||
print(f'PASS: {"host" if args.host_only else "host and actual static ARM"} signature/artifact verification and rejection matrix: {work}', flush=True)
|
||||
print('NOTE: this is a tool acceptance fixture, not a signed FDS/OS release or a secure-boot claim')
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
use_xbps
|
||||
[[ -f out/rootfs-cli.tar ]] || die 'Run make rootfs PROFILE=cli first'
|
||||
if ! xbps-query -r "$FDS_VOID/masterdir-x86_64" qemu-system-aarch64 >/dev/null 2>&1; then
|
||||
tools/in-void xbps-install -y qemu-system-aarch64
|
||||
fi
|
||||
tools/prepare-vm
|
||||
work=$(mktemp -d "$FDS_ROOT/out/m2-vm.XXXXXX")
|
||||
python3 tools/make-vm-image "$work"
|
||||
sha256sum config/vm-test.conf tools/prepare-vm tools/make-vm-image tools/run-vm \
|
||||
tests/integration/m2-checks tests/integration/m2-guest >"$work/test-inputs.sha256"
|
||||
sha256sum "$work/rootfs.ext4" >"$work/disk-before.sha256"
|
||||
{
|
||||
cat config/vm-test.conf
|
||||
printf '\n'
|
||||
tools/in-void qemu-system-aarch64 --version
|
||||
mkfs.ext4 -V 2>&1
|
||||
} >"$work/test-environment.txt"
|
||||
printf 'ARM boot test artifacts: %s\n' "$work"
|
||||
timeout --foreground 180 tools/run-vm "$work" 2>&1 | tee "$work/serial.log"
|
||||
! grep -q 'FDS_M2_FAIL\|Kernel panic' "$work/serial.log" || die 'ARM guest reported a failure'
|
||||
for marker in FDS_M2_PID1 FDS_M2_FILESYSTEM FDS_M2_BOOT FDS_M2_SERVICE FDS_M2_PASS; do
|
||||
grep -q "$marker" "$work/serial.log" || die "Missing guest evidence: $marker"
|
||||
done
|
||||
grep -q 'Power down' "$work/serial.log" || die 'Guest did not complete orderly power-off'
|
||||
sha256sum -c "$work/disk-before.sha256"
|
||||
sha256sum out/rootfs-cli.tar "$work/test-rootfs.tar" "$work/rootfs.ext4" \
|
||||
out/vm-kernel/boot/vmlinux-* >"$work/artifacts.sha256"
|
||||
ln -sfn "${work##*/}" out/m2-vm-latest
|
||||
printf 'PASS: M2 ARM boot, native s6 PID 1, service control, and orderly power-off\n'
|
||||
printf 'SKIP: physical Pi boot and Dasung display recovery require M4/hardware\n'
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Test-image-only stage-2 child. It never ships in the normal rootfs or opens hardware.
|
||||
set -euo pipefail
|
||||
exec >/dev/console 2>&1
|
||||
trap 'printf "FDS_M2_FAIL: guest check failed at line %s\n" "$LINENO"; /usr/bin/poweroff' ERR
|
||||
|
||||
[[ $(readlink /proc/1/exe) == /usr/bin/s6-svscan ]]
|
||||
printf 'FDS_M2_PID1: %s\n' "$(readlink /proc/1/exe)"
|
||||
[[ $(findmnt -n -o FSTYPE /) == "${FDS_TEST_ROOT_TYPE:-ext4}" ]]
|
||||
findmnt -n -o OPTIONS / | grep -qw ro
|
||||
if touch /etc/fds-unexpected-write 2>/dev/null; then
|
||||
printf 'ERROR: the root filesystem unexpectedly accepted a write\n'
|
||||
false
|
||||
fi
|
||||
for mount in /run /tmp /var/tmp /var/log; do
|
||||
[[ $(findmnt -n -o FSTYPE "$mount") == tmpfs ]]
|
||||
done
|
||||
[[ $(hostname) == fds ]]
|
||||
[[ $(stat -c %u:%g:%a /) == 0:0:755 ]]
|
||||
[[ $(stat -c %u /etc/shadow) == 0 && $(stat -c %a /etc/shadow) == 600 ]]
|
||||
getcap /usr/bin/iputils-ping | grep -q cap_net_raw
|
||||
[[ $(stat -c %g /usr/lib/utempter/utempter) == 14 ]]
|
||||
ip -o link show dev lo | grep -q UP
|
||||
printf 'FDS_M2_FILESYSTEM: read-only root, writable tmpfs, root ownership and capabilities\n'
|
||||
|
||||
# Wait on s6 readiness/events, not elapsed time or a boot-time compiler.
|
||||
s6-svwait -U -t 15000 /run/service/eudevd
|
||||
udevadm control --timeout=2 --log-priority=info
|
||||
s6-svstat /run/service/getty
|
||||
s6-svstat /run/service/dasungd
|
||||
s6-svwait -U -t 15000 /run/service/cartridged
|
||||
fds bays | grep -q "BAY 12"
|
||||
if [[ $(cat /usr/share/fds/image-profile) != recovery ]]; then
|
||||
if fds recovery check 2 >/tmp/recovery-rejection 2>&1; then
|
||||
printf 'ERROR: normal SYSTEM accepted a privileged recovery operation\n'
|
||||
false
|
||||
fi
|
||||
grep -q 'requires the local root console in the recovery image' /tmp/recovery-rejection
|
||||
printf 'FDS_M12_RECOVERY_BOUNDARY: normal SYSTEM rejects recovery operations even for root\n'
|
||||
fi
|
||||
[[ $(s6-svstat -o up /run/service/getty) == true ]]
|
||||
[[ $(s6-svstat -o up /run/service/dasungd) == true ]]
|
||||
# Getty has now been verified. Stop it before reporting the remaining checks:
|
||||
# its shutdown hangup can flush queued console output, including the final marker.
|
||||
s6-rc -l /run/s6-rc -t 5000 -d change getty
|
||||
exec >/dev/console 2>&1
|
||||
printf 'FDS_M2_BOOT: getty, eudev and Dasung supervised without a monitor\n'
|
||||
|
||||
# The original rc.init completed the boot transaction before invoking this child.
|
||||
[[ $(s6-svstat -o up /run/service/test-echo) == false ]]
|
||||
s6-rc -l /run/s6-rc -t 10000 -u change test-echo
|
||||
[[ $(s6-svstat -o up,ready /run/service/test-echo) == 'true true' ]]
|
||||
response=$(printf 'FDS echo roundtrip\n' | s6-ipcclient /run/fds-test/echo.sock s6-ioconnect -t 3000)
|
||||
[[ $response == 'FDS echo roundtrip' ]]
|
||||
old_pid=$(s6-svstat -o pid /run/service/test-echo)
|
||||
s6-svc -wR -T 5000 -r /run/service/test-echo
|
||||
new_pid=$(s6-svstat -o pid /run/service/test-echo)
|
||||
[[ $new_pid != "$old_pid" ]]
|
||||
s6-rc -l /run/s6-rc -t 5000 -d change test-echo
|
||||
[[ $(s6-svstat -o up /run/service/test-echo) == false ]]
|
||||
s6-rc -l /run/s6-rc -t 5000 -u change test-echo
|
||||
[[ $(s6-svstat -o up,ready /run/service/test-echo) == 'true true' ]]
|
||||
s6-rc -l /run/s6-rc -t 5000 -d change test-echo
|
||||
printf 'FDS_M2_SERVICE: s6-rc start, echo, supervised restart, stop and start passed\n'
|
||||
printf 'FDS_M2_PASS\n'
|
||||
if grep -qw 'fds.vm_shell=1' /proc/cmdline; then
|
||||
s6-rc -l /run/s6-rc -t 5000 -d change getty
|
||||
printf '\nFDS development VM: temporary root shell; no network or host devices.\n'
|
||||
printf 'Use s6-rc to control test-echo. Type exit to power off this VM.\n'
|
||||
setsid --ctty /bin/bash --login </dev/console >/dev/console 2>&1
|
||||
fi
|
||||
/usr/bin/poweroff
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
use_xbps
|
||||
need python3
|
||||
[[ -f out/manifests/fds-tools.sha256 ]] || die 'Run make tooling first'
|
||||
sha256sum -c out/manifests/fds-tools.sha256 out/manifests/fds-tools-inputs.sha256
|
||||
cargo test --locked --offline --target x86_64-unknown-linux-gnu \
|
||||
-p fds-common -p fds-cli -p fds-stage0 -p fds-boottrace -p fds-cartridged -p fds-burn -p fds-release
|
||||
for binary in fds fds-stage0 fds-boottrace fds-cartridged fds-profile fds-burn fds-inspect fds-eject fds-power fds-release; do
|
||||
tools/verify-elf "out/$binary" aarch64 static
|
||||
# ldd is supplemental only; the ELF checker establishes actual linkage.
|
||||
ldd "out/$binary" 2>&1 || true
|
||||
done
|
||||
if ! xbps-query -r "$FDS_VOID/masterdir-x86_64" qemu-user-aarch64 >/dev/null 2>&1; then
|
||||
tools/in-void xbps-install -y qemu-user-aarch64
|
||||
fi
|
||||
python3 tests/integration/m3-runtime.py
|
||||
printf 'PASS: M3 shared contracts, static ARM tools and diagnostic execution\n'
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the actual ARM programs against disposable metadata and sysfs fixtures."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
|
||||
def run(binary, *args, ok=True):
|
||||
result = subprocess.run([str(project/'tools/in-void'), 'qemu-aarch64',
|
||||
str(project/'out'/binary), *map(str, args)],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
assert (result.returncode == 0) == ok, (args, result.returncode, result.stdout, result.stderr)
|
||||
return result.stdout
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='m3-', dir=project/'out') as directory:
|
||||
work = Path(directory)
|
||||
sysfs = work/'sys'
|
||||
blocks = sysfs/'class/block'
|
||||
blocks.mkdir(parents=True)
|
||||
for helper in ['fds', 'fds-burn', 'fds-inspect', 'fds-eject', 'fds-power',
|
||||
'fds-stage0', 'fds-boottrace', 'fds-cartridged', 'fds-profile', 'fds-release']:
|
||||
assert f'Usage: {helper}' in run(helper, '--help')
|
||||
assert '0.1.0' in run(helper, '--version')
|
||||
assert 'activate windowmaker' in run('fds-profile', '--help')
|
||||
run('fds-profile', 'activate', 'windowmaker', ok=False)
|
||||
assert '0.1.0' in run('fds', '--version')
|
||||
assert '0.1.0' in run('fds-stage0', '--version')
|
||||
run('fds-stage0', ok=False) # Must refuse root-switch operations outside PID 1.
|
||||
assert json.loads(run('fds', '--json', 'info'))['target'] == 'aarch64 static-musl'
|
||||
fixture = project/'tests/fixtures/manifests/windowmaker.toml'
|
||||
assert json.loads(run('fds', '--json', 'inspect', fixture))['activation']['profile'] == 'windowmaker'
|
||||
assert json.loads(run('fds-inspect', fixture, '--json'))['activation']['profile'] == 'windowmaker'
|
||||
assert json.loads(run('fds', 'inspect', fixture, '--json'))['activation']['profile'] == 'windowmaker'
|
||||
assert 'BAY' in run('fds', 'format', 'data', '--help')
|
||||
assert 'TOKEN' in run('fds', 'recovery', 'repair', '--help')
|
||||
# Parse the legacy image form with global options in both positions. The
|
||||
# deliberately absent regular file reaches the operation's filesystem check.
|
||||
for binary, arguments in [
|
||||
('fds', ['inspect', '--json', 'image']),
|
||||
('fds-inspect', ['--json', 'image']),
|
||||
('fds-inspect', ['image', '--json']),
|
||||
]:
|
||||
result = subprocess.run([str(project/'tools/in-void'), 'qemu-aarch64',
|
||||
str(project/'out'/binary), *arguments, str(work/'absent.img')],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
assert result.returncode == 2 and result.stderr.startswith('fds: No such file'), result
|
||||
for binary, arguments in [
|
||||
('fds', ['run', '1', '/bin/app']),
|
||||
('fds', ['recovery', 'repair', '1', '--confirm']),
|
||||
('fds', ['format', 'system', 'root', '1', '--label', 'wrong']),
|
||||
('fds-burn', ['data', '1', '--label', 'one', '--label', 'two']),
|
||||
('fds-burn', ['--worker', '--unknown']),
|
||||
('fds-cartridged', ['--notify', '--topology', '/sys']),
|
||||
('fds-stage0', ['--probe', '/sys', '--check-cmdline', 'cmdline']),
|
||||
('fds-profile', ['--session', 'activate', 'cli']),
|
||||
('fds-power', ['--record-final', 'invalid']),
|
||||
('fds-release', ['sign', 'directory']),
|
||||
]:
|
||||
result = subprocess.run([str(project/'tools/in-void'), 'qemu-aarch64',
|
||||
str(project/'out'/binary), *arguments],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
assert result.returncode == 2, (binary, arguments, result)
|
||||
assert 'error:' in result.stderr and '--help' in result.stderr, result
|
||||
malformed = work/'bad.toml'
|
||||
malformed.write_text(fixture.read_text().replace('profile = "windowmaker"', 'run = "/FDS/autorun.sh"'))
|
||||
run('fds', 'inspect', malformed, ok=False)
|
||||
malformed.write_bytes(b' ' * 65537)
|
||||
run('fds', 'inspect', malformed, ok=False)
|
||||
run('fds', 'eject', '99', ok=False)
|
||||
assert json.loads(run('fds-stage0', '--probe', sysfs))['state'] == 'missing'
|
||||
for name, major, minor in [('sdz1', 8, 241), ('nvme0n1p2', 259, 2)]:
|
||||
path = blocks/name
|
||||
path.mkdir()
|
||||
(path/'uevent').write_text(f'DEVTYPE=partition\nDEVNAME={name}\nMAJOR={major}\nMINOR={minor}\nPARTNAME=FDS_SYSTEM\n')
|
||||
result = json.loads(run('fds-stage0', '--probe', sysfs))
|
||||
assert result['state'] == ('unique' if name == 'sdz1' else 'ambiguous')
|
||||
# A path escape must not turn a kernel identity into an arbitrary device path.
|
||||
(blocks/'sdz1/uevent').write_text('DEVTYPE=partition\nDEVNAME=../../etc/shadow\nMAJOR=8\nMINOR=241\nPARTNAME=FDS_SYSTEM\n')
|
||||
run('fds-stage0', '--probe', sysfs, ok=False)
|
||||
cmdline = work/'cmdline'
|
||||
cmdline.write_text('console=ttyAMA0 fds.boot=recovery fds.debug=1\n')
|
||||
assert json.loads(run('fds-stage0', '--check-cmdline', cmdline)) == {'mode': 'recovery', 'debug': True, 'emulator': False}
|
||||
cmdline.write_text('fds.emulator=1')
|
||||
assert json.loads(run('fds-stage0', '--check-cmdline', cmdline)) == {'mode': 'normal', 'debug': False, 'emulator': True}
|
||||
cmdline.write_text('fds.emulator=2')
|
||||
run('fds-stage0', '--check-cmdline', cmdline, ok=False)
|
||||
cmdline.write_text('fds.boot=normal fds.boot=recovery')
|
||||
run('fds-stage0', '--check-cmdline', cmdline, ok=False)
|
||||
print('PASS: real ARM CLI, bounded/strict manifests, missing/unique/ambiguous discovery and boot options')
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
||||
cd "$FDS_ROOT"
|
||||
for file in out/kernel/boot/kernel_2712.img out/initramfs/initramfs.cpio out/rootfs-cli.tar; do
|
||||
[[ -s $file ]] || die "Build required input first: $file"
|
||||
done
|
||||
tools/in-void qemu-system-aarch64 --version
|
||||
work=$(mktemp -d "$FDS_ROOT/out/m4-vm.XXXXXX")
|
||||
tools/make-stage0-vm "$work" >"$work.image-build.log" 2>&1
|
||||
mv "$work.image-build.log" "$work/image-build.log"
|
||||
sha256sum out/kernel/boot/kernel_2712.img out/initramfs/initramfs.cpio* \
|
||||
out/rootfs-cli.tar "$work/test-rootfs.tar" >"$work/artifacts.sha256"
|
||||
sha256sum tools/make-stage0-vm tests/integration/m4-{checks,runtime.py,guest} \
|
||||
tests/integration/m2-guest >"$work/test-inputs.sha256"
|
||||
python3 tests/integration/m4-runtime.py "$work"
|
||||
ln -sfn "${work##*/}" out/m4-vm-latest
|
||||
printf 'PASS: M4 software boot matrix: %s\n' "$work"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Test-only checker, invoked after the unmodified base boot transaction.
|
||||
set -euo pipefail
|
||||
exec >/dev/console 2>&1
|
||||
trap 'printf "FDS_M4_FAIL: check failed at line %s\n" "$LINENO"; /usr/bin/poweroff' ERR
|
||||
[[ $(findmnt -n -o FSTYPE /) == erofs ]]
|
||||
[[ $(cat /proc/sys/kernel/osrelease) == 6.12.87-fds1* ]]
|
||||
[[ $(getconf PAGESIZE) == 16384 ]]
|
||||
[[ $(findmnt -n -o FSTYPE /dev) == devtmpfs ]]
|
||||
[[ $(findmnt -n -o FSTYPE /proc) == proc ]]
|
||||
[[ $(findmnt -n -o FSTYPE /sys) == sysfs ]]
|
||||
[[ $(readlink /proc/1/exe) == /usr/bin/s6-svscan ]]
|
||||
[[ $(pgrep -x dasungd | wc -l) == 1 ]]
|
||||
! pgrep -fa '^/sbin/dasungd --config /etc/dasungd-early.toml daemon$'
|
||||
[[ -s /FDS/CARTRIDGE.TOML ]]
|
||||
fds --json info | grep -q 'aarch64 static-musl'
|
||||
[[ $(cat /usr/share/fds/kernel-release) == "$(uname -r)" ]]
|
||||
modprobe dummy numdummies=0
|
||||
grep -q '^dummy ' /proc/modules
|
||||
modprobe -r dummy
|
||||
printf 'FDS_M4_PASS: Pi kernel/modules, static FDS CLI, 16K pages, EROFS, mount handoff, s6 PID1, single base Dasung owner\n'
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise production stage0 and the Pi kernel using isolated ARM VM disks."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project/'tools'))
|
||||
from image_formats import digest, gpt, LINUX_FILESYSTEM
|
||||
|
||||
work = Path(sys.argv[1]).resolve(strict=True)
|
||||
system = work/'system/system.img'
|
||||
recovery = work/'recovery.img'
|
||||
gpt(recovery, [('FDS_RECOVERY', LINUX_FILESYSTEM, work/'system/system.erofs')])
|
||||
invalid = work/'invalid.img'
|
||||
(work/'invalid.payload').write_bytes(bytes(1048576))
|
||||
gpt(invalid, [('FDS_SYSTEM', LINUX_FILESYSTEM, work/'invalid.payload')])
|
||||
foreign = work/'foreign.img'
|
||||
with tarfile.open(work/'foreign.tar', 'w') as archive:
|
||||
payload = b'ID=another-os\n'
|
||||
info = tarfile.TarInfo('usr/lib/os-release')
|
||||
info.mode = 0o644
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
subprocess.run([str(project/'tools/in-image-tools'), 'mkfs.erofs', '-T', '0', '--tar=f',
|
||||
str(work/'foreign.erofs'), str(work/'foreign.tar')], check=True, stdout=subprocess.DEVNULL)
|
||||
gpt(foreign, [('FDS_SYSTEM', LINUX_FILESYSTEM, work/'foreign.erofs')])
|
||||
images = {'system': system, 'recovery': recovery, 'invalid': invalid, 'foreign': foreign}
|
||||
before = {str(path): digest(path) for path in images.values()}
|
||||
|
||||
|
||||
def run_case(name, suffix='', disks=('system',), command_line='', hot_insert=False, recover=False, wait_message=b'MULTIPLE FDS_SYSTEM CARTRIDGES (2)'):
|
||||
qmp_path = work/(name+'.qmp')
|
||||
log = work/(name+'.log')
|
||||
command = [str(project/'tools/in-void'), 'qemu-system-aarch64',
|
||||
'-machine', 'virt', '-cpu', 'max', '-accel', 'tcg', '-m', '1024', '-smp', '2',
|
||||
'-nodefaults', '-display', 'none', '-serial', 'stdio', '-nic', 'none', '-no-reboot',
|
||||
'-qmp', f'unix:{qmp_path},server=on,wait=off',
|
||||
'-kernel', str(project/'out/kernel/boot/kernel_2712.img'),
|
||||
'-initrd', str(project/'out/initramfs'/('initramfs.cpio'+suffix)),
|
||||
'-append', 'console=ttyAMA0 rdinit=/init panic=-1 fds.debug=1 '+command_line]
|
||||
for number, disk in enumerate(disks):
|
||||
image = images[disk]
|
||||
command += ['-drive', f'file={image},if=none,id=disk{number},format=raw,readonly=on',
|
||||
'-device', f'virtio-blk-pci,drive=disk{number}']
|
||||
if hot_insert:
|
||||
command += ['-device', 'qemu-xhci,id=xhci', '-drive',
|
||||
f'file={system},if=none,id=inserted,format=raw,readonly=on']
|
||||
data = bytearray()
|
||||
transition = False
|
||||
child = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(child.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic()+120
|
||||
try:
|
||||
with log.open('wb') as stream:
|
||||
while selector.get_map():
|
||||
remaining = deadline-time.monotonic()
|
||||
assert remaining > 0, f'{name}: boot deadline; inspect {log}'
|
||||
for key, _ in selector.select(remaining):
|
||||
chunk = os.read(key.fd, 65536)
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
continue
|
||||
stream.write(chunk)
|
||||
stream.flush()
|
||||
data.extend(chunk)
|
||||
assert b'Kernel panic' not in data and b'FDS_STAGE0_ERROR' not in data, f'{name}: boot failure; {log}'
|
||||
assert b'FDS_M2_FAIL' not in data and b'FDS_M4_FAIL' not in data, f'{name}: guest check failed; {log}'
|
||||
if hot_insert and not transition and b'FDS_STAGE0_WAIT' in data:
|
||||
assert b'FDS_SYSTEM MEDIA NOT PRESENT' in data and b'FDS_STAGE0_HANDOFF' not in data
|
||||
# The prompt proves initial discovery is finished. Now inject a USB event.
|
||||
with socket.socket(socket.AF_UNIX) as control:
|
||||
control.settimeout(10)
|
||||
control.connect(str(qmp_path))
|
||||
with control.makefile('rwb') as protocol:
|
||||
assert 'QMP' in json.loads(protocol.readline())
|
||||
def request(execute, arguments=None):
|
||||
protocol.write((json.dumps({'execute': execute, 'arguments': arguments or {}})+'\n').encode())
|
||||
protocol.flush()
|
||||
while True:
|
||||
reply = json.loads(protocol.readline())
|
||||
if 'event' in reply: continue
|
||||
assert 'return' in reply, reply
|
||||
return reply
|
||||
request('qmp_capabilities')
|
||||
request('device_add', {'driver': 'usb-storage', 'drive': 'inserted', 'bus': 'xhci.0'})
|
||||
transition = True
|
||||
if recover and not transition and b'FDS_STAGE0_WAIT' in data:
|
||||
assert wait_message in data, f'{name}: missing expected refusal; {log}'
|
||||
assert b'FDS_STAGE0_HANDOFF' not in data
|
||||
child.stdin.write(b'recovery\n')
|
||||
child.stdin.flush()
|
||||
transition = True
|
||||
assert child.wait(timeout=10) == 0, f'{name}: QEMU failed'
|
||||
finally:
|
||||
selector.close()
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
try: child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
for marker in (b'FDS_STAGE0_ROOT', b'FDS_STAGE0_HANDOFF', b'FDS_M4_PASS', b'FDS_M2_PASS', b'Power down'):
|
||||
assert marker in data, f'{name}: missing {marker!r}; inspect {log}'
|
||||
if hot_insert or recover: assert transition
|
||||
if recover or 'fds.boot=recovery' in command_line:
|
||||
assert b'FDS_STAGE0_ROOT: FDS_RECOVERY' in data
|
||||
print(f'PASS: {name}', flush=True)
|
||||
|
||||
|
||||
for name, suffix in [('uncompressed', ''), ('gzip', '.gz'), ('lz4', '.lz4'), ('zstandard', '.zst')]:
|
||||
run_case(name, suffix)
|
||||
run_case('missing-then-insert', disks=(), hot_insert=True)
|
||||
run_case('multiple-then-recovery', disks=('system', 'system', 'recovery'), recover=True)
|
||||
run_case('explicit-recovery', disks=('system', 'recovery'), command_line='fds.boot=recovery')
|
||||
run_case('invalid-filesystem', disks=('invalid', 'recovery'), recover=True, wait_message=b'SYSTEM CANNOT BE USED: mount:')
|
||||
run_case('foreign-root', disks=('foreign', 'recovery'), recover=True, wait_message=b'Selected root is not an FDS image')
|
||||
assert all(digest(Path(path)) == sha for path, sha in before.items()), 'VM altered an input disk'
|
||||
(work/'disk-digests.json').write_text(json.dumps(before, indent=2)+'\n')
|
||||
print('PASS: all stage0 cases; input images unchanged')
|
||||
print('SKIP: Pi firmware, NVMe/RP1, physical USB/display behavior and real boot timings')
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the real unprivileged FDS prompt and readiness independent of eudev."""
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project/'tools'))
|
||||
from image_formats import digest
|
||||
from vm_test import VM
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix='m5-vm.', dir=project/'out'))
|
||||
rootfs = project/'out/rootfs-cli.tar'
|
||||
normal = (project/'out/fds-system-cli.img').resolve()
|
||||
# Preserve the compiled graph and actual scripts. Only gate the udev executable
|
||||
# in a disposable image; no clock delay or hardware access is involved.
|
||||
stage2 = 'etc/s6-linux-init/current/scripts/rc.init'
|
||||
replacements = {
|
||||
stage2: f'#!/bin/bash\nset -euo pipefail\nmkfifo -m 0666 /run/fds-test-udev-gate\nexec /{stage2}.real "$@"\n',
|
||||
'usr/bin/udevd': '#!/bin/bash\nset -euo pipefail\nprintf "FDS_TEST_UDEV_WAIT\\n" >/dev/console\nIFS= read -r release </run/fds-test-udev-gate\nexec /usr/bin/udevd.real "$@"\n',
|
||||
}
|
||||
with tarfile.open(rootfs) as source, tarfile.open(work/'gated.tar', 'w', format=tarfile.PAX_FORMAT) as output:
|
||||
found = set()
|
||||
for member in source:
|
||||
name = member.name
|
||||
if name in replacements:
|
||||
member.name += '.real'
|
||||
found.add(name)
|
||||
output.addfile(member, source.extractfile(member) if member.isfile() else None)
|
||||
assert found == replacements.keys()
|
||||
for name, text in replacements.items():
|
||||
data = text.encode()
|
||||
member = tarfile.TarInfo(name)
|
||||
member.mode = 0o755
|
||||
member.size = len(data)
|
||||
output.addfile(member, io.BytesIO(data))
|
||||
(work/'gated').mkdir()
|
||||
with (work/'gated-image.log').open('wb') as log:
|
||||
subprocess.run([str(project/'image/build-system-cartridge'), '--rootfs', str(work/'gated.tar'),
|
||||
'--output-directory', str(work/'gated')], check=True, stdout=log, stderr=subprocess.STDOUT)
|
||||
images = [('normal', normal), ('blocked-eudev', work/'gated/system.img')]
|
||||
for name, image in images:
|
||||
before = digest(image)
|
||||
with VM(work, name, image) as vm:
|
||||
vm.expect(rb'FDS> ')
|
||||
vm.send('printf "\\nFDS_UID:%s\\n" "$(id -u)"; printf "FDS_HOME:%s:%s\\n" "$HOME" "$PWD"; touch "$HOME/prompt-write-check" && printf "FDS_HOME_WRITABLE\\n"; if touch /etc/unexpected-write 2>/dev/null; then printf "FDS_ROOT_BAD\\n"; else printf "FDS_ROOT_READ_ONLY\\n"; fi')
|
||||
vm.expect(rb'^FDS_UID:1000\r?$')
|
||||
vm.expect(rb'^FDS_HOME:/home/fds:/home/fds\r?$')
|
||||
vm.expect(rb'^FDS_HOME_WRITABLE\r?$')
|
||||
vm.expect(rb'^FDS_ROOT_READ_ONLY\r?$')
|
||||
if name == 'blocked-eudev':
|
||||
assert b'FDS_TEST_UDEV_WAIT' in vm.data, 'eudev was not held at the readiness gate'
|
||||
vm.send('printf "release\\n" >/run/fds-test-udev-gate; printf "FDS_GATE_RELEASED\\n"')
|
||||
vm.expect(rb'^FDS_GATE_RELEASED\r?$')
|
||||
vm.send('printf "\\nFDS_TRACE_BEGIN\\n"; fds --json boot-profile; printf "\\nFDS_TRACE_END\\n"')
|
||||
captured = vm.expect(rb'^FDS_TRACE_BEGIN\r?\n(\{.*?\})\r?\n\r?\nFDS_TRACE_END\r?$')
|
||||
report = json.loads(captured.group(1))
|
||||
assert report['missing_events'] == ['desktop-ready'], report
|
||||
assert 'virt' in report['platform'].lower(), report
|
||||
assert report['durations_ns']['kernel-to-console'] == report['events_ns']['console-ready']
|
||||
assert report['durations_ns']['s6-to-console'] > 0
|
||||
assert b'FDS_STAGE0_HANDOFF' not in vm.data, 'Production console leaked debug markers'
|
||||
(work/(name+'-boot-profile.json')).write_text(json.dumps(report, indent=2)+'\n')
|
||||
if name == 'normal':
|
||||
vm.send('exit')
|
||||
vm.expect(rb'READY\.\r?\nFDS> ')
|
||||
vm.send('printf "\\nFDS_TRACE_BEGIN\\n"; fds --json boot-profile; printf "\\nFDS_TRACE_END\\n"')
|
||||
restarted = json.loads(vm.expect(rb'^FDS_TRACE_BEGIN\r?\n(\{.*?\})\r?\n\r?\nFDS_TRACE_END\r?$').group(1))
|
||||
assert restarted['events_ns']['console-ready'] == report['events_ns']['console-ready'], 'Console restart replaced the first boot observation'
|
||||
print(f'PASS: {name}: FDS user prompt, ephemeral home, read-only SYSTEM and measured trace', flush=True)
|
||||
assert digest(image) == before, 'Read-only VM test altered SYSTEM'
|
||||
|
||||
# Run the real ARM regression tool on measured reports with controlled deltas.
|
||||
report = json.loads((work/'normal-boot-profile.json').read_text())
|
||||
baseline = work/'baseline.json'
|
||||
baseline.write_text(json.dumps(report))
|
||||
current = work/'current.json'
|
||||
report['durations_ns']['kernel-to-console'] += 100_000_001
|
||||
current.write_text(json.dumps(report))
|
||||
command = [str(project/'tools/in-void'), 'qemu-aarch64', str(project/'out/fds-boottrace'), 'compare', str(baseline), str(current)]
|
||||
assert subprocess.run(command, capture_output=True).returncode != 0
|
||||
subprocess.run([*command, '--explain', 'Deliberate threshold test; not a measured regression'], check=True)
|
||||
report['platform'] = 'different-test-platform'
|
||||
current.write_text(json.dumps(report))
|
||||
assert subprocess.run(command, capture_output=True).returncode != 0
|
||||
target = project/'out/m5-vm-latest'
|
||||
temporary = target.with_suffix('.next')
|
||||
temporary.symlink_to(work.name)
|
||||
temporary.replace(target)
|
||||
print(f'PASS: M5 console and boottrace verification: {work}')
|
||||
print('NOTE: test VMs exit via QMP after console checks; orderly shutdown is covered by M4/M10')
|
||||
print('SKIP: physical Pi boot latency, firmware timing and display responsiveness')
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/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')
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/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')
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual ARM WindowMaker input, ENVIRONMENT lifecycle, PROGRAM launch and DHCP."""
|
||||
import base64
|
||||
import io
|
||||
import gzip
|
||||
import itertools
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import zlib
|
||||
|
||||
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='m8-vm.', dir=project/'out'))
|
||||
private_network=subprocess.check_output([str(project/'tools/in-void'),'--isolated-network','readlink','/proc/self/ns/net'],text=True).strip()
|
||||
assert private_network != str(Path('/proc/self/ns/net').readlink()), 'VM transport must not use the host network namespace'
|
||||
(work/'network-isolation.txt').write_text('QEMU runs in a private network namespace; virtual DHCP/DNS has no host or Internet route.\n')
|
||||
controller = 'qemu-xhci,id=xhci,addr=05.0'
|
||||
|
||||
def capture(vm, command):
|
||||
vm.send('printf "\\nM8_BEGIN\\n"; '+command+'; printf "\\nM8_END\\n"')
|
||||
return vm.expect(rb'^M8_BEGIN\r?\n(.*?)\r?\nM8_END\r?$').group(1).decode().strip()
|
||||
def query(vm, command='fds --json profiles'): 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=45):
|
||||
deadline=time.monotonic()+timeout
|
||||
while True:
|
||||
result=capture(vm, command)
|
||||
if condition(result): return result
|
||||
if time.monotonic()>deadline:
|
||||
details=capture(vm,'cat /run/log/network/current 2>/dev/null; ip -brief address')
|
||||
raise AssertionError((command,result,details))
|
||||
def bay(vm, n, state):
|
||||
return json.loads(wait(vm, f'fds --json bay {n}', lambda s:json.loads(s)['bays'][0]['state']==state))['bays'][0]
|
||||
def desktop(vm, enabled):
|
||||
if enabled:
|
||||
result=capture(vm, 's6-svwait -U -t 25000 /run/service/desktop-session; printf "WAIT_STATUS:%s" "$?"')
|
||||
if result != 'WAIT_STATUS:0':
|
||||
raise AssertionError((result,capture(vm,'cat /run/log/xserver/current /run/log/desktop/current /run/log/cartridged/current')))
|
||||
def condition(s):
|
||||
p=json.loads(s)['profiles']
|
||||
if p['error']: raise AssertionError(p)
|
||||
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']}}
|
||||
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()
|
||||
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.items():
|
||||
info=tarfile.TarInfo(filename);info.size=len(data);info.mode=0o755 if filename.startswith('usr/') 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(work/(name+'.tar')),'--output-directory',str(destination)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
return destination/'system.img'
|
||||
root_console=b'#!/bin/bash\n# Isolated test image only.\nexec env HOME=/root bash --login\n'
|
||||
base={'usr/libexec/fds/console-session':root_console,'etc/fds/xserver':b'xvfb\n'}
|
||||
probe=image('probe-system',base)
|
||||
with VM(work,'probe',probe,extra=['-device',controller,'-device','usb-kbd,bus=xhci.0,port=3']) as vm:
|
||||
vm.expect(rb'FDS# ')
|
||||
shell(vm,'fds-boottrace mark console-ready','M8_TEST_CONSOLE_OBSERVED')
|
||||
topology=query(vm,'fds --json topology')
|
||||
hub=next(d['topology'] for d in topology['unmapped'] if '03' in d['interfaces']).rsplit('/',1)[0]
|
||||
shell(vm,'ip -o link show dev lo | grep -q UP','M8_LOCAL_LOOPBACK')
|
||||
p=query(vm)['profiles'];assert p['desktop']=='cli' and p['network']==[] and p['ready_ns'] is None
|
||||
shell(vm,'! pgrep -x Xvfb && ! pgrep -x wmaker && ! pgrep -x dhcpcd','M8_OPTIONAL_SERVICES_OFF')
|
||||
shell(vm,'s6-setuidgid fds fds profile activate windowmaker','M8_ACTIVATION_REQUESTED')
|
||||
try:
|
||||
p=desktop(vm,True)
|
||||
except BaseException:
|
||||
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')
|
||||
(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')
|
||||
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')
|
||||
shell(vm,'s6-setuidgid fds xwd -root -silent -out /home/fds/desktop.xwd','M8_SCREENSHOT_SAVED')
|
||||
encoded=capture(vm,'gzip -c /home/fds/desktop.xwd | base64 -w0')
|
||||
(work/'desktop.xwd').write_bytes(gzip.decompress(base64.b64decode(encoded)))
|
||||
shell(vm,'s6-setuidgid fds fds profile deactivate && ! pgrep -x Xvfb && ! pgrep -x wmaker && ! pgrep -x xterm && pgrep -x dasungd && test "$(cat /home/fds/desktop-input)" = M8_INPUT_WORKED','M8_BACK_TO_CONSOLE')
|
||||
desktop(vm,False)
|
||||
shell(vm,'s6-setuidgid fds fds profile activate windowmaker','M8_REACTIVATE')
|
||||
second=desktop(vm,True);assert second['activation_ns']>p['activation_ns']
|
||||
report=query(vm,'fds --json boot-profile');assert report['events_ns']['desktop-ready']==p['ready_ns']
|
||||
shell(vm,'fds profile deactivate','M8_STOP_SECOND_SESSION')
|
||||
print('PASS: optional desktop, authenticated X11, WindowMaker input, terminal, clean stop/restart and measured activation',flush=True)
|
||||
|
||||
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))
|
||||
fixture=image('mapped-system',{**base,'etc/fds/bays.toml':config.encode()})
|
||||
def cartridge(name, manifest, label, files=None):
|
||||
with tarfile.open(work/(name+'.tar'),'w') as output:
|
||||
for folder in ['FDS','app','app/bin','app/lib','app/share']:
|
||||
info=tarfile.TarInfo(folder);info.type=tarfile.DIRTYPE;info.mode=0o755;output.addfile(info)
|
||||
for filename,data in {'FDS/CARTRIDGE.TOML':manifest,**(files or {})}.items():
|
||||
info=tarfile.TarInfo(filename);info.mode=0o755 if filename.startswith('app/bin/') else 0o644
|
||||
if isinstance(data,tuple):
|
||||
info.type=tarfile.SYMTYPE;info.linkname=data[0];output.addfile(info)
|
||||
else:
|
||||
info.size=len(data);output.addfile(info,io.BytesIO(data))
|
||||
erofs=work/(name+'.erofs')
|
||||
subprocess.run([str(project/'tools/in-image-tools'),'mkfs.erofs','-T','0','--tar=f',str(erofs),str(work/(name+'.tar'))],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()
|
||||
env=cartridge('environment',manifest,'FDS_ENVIRONMENT')
|
||||
unknown=cartridge('unknown-profile',manifest.replace(b'profile = "windowmaker"',b'profile = "unknown"'),'FDS_ENVIRONMENT')
|
||||
program_manifest=b'format=1\n[cartridge]\nid="fds.program.test"\nname="PROGRAM TEST"\nclass="program"\nversion="1"\n[media]\nwritable=false\n'
|
||||
program=cartridge('program',program_manifest,'FDS_PROGRAM',{'app/bin/escape':('/usr/bin/id',),'app/bin/check':b'#!/bin/bash\nset -eu\nid > "$HOME/program-identity"\nprintf "%s\\n" "$FDS_APP" "$LD_LIBRARY_PATH" > "$HOME/program-paths"\nexec /usr/bin/tail -f /dev/null\n'})
|
||||
sequence=itertools.count()
|
||||
with VM(work,'cartridges',fixture,extra=['-device',controller,'-netdev','user,id=net']) as vm:
|
||||
vm.expect(rb'FDS# ')
|
||||
shell(vm,'fds-boottrace mark console-ready','M8_TEST_CONSOLE_OBSERVED')
|
||||
def add(path,port):
|
||||
node=f'media{next(sequence)}'
|
||||
vm.qmp('blockdev-add',{'driver':'raw','node-name':node,'read-only':True,'file':{'driver':'file','filename':str(path)}})
|
||||
vm.qmp('device_add',{'driver':'usb-storage','id':f'bay{port}','drive':node,'bus':'xhci.0','port':str(port)})
|
||||
def remove(port):vm.qmp('device_del',{'id':f'bay{port}'});bay(vm,port,'empty')
|
||||
add(unknown,2);bay(vm,2,'mounted_read_only');assert query(vm)['profiles']['desktop']=='cli';remove(2)
|
||||
add(env,2);bay(vm,2,'mounted_read_only');assert desktop(vm,True)['environment_bay']==2
|
||||
shell(vm,'s6-setuidgid fds fds eject 2 && ! pgrep -x wmaker && pgrep -x dasungd','M8_ENV_SAFE_RETURN')
|
||||
bay(vm,2,'safe');desktop(vm,False);remove(2)
|
||||
add(env,2);bay(vm,2,'mounted_read_only');desktop(vm,True)
|
||||
shell(vm,'fds profile deactivate; fds rescan >/dev/null','M8_ENV_MANUAL_DEACTIVATION')
|
||||
desktop(vm,False);remove(2)
|
||||
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
|
||||
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')
|
||||
assert bay(vm,4,'safe')['consumers']==0;remove(4)
|
||||
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)
|
||||
address=json.loads(wait(vm,'ip -j -4 address show',lambda s:any(a.get('local')=='10.0.2.15' for i in json.loads(s) for a in i['addr_info'])))
|
||||
(work/'dhcp.json').write_text(json.dumps(address,indent=2)+'\n')
|
||||
wait(vm,'cat /run/fds/resolv.conf',lambda s:'nameserver 10.0.2.3' in s)
|
||||
shell(vm,'s6-setuidgid fds fds network off && ! pgrep -x dhcpcd','M8_NETWORK_OFF')
|
||||
assert not any('UP' in i['flags'] for i in json.loads(capture(vm,'ip -j link show')) if i['ifname']!='lo')
|
||||
shell(vm,'s6-setuidgid fds fds network on','M8_NETWORK_EXPLICIT')
|
||||
wait(vm,'ip -j -4 address show',lambda s:any(a.get('local')=='10.0.2.15' for i in json.loads(s) for a in i['addr_info']))
|
||||
shell(vm,'fds network off','M8_NETWORK_END')
|
||||
vm.qmp('device_del',{'id':'ethernet'});bay(vm,3,'empty')
|
||||
assert query(vm)['profiles']['network']==[]
|
||||
print('PASS: ENVIRONMENT insertion/eject/pull, PROGRAM explicit unprivileged execution, managed removal, USB Ethernet and on-demand DHCP',flush=True)
|
||||
with VM(work,'no-dhcp-server',fixture,extra=['-device',controller,'-netdev','hubport,id=isolated,hubid=0','-device','usb-net,id=ethernet,bus=xhci.0,port=3,netdev=isolated']) as vm:
|
||||
vm.expect(rb'FDS# ')
|
||||
shell(vm,'printf prompt-usable; fds-boottrace mark console-ready','M8_PROMPT_WITHOUT_DHCP')
|
||||
wait(vm,'fds --json profiles',lambda s:len(json.loads(s)['profiles']['network'])==1)
|
||||
shell(vm,'s6-svwait -u -t 5000 /run/service/dhcp && test -z "$(ip -o -4 address show scope global)" && printf still-usable','M8_DHCP_NOT_A_BARRIER')
|
||||
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
|
||||
assert version==7 and fmt==2 and bpp==32 and (rm,gm,bm)==(0xff0000,0xff00,0xff)
|
||||
offset=size+header[19]*12
|
||||
raw=bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0)
|
||||
for x in range(width):
|
||||
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''))
|
||||
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')
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-check the actual ARM media builder using disposable regular files only."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project/'tools'))
|
||||
from image_formats import digest, gpt, LINUX_FILESYSTEM
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix='m9-images.', dir=project/'out'))
|
||||
root = work/'root'
|
||||
source = (project/'out/rootfs-cli.tar').resolve().parent/'root'
|
||||
assert source.is_dir(), 'Build the configured rootfs first'
|
||||
subprocess.run(['cp', '-al', str(source), str(root)], check=True)
|
||||
fixture = root/'m9-test'
|
||||
fixture.mkdir()
|
||||
shutil.copy2(project/'target/aarch64-unknown-linux-musl/release/fds-burn', fixture/'fds-burn')
|
||||
subprocess.run(['cp', '-al', str(source), str(fixture/'system')], check=True)
|
||||
|
||||
def manifest(kind, label, profile=''):
|
||||
text = f'''format = 1
|
||||
[cartridge]
|
||||
id = "fds.test.{kind}"
|
||||
name = "{label}"
|
||||
class = "{kind}"
|
||||
version = "0.1.0"
|
||||
[media]
|
||||
writable = {str(kind == 'data').lower()}
|
||||
'''
|
||||
if profile:
|
||||
text += f'\n[activation]\nprofile = "{profile}"\n'
|
||||
return text
|
||||
|
||||
for kind in ('system', 'data', 'program', 'environment'):
|
||||
tree = fixture/kind
|
||||
(tree/'FDS').mkdir(parents=True)
|
||||
(tree/'FDS/CARTRIDGE.TOML').write_text(manifest(kind, f'TEST {kind.upper()}', 'windowmaker' if kind == 'environment' else ''))
|
||||
if kind == 'program':
|
||||
(tree/'app/bin').mkdir(parents=True)
|
||||
shutil.copy2(project/'out/fds', tree/'app/bin/fds')
|
||||
if kind == 'data':
|
||||
(tree/'read-me.txt').write_text('Created on ARM by FDS.\n')
|
||||
|
||||
# One namespace call supplies a private ARM binfmt handler for child utilities.
|
||||
script = '''#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
cd /m9-test
|
||||
for kind in system data environment; do
|
||||
options=()
|
||||
if [[ $kind == data ]]; then options=(--size-mib 32); fi
|
||||
./fds-burn create "$kind" "$kind" "$kind.img" "${options[@]}" >"$kind.build.log" 2>&1
|
||||
./fds-burn inspect "$kind.img" >"$kind.json"
|
||||
done
|
||||
if ./fds-burn create data data data.img --size-mib 32 >overwrite.log 2>&1; then exit 91; fi
|
||||
if ./fds-burn create system data wrong.img >wrong-class.log 2>&1; then exit 92; fi
|
||||
if ./fds-burn create data data data/recursive.img >recursive.log 2>&1; then exit 93; fi
|
||||
ln -s data.img image-link
|
||||
if ./fds-burn inspect image-link >symlink.log 2>&1; then exit 94; fi
|
||||
if ./fds-burn create program program forbidden.img >program-build-rejected.log 2>&1; then exit 95; fi
|
||||
printf 'PASS: actual ARM SYSTEM/DATA/ENVIRONMENT builder; PROGRAM creation requires a workstation\\n'
|
||||
'''
|
||||
(fixture/'run').write_text(script)
|
||||
result = subprocess.run([str(project/'tools/in-rootfs'), str(root), '/usr/bin/bash', '/m9-test/run'],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
(work/'arm.log').write_text(result.stdout+result.stderr)
|
||||
assert result.returncode == 0, f'ARM image checks failed: {work}/arm.log; per-class logs: {fixture}'
|
||||
print(result.stdout.strip(), flush=True)
|
||||
|
||||
# Legacy PROGRAM reading/writing remains supported, but new software creation
|
||||
# belongs to the workstation tool. Build this compatibility fixture on the host.
|
||||
legacy = work / 'legacy-program.erofs'
|
||||
subprocess.run([str(project/'tools/in-image-tools'), 'mkfs.erofs', '--quiet', '-b', '4096', '-T', '0', '-L', 'FDS_PROGRAM', str(legacy), str(fixture/'program')], check=True)
|
||||
gpt(fixture/'program.img', [('FDS_PROGRAM', LINUX_FILESYSTEM, legacy)])
|
||||
with (fixture/'program.json').open('w') as output:
|
||||
subprocess.run([str(project/'tools/in-void'), 'qemu-aarch64', str(fixture/'fds-burn'), 'inspect', str(fixture/'program.img')], check=True, stdout=output)
|
||||
for kind in ('system', 'data', 'program', 'environment'):
|
||||
image = fixture/f'{kind}.img'
|
||||
reported = json.loads((fixture/f'{kind}.json').read_text())
|
||||
table = json.loads(subprocess.check_output(['sfdisk', '--json', str(image)]))['partitiontable']
|
||||
assert table['label'] == 'gpt' and len(table['partitions']) == 1
|
||||
part = table['partitions'][0]
|
||||
assert part['name'] == f'FDS_{kind.upper()}'
|
||||
assert part['start']*512 == reported['partition_start']
|
||||
assert part['size']*512 == reported['partition_bytes']
|
||||
assert digest(image) == reported['sha256']
|
||||
assert image.stat().st_size == reported['bytes']
|
||||
payload = work/f'{kind}.filesystem'
|
||||
with image.open('rb') as src, payload.open('wb') as dst:
|
||||
src.seek(part['start']*512)
|
||||
remaining = part['size']*512
|
||||
while remaining:
|
||||
chunk = src.read(min(1024*1024, remaining))
|
||||
assert chunk
|
||||
dst.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
if kind == 'data':
|
||||
subprocess.run(['e2fsck', '-fn', str(payload)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
value = subprocess.check_output(['debugfs', '-R', 'cat /FDS/CARTRIDGE.TOML', str(payload)], stderr=subprocess.DEVNULL).decode()
|
||||
assert value == manifest(kind, 'TEST DATA')
|
||||
else:
|
||||
subprocess.run([str(project/'tools/in-image-tools'), 'fsck.erofs', '--extract', str(payload)], check=True, stdout=subprocess.DEVNULL)
|
||||
shutil.copy2(fixture/f'{kind}.json', work/f'{kind}.json')
|
||||
print('PASS: independent GPT geometry, Python SHA-256, EROFS and ext4 integrity checks', flush=True)
|
||||
|
||||
# Valid CRCs are not sufficient: reject a second partition, out-of-range extents,
|
||||
# an internal label and disagreeing backup geometry. Use the host writer as an
|
||||
# independent source, then exercise the ARM parser against deliberate mutations.
|
||||
base = work/'negative-base.img'
|
||||
gpt(base, [('FDS_ENVIRONMENT', LINUX_FILESYSTEM, work/'environment.filesystem')])
|
||||
|
||||
def arm_inspect(path, ok):
|
||||
result = subprocess.run([str(project/'tools/in-void'), 'qemu-aarch64',
|
||||
str(fixture/'fds-burn'), 'inspect', str(path)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert (result.returncode == 0) == ok, (path, result.stdout, result.stderr)
|
||||
|
||||
arm_inspect(base, True)
|
||||
for case in ('extra_partition', 'out_of_range', 'internal_label', 'bad_backup'):
|
||||
target = work/f'{case}.img'
|
||||
shutil.copyfile(base, target)
|
||||
data = bytearray(target.read_bytes())
|
||||
last = len(data)//512-1
|
||||
table = bytearray(data[1024:1024+16384])
|
||||
if case == 'extra_partition':
|
||||
table[128:256] = table[:128]
|
||||
elif case == 'out_of_range':
|
||||
struct.pack_into('<Q', table, 40, last)
|
||||
elif case == 'internal_label':
|
||||
table[56:128] = 'FDS_INTERNAL'.encode('utf-16le').ljust(72, b'\0')
|
||||
for start in (1024, (last-32)*512):
|
||||
data[start:start+16384] = table
|
||||
for sector in (1, last):
|
||||
header = bytearray(data[sector*512:(sector+1)*512])
|
||||
struct.pack_into('<I', header, 88, zlib.crc32(table))
|
||||
if case == 'bad_backup' and sector == last:
|
||||
header[56] ^= 1
|
||||
struct.pack_into('<I', header, 16, 0)
|
||||
struct.pack_into('<I', header, 16, zlib.crc32(header[:92]))
|
||||
data[sector*512:(sector+1)*512] = header
|
||||
target.write_bytes(data)
|
||||
arm_inspect(target, False)
|
||||
print('PASS: malformed image rejection with recomputed valid GPT CRCs', flush=True)
|
||||
print(f'PASS: M9 image construction evidence: {work}')
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/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')
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create SYSTEM on ARM from a mounted prepared root, burn it, then boot the result."""
|
||||
import io
|
||||
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
|
||||
work=Path(tempfile.mkdtemp(prefix='m9-system.',dir=project/'out'))
|
||||
# Reuse only the independently observed virtual hardware map; all images below
|
||||
# are rebuilt from the current packaged rootfs. No workstation disk is exposed.
|
||||
runs=sorted((project/'out').glob('m9-vm.*'),key=lambda p:p.stat().st_mtime,reverse=True)
|
||||
previous=next(p for p in runs if (p/'verified-data.ext4').is_file())
|
||||
with tarfile.open(previous/'burn-system.tar') as archive:
|
||||
bays=archive.extractfile('etc/fds/bays.toml').read()
|
||||
assert b'3 = 3' in bays,'Run the M9 source-DATA protection test first'
|
||||
|
||||
def build(name,rootfs):
|
||||
output=work/name;output.mkdir()
|
||||
with (work/f'{name}.image.log').open('wb') as log:
|
||||
subprocess.run([str(project/'image/build-system-cartridge'),'--rootfs',str(rootfs),'--output-directory',str(output)],check=True,stdout=log,stderr=subprocess.STDOUT)
|
||||
return output/'system.img'
|
||||
source=build('prepared-source',project/'out/rootfs-cli.tar')
|
||||
replacements={'etc/fds/bays.toml':bays,'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']})
|
||||
with tarfile.open(project/'out/rootfs-cli.tar') as src,tarfile.open(work/'creator.tar','w',format=tarfile.PAX_FORMAT) as out:
|
||||
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.items():
|
||||
member=tarfile.TarInfo(path);member.size=len(data);member.mode=0o755 if path.startswith('usr/') else 0o644
|
||||
out.addfile(member,io.BytesIO(data))
|
||||
creator=build('creator',work/'creator.tar')
|
||||
target=work/'created-system.img'
|
||||
with target.open('wb') as stream:stream.truncate(640*1024*1024)
|
||||
|
||||
def capture(vm,command,timeout=300):
|
||||
vm.send('printf "\\nM9_SYSTEM_BEGIN\\n"; '+command+'; printf "\\nM9_SYSTEM_END\\n"')
|
||||
return vm.expect(rb'^M9_SYSTEM_BEGIN\r?\n(.*?)\r?\nM9_SYSTEM_END\r?$',timeout=timeout).group(1).decode().strip()
|
||||
def user(vm,args):
|
||||
output=capture(vm,'s6-setuidgid fds fds --json '+shlex.join(args)+' 2>/home/fds/system-command.err; printf "\\nSTATUS:%s" "$?"')
|
||||
text,status=output.rsplit('STATUS:',1)
|
||||
assert status.strip()=='0',(args,output,capture(vm,'cat /home/fds/system-command.err; tail -n 40 /run/log/cartridged/current'))
|
||||
return json.loads(text)
|
||||
|
||||
with VM(work,'create',creator,system_usb=True,extra=['-m','2048','-drive',f'file={target},if=none,id=target,format=raw','-device','usb-storage,id=targetusb,drive=target,bus=xhci.0,port=2,serial=M9SYSTEM']) as vm:
|
||||
vm.expect(rb'FDS# ')
|
||||
capture(vm,'fds-boottrace mark console-ready; cd /home/fds')
|
||||
vm.qmp('blockdev-add',{'driver':'raw','node-name':'preparedsource','read-only':True,'file':{'driver':'file','filename':str(source)}})
|
||||
vm.qmp('device_add',{'driver':'usb-storage','id':'sourceusb','drive':'preparedsource','bus':'xhci.0','port':'3','serial':'M9PREPARED'})
|
||||
deadline=time.monotonic()+45
|
||||
while True:
|
||||
bay=user(vm,['bay','3'])['bays'][0]
|
||||
if bay['state']=='mounted_read_only':break
|
||||
assert time.monotonic()<deadline,bay
|
||||
assert bay['manifest']['cartridge']['class']=='system'
|
||||
result=capture(vm,'fds-burn create system /run/fds/media/03 /home/fds/new-system.img >/home/fds/system-info.json 2>/home/fds/create-system.log; printf "CREATE_STATUS:%s" "$?"')
|
||||
assert result=='CREATE_STATUS:0',(result,capture(vm,'tail -n 40 /home/fds/create-system.log'))
|
||||
info=json.loads(capture(vm,'cat /home/fds/system-info.json'))
|
||||
assert info['class']=='system' and info['bytes']<target.stat().st_size
|
||||
(work/'created-source-info.json').write_text(json.dumps(info,indent=2)+'\n')
|
||||
print('PASS: actual ARM SYSTEM construction from a complete mounted prepared root',flush=True)
|
||||
prepared=user(vm,['burn','system','/home/fds/new-system.img','BAY02'])
|
||||
assert prepared['image_sha256']==info['sha256']
|
||||
result=user(vm,['burn','confirm',prepared['id'],prepared['confirmation']])
|
||||
assert result['phase']=='complete'
|
||||
(work/'write-result.json').write_text(json.dumps(result,indent=2)+'\n')
|
||||
user(vm,['eject','BAY02'])
|
||||
print('PASS: ordinary-user confirmed SYSTEM write, device readback and SAFE eject',flush=True)
|
||||
|
||||
layout=json.loads(subprocess.check_output(['sfdisk','--json',str(target)]))['partitiontable']
|
||||
assert len(layout['partitions'])==1 and layout['partitions'][0]['name']=='FDS_SYSTEM'
|
||||
assert layout['lastlba']==target.stat().st_size//512-34
|
||||
(work/'layout.json').write_text(json.dumps(layout,indent=2)+'\n')
|
||||
(work/'target.sha256').write_text(digest(target)+' created-system.img\n')
|
||||
with VM(work,'boot-written-system',target,system_usb=True) as vm:
|
||||
vm.expect(rb'FDS> ',timeout=120)
|
||||
assert capture(vm,'id -u')=='1000'
|
||||
identity=json.loads(capture(vm,'fds --json info'))
|
||||
assert identity['target']=='aarch64 static-musl' and identity['pid1'].endswith('s6-svscan'),identity
|
||||
assert capture(vm,'stat -c "%u:%g:%a" /usr/lib/utempter/utempter')=='0:14:2711'
|
||||
assert capture(vm,'touch /etc/m9-write-rejection 2>/dev/null; printf "STATUS:%s" "$?"')=='STATUS:1'
|
||||
assert capture(vm,'pgrep -x dasungd').isdigit()
|
||||
assert 'Terminus' in capture(vm,'fc-match Terminus')
|
||||
assert capture(vm,'test ! -d /home/fds/.cache/fontconfig; printf "CACHE_STATUS:%s" "$?"')=='CACHE_STATUS:0'
|
||||
(work/'booted-identity.json').write_text(json.dumps(identity,indent=2)+'\n')
|
||||
print('PASS: the SYSTEM created and written on ARM boots through stage0 to native s6 and the ordinary FDS console',flush=True)
|
||||
print(f'PASS: M9 SYSTEM creation/write/boot evidence: {work}')
|
||||
print('SKIP: physical Raspberry Pi boot, storage power-loss behavior and throughput')
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate private ARM execution and prove the host binfmt registry is unchanged."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
registry = Path('/proc/sys/fs/binfmt_misc')
|
||||
def snapshot():
|
||||
return {path.name: path.read_bytes() for path in registry.iterdir() if path.name != 'register'}
|
||||
before = snapshot()
|
||||
command = [str(project/'tools/in-rootfs'), str(project/'out/rootfs-aarch64'), '/bin/bash', '-euc']
|
||||
result = subprocess.run([*command, 'test -f /tmp/fds-binfmt/fds-aarch64; grep -qx ID=fds /usr/lib/os-release; printf "nested ARM execution\\n" | gzip | gzip -d; grep CapEff /proc/self/status'], check=True, capture_output=True, text=True)
|
||||
assert 'nested ARM execution\n' in result.stdout
|
||||
assert '0000000080000000' in result.stdout, 'Setup capabilities were not dropped'
|
||||
failure = subprocess.run([*command, 'exit 73'])
|
||||
assert failure.returncode == 73, 'Target failure exit code was not preserved'
|
||||
assert snapshot() == before, 'Host binfmt registry changed'
|
||||
print('PASS: private ARM child execution, restricted build capabilities, exit status and unchanged host registry')
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual public QEMU CLI, guest software, twelve ports and removal lifecycle."""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import selectors
|
||||
import termios
|
||||
from pathlib import Path
|
||||
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 digest, gpt, LINUX_FILESYSTEM
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cli', type=Path, required=True)
|
||||
parser.add_argument('--qemu-runner', type=Path)
|
||||
args = parser.parse_args()
|
||||
work = Path(tempfile.mkdtemp(prefix='emu-test.', dir=project / 'out'))
|
||||
builder = Path((project / 'out/workstation-images-current.txt').read_text().strip())
|
||||
assert json.loads((builder / 'acceptance.json').read_text())['status'] == 'passed'
|
||||
software = builder / 'software.img'
|
||||
shared = builder / 'shared.img'
|
||||
log = (work / 'commands.log').open('w')
|
||||
cli = str(args.cli.resolve())
|
||||
session = work / 'normal'
|
||||
start_options = ['--qemu-runner', str(args.qemu_runner.resolve())] if args.qemu_runner else []
|
||||
inputs = [software, shared, project / 'out/fds-system-cli.img', project / 'out/fds-initramfs.img', project / 'out/kernel/boot/kernel_2712.img', Path(cli)]
|
||||
(work / 'inputs.sha256').write_text(''.join(f'{digest(path)} {path}\n' for path in inputs))
|
||||
|
||||
def invoke(*command, ok=True):
|
||||
result = subprocess.run([cli, '--session', str(session), *map(str, command)], capture_output=True, text=True, timeout=240)
|
||||
log.write(repr(command) + '\n' + result.stdout + result.stderr)
|
||||
log.flush()
|
||||
assert (result.returncode == 0) == ok, (command, result.returncode, result.stdout, result.stderr)
|
||||
return result.stdout
|
||||
|
||||
def guest(*command, ok=True):
|
||||
return invoke('guest', '--', *command, ok=ok)
|
||||
|
||||
def query(*command):
|
||||
return json.loads(guest('fds', '--json', *command))
|
||||
|
||||
def wait_bay(number, expected, timeout=60):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
state = query('bay', number)['bays'][0]
|
||||
if state['state'] == expected:
|
||||
return state
|
||||
assert state['state'] != 'error' or expected == 'error', state
|
||||
assert time.monotonic() < deadline, state
|
||||
|
||||
def mounts():
|
||||
return guest('cat', '/proc/self/mountinfo')
|
||||
|
||||
def clean(number):
|
||||
text = mounts()
|
||||
assert f'/run/fds/software/{number:02}/' not in text
|
||||
assert '/run/fds/apps/demo.workstation' not in text
|
||||
|
||||
# A workstation-created disposable DATA fixture, never a host block device.
|
||||
data_root = work / 'data-root'
|
||||
(data_root / 'FDS').mkdir(parents=True)
|
||||
(data_root / 'FDS/CARTRIDGE.TOML').write_text('format=1\n[cartridge]\nid="demo.data"\nname="Emulator DATA"\nclass="data"\nversion="1"\n[media]\nwritable=true\n')
|
||||
filesystem = work / 'data.ext4'
|
||||
with filesystem.open('xb') as stream: stream.truncate(32 * 1024 * 1024)
|
||||
subprocess.run([str(project / 'tools/in-image-tools'), 'mke2fs', '-q', '-t', 'ext4', '-b', '4096', '-L', 'FDS_DATA', '-E', 'lazy_itable_init=0,lazy_journal_init=0,root_owner=1000:1000', '-d', str(data_root), str(filesystem)], check=True)
|
||||
data = work / 'data.img'
|
||||
gpt(data, [('FDS_DATA', LINUX_FILESYSTEM, filesystem)])
|
||||
original_data = digest(data)
|
||||
original_software = digest(software)
|
||||
try:
|
||||
report = json.loads(invoke('start', *start_options))
|
||||
assert report['qemu']['running']
|
||||
assert guest('id', '-u').strip() == '1000'
|
||||
master, slave = pty.openpty()
|
||||
original_termios = 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)
|
||||
def console_until(marker):
|
||||
output = bytearray(); deadline = time.monotonic() + 20
|
||||
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)
|
||||
return output
|
||||
try:
|
||||
console_until(b'FDS> ')
|
||||
os.write(master, b'id -u\n')
|
||||
assert b'1000' in console_until(b'\r\n1000\r\n')
|
||||
os.write(master, b'\x1d')
|
||||
assert console.wait(timeout=10) == 0
|
||||
assert termios.tcgetattr(slave) == original_termios
|
||||
assert json.loads(invoke('status'))['qemu']['running']
|
||||
finally:
|
||||
if console.poll() is None: console.kill(); console.wait()
|
||||
selector.close(); os.close(master); os.close(slave)
|
||||
assert all(b['state'] == 'empty' for b in query('bays')['bays'])
|
||||
invoke('insert', 13, software, ok=False)
|
||||
invoke('insert', 1, '/dev/null', ok=False)
|
||||
assert guest('printf', '%s', 'literal $(id); and spaces') == 'literal $(id); and spaces'
|
||||
assert guest('printf', '%s', 'Kernel panic / FDS_STAGE0_ERROR: is just guest data') == 'Kernel panic / FDS_STAGE0_ERROR: is just guest data'
|
||||
guest('false', ok=False)
|
||||
for number in range(1, 13):
|
||||
invoke('insert', number, shared if number == 6 else software)
|
||||
state = wait_bay(number, 'mounted_read_only')
|
||||
assert state['devices'][0]['serial'] == f'FDS-{number:02}'
|
||||
assert state['devices'][0]['topology'].endswith('/' + str(number))
|
||||
catalogue = state['software']['software']
|
||||
assert [s['partition'] for s in catalogue] == ([2, 2] if number == 6 else [2, 3])
|
||||
invoke('insert', number, software, ok=False)
|
||||
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('fds', 'run', number, '--', '../escape', ok=False)
|
||||
guest('fds', 'run', number, '--', 'demo.hello:missing', ok=False)
|
||||
if number == 6:
|
||||
guest('fds', 'run', number, '--', 'demo.report:report', 'hold')
|
||||
assert query('bay', number)['bays'][0]['consumers'] > 0
|
||||
invoke('unplug', number)
|
||||
else:
|
||||
invoke('eject', number)
|
||||
state = wait_bay(number, 'empty')
|
||||
assert state['consumers'] == 0
|
||||
clean(number)
|
||||
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 '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)
|
||||
wait_bay(3, 'empty')
|
||||
invoke('insert', 4, builder / 'catalogue-mapping.img')
|
||||
wait_bay(4, 'error')
|
||||
invoke('eject', 4, ok=False)
|
||||
invoke('unplug', 4)
|
||||
wait_bay(4, 'empty')
|
||||
clean(4)
|
||||
inserted = json.loads(invoke('insert', 2, data))
|
||||
overlay = Path(inserted['inserted']['overlay'])
|
||||
assert overlay.is_file() and overlay.suffix == '.qcow2'
|
||||
wait_bay(2, 'mounted_read_write')
|
||||
guest('sh', '-c', 'printf "overlay survived safe eject\\n" >/data/workstation.txt')
|
||||
assert guest('cat', '/data/workstation.txt').strip() == 'overlay survived safe eject'
|
||||
invoke('eject', 2)
|
||||
wait_bay(2, 'empty')
|
||||
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', 12, software)
|
||||
wait_bay(12, 'mounted_read_only')
|
||||
guest('fds', 'run', 12, '--', 'demo.report:report', 'hold')
|
||||
invoke('stop')
|
||||
assert 'FDS_SHUTDOWN_FINAL' in (session / 'console.log').read_text()
|
||||
finally:
|
||||
subprocess.run([cli, '--session', str(session), 'stop', '--force'], capture_output=True, timeout=40)
|
||||
|
||||
# Isolated admin console fixture for service interruption and recovery. The
|
||||
# preceding normal-image tests used UID 1000; production login is unchanged.
|
||||
archive = work / 'admin.tar'
|
||||
with tarfile.open(project / 'out/rootfs-cli.tar') as source, tarfile.open(archive, 'w', format=tarfile.PAX_FORMAT) as target:
|
||||
name = 'usr/libexec/fds/console-session'
|
||||
assert source.getmember(name)
|
||||
for member in source:
|
||||
if member.name == name: continue
|
||||
target.addfile(member, source.extractfile(member) if member.isfile() else None)
|
||||
member = tarfile.TarInfo(name)
|
||||
payload = b"#!/bin/bash\n# Isolated workstation lifecycle test only.\nexec env HOME=/root PS1='FDS> ' bash --noprofile --norc\n"
|
||||
member.size = len(payload); member.mode = 0o755
|
||||
target.addfile(member, io.BytesIO(payload))
|
||||
admin = work / 'admin-image'; admin.mkdir()
|
||||
with (work / 'admin-build.log').open('w') as build_log:
|
||||
subprocess.run([str(project / 'image/build-system-cartridge'), '--rootfs', str(archive), '--output-directory', str(admin)], check=True, stdout=build_log, stderr=subprocess.STDOUT)
|
||||
session = work / 'admin'
|
||||
try:
|
||||
invoke('start', '--system', admin / 'system.img', *start_options)
|
||||
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('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.
|
||||
guest('mkdir', '/run/extra-payload')
|
||||
guest('mount', '--bind', '/run/fds/software/01/payload02', '/run/extra-payload')
|
||||
invoke('eject', 1, ok=False)
|
||||
guest('umount', '/run/extra-payload')
|
||||
invoke('eject', 1)
|
||||
wait_bay(1, 'empty')
|
||||
clean(1)
|
||||
guest('fds-burn', 'create', 'program', '/tmp', '/tmp/program.img', ok=False)
|
||||
invoke('stop')
|
||||
finally:
|
||||
subprocess.run([cli, '--session', str(session), 'stop', '--force'], capture_output=True, timeout=40)
|
||||
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,
|
||||
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,
|
||||
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')
|
||||
print('PASS: public emulator, guest software and lifecycle acceptance:', work)
|
||||
print('SKIP: physical Pi, USB power-cycle behavior and timing require hardware')
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the public Linux builder/writer with actual software and regular files."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
project = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project / 'tools'))
|
||||
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'])
|
||||
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)]
|
||||
log = (work / 'commands.log').open('w')
|
||||
|
||||
|
||||
def invoke(arguments, ok=True):
|
||||
result = subprocess.run([*cli, *map(str, arguments)], capture_output=True, text=True)
|
||||
log.write(repr(arguments) + '\n' + result.stdout + result.stderr)
|
||||
log.flush()
|
||||
assert (result.returncode == 0) == ok, (arguments, result.returncode, result.stdout, result.stderr)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as stream:
|
||||
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')
|
||||
image = work / 'software.img'
|
||||
original = json.loads(invoke(['create', work / 'cartridge.toml', image]))
|
||||
assert len(original['image']['partitions']) == 3
|
||||
assert [s['partition'] for s in original['catalogue']['software']] == [2, 3]
|
||||
observed = json.loads(subprocess.check_output(['sfdisk', '--json', str(image)]))['partitiontable']
|
||||
assert [p['name'] for p in observed['partitions']] == ['FDS_METADATA', 'FDS_PAYLOAD02', 'FDS_PAYLOAD03']
|
||||
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 / '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')
|
||||
with target.open('xb') as stream:
|
||||
stream.truncate(size + extra)
|
||||
preview = work / (label + '.preview.json')
|
||||
approval = json.loads(invoke(['preview', image, target, preview, '--file-target']))
|
||||
before = digest(target)
|
||||
invoke(['write', preview, '--confirm', 'WRONG'], False)
|
||||
assert digest(target) == before
|
||||
invoke(['write', preview, '--confirm', approval['confirmation']])
|
||||
subprocess.run(['sfdisk', '--verify', str(target)], check=True, stdout=log, stderr=log)
|
||||
assert json.loads(invoke(['inspect', target]))['catalogue'] == original['catalogue']
|
||||
invoke(['write', preview, '--confirm', approval['confirmation']], False)
|
||||
|
||||
target = work / 'changed-source.target'
|
||||
with target.open('xb') as stream:
|
||||
stream.truncate(size)
|
||||
source = work / 'changed-source.img'
|
||||
source.write_bytes(image.read_bytes())
|
||||
preview = work / 'changed-source.preview.json'
|
||||
approval = json.loads(invoke(['preview', source, target, preview, '--file-target']))
|
||||
before = digest(target)
|
||||
with source.open('r+b') as stream:
|
||||
stream.seek(2 * 1024 * 1024 + 8192)
|
||||
stream.write(b'X')
|
||||
invoke(['write', preview, '--confirm', approval['confirmation']], False)
|
||||
assert digest(target) == before
|
||||
bad = work / 'corrupt.img'
|
||||
bad.write_bytes(image.read_bytes())
|
||||
with bad.open('r+b') as stream:
|
||||
stream.seek(1024)
|
||||
stream.write(b'corrupt!')
|
||||
invoke(['inspect', bad], False)
|
||||
# Rebuild trusted fixture payloads independently, retaining valid GPT and EROFS
|
||||
# while making the software data invalid. Both host and guest must reject them.
|
||||
parts = original['image']['partitions']
|
||||
filesystems = []
|
||||
for part in parts:
|
||||
filesystem = work / f"part{part['number']}.erofs"
|
||||
with image.open('rb') as source, filesystem.open('wb') as target:
|
||||
source.seek(part['start'])
|
||||
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
|
||||
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)
|
||||
else:
|
||||
metadata = tree / 'FDS/SOFTWARE.TOML'
|
||||
metadata.write_text(metadata.read_text().replace('partition = 2', 'partition = 4'))
|
||||
changed = work / (case + '.erofs')
|
||||
subprocess.run([*tools, 'mkfs.erofs', '--quiet', '-b', '4096', '-T', '0', str(changed), str(tree)], check=True, stdout=log, stderr=log)
|
||||
selected = list(filesystems); selected[number-1] = changed
|
||||
malformed = work / (case + '.img')
|
||||
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,
|
||||
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')
|
||||
(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)
|
||||
print('SKIP: physical USB writes and Raspberry Pi execution require hardware')
|
||||
Reference in New Issue
Block a user