FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
format = 1
[cartridge]
id = "fds.windowmaker"
name = "WINDOW SYSTEM"
class = "environment"
version = "0.1"
[media]
writable = false
[activation]
profile = "windowmaker"
View File
+26
View File
@@ -0,0 +1,26 @@
# Physical acceptance records
Physical tests are deferred until the Pi and its attached hardware are available.
Follow [the complete procedure](../../docs/stress-testing.md), including bay
calibration, repeatable device populations, explicit SAFE handling and separate
external timing measurements.
Copy `session-template.json` into a new results directory outside this source
folder. Replace nulls with observed values, retain raw captures and hashes, and
leave unperformed cases marked `not_run`. Record failures before attempting a
fix. Do not fill this record from VM results.
For example, on the build workstation:
```sh
mkdir -p out/hardware-session
cp tests/hardware/session-template.json out/hardware-session/session.json
```
On FDS, copy `tools/capture-hardware` to DATA and run it explicitly through Bash.
Use a fresh output directory each time; the usage guide explains volatile
captures when DATA itself is under test. Copy the resulting files into the
session directory after the test, preserving `status.tsv` and `SHA256SUMS`.
No file in this directory is evidence of a completed physical test until an
actual run is recorded. The template's null values mean unknown, never zero.
+211
View File
@@ -0,0 +1,211 @@
{
"format": 1,
"status": "not_run",
"started_utc": null,
"operator": null,
"software": {
"fds_version": null,
"source_sha256": null,
"system_sha256": null,
"boot_sha256": null,
"kernel_version": null
},
"hardware": {
"pi_revision": null,
"eeprom_version": null,
"nvme_model": null,
"power_source": null,
"battery_state": null,
"hub_models": [],
"display_model": "Dasung Paperlike 13K"
},
"bay_map": [
{
"bay": 1,
"physical_label": "BAY 01",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 2,
"physical_label": "BAY 02",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 3,
"physical_label": "BAY 03",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 4,
"physical_label": "BAY 04",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 5,
"physical_label": "BAY 05",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 6,
"physical_label": "BAY 06",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 7,
"physical_label": "BAY 07",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 8,
"physical_label": "BAY 08",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 9,
"physical_label": "BAY 09",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 10,
"physical_label": "BAY 10",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 11,
"physical_label": "BAY 11",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
},
{
"bay": 12,
"physical_label": "BAY 12",
"stable_usb2_path": null,
"stable_usb3_path": null,
"cartridge_class": null,
"cartridge_serial": null
}
],
"cases": [
{
"name": "cold_empty",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "cold_twelve",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "hot_insert",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "safe_remove",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "rotated_bays",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "simultaneous_io",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "reboot_under_load",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "poweroff_under_load",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "power_and_usb",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
},
{
"name": "dasung_recovery",
"status": "not_run",
"captures": [],
"samples": [],
"errors": [],
"notes": null
}
],
"timing": {
"external_instrument": null,
"resolution": null,
"clock_alignment": null,
"physical_poweroff_observed": null
},
"payloads": [],
"unresolved_failures": [],
"deferred_cases": []
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "fds-test-helpers"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
description = "Disposable VM workloads; never installed in FDS images"
[dependencies]
clap.workspace = true
libc = "0.2"
[[bin]]
name = "m6-client"
path = "cartridge-client.rs"
[[bin]]
name = "m7-writer"
path = "data-writer.rs"
[[bin]]
name = "m11-stress"
path = "usb-stress.rs"
+52
View File
@@ -0,0 +1,52 @@
// Test-only static ARM client. Never installed in a production image.
use clap::Parser;
use std::{
io::{Read, Write},
os::unix::net::UnixStream,
process::{Command, ExitCode},
time::Duration,
};
#[derive(Parser)]
#[command(about = "Exercise the cartridge socket with slow, oversize, or a JSON request")]
struct Cli {
mode: String,
}
fn main() -> ExitCode {
let mode = Cli::parse().mode;
let socket = "/run/fds/control.sock";
let result = (|| -> std::io::Result<()> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(Duration::from_secs(8)))?;
if mode == "slow" {
stream.write_all(b"{")?;
assert!(Command::new("fds").arg("bays").status()?.success());
println!("FDS_SLOW_CLIENT_OK");
} else if mode == "oversize" {
let _ = stream.write_all(&vec![b'x'; 65537]);
let mut bytes = Vec::new();
let read = stream.read_to_end(&mut bytes);
assert!(bytes.is_empty());
assert!(
read.is_ok() || read.unwrap_err().kind() == std::io::ErrorKind::ConnectionReset
);
println!("FDS_OVERSIZE_OK");
} else {
stream.write_all(mode.as_bytes())?;
stream.write_all(b"\n")?;
let mut text = String::new();
stream.take(65537).read_to_string(&mut text)?;
if text.is_empty() {
return Err(std::io::Error::other("peer rejected"));
}
println!("{text}");
}
Ok(())
})();
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("test-client: {e}");
ExitCode::FAILURE
}
}
}
+55
View File
@@ -0,0 +1,55 @@
// Test-only workload: sustained dirty writes plus a child that ignores TERM.
use clap::{Parser, ValueEnum};
use std::{
fs::{self, File},
io::{Seek, SeekFrom, Write},
process::Command,
};
unsafe extern "C" {
fn signal(number: i32, handler: usize) -> usize;
}
#[derive(Parser)]
#[command(about = "Write disposable DATA stress output")]
struct Cli {
role: Option<Role>,
}
#[derive(Clone, ValueEnum)]
enum Role {
Child,
}
fn main() {
let cli = Cli::parse();
unsafe {
signal(15, 1);
}
if matches!(cli.role, Some(Role::Child)) {
loop {
std::thread::park();
}
}
fs::write(
"/data/process-status",
fs::read("/proc/self/status").unwrap(),
)
.unwrap();
let child = Command::new(std::env::current_exe().unwrap())
.arg("child")
.spawn()
.unwrap();
fs::write("/data/child-pid", child.id().to_string()).unwrap();
let mut file = File::create("/data/stress.bin").unwrap();
let buffer: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
let mut blocks = 0_u64;
loop {
if blocks % 64 == 0 {
file.seek(SeekFrom::Start(0)).unwrap();
}
if let Err(error) = file.write_all(&buffer) {
eprintln!("write fault: {error}");
return;
}
blocks += 1;
fs::write("/data/progress.next", blocks.to_string()).unwrap();
fs::rename("/data/progress.next", "/data/progress").unwrap();
}
}
+69
View File
@@ -0,0 +1,69 @@
// Test-only, unprivileged concurrent direct reads and durable DATA writes.
use clap::Parser;
use std::{
fs::{File, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
os::unix::fs::OpenOptionsExt,
path::PathBuf,
sync::{Arc, Barrier},
time::Instant,
};
#[repr(align(16384))]
struct Block([u8; 65536]);
#[derive(Parser)]
#[command(about = "Read eleven cartridge fixtures while writing disposable DATA")]
struct Cli {
#[arg(required = true, num_args = 11)]
paths: Vec<PathBuf>,
}
fn main() {
let paths = Cli::parse().paths;
let barrier = Arc::new(Barrier::new(12));
let origin = Instant::now();
let mut jobs = Vec::new();
for (index, path) in paths.into_iter().enumerate() {
let barrier = barrier.clone();
jobs.push(std::thread::spawn(move || {
let mut file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECT)
.open(path)
.unwrap(); // O_DIRECT
let mut block = Box::new(Block([0; 65536]));
barrier.wait();
let start = origin.elapsed().as_nanos();
for _ in 0..4 {
file.seek(SeekFrom::Start(0)).unwrap();
for _ in 0..128 {
file.read_exact(&mut block.0).unwrap();
assert!(block.0.iter().all(|b| *b == (index + 2) as u8));
}
}
(
index + 2,
start,
origin.elapsed().as_nanos(),
32 * 1024 * 1024,
)
}));
}
let writer = std::thread::spawn(move || {
let mut file = File::create("/data/stress.bin").unwrap();
let mut block = [0_u8; 65536];
for (i, byte) in block.iter_mut().enumerate() {
*byte = (i % 256) as u8;
}
barrier.wait();
let start = origin.elapsed().as_nanos();
for _ in 0..1024 {
file.write_all(&block).unwrap();
}
file.sync_all().unwrap();
(1, start, origin.elapsed().as_nanos(), 64 * 1024 * 1024)
});
let mut results: Vec<_> = jobs.into_iter().map(|j| j.join().unwrap()).collect();
results.push(writer.join().unwrap());
for (bay, start, end, bytes) in results {
println!("{{\"bay\":{bay},\"start_ns\":{start},\"end_ns\":{end},\"bytes\":{bytes}}}");
}
}
+43
View File
@@ -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'
+63
View File
@@ -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
+63
View File
@@ -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'
+137
View File
@@ -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'
+254
View File
@@ -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')
+142
View File
@@ -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')
+264
View File
@@ -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')
+71
View File
@@ -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')
+84
View File
@@ -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')
+63
View File
@@ -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')
+198
View File
@@ -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')
+299
View File
@@ -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')
+134
View File
@@ -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')
+132
View File
@@ -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')
+33
View File
@@ -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'
+72
View File
@@ -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
+19
View File
@@ -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'
+91
View File
@@ -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')
+17
View File
@@ -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"
+21
View File
@@ -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'
+129
View File
@@ -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')
+96
View File
@@ -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')
+157
View File
@@ -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')
+182
View File
@@ -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')
+189
View File
@@ -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')
+150
View File
@@ -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}')
+239
View File
@@ -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')
+99
View File
@@ -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')
+18
View File
@@ -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')
+227
View File
@@ -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')
+164
View File
@@ -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')
View File