From 99bc3d15c5971367291be9a812cf33c90b3eddc2 Mon Sep 17 00:00:00 2001 From: Felis Date: Mon, 21 Sep 2026 22:29:23 +0800 Subject: [PATCH] FDS/OS 1.0 --- .cargo/config.toml | 6 + .gitignore | 8 + .gitmodules | 4 + AGENTS.md | 43 + Cargo.lock | 846 ++++++ Cargo.toml | 24 + LICENSE | 23 + Makefile | 241 ++ README.md | 293 ++ VOID_PACKAGES_COMMIT | 1 + config/eeprom/development.conf | 8 + config/eeprom/inputs.json | 10 + config/eeprom/production.conf | 8 + config/host-tools.conf | 4 + config/machine/bays.toml | 2 + config/machine/hardware-catalog.toml | 2 + config/machine/machine.toml | 2 + config/pi-firmware.conf | 4 + config/vm-test.conf | 6 + config/xbps-src.conf | 4 + docs/README.md | 86 + docs/architecture.md | 167 ++ docs/boot.md | 178 ++ docs/build-host.md | 236 ++ docs/cartridges.md | 197 ++ docs/clap-validation.md | 128 + docs/dasung-validation.md | 69 + docs/dasung.md | 185 ++ docs/data.md | 138 + docs/desktop.md | 212 ++ docs/development.md | 309 ++ docs/eeprom.md | 149 + docs/getting-started.md | 232 ++ docs/glossary.md | 52 + docs/implementation-status.md | 134 + docs/init.md | 187 ++ docs/internal-storage.md | 273 ++ docs/m0-validation.md | 191 ++ docs/m1-validation.md | 125 + docs/m10-validation.md | 111 + docs/m11-validation.md | 107 + docs/m12-validation.md | 407 +++ docs/m2-validation.md | 142 + docs/m3-validation.md | 40 + docs/m4-validation.md | 93 + docs/m4-work.md | 57 + docs/m5-validation.md | 69 + docs/m6-validation.md | 74 + docs/m7-validation.md | 75 + docs/m8-validation.md | 119 + docs/m9-validation.md | 158 + docs/master-plan.md | 2564 +++++++++++++++++ docs/media-tools.md | 155 + docs/packages.md | 151 + docs/performance.md | 177 ++ docs/power.md | 132 + docs/recovery.md | 207 ++ docs/releases.md | 167 ++ docs/reproducible-builds.md | 180 ++ docs/roadmap.md | 77 + docs/rootfs.md | 237 ++ docs/services.md | 142 + docs/software-format.md | 117 + docs/stress-testing.md | 151 + docs/tooling.md | 143 + docs/troubleshooting.md | 243 ++ docs/workstation-tooling-plan.md | 60 + docs/workstation-validation.md | 98 + docs/workstation.md | 341 +++ examples/software/cartridge.toml | 11 + examples/software/hello/hello.c | 6 + examples/software/hello/software.toml | 14 + examples/software/report/root/bin/report | 4 + examples/software/report/software.toml | 9 + image/.gitkeep | 0 image/base-packages.list | 5 + image/build-boot-volume | 102 + image/build-initramfs | 60 + image/build-internal | 97 + image/build-recovery | 50 + image/build-system-cartridge | 70 + image/kernel/dasung.config | 6 + image/pi5/cmdline-development.txt | 1 + image/pi5/cmdline-production.txt | 1 + image/pi5/config.txt | 12 + out/.gitkeep | 0 packages/fds-base-files/.gitkeep | 0 packages/fds-base-files/files/10-fds.conf | 4 + packages/fds-base-files/files/fstab | 2 + packages/fds-base-files/files/group | 31 + packages/fds-base-files/files/gshadow | 31 + packages/fds-base-files/files/hostname | 1 + packages/fds-base-files/files/hosts | 3 + packages/fds-base-files/files/issue | 1 + packages/fds-base-files/files/layout.json | 13 + packages/fds-base-files/files/locale.conf | 1 + packages/fds-base-files/files/nsswitch.conf | 9 + packages/fds-base-files/files/os-release | 6 + packages/fds-base-files/files/passwd | 3 + packages/fds-base-files/files/profile | 28 + packages/fds-base-files/files/shadow | 3 + packages/fds-base-files/template | 30 + packages/fds-base/.gitkeep | 0 packages/fds-base/template | 16 + packages/fds-cartridged/.gitkeep | 0 packages/fds-cartridged/files/bays.toml | 3 + .../files/hardware-catalog.toml | 3 + packages/fds-cartridged/files/network-run | 13 + packages/fds-cartridged/files/network-runtime | 6 + packages/fds-cartridged/files/network-stop | 11 + packages/fds-cartridged/template | 26 + packages/fds-cli/.gitkeep | 0 packages/fds-cli/template | 25 + .../fds-dasungd/files/99-dasung-spi.rules | 4 + packages/fds-dasungd/files/dasungd.toml | 22 + packages/fds-dasungd/template | 29 + packages/fds-dhcpcd/files/dhcpcd.conf | 8 + packages/fds-dhcpcd/template | 27 + packages/fds-eink/.gitkeep | 0 packages/fds-eink/files/20-fds.conf | 10 + packages/fds-eink/files/WMRootMenu | 6 + packages/fds-eink/files/WMWindowAttributes | 4 + packages/fds-eink/files/WindowMaker | 44 + packages/fds-eink/files/desktop-runtime | 7 + packages/fds-eink/files/terminal | 5 + packages/fds-eink/files/windowmaker-session | 13 + packages/fds-eink/files/xserver | 1 + packages/fds-eink/template | 22 + packages/fds-init/files/console | 6 + packages/fds-init/files/console-session | 17 + packages/fds-init/files/hostname | 4 + packages/fds-init/files/runtime-fs | 19 + packages/fds-init/files/skel/rc.init | 6 + packages/fds-init/files/skel/rc.shutdown | 17 + .../fds-init/files/skel/rc.shutdown.final | 2 + packages/fds-init/files/skel/runlevel | 6 + packages/fds-init/template | 21 + packages/fds-kernel/.gitkeep | 0 packages/fds-kernel/files/fds.config | 68 + packages/fds-kernel/template | 63 + profiles/.gitkeep | 0 profiles/cli.list | 1 + profiles/development.list | 23 + profiles/recovery.list | 4 + rust-toolchain.toml | 5 + rust/dasungd/Cargo.toml | 20 + rust/dasungd/IMPORT.md | 27 + rust/dasungd/LICENSE | 21 + rust/dasungd/README.md | 16 + rust/dasungd/VALIDATION.md | 29 + rust/dasungd/profiles/paperlike13k-37hz.edid | Bin 0 -> 256 bytes rust/dasungd/src/config.rs | 103 + rust/dasungd/src/display.rs | 231 ++ rust/dasungd/src/main.rs | 573 ++++ rust/dasungd/src/protocol.rs | 131 + rust/dasungd/src/transport.rs | 306 ++ rust/dasungd/tests/pty_smoke.py | 168 ++ rust/fds-boottrace/.gitkeep | 0 rust/fds-boottrace/Cargo.toml | 12 + rust/fds-boottrace/src/main.rs | 232 ++ rust/fds-burn/.gitkeep | 0 rust/fds-burn/Cargo.toml | 15 + rust/fds-burn/src/cli.rs | 215 ++ rust/fds-burn/src/client.rs | 326 +++ rust/fds-burn/src/create.rs | 231 ++ rust/fds-burn/src/device.rs | 526 ++++ rust/fds-burn/src/image.rs | 538 ++++ rust/fds-burn/src/lib.rs | 9 + rust/fds-burn/src/main.rs | 132 + rust/fds-burn/src/worker.rs | 435 +++ rust/fds-burn/src/write.rs | 277 ++ rust/fds-cartridged/.gitkeep | 0 rust/fds-cartridged/Cargo.toml | 23 + rust/fds-cartridged/src/burning.rs | 358 +++ rust/fds-cartridged/src/consumers.rs | 257 ++ rust/fds-cartridged/src/data_sessions.rs | 87 + rust/fds-cartridged/src/main.rs | 66 + rust/fds-cartridged/src/media.rs | 548 ++++ rust/fds-cartridged/src/power.rs | 139 + rust/fds-cartridged/src/profile-main.rs | 328 +++ rust/fds-cartridged/src/profiles.rs | 288 ++ rust/fds-cartridged/src/recovery.rs | 291 ++ rust/fds-cartridged/src/server.rs | 1216 ++++++++ rust/fds-cartridged/src/software.rs | 318 ++ rust/fds-cartridged/src/x11.rs | 194 ++ rust/fds-cli/.gitkeep | 0 rust/fds-cli/Cargo.toml | 17 + rust/fds-cli/src/cli.rs | 409 +++ rust/fds-cli/src/machine.rs | 510 ++++ rust/fds-cli/src/main.rs | 271 ++ rust/fds-cli/src/power.rs | 134 + rust/fds-cli/src/recovery.rs | 47 + rust/fds-common/.gitkeep | 0 rust/fds-common/Cargo.toml | 12 + rust/fds-common/src/boot.rs | 84 + rust/fds-common/src/control.rs | 233 ++ rust/fds-common/src/lib.rs | 102 + rust/fds-common/src/machine.rs | 217 ++ rust/fds-common/src/manifest.rs | 168 ++ rust/fds-common/src/software.rs | 184 ++ rust/fds-common/src/sysfs.rs | 115 + rust/fds-common/src/topology.rs | 322 +++ rust/fds-common/src/trace.rs | 308 ++ rust/fds-release/Cargo.toml | 16 + rust/fds-release/src/lib.rs | 402 +++ rust/fds-release/src/main.rs | 109 + rust/fds-smoketest/Cargo.toml | 5 + rust/fds-smoketest/src/main.rs | 9 + rust/fds-software/Cargo.toml | 14 + rust/fds-software/src/archive.rs | 499 ++++ rust/fds-software/src/lib.rs | 3 + rust/fds-stage0/.gitkeep | 0 rust/fds-stage0/Cargo.toml | 12 + rust/fds-stage0/src/linux.rs | 533 ++++ rust/fds-stage0/src/main.rs | 104 + rust/fds-workstation/Cargo.toml | 25 + rust/fds-workstation/src/cartridge.rs | 283 ++ rust/fds-workstation/src/doctor.rs | 57 + rust/fds-workstation/src/emulator-main.rs | 135 + rust/fds-workstation/src/emulator.rs | 359 +++ rust/fds-workstation/src/lib.rs | 101 + rust/fds-workstation/src/main.rs | 137 + rust/fds-workstation/src/qmp.rs | 127 + rust/fds-workstation/src/serial.rs | 219 ++ rust/fds-workstation/src/software.rs | 152 + rust/fds-workstation/src/writing.rs | 253 ++ s6/source/.gitkeep | 0 s6/source/boot/contents.d/cartridged | 0 s6/source/boot/contents.d/dasungd | 0 s6/source/boot/contents.d/getty | 0 s6/source/boot/contents.d/hostname | 0 s6/source/boot/contents.d/runtime-fs | 0 s6/source/boot/contents.d/udev-trigger | 0 s6/source/boot/type | 1 + s6/source/cartridged-log/consumer-for | 1 + .../dependencies.d/cartridged-runtime | 0 s6/source/cartridged-log/run | 2 + s6/source/cartridged-log/type | 1 + .../dependencies.d/runtime-fs | 0 s6/source/cartridged-runtime/type | 1 + s6/source/cartridged-runtime/up | 1 + .../dependencies.d/cartridged-runtime | 0 .../cartridged/dependencies.d/machine-config | 0 s6/source/cartridged/notification-fd | 1 + s6/source/cartridged/producer-for | 1 + s6/source/cartridged/run | 3 + s6/source/cartridged/type | 1 + s6/source/console/dependencies.d/runtime-fs | 0 s6/source/console/type | 1 + s6/source/console/up | 1 + s6/source/dasungd-log/consumer-for | 1 + .../dependencies.d/dasungd-runtime | 0 s6/source/dasungd-log/run | 2 + s6/source/dasungd-log/type | 1 + .../dasungd-runtime/dependencies.d/runtime-fs | 0 s6/source/dasungd-runtime/type | 1 + s6/source/dasungd-runtime/up | 1 + .../dasungd/dependencies.d/dasungd-runtime | 0 s6/source/dasungd/producer-for | 1 + s6/source/dasungd/run | 3 + s6/source/dasungd/timeout-kill | 1 + s6/source/dasungd/type | 1 + s6/source/desktop-log/consumer-for | 1 + .../dependencies.d/desktop-runtime | 0 s6/source/desktop-log/run | 2 + s6/source/desktop-log/type | 1 + .../desktop-runtime/dependencies.d/runtime-fs | 0 s6/source/desktop-runtime/type | 1 + s6/source/desktop-runtime/up | 1 + .../desktop-session/dependencies.d/cartridged | 0 .../dependencies.d/desktop-log | 0 .../desktop-session/dependencies.d/xserver | 0 s6/source/desktop-session/finish | 2 + s6/source/desktop-session/notification-fd | 1 + s6/source/desktop-session/producer-for | 1 + s6/source/desktop-session/run | 3 + s6/source/desktop-session/timeout-kill | 1 + s6/source/desktop-session/timeout-up | 1 + s6/source/desktop-session/type | 1 + s6/source/desktop/contents.d/desktop-session | 0 s6/source/desktop/contents.d/xserver | 0 s6/source/desktop/type | 1 + s6/source/dhcp/dependencies.d/cartridged | 0 s6/source/dhcp/dependencies.d/network-log | 0 s6/source/dhcp/dependencies.d/network-runtime | 0 s6/source/dhcp/finish | 3 + s6/source/dhcp/producer-for | 1 + s6/source/dhcp/run | 3 + s6/source/dhcp/timeout-kill | 1 + s6/source/dhcp/type | 1 + s6/source/eudevd/dependencies.d/runtime-fs | 0 s6/source/eudevd/notification-fd | 1 + s6/source/eudevd/run | 5 + s6/source/eudevd/timeout-kill | 1 + s6/source/eudevd/timeout-up | 1 + s6/source/eudevd/type | 1 + s6/source/getty/dependencies.d/console | 0 s6/source/getty/dependencies.d/hostname | 0 s6/source/getty/down-signal | 1 + s6/source/getty/run | 2 + s6/source/getty/timeout-kill | 1 + s6/source/getty/type | 1 + s6/source/hostname/type | 1 + s6/source/hostname/up | 1 + .../machine-config/dependencies.d/runtime-fs | 0 s6/source/machine-config/type | 1 + s6/source/machine-config/up | 1 + s6/source/network-log/consumer-for | 1 + .../dependencies.d/network-runtime | 0 s6/source/network-log/run | 2 + s6/source/network-log/type | 1 + .../network-runtime/dependencies.d/runtime-fs | 0 s6/source/network-runtime/type | 1 + s6/source/network-runtime/up | 1 + s6/source/network/contents.d/dhcp | 0 s6/source/network/type | 1 + s6/source/runtime-fs/type | 1 + s6/source/runtime-fs/up | 1 + .../test-echo/dependencies.d/test-runtime | 0 s6/source/test-echo/notification-fd | 1 + s6/source/test-echo/run | 3 + s6/source/test-echo/timeout-kill | 1 + s6/source/test-echo/timeout-up | 1 + s6/source/test-echo/type | 1 + .../test-runtime/dependencies.d/runtime-fs | 0 s6/source/test-runtime/type | 1 + s6/source/test-runtime/up | 1 + s6/source/udev-trigger/dependencies.d/eudevd | 0 s6/source/udev-trigger/type | 1 + s6/source/udev-trigger/up | 2 + s6/source/xserver-log/consumer-for | 1 + .../dependencies.d/desktop-runtime | 0 s6/source/xserver-log/run | 2 + s6/source/xserver-log/type | 1 + s6/source/xserver/dependencies.d/cartridged | 0 .../xserver/dependencies.d/desktop-runtime | 0 s6/source/xserver/dependencies.d/udev-trigger | 0 s6/source/xserver/dependencies.d/xserver-log | 0 s6/source/xserver/finish | 3 + s6/source/xserver/notification-fd | 1 + s6/source/xserver/producer-for | 1 + s6/source/xserver/run | 3 + s6/source/xserver/timeout-kill | 1 + s6/source/xserver/timeout-up | 1 + s6/source/xserver/type | 1 + tests/fixtures/manifests/windowmaker.toml | 13 + tests/hardware/.gitkeep | 0 tests/hardware/README.md | 26 + tests/hardware/session-template.json | 211 ++ tests/helpers/Cargo.toml | 23 + tests/helpers/cartridge-client.rs | 52 + tests/helpers/data-writer.rs | 55 + tests/helpers/usb-stress.rs | 69 + tests/integration/dasung-checks | 43 + tests/integration/dasung-s6-check | 63 + tests/integration/m0-checks | 63 + tests/integration/m1-checks | 137 + tests/integration/m10-runtime.py | 254 ++ tests/integration/m11-media-faults.py | 142 + tests/integration/m11-runtime.py | 264 ++ tests/integration/m12-clock.py | 71 + tests/integration/m12-development.py | 84 + tests/integration/m12-eeprom.py | 63 + tests/integration/m12-internal.py | 198 ++ tests/integration/m12-recovery.py | 299 ++ tests/integration/m12-release-build.py | 134 + tests/integration/m12-signing.py | 132 + tests/integration/m2-checks | 33 + tests/integration/m2-guest | 72 + tests/integration/m3-checks | 19 + tests/integration/m3-runtime.py | 91 + tests/integration/m4-checks | 17 + tests/integration/m4-guest | 21 + tests/integration/m4-runtime.py | 129 + tests/integration/m5-runtime.py | 96 + tests/integration/m6-runtime.py | 157 + tests/integration/m7-runtime.py | 182 ++ tests/integration/m8-runtime.py | 189 ++ tests/integration/m9-images.py | 150 + tests/integration/m9-runtime.py | 239 ++ tests/integration/m9-system.py | 99 + tests/integration/rootfs-emulation.py | 18 + tests/integration/workstation-emulator.py | 227 ++ tests/integration/workstation-images.py | 164 ++ tests/unit/.gitkeep | 0 tools/assemble-release | 141 + tools/bootstrap-host | 64 + tools/build-base-packages | 41 + tools/build-dasungd | 71 + tools/build-fds | 19 + tools/build-fds-package | 42 + tools/build-kernel | 46 + tools/build-package | 16 + tools/build-rootfs | 91 + tools/build-workstation | 15 + tools/capture-hardware | 42 + tools/cargo-build | 24 + tools/compare-builds | 30 + tools/compare-rust-profiles | 58 + tools/configure-pi-eeprom | 87 + tools/eeprom_inputs.py | 34 + tools/finalize-rootfs | 45 + tools/frozen-inputs | 316 ++ tools/image_formats.py | 97 + tools/in-frozen-build | 17 + tools/in-image-tools | 11 + tools/in-rootfs | 31 + tools/in-void | 29 + tools/lib.sh | 48 + tools/make-stage0-vm | 42 + tools/make-vm-image | 44 + tools/prepare-image-tools | 25 + tools/prepare-pi-firmware | 18 + tools/prepare-vm | 24 + tools/prepare-void | 25 + tools/release-readme.md | 79 + tools/release_artifacts.py | 66 + tools/rootfs-archive | 107 + tools/rootfs-audit | 101 + tools/rootfs-layout | 16 + tools/rootfs-namespace | 17 + tools/rootfs-runtime-check | 38 + tools/rootfs_lib.py | 42 + tools/run-system-vm | 18 + tools/run-vm | 17 + tools/rust-notices | 45 + tools/smoke-test | 48 + tools/verify-elf | 40 + tools/vm_test.py | 107 + vendor/void-packages | 1 + 430 files changed, 34876 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 VOID_PACKAGES_COMMIT create mode 100644 config/eeprom/development.conf create mode 100644 config/eeprom/inputs.json create mode 100644 config/eeprom/production.conf create mode 100644 config/host-tools.conf create mode 100644 config/machine/bays.toml create mode 100644 config/machine/hardware-catalog.toml create mode 100644 config/machine/machine.toml create mode 100644 config/pi-firmware.conf create mode 100644 config/vm-test.conf create mode 100644 config/xbps-src.conf create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/boot.md create mode 100644 docs/build-host.md create mode 100644 docs/cartridges.md create mode 100644 docs/clap-validation.md create mode 100644 docs/dasung-validation.md create mode 100644 docs/dasung.md create mode 100644 docs/data.md create mode 100644 docs/desktop.md create mode 100644 docs/development.md create mode 100644 docs/eeprom.md create mode 100644 docs/getting-started.md create mode 100644 docs/glossary.md create mode 100644 docs/implementation-status.md create mode 100644 docs/init.md create mode 100644 docs/internal-storage.md create mode 100644 docs/m0-validation.md create mode 100644 docs/m1-validation.md create mode 100644 docs/m10-validation.md create mode 100644 docs/m11-validation.md create mode 100644 docs/m12-validation.md create mode 100644 docs/m2-validation.md create mode 100644 docs/m3-validation.md create mode 100644 docs/m4-validation.md create mode 100644 docs/m4-work.md create mode 100644 docs/m5-validation.md create mode 100644 docs/m6-validation.md create mode 100644 docs/m7-validation.md create mode 100644 docs/m8-validation.md create mode 100644 docs/m9-validation.md create mode 100644 docs/master-plan.md create mode 100644 docs/media-tools.md create mode 100644 docs/packages.md create mode 100644 docs/performance.md create mode 100644 docs/power.md create mode 100644 docs/recovery.md create mode 100644 docs/releases.md create mode 100644 docs/reproducible-builds.md create mode 100644 docs/roadmap.md create mode 100644 docs/rootfs.md create mode 100644 docs/services.md create mode 100644 docs/software-format.md create mode 100644 docs/stress-testing.md create mode 100644 docs/tooling.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/workstation-tooling-plan.md create mode 100644 docs/workstation-validation.md create mode 100644 docs/workstation.md create mode 100644 examples/software/cartridge.toml create mode 100644 examples/software/hello/hello.c create mode 100644 examples/software/hello/software.toml create mode 100755 examples/software/report/root/bin/report create mode 100644 examples/software/report/software.toml create mode 100644 image/.gitkeep create mode 100644 image/base-packages.list create mode 100755 image/build-boot-volume create mode 100755 image/build-initramfs create mode 100755 image/build-internal create mode 100755 image/build-recovery create mode 100755 image/build-system-cartridge create mode 100644 image/kernel/dasung.config create mode 100644 image/pi5/cmdline-development.txt create mode 100644 image/pi5/cmdline-production.txt create mode 100644 image/pi5/config.txt create mode 100644 out/.gitkeep create mode 100644 packages/fds-base-files/.gitkeep create mode 100644 packages/fds-base-files/files/10-fds.conf create mode 100644 packages/fds-base-files/files/fstab create mode 100644 packages/fds-base-files/files/group create mode 100644 packages/fds-base-files/files/gshadow create mode 100644 packages/fds-base-files/files/hostname create mode 100644 packages/fds-base-files/files/hosts create mode 100644 packages/fds-base-files/files/issue create mode 100644 packages/fds-base-files/files/layout.json create mode 100644 packages/fds-base-files/files/locale.conf create mode 100644 packages/fds-base-files/files/nsswitch.conf create mode 100644 packages/fds-base-files/files/os-release create mode 100644 packages/fds-base-files/files/passwd create mode 100644 packages/fds-base-files/files/profile create mode 100644 packages/fds-base-files/files/shadow create mode 100644 packages/fds-base-files/template create mode 100644 packages/fds-base/.gitkeep create mode 100644 packages/fds-base/template create mode 100644 packages/fds-cartridged/.gitkeep create mode 100644 packages/fds-cartridged/files/bays.toml create mode 100644 packages/fds-cartridged/files/hardware-catalog.toml create mode 100644 packages/fds-cartridged/files/network-run create mode 100644 packages/fds-cartridged/files/network-runtime create mode 100644 packages/fds-cartridged/files/network-stop create mode 100644 packages/fds-cartridged/template create mode 100644 packages/fds-cli/.gitkeep create mode 100644 packages/fds-cli/template create mode 100644 packages/fds-dasungd/files/99-dasung-spi.rules create mode 100644 packages/fds-dasungd/files/dasungd.toml create mode 100644 packages/fds-dasungd/template create mode 100644 packages/fds-dhcpcd/files/dhcpcd.conf create mode 100644 packages/fds-dhcpcd/template create mode 100644 packages/fds-eink/.gitkeep create mode 100644 packages/fds-eink/files/20-fds.conf create mode 100644 packages/fds-eink/files/WMRootMenu create mode 100644 packages/fds-eink/files/WMWindowAttributes create mode 100644 packages/fds-eink/files/WindowMaker create mode 100644 packages/fds-eink/files/desktop-runtime create mode 100644 packages/fds-eink/files/terminal create mode 100644 packages/fds-eink/files/windowmaker-session create mode 100644 packages/fds-eink/files/xserver create mode 100644 packages/fds-eink/template create mode 100755 packages/fds-init/files/console create mode 100755 packages/fds-init/files/console-session create mode 100755 packages/fds-init/files/hostname create mode 100755 packages/fds-init/files/runtime-fs create mode 100755 packages/fds-init/files/skel/rc.init create mode 100755 packages/fds-init/files/skel/rc.shutdown create mode 100755 packages/fds-init/files/skel/rc.shutdown.final create mode 100755 packages/fds-init/files/skel/runlevel create mode 100644 packages/fds-init/template create mode 100644 packages/fds-kernel/.gitkeep create mode 100644 packages/fds-kernel/files/fds.config create mode 100644 packages/fds-kernel/template create mode 100644 profiles/.gitkeep create mode 100644 profiles/cli.list create mode 100644 profiles/development.list create mode 100644 profiles/recovery.list create mode 100644 rust-toolchain.toml create mode 100644 rust/dasungd/Cargo.toml create mode 100644 rust/dasungd/IMPORT.md create mode 100644 rust/dasungd/LICENSE create mode 100644 rust/dasungd/README.md create mode 100644 rust/dasungd/VALIDATION.md create mode 100644 rust/dasungd/profiles/paperlike13k-37hz.edid create mode 100644 rust/dasungd/src/config.rs create mode 100644 rust/dasungd/src/display.rs create mode 100644 rust/dasungd/src/main.rs create mode 100644 rust/dasungd/src/protocol.rs create mode 100644 rust/dasungd/src/transport.rs create mode 100644 rust/dasungd/tests/pty_smoke.py create mode 100644 rust/fds-boottrace/.gitkeep create mode 100644 rust/fds-boottrace/Cargo.toml create mode 100644 rust/fds-boottrace/src/main.rs create mode 100644 rust/fds-burn/.gitkeep create mode 100644 rust/fds-burn/Cargo.toml create mode 100644 rust/fds-burn/src/cli.rs create mode 100644 rust/fds-burn/src/client.rs create mode 100644 rust/fds-burn/src/create.rs create mode 100644 rust/fds-burn/src/device.rs create mode 100644 rust/fds-burn/src/image.rs create mode 100644 rust/fds-burn/src/lib.rs create mode 100644 rust/fds-burn/src/main.rs create mode 100644 rust/fds-burn/src/worker.rs create mode 100644 rust/fds-burn/src/write.rs create mode 100644 rust/fds-cartridged/.gitkeep create mode 100644 rust/fds-cartridged/Cargo.toml create mode 100644 rust/fds-cartridged/src/burning.rs create mode 100644 rust/fds-cartridged/src/consumers.rs create mode 100644 rust/fds-cartridged/src/data_sessions.rs create mode 100644 rust/fds-cartridged/src/main.rs create mode 100644 rust/fds-cartridged/src/media.rs create mode 100644 rust/fds-cartridged/src/power.rs create mode 100644 rust/fds-cartridged/src/profile-main.rs create mode 100644 rust/fds-cartridged/src/profiles.rs create mode 100644 rust/fds-cartridged/src/recovery.rs create mode 100644 rust/fds-cartridged/src/server.rs create mode 100644 rust/fds-cartridged/src/software.rs create mode 100644 rust/fds-cartridged/src/x11.rs create mode 100644 rust/fds-cli/.gitkeep create mode 100644 rust/fds-cli/Cargo.toml create mode 100644 rust/fds-cli/src/cli.rs create mode 100644 rust/fds-cli/src/machine.rs create mode 100644 rust/fds-cli/src/main.rs create mode 100644 rust/fds-cli/src/power.rs create mode 100644 rust/fds-cli/src/recovery.rs create mode 100644 rust/fds-common/.gitkeep create mode 100644 rust/fds-common/Cargo.toml create mode 100644 rust/fds-common/src/boot.rs create mode 100644 rust/fds-common/src/control.rs create mode 100644 rust/fds-common/src/lib.rs create mode 100644 rust/fds-common/src/machine.rs create mode 100644 rust/fds-common/src/manifest.rs create mode 100644 rust/fds-common/src/software.rs create mode 100644 rust/fds-common/src/sysfs.rs create mode 100644 rust/fds-common/src/topology.rs create mode 100644 rust/fds-common/src/trace.rs create mode 100644 rust/fds-release/Cargo.toml create mode 100644 rust/fds-release/src/lib.rs create mode 100644 rust/fds-release/src/main.rs create mode 100644 rust/fds-smoketest/Cargo.toml create mode 100644 rust/fds-smoketest/src/main.rs create mode 100644 rust/fds-software/Cargo.toml create mode 100644 rust/fds-software/src/archive.rs create mode 100644 rust/fds-software/src/lib.rs create mode 100644 rust/fds-stage0/.gitkeep create mode 100644 rust/fds-stage0/Cargo.toml create mode 100644 rust/fds-stage0/src/linux.rs create mode 100644 rust/fds-stage0/src/main.rs create mode 100644 rust/fds-workstation/Cargo.toml create mode 100644 rust/fds-workstation/src/cartridge.rs create mode 100644 rust/fds-workstation/src/doctor.rs create mode 100644 rust/fds-workstation/src/emulator-main.rs create mode 100644 rust/fds-workstation/src/emulator.rs create mode 100644 rust/fds-workstation/src/lib.rs create mode 100644 rust/fds-workstation/src/main.rs create mode 100644 rust/fds-workstation/src/qmp.rs create mode 100644 rust/fds-workstation/src/serial.rs create mode 100644 rust/fds-workstation/src/software.rs create mode 100644 rust/fds-workstation/src/writing.rs create mode 100644 s6/source/.gitkeep create mode 100644 s6/source/boot/contents.d/cartridged create mode 100644 s6/source/boot/contents.d/dasungd create mode 100644 s6/source/boot/contents.d/getty create mode 100644 s6/source/boot/contents.d/hostname create mode 100644 s6/source/boot/contents.d/runtime-fs create mode 100644 s6/source/boot/contents.d/udev-trigger create mode 100644 s6/source/boot/type create mode 100644 s6/source/cartridged-log/consumer-for create mode 100644 s6/source/cartridged-log/dependencies.d/cartridged-runtime create mode 100755 s6/source/cartridged-log/run create mode 100644 s6/source/cartridged-log/type create mode 100644 s6/source/cartridged-runtime/dependencies.d/runtime-fs create mode 100644 s6/source/cartridged-runtime/type create mode 100644 s6/source/cartridged-runtime/up create mode 100644 s6/source/cartridged/dependencies.d/cartridged-runtime create mode 100644 s6/source/cartridged/dependencies.d/machine-config create mode 100644 s6/source/cartridged/notification-fd create mode 100644 s6/source/cartridged/producer-for create mode 100755 s6/source/cartridged/run create mode 100644 s6/source/cartridged/type create mode 100644 s6/source/console/dependencies.d/runtime-fs create mode 100644 s6/source/console/type create mode 100644 s6/source/console/up create mode 100644 s6/source/dasungd-log/consumer-for create mode 100644 s6/source/dasungd-log/dependencies.d/dasungd-runtime create mode 100755 s6/source/dasungd-log/run create mode 100644 s6/source/dasungd-log/type create mode 100644 s6/source/dasungd-runtime/dependencies.d/runtime-fs create mode 100644 s6/source/dasungd-runtime/type create mode 100644 s6/source/dasungd-runtime/up create mode 100644 s6/source/dasungd/dependencies.d/dasungd-runtime create mode 100644 s6/source/dasungd/producer-for create mode 100755 s6/source/dasungd/run create mode 100644 s6/source/dasungd/timeout-kill create mode 100644 s6/source/dasungd/type create mode 100644 s6/source/desktop-log/consumer-for create mode 100644 s6/source/desktop-log/dependencies.d/desktop-runtime create mode 100755 s6/source/desktop-log/run create mode 100644 s6/source/desktop-log/type create mode 100644 s6/source/desktop-runtime/dependencies.d/runtime-fs create mode 100644 s6/source/desktop-runtime/type create mode 100644 s6/source/desktop-runtime/up create mode 100644 s6/source/desktop-session/dependencies.d/cartridged create mode 100644 s6/source/desktop-session/dependencies.d/desktop-log create mode 100644 s6/source/desktop-session/dependencies.d/xserver create mode 100755 s6/source/desktop-session/finish create mode 100644 s6/source/desktop-session/notification-fd create mode 100644 s6/source/desktop-session/producer-for create mode 100755 s6/source/desktop-session/run create mode 100644 s6/source/desktop-session/timeout-kill create mode 100644 s6/source/desktop-session/timeout-up create mode 100644 s6/source/desktop-session/type create mode 100644 s6/source/desktop/contents.d/desktop-session create mode 100644 s6/source/desktop/contents.d/xserver create mode 100644 s6/source/desktop/type create mode 100644 s6/source/dhcp/dependencies.d/cartridged create mode 100644 s6/source/dhcp/dependencies.d/network-log create mode 100644 s6/source/dhcp/dependencies.d/network-runtime create mode 100755 s6/source/dhcp/finish create mode 100644 s6/source/dhcp/producer-for create mode 100755 s6/source/dhcp/run create mode 100644 s6/source/dhcp/timeout-kill create mode 100644 s6/source/dhcp/type create mode 100644 s6/source/eudevd/dependencies.d/runtime-fs create mode 100644 s6/source/eudevd/notification-fd create mode 100755 s6/source/eudevd/run create mode 100644 s6/source/eudevd/timeout-kill create mode 100644 s6/source/eudevd/timeout-up create mode 100644 s6/source/eudevd/type create mode 100644 s6/source/getty/dependencies.d/console create mode 100644 s6/source/getty/dependencies.d/hostname create mode 100644 s6/source/getty/down-signal create mode 100755 s6/source/getty/run create mode 100644 s6/source/getty/timeout-kill create mode 100644 s6/source/getty/type create mode 100644 s6/source/hostname/type create mode 100644 s6/source/hostname/up create mode 100644 s6/source/machine-config/dependencies.d/runtime-fs create mode 100644 s6/source/machine-config/type create mode 100644 s6/source/machine-config/up create mode 100644 s6/source/network-log/consumer-for create mode 100644 s6/source/network-log/dependencies.d/network-runtime create mode 100755 s6/source/network-log/run create mode 100644 s6/source/network-log/type create mode 100644 s6/source/network-runtime/dependencies.d/runtime-fs create mode 100644 s6/source/network-runtime/type create mode 100644 s6/source/network-runtime/up create mode 100644 s6/source/network/contents.d/dhcp create mode 100644 s6/source/network/type create mode 100644 s6/source/runtime-fs/type create mode 100644 s6/source/runtime-fs/up create mode 100644 s6/source/test-echo/dependencies.d/test-runtime create mode 100644 s6/source/test-echo/notification-fd create mode 100755 s6/source/test-echo/run create mode 100644 s6/source/test-echo/timeout-kill create mode 100644 s6/source/test-echo/timeout-up create mode 100644 s6/source/test-echo/type create mode 100644 s6/source/test-runtime/dependencies.d/runtime-fs create mode 100644 s6/source/test-runtime/type create mode 100644 s6/source/test-runtime/up create mode 100644 s6/source/udev-trigger/dependencies.d/eudevd create mode 100644 s6/source/udev-trigger/type create mode 100644 s6/source/udev-trigger/up create mode 100644 s6/source/xserver-log/consumer-for create mode 100644 s6/source/xserver-log/dependencies.d/desktop-runtime create mode 100755 s6/source/xserver-log/run create mode 100644 s6/source/xserver-log/type create mode 100644 s6/source/xserver/dependencies.d/cartridged create mode 100644 s6/source/xserver/dependencies.d/desktop-runtime create mode 100644 s6/source/xserver/dependencies.d/udev-trigger create mode 100644 s6/source/xserver/dependencies.d/xserver-log create mode 100755 s6/source/xserver/finish create mode 100644 s6/source/xserver/notification-fd create mode 100644 s6/source/xserver/producer-for create mode 100755 s6/source/xserver/run create mode 100644 s6/source/xserver/timeout-kill create mode 100644 s6/source/xserver/timeout-up create mode 100644 s6/source/xserver/type create mode 100644 tests/fixtures/manifests/windowmaker.toml create mode 100644 tests/hardware/.gitkeep create mode 100644 tests/hardware/README.md create mode 100644 tests/hardware/session-template.json create mode 100644 tests/helpers/Cargo.toml create mode 100644 tests/helpers/cartridge-client.rs create mode 100644 tests/helpers/data-writer.rs create mode 100644 tests/helpers/usb-stress.rs create mode 100755 tests/integration/dasung-checks create mode 100755 tests/integration/dasung-s6-check create mode 100755 tests/integration/m0-checks create mode 100755 tests/integration/m1-checks create mode 100644 tests/integration/m10-runtime.py create mode 100644 tests/integration/m11-media-faults.py create mode 100644 tests/integration/m11-runtime.py create mode 100644 tests/integration/m12-clock.py create mode 100644 tests/integration/m12-development.py create mode 100644 tests/integration/m12-eeprom.py create mode 100644 tests/integration/m12-internal.py create mode 100644 tests/integration/m12-recovery.py create mode 100644 tests/integration/m12-release-build.py create mode 100644 tests/integration/m12-signing.py create mode 100755 tests/integration/m2-checks create mode 100755 tests/integration/m2-guest create mode 100755 tests/integration/m3-checks create mode 100755 tests/integration/m3-runtime.py create mode 100755 tests/integration/m4-checks create mode 100755 tests/integration/m4-guest create mode 100755 tests/integration/m4-runtime.py create mode 100755 tests/integration/m5-runtime.py create mode 100755 tests/integration/m6-runtime.py create mode 100755 tests/integration/m7-runtime.py create mode 100755 tests/integration/m8-runtime.py create mode 100644 tests/integration/m9-images.py create mode 100644 tests/integration/m9-runtime.py create mode 100644 tests/integration/m9-system.py create mode 100644 tests/integration/rootfs-emulation.py create mode 100644 tests/integration/workstation-emulator.py create mode 100644 tests/integration/workstation-images.py create mode 100644 tests/unit/.gitkeep create mode 100755 tools/assemble-release create mode 100755 tools/bootstrap-host create mode 100755 tools/build-base-packages create mode 100755 tools/build-dasungd create mode 100755 tools/build-fds create mode 100755 tools/build-fds-package create mode 100755 tools/build-kernel create mode 100755 tools/build-package create mode 100755 tools/build-rootfs create mode 100755 tools/build-workstation create mode 100755 tools/capture-hardware create mode 100755 tools/cargo-build create mode 100755 tools/compare-builds create mode 100755 tools/compare-rust-profiles create mode 100755 tools/configure-pi-eeprom create mode 100644 tools/eeprom_inputs.py create mode 100755 tools/finalize-rootfs create mode 100755 tools/frozen-inputs create mode 100644 tools/image_formats.py create mode 100755 tools/in-frozen-build create mode 100755 tools/in-image-tools create mode 100755 tools/in-rootfs create mode 100755 tools/in-void create mode 100644 tools/lib.sh create mode 100755 tools/make-stage0-vm create mode 100755 tools/make-vm-image create mode 100755 tools/prepare-image-tools create mode 100755 tools/prepare-pi-firmware create mode 100755 tools/prepare-vm create mode 100755 tools/prepare-void create mode 100644 tools/release-readme.md create mode 100644 tools/release_artifacts.py create mode 100755 tools/rootfs-archive create mode 100755 tools/rootfs-audit create mode 100755 tools/rootfs-layout create mode 100644 tools/rootfs-namespace create mode 100755 tools/rootfs-runtime-check create mode 100644 tools/rootfs_lib.py create mode 100755 tools/run-system-vm create mode 100755 tools/run-vm create mode 100755 tools/rust-notices create mode 100755 tools/smoke-test create mode 100755 tools/verify-elf create mode 100644 tools/vm_test.py create mode 160000 vendor/void-packages diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d1c8493 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[build] +target = "aarch64-unknown-linux-musl" + +[target.aarch64-unknown-linux-musl] +linker = "rust-lld" +rustflags = ["-C", "target-feature=+crt-static", "-C", "link-self-contained=yes"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68abac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/out/* +!/out/.gitkeep +/target/ +/.host/ +__pycache__/ + +# Output of the documented workstation C example. +/examples/software/hello/root/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f5c1488 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vendor/void-packages"] + path = vendor/void-packages + url = https://github.com/void-linux/void-packages.git + shallow = true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4434523 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# FDS/OS implementation scope + +Read `docs/master-plan.md` and `docs/build-host.md` before changing the build. +The user has explicitly authorized implementation through M12. Proceed through +the remaining milestones in order, keeping the repository buildable and recording +actual acceptance evidence. Defer tests that require the physical Raspberry Pi +or its attached hardware; implement the corresponding software and hardware-test +procedures, and never substitute VM results for physical measurements. +Empty future component directories are intentional until their implementation; +do not fill them with placeholder implementations that claim to work. + +The user explicitly authorized importing the existing Paperlike 13K `dasungd` +into the base system. Its Rust workspace member, static ARM package, base package +selection, and native s6 service are required throughout M0–M12. Keep it in the base boot bundle, independent of +GUI or ENVIRONMENT cartridges. Read `docs/dasung.md` before changing it; do not +claim Pi boot or physical power-cycle recovery is verified. + +- Use English for all project-owned documentation, comments, diagnostics, task + summaries, and conversation. Keep the master plan and build context in English. +- General userspace and XBPS target architecture: aarch64 + glibc. +- FDS executables: Rust + static aarch64-unknown-linux-musl by default. +- Use Clap for every Rust command-line interface, including test helpers. Define + commands and options with typed parsers; do not hand-parse argument vectors. +- No systemd, runit, BusyBox, shell PID1, boot-time cache compilation, or sleeps + to hide races in the target system. +- Keep upstream Void files unchanged; pin its submodule and use `packages/` overlays. +- Explain every new dependency and keep documentation accurate. +- Run `make bootstrap`, `make smoke-test`, and `make check` for M0 build changes. + Report skips and missing hardware honestly. Do not invent boot benchmarks. +- Run `make rootfs PROFILE=cli` and `make rootfs-test` for M1 composition changes. + M2 adds native init. Run `make init-test` to verify PID 1 and service control + in the isolated ARM virtual machine. The VM kernel is a test fixture, not a Pi image. + +# Workstation software and emulator follow-up + +Read `docs/workstation.md`, `docs/software-format.md` and +`docs/workstation-tooling-plan.md` for this extension. Software builds and new +PROGRAM cartridge creation run on generic Linux workstations. The public native +Clap tools are `fds-cartridge` and `fds-emulator`. Software images have GPT +metadata partition 1 plus m EROFS payload partitions holding xz tarballs. Keep +legacy reading and base Dasung integration. Do not mutate the frozen 0.1.0 release +or treat its historical acceptance as evidence for changed sources. Validate +with `make workstation-test` and `make emulator-test` plus relevant guest checks. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fdb0183 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,846 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dasungd" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ctrlc", + "libc", + "rusb", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.11.0", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fds-boottrace" +version = "0.1.0" +dependencies = [ + "clap", + "fds-common", + "libc", + "serde_json", +] + +[[package]] +name = "fds-burn" +version = "0.1.0" +dependencies = [ + "clap", + "fds-common", + "libc", + "serde", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "fds-cartridged" +version = "0.1.0" +dependencies = [ + "clap", + "fds-burn", + "fds-common", + "fds-software", + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "fds-cli" +version = "0.1.0" +dependencies = [ + "clap", + "fds-burn", + "fds-common", + "libc", + "serde_json", +] + +[[package]] +name = "fds-common" +version = "0.1.0" +dependencies = [ + "libc", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "fds-release" +version = "0.1.0" +dependencies = [ + "clap", + "ed25519-dalek", + "fds-common", + "libc", + "serde", + "serde_json", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "fds-smoketest" +version = "0.1.0" + +[[package]] +name = "fds-software" +version = "0.1.0" +dependencies = [ + "fds-common", + "libc", + "serde", + "sha2 0.10.9", + "tar", + "toml", +] + +[[package]] +name = "fds-stage0" +version = "0.1.0" +dependencies = [ + "clap", + "fds-common", + "libc", + "serde_json", +] + +[[package]] +name = "fds-test-helpers" +version = "0.1.0" +dependencies = [ + "clap", + "libc", +] + +[[package]] +name = "fds-workstation" +version = "0.1.0" +dependencies = [ + "clap", + "fds-burn", + "fds-common", + "fds-software", + "libc", + "serde", + "serde_json", + "sha2 0.10.9", + "toml", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f80601c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[workspace] +resolver = "2" +members = [ + "rust/fds-smoketest", "rust/dasungd", "rust/fds-common", + "rust/fds-cli", "rust/fds-stage0", "rust/fds-boottrace", "rust/fds-cartridged", + "rust/fds-burn", + "rust/fds-release", + "rust/fds-software", + "rust/fds-workstation", + "tests/helpers", +] + +[workspace.dependencies] +clap = { version = "4", features = ["derive"] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true + +[profile.release.package.fds-test-helpers] +opt-level = 2 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c9aa4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2026 FDS/OS contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Third-party sources and imported components retain their own licenses. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7327fe0 --- /dev/null +++ b/Makefile @@ -0,0 +1,241 @@ +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help +.NOTPARALLEL: +.PHONY: console-vm cartridge-test data-test desktop-test + +.PHONY: help bootstrap smoke-test check dasung dasung-test rootfs rootfs-test init-test vm tooling tooling-test kernel initramfs system-card boot-volume boot-test console-test performance-test +help: + @printf '%s\n' 'FDS/OS — static Rust tools and native s6' \ + 'make workstation Build native Linux software/cartridge and QEMU command-line tools' \ + 'make workstation-test Verify software builds, multi-partition images and file write/readback' \ + 'make emulator-test Boot FDS and test cartridge hotplug with the public emulator' \ + 'make bootstrap Prepare Arch x86_64 host and Void build container' \ + 'make packages Build and export every FDS base package' \ + 'make all Build CLI, development, recovery, Pi boot and complete internal images' \ + 'make smoke-test Cross-build and verify GNU hello + static-musl Rust' \ + 'make check Run validation failure-path tests and formatting checks' \ + 'make kernel Cross-build the pinned Raspberry Pi 5 kernel' \ + 'make initramfs Build early Rust userspace in four archive formats' \ + 'make system-card PROFILE=cli Make GPT/EROFS from the exported rootfs' \ + 'make boot-volume BOOT_MODE=production Make a Pi FAT32 boot partition' \ + 'make boot-test Test stage0, media insertion, recovery and all archive formats' \ + 'make desktop-test Verify ARM WindowMaker, PROGRAM and on-demand DHCP' \ + 'make media-image-test Check SYSTEM/DATA/ENVIRONMENT creation and legacy PROGRAM compatibility' \ + 'make media-test Verify confirmed writes to disposable virtual USB media' \ + 'make eeprom EEPROM_PROFILE=production Prepare a reversible EEPROM preview' \ + 'make eeprom-test Verify EEPROM configuration and rollback without hardware' \ + 'make stress-test Verify twelve USB devices and media I/O failure paths' \ + 'make media-fault-test Verify write/flush/readback errors and interrupted writes' \ + 'make power-test Verify ordered ARM shutdown, DATA safety and blocked failure paths' \ + 'make data-test Verify writable DATA and sustained-write safe eject' \ + 'make cartridge-test Verify virtual USB bays, metadata, mounts and eject' \ + 'make console-test Verify the FDS user prompt and boot-event reporting' \ + 'make console-vm Open the ordinary-user FDS console in an ARM VM' \ + 'make performance-test Compare z/2/3 stage0 builds in the ARM VM' \ + 'make tooling Build the static ARM FDS CLI and stage0 tools' \ + 'make tooling-test Test shared contracts and execute the ARM tools' \ + 'make dasung Build static ARM dasungd, XBPS package and s6 database' \ + 'make dasung-test Test the daemon with simulated hardware and inspect its package' \ + 'make rootfs PROFILE=cli Assemble, configure and test the ARM base filesystem' \ + 'make recovery Build the independent recovery rootfs and EROFS payload' \ + 'make recovery-test Verify SYSTEM-independent recovery and DATA repair in ARM VMs' \ + 'make internal-image Assemble FDS_BOOT, FDS_RECOVERY and machine settings as one GPT disk' \ + 'make internal-test Verify virtual NVMe settings, persistence and rejection paths' \ + 'make development-test Compile, execute and debug C/C++/Rust in the development SYSTEM' \ + 'make clock-test Verify that native startup preserves a newer guest clock' \ + 'make signing Build the host release signer and static ARM verifier' \ + 'make signing-test Check signatures, corruption rejection and OpenSSL interoperability' \ + 'make release-contract-test Check frozen-lock and complete-build comparison rejection paths' \ + 'make rootfs-test Verify the exported archive and rejection checks' \ + 'make init-test Boot an ARM VM and verify s6 PID 1 and service control' \ + 'make vm Open a temporary root shell in the verified test VM' + +bootstrap: + @mkdir -p out/logs + ./tools/bootstrap-host 2>&1 | tee out/logs/bootstrap.log + +.PHONY: packages all +packages: + @mkdir -p out/logs + ./tools/build-base-packages 2>&1 | tee out/logs/packages-build.log + +all: + $(MAKE) bootstrap + $(MAKE) packages + $(MAKE) rootfs PROFILE=cli + $(MAKE) system-card PROFILE=cli + $(MAKE) rootfs PROFILE=development + $(MAKE) system-card PROFILE=development + $(MAKE) recovery + $(MAKE) initramfs + $(MAKE) boot-volume BOOT_MODE=production + $(MAKE) internal-image + $(MAKE) signing + $(MAKE) eeprom EEPROM_PROFILE=production + +smoke-test: + @mkdir -p out/logs + ./tools/smoke-test 2>&1 | tee out/logs/smoke-test.log + +check: + ./tests/integration/m0-checks + cargo fmt --all -- --check + +dasung: + @mkdir -p out/logs + ./tools/build-dasungd 2>&1 | tee out/logs/dasung-build.log + +dasung-test: + @mkdir -p out/logs + ./tests/integration/dasung-checks 2>&1 | tee out/logs/dasung-checks.log + +rootfs: + @mkdir -p out/logs + PROFILE=$(or $(PROFILE),cli) ./tools/build-rootfs 2>&1 | tee out/logs/rootfs-build.log + +rootfs-test: + @mkdir -p out/logs + ./tests/integration/m1-checks 2>&1 | tee out/logs/rootfs-checks.log + +init-test: + @mkdir -p out/logs + ./tests/integration/m2-checks 2>&1 | tee out/logs/init-checks.log + +vm: + ./tools/run-vm out/m2-vm-latest --shell + +tooling: + @mkdir -p out/logs + ./tools/build-fds 2>&1 | tee out/logs/fds-tools-build.log + +tooling-test: + @mkdir -p out/logs + ./tests/integration/m3-checks 2>&1 | tee out/logs/fds-tools-checks.log + +kernel: + @mkdir -p out/logs + ./tools/build-kernel 2>&1 | tee out/logs/kernel-build.log + +initramfs: tooling dasung + ./image/build-initramfs 2>&1 | tee out/logs/initramfs-build.log + +system-card: + ./tools/prepare-image-tools + ./image/build-system-cartridge --profile $(or $(PROFILE),cli) --rootfs out/rootfs-$(or $(PROFILE),cli).tar 2>&1 | tee out/logs/system-card-build.log + +boot-volume: + ./image/build-boot-volume --mode $(or $(BOOT_MODE),production) 2>&1 | tee out/logs/boot-volume-build.log + +boot-test: + ./tests/integration/m4-checks 2>&1 | tee out/logs/boot-checks.log + +console-test: + python3 tests/integration/m5-runtime.py 2>&1 | tee out/logs/console-checks.log + +console-vm: + ./tools/run-system-vm + +performance-test: + ./tools/compare-rust-profiles 2>&1 | tee out/logs/performance-checks.log + +cartridge-test: + cargo test --locked --offline --target x86_64-unknown-linux-gnu -p fds-common -p fds-cartridged + ./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-test-helpers --bin m6-client + cp target/aarch64-unknown-linux-musl/release/m6-client out/m6-client + python3 tests/integration/m6-runtime.py 2>&1 | tee out/logs/cartridge-checks.log + +data-test: + ./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-test-helpers --bin m7-writer + cp target/aarch64-unknown-linux-musl/release/m7-writer out/m7-writer + python3 tests/integration/m7-runtime.py 2>&1 | tee out/logs/data-checks.log + +desktop-test: + python3 tests/integration/m8-runtime.py 2>&1 | tee out/logs/desktop-checks.log + +.PHONY: media-image-test media-test +media-image-test: tooling + python3 tests/integration/m9-images.py 2>&1 | tee out/logs/media-image-checks.log + +media-test: tooling + python3 tests/integration/m9-runtime.py 2>&1 | tee out/logs/media-checks.log + python3 tests/integration/m9-system.py 2>&1 | tee out/logs/media-system-checks.log + +.PHONY: power-test +power-test: + ./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-test-helpers --bin m7-writer + cp target/aarch64-unknown-linux-musl/release/m7-writer out/m7-writer + python3 tests/integration/m10-runtime.py 2>&1 | tee out/logs/power-checks.log + +.PHONY: stress-test media-fault-test +stress-test: + @mkdir -p out/logs + ./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-test-helpers --bin m11-stress + cp target/aarch64-unknown-linux-musl/release/m11-stress out/m11-stress + ./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-test-helpers --bin m7-writer + cp target/aarch64-unknown-linux-musl/release/m7-writer out/m7-writer + python3 tests/integration/m11-runtime.py 2>&1 | tee out/logs/stress-checks.log + $(MAKE) media-fault-test + +media-fault-test: + @mkdir -p out/logs + python3 tests/integration/m11-media-faults.py 2>&1 | tee out/logs/media-fault-checks.log + +.PHONY: eeprom eeprom-test +eeprom: + ./tools/configure-pi-eeprom --profile $(or $(EEPROM_PROFILE),production) + +eeprom-test: + @mkdir -p out/logs + python3 tests/integration/m12-eeprom.py 2>&1 | tee out/logs/eeprom-checks.log + +.PHONY: recovery recovery-test +recovery: + $(MAKE) rootfs PROFILE=recovery + ./tools/prepare-image-tools + ./image/build-recovery 2>&1 | tee out/logs/recovery-build.log + +recovery-test: + python3 tests/integration/m12-recovery.py 2>&1 | tee out/logs/recovery-checks.log + +.PHONY: internal-image internal-test +internal-image: + ./image/build-internal $(if $(MACHINE_CONFIG),--machine-config "$(MACHINE_CONFIG)") 2>&1 | tee out/logs/internal-build.log + +internal-test: + python3 tests/integration/m12-internal.py 2>&1 | tee out/logs/internal-checks.log + +.PHONY: development-test clock-test +development-test: + python3 tests/integration/m12-development.py 2>&1 | tee out/logs/development-checks.log + +clock-test: + python3 tests/integration/m12-clock.py 2>&1 | tee out/logs/clock-checks.log + +.PHONY: signing signing-test +signing: + @mkdir -p out/logs + ./tools/cargo-build --locked --offline --release --target x86_64-unknown-linux-gnu -p fds-release + ./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-release + ./tools/verify-elf target/aarch64-unknown-linux-musl/release/fds-release aarch64 static + +signing-test: signing + cargo test --locked --offline --target x86_64-unknown-linux-gnu -p fds-release + python3 tests/integration/m12-signing.py 2>&1 | tee out/logs/signing-checks.log + +.PHONY: release-contract-test +release-contract-test: + @mkdir -p out/logs + python3 tests/integration/m12-release-build.py 2>&1 | tee out/logs/release-contract-checks.log + +.PHONY: workstation workstation-test emulator-test +workstation: + @mkdir -p out/logs + ./tools/build-workstation 2>&1 | tee out/logs/workstation-build.log + +workstation-test: workstation + cargo test --locked --offline --target $$(rustc -vV | sed -n 's/^host: //p') -p fds-common -p fds-burn -p fds-software -p fds-workstation + python3 tests/integration/workstation-images.py --cli out/workstation/fds-cartridge --image-tool-runner tools/in-image-tools --cc "$(CURDIR)/tools/in-void" aarch64-linux-gnu-gcc 2>&1 | tee out/logs/workstation-images.log + +emulator-test: workstation + python3 tests/integration/workstation-emulator.py --cli out/workstation/fds-emulator --qemu-runner tools/in-void 2>&1 | tee out/logs/workstation-emulator.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa2c50a --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# FDS/OS — Felis Data Systems Operating System + +FDS/OS is an operating system project for the **Felis FP-85**, a planned retro +portable computer built around a Raspberry Pi 5, a Dasung Paperlike 13K grayscale E-Ink display, a removable +battery, and 12 USB cartridge bays. + +The idea is to make the computer's software and data removable. A **SYSTEM** +cartridge carries the operating system, a **DATA** cartridge carries the user's +files, and **PROGRAM** or **ENVIRONMENT** cartridges add applications or select +an environment such as WindowMaker. The internal NVMe holds the boot machinery, +recovery environment, and machine configuration. Swapping a SYSTEM cartridge is +the intended update and rollback mechanism. + +The project aims to combine familiar Linux software with fast, predictable +startup and shutdown. It uses the Void/XBPS package ecosystem for ordinary Linux +applications, uses native s6 service management, and builds FDS-owned system +tools as small, self-contained Rust executables. + +## What you can use today + +**M0–M12 software acceptance is complete, including a signed local FDS/OS 0.1.0 release.** +Current development also adds [Linux workstation software cartridges and a QEMU emulator](docs/workstation.md). These changes are separate from the frozen 0.1.0 release. +Physical Pi testing is deferred; see +the [implementation ledger](docs/implementation-status.md). +The cross-build environment is implemented and verified on x86_64 Arch Linux. You can build and inspect ARM Linux software from your +Arch machine without owning a Pi. + +| Available now | What it gives you | +| --- | --- | +| `out/fds-os-0.1.0/` | Signed local images, preserved source/build inputs and independent-build comparison; start with its `README.md` | +| `make workstation` | Native Linux `fds-cartridge` and `fds-emulator`: build xz bundles, create/verify 1+m GPT images, preview USB writes, and hotplug images into twelve virtual bays | +| `make bootstrap` | Project-local XBPS tools, a Void build container, and the pinned Rust toolchain | +| `make all` / `make packages` | The complete image set, or just the FDS base packages; see [Complete builds](docs/reproducible-builds.md#prepare-the-snapshot) | +| `make smoke-test` | An ARM glibc XBPS package and an ARM static-musl Rust executable, with automated checks | +| `make check` | Checks for invalid binaries, source pin drift, unsafe overlay replacement, and Rust formatting | +| `make tooling` / `make tooling-test` | Static ARM `fds` and stage0 diagnostic tools, with parsing/discovery tests | +| `make dasung` / `make dasung-test` | Static ARM monitor daemon, base XBPS package, s6 definitions, and simulated monitor tests | +| `make rootfs PROFILE=cli` / `make rootfs-test` | Configured ARM rootfs archive, emulated shell access, and archive acceptance checks | +| `make init-test` / `make vm` | Full ARM boot test, native s6 PID 1, service control, and a temporary development shell | +| `make kernel`, `make initramfs`, `make system-card`, `make boot-volume` | Pi kernel, early userspace, GPT/EROFS SYSTEM and FAT32 boot partition; see [Boot images](docs/boot.md) | +| `make boot-test` | Actual stage0 handoff, missing-media insertion, recovery and archive-format checks in an ARM VM | +| `make console-vm`, `make console-test`, `make performance-test` | Ordinary-user FDS console, boot tracing and measured VM optimization comparison; see [M5 evidence](docs/m5-validation.md) | +| `make cartridge-test` | Virtual USB bay mapping, hardware recognition, metadata/mount rejection checks and safe read-only eject | +| `make data-test` | Writable `/data`, managed background programs, sustained writes, safe eject and independent ext4 checks; see [DATA usage](docs/data.md) | +| `make rootfs PROFILE=development` / `make desktop-test` | WindowMaker, grayscale styling, trusted ENVIRONMENT activation, managed PROGRAM execution and on-demand Ethernet; see [Desktop usage](docs/desktop.md) | +| `make media-image-test` / `make media-test` | SYSTEM/DATA/ENVIRONMENT creation, legacy PROGRAM compatibility, confirmed writes, readback verification and boot of a newly written SYSTEM; see [Media tools](docs/media-tools.md) | +| `make recovery` / `make recovery-test` | Separate recovery rootfs and EROFS, local maintenance console, DATA checks/repairs and replacement SYSTEM workflow; software checks passed, see [Recovery](docs/recovery.md) | +| `make internal-image` / `make internal-test` | Complete internal GPT image and persistent bay/hardware settings; see [Internal storage](docs/internal-storage.md) | +| `make development-test` | Native C/C++/Rust builds, debuggers, build systems, Git and Vim inside the ARM development image | +| `make signing` / `make signing-test` | Host and ARM release signing/verification tools, checked independently against OpenSSL | +| `make eeprom` / `make eeprom-test` | Reversible, verified EEPROM configuration files without applying anything to hardware; see [EEPROM](docs/eeprom.md) | +| Source pinning and input records | A fixed Void source commit plus logs and hashes of the actual build inputs | + +**The kernel → stage0 → SYSTEM → s6 path boots in an ARM virtual machine.** +The filesystem includes native s6 init, runtime mounts, console and device +services, and Dasung supervision. The ordinary-user FDS console and boot tracing are available. M6 adds the cartridge daemon and `fds bays`, `fds bay`, `fds cartridge`, and +`fds eject`; see [Cartridge usage](docs/cartridges.md). The complete internal NVMe image and independent recovery are implemented; +[release signature tooling](docs/releases.md) has passed host and ARM checks. +[Frozen inputs, offline rebuilds and complete release assembly](docs/reproducible-builds.md) +passed two independent offline builds with all 27 artifacts identical. The local +release is at `out/fds-os-0.1.0/`; [M12 validation](docs/m12-validation.md#final-local-release-acceptance) +records the signing identity, verification commands and exact evidence. +M10's `fds poweroff` and +`fds reboot` have passed software validation; see [Shutdown usage](docs/power.md). The `fds` +tool also provides identity, boot tracing and manifest inspection; see [Rust tooling](docs/tooling.md). See the [roadmap](docs/roadmap.md). + +## Start here + +- **I want to build software cartridges or try virtual insertion/removal:** follow the [workstation walkthrough](docs/workstation.md). +- **I want to understand the project:** read [Architecture](docs/architecture.md). +- **I want to try the working code:** follow [Your first build](docs/getting-started.md). +- **I want to build the OS filesystem:** follow [Rootfs](docs/rootfs.md). +- **I want to boot it and control services:** follow [Native init and ARM VM](docs/init.md). +- **I want to build the Pi boot files and SYSTEM image:** follow [Boot images](docs/boot.md). +- **I want to use the desktop, applications or Ethernet:** follow [Desktop usage](docs/desktop.md). +- **I want to create or write a cartridge:** follow [Media tools](docs/media-tools.md). +- **I want persistent bay settings and an internal NVMe image:** follow [Internal storage](docs/internal-storage.md). +- **SYSTEM will not boot, or DATA needs checking:** follow [Recovery](docs/recovery.md). +- **I have already built it and want to develop:** use the [Development guide](docs/development.md). +- **A command failed:** look up its message in [Troubleshooting](docs/troubleshooting.md). +- **I do not know the terminology:** keep the [Glossary](docs/glossary.md) open. + +The [documentation index](docs/README.md) organizes the remaining guides. The +[master plan](docs/master-plan.md) is the full specification, rather than the +recommended starting point for learning to use the repository. + +## Quick start: software cartridges and emulation + +On a Linux workstation, build the native tools with `make workstation`, then +follow [the complete walkthrough](docs/workstation.md) to package the included +hello/report examples, assemble a cartridge, boot FDS and insert it. This tooling +uses normal Linux QEMU, EROFS and xz utilities; it does not run on the Pi. +Building the complete OS images still uses the environment below. + +## Quick start on Arch Linux + +You need an **x86_64 Arch Linux host**, a regular user account with access to sudo +for installing prerequisites, a network connection, and several GB of free disk +space. The kernel must permit unprivileged user namespaces. A Pi, Docker, and a +separate musl C compiler are not required for the current smoketest. + +Use a checkout path without spaces. In the current workspace: + +```sh +cd /home/felis/source/fds +``` + +On another machine, enter your own FDS checkout directory instead. Use an actual +Git checkout so the pinned Void submodule can be initialized for a normal online +build. A complete [frozen-input snapshot](docs/reproducible-builds.md) instead +restores the source and its bundled Void checkout for offline building; a bare +source archive alone does not supply those inputs. This repository does not currently +configure a public Git remote, so no public clone URL is assumed here. + +Install the prerequisites once: + +```sh +sudo pacman -S --needed bash coreutils binutils git curl make file tar xz gzip zstd \ + bubblewrap rustup ca-certificates findutils diffutils grep sed gawk util-linux +``` + +Then run these commands **in order, as your regular user**: + +```sh +make bootstrap +make smoke-test +make check +``` + +`make bootstrap` downloads and prepares the build tools. `make smoke-test` +compiles the two ARM artifacts and verifies their architectures and library +requirements. `make check` exercises the validation failure cases and checks +formatting. If a command fails, stop at that step and use the linked +[troubleshooting guide](docs/troubleshooting.md); a partially completed run is +not a successful build. + +The first run downloads hundreds of MB of compiler and build dependencies and +may take a while on a slow connection. Later runs reuse caches, although XBPS +operations may still access the network. `cargo --offline` applies only to Rust +crate downloads, not to the whole pipeline. + +Successful smoke-test output ends with: + +```text +PASS: aarch64 static ELF +... +PASS: XBPS package hello-2.12.3_1 architecture=aarch64 (glibc) +SKIP: ARM execution (optional qemu-aarch64 not installed); ELF verification passed +PASS: M0 smoke test complete +``` + +The QEMU line is a documented optional skip. If QEMU is installed, a runtime PASS +replaces it. The [first-build guide](docs/getting-started.md) explains how to run +the executable with QEMU and interpret every result. + +To build the current OS filesystem and boot its development VM, continue with: + +```sh +sudo pacman -S --needed python e2fsprogs libarchive lz4 +make rootfs PROFILE=cli +make rootfs-test +make init-test +make vm +``` + +Python must be version 3.14 or newer. Additional emulation tools are installed +inside the project-local build container. `make init-test` boots ARM Linux, +checks native init and service control, and powers off. `make vm` repeats those +checks and opens a temporary root shell; type `exit` to shut it down. Nothing is +flashed to a physical disk. The [VM guide](docs/init.md) explains the console, +service commands, logs, and limits in detail. + +To build the complete operating-system image set and try its ordinary user +console, continue from that prepared workstation: + +The complete images require substantially more space than the initial smoke +test: the offline diagnostic build occupied about 24 GB, before additional VM +test copies. See [build-space observations](docs/reproducible-builds.md#what-is-frozen) +before preparing two independent release builds. + +```sh +make all +make init-test +make boot-test +make console-vm +``` + +`make all` builds CLI and development SYSTEM cartridges, independent recovery +and the internal disk. `make init-test` prepares the full QEMU environment and +checks native init. `make boot-test` checks startup, missing-media recovery and +all initramfs formats. `make console-vm` opens the actual `FDS>` console. +Enter these commands **inside that console**: + +```sh +fds info +fds bays +fds boot-profile +fds poweroff +``` + +This VM has no physical USB devices attached, so empty cartridge bays are +expected. Its home directory is temporary; use a DATA cartridge for persistent +files on the real machine. `fds poweroff` performs an orderly shutdown. The +[boot guide](docs/boot.md#use-the-ordinary-fds-console) explains console controls, +and the [internal-storage guide](docs/internal-storage.md) covers physical +installation when the Pi hardware is ready. + +## Dasung monitor support + +The existing Rust `dasungd` from the monitor troubleshooting task is integrated +as a required base-system package, with a native s6 boot service. Build it with +`make dasung`, then run `make dasung-test`. The [Dasung guide](docs/dasung.md) +explains the exact monitor profile, dependencies, configuration, and Pi validation +still needed. M2 starts the controller under native s6 in the ARM VM; the VM has no physical monitor. + +## What the build produces + +| Path | Contents | How to use it | +| --- | --- | --- | +| `out/fds-internal.img` | Complete GPT disk with BOOT, independent RECOVERY and persistent machine-settings partitions | Use the [internal installation procedure](docs/internal-storage.md); this belongs on the internal NVMe | +| `out/fds-system-cli.img` | Immutable CLI SYSTEM cartridge, including base Dasung support | Build with `make system-card PROFILE=cli`; use the [media writer](docs/media-tools.md) to write a SYSTEM cartridge | +| `out/fds-system-development.img` | SYSTEM with native compilers, debugging tools and the optional desktop runtime | Build with `make rootfs PROFILE=development` followed by `make system-card PROFILE=development` | +| `out/fds-boot.img` / `out/fds-recovery.img` | Individual FAT32 BOOT and EROFS RECOVERY partition payloads | Normally use the complete internal image; these are component images, not interchangeable whole disks | +| `out/fds-initramfs.img` | Early boot program and base display support | Included in BOOT; four compressed/uncompressed formats are also under `out/initramfs/` | +| `out/rootfs-aarch64.tar` | Configured ARM glibc userspace, including Dasung | Follow [M1 rootfs](docs/rootfs.md) to build, inspect, and run it | +| `out/m2-vm-latest/` | Last successful ARM test disk, serial log, environment and hashes | Use `make vm` to boot it; see [Native init](docs/init.md) | +| `out/fds-smoketest` | Static-musl AArch64 Rust executable | Inspect with `file`/`readelf`; optionally run with `qemu-aarch64` | +| `out/packages/hello-2.12.3_1.aarch64.xbps` | GNU hello packaged for ARM glibc | Query or extract it with the [package guide](docs/packages.md) | +| `out/packages/aarch64-repodata` | Local package index | Lets XBPS query the exported package | +| `out/logs/` | Bootstrap and build logs | Diagnose a failed or apparently stalled step | +| `out/manifests/` | Package inventories and SHA-256 digests | Inspect which inputs and outputs were used | + +`make all` produces the complete image set above in order. It prepares files and +does not write physical disks. Build and test commands share a build container; +run them sequentially. Stable paths point to the latest successful versioned +build directories. See [Offline rebuilds](docs/reproducible-builds.md) for the +separate frozen-input and release-verification workflow. + +The rootfs tar and individual binaries are not flashable Pi images. The separate +VM disk boots with the generic ARM test kernel. The Rust +executable is ARM code and normally cannot run directly on the x86_64 host. +The XBPS package is not an Arch package and is not installed with pacman. + +## How the build relates to the future machine + +```text +Working today: x86_64 Arch workstation + ├─ pinned Void + xbps-src + GNU cross compiler → aarch64 glibc package + └─ pinned Rust + bundled musl + rust-lld → static AArch64 executable + +Pi boot path, software-tested in a VM; physical validation pending: + internal NVMe kernel/initramfs → SYSTEM cartridge → native s6 → FDS console +``` + +The first line supplies conventional Linux packages. The second supplies FDS's +own control tools without a dependency on the installed glibc version. Both +produce ARM code; only their library/linking strategy differs. See +[the architecture guide](docs/architecture.md#two-library-strategies). + +## Where to find things + +| Directory or file | Purpose today | +| --- | --- | +| `rust/fds-smoketest/`, `rust/dasungd/`, `rust/fds-common/`, `rust/fds-cli/`, `rust/fds-stage0/` | Static-linking smoketest, monitor controller, shared contracts, CLI and early boot | +| `tools/` | Bootstrap, package build, overlay preparation, and ELF checks | +| `config/` | Pinned host tool download and Void build configuration | +| `vendor/void-packages/` | Unmodified upstream source at the recorded commit | +| `tests/integration/m0-checks` | Executable validation and protection checks | +| `docs/` | Tutorials, design explanations, references, and validation evidence | +| `packages/fds-dasungd/`, `s6/source/`, `image/base-packages.list` | Monitor package, native service definitions, and mandatory base inclusion | +| `packages/fds-base/`, `packages/fds-base-files/`, `packages/fds-init/` | Base dependencies, identity, accounts, layout and native init | +| `packages/fds-kernel/`, `packages/fds-cli/`, `image/` | Pi kernel delta, static CLI package and filesystem/boot builders | +| `profiles/` | CLI, development and recovery package selections | +| `rust/fds-release/`, `tools/frozen-inputs`, `tools/assemble-release` | Signature verification, input preservation and local release assembly | + +Generated `.host/`, `target/`, and `out/` contents are ignored by Git. Rustup also +installs the pinned toolchain in your user account's Rustup directory. The +[build reference](docs/build-host.md) details prerequisites, storage, and pins. + +## Project status and evidence + +The [M0 validation report](docs/m0-validation.md) records the actual build commands, +results, and initial artifact hashes. QEMU execution and Pi hardware were not +validated in that initial run. Later milestones measured VM boot and shutdown; +physical Pi performance remains unmeasured. Source and toolchain pins are in +place, and the complete frozen-input/offline release workflow is undergoing M12 +acceptance. See [current evidence](docs/m12-validation.md) for the distinction. + +The [M1 guide](docs/rootfs.md) explains rootfs usage; [M1 validation](docs/m1-validation.md) records the results. +M2 usage is in [Native init and ARM VM](docs/init.md), with results in [M2 validation](docs/m2-validation.md). +M3 adds [static-musl FDS tooling](docs/tooling.md). Work continues through M12 +under [AGENTS.md](AGENTS.md), with physical hardware checks deferred. Project-owned +source comments, documentation, diagnostics, and development discussions use English. diff --git a/VOID_PACKAGES_COMMIT b/VOID_PACKAGES_COMMIT new file mode 100644 index 0000000..5824c16 --- /dev/null +++ b/VOID_PACKAGES_COMMIT @@ -0,0 +1 @@ +02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1 diff --git a/config/eeprom/development.conf b/config/eeprom/development.conf new file mode 100644 index 0000000..e5c3f45 --- /dev/null +++ b/config/eeprom/development.conf @@ -0,0 +1,8 @@ +# Pi 5 development: NVMe first, SD rescue fallback, diagnostic UART enabled. +[all] +BOOT_ORDER=0xf16 +BOOT_UART=1 +NET_INSTALL_ENABLED=0 +NET_INSTALL_AT_POWER_ON=0 +POWER_OFF_ON_HALT=1 +WAIT_FOR_POWER_BUTTON=0 diff --git a/config/eeprom/inputs.json b/config/eeprom/inputs.json new file mode 100644 index 0000000..85e4782 --- /dev/null +++ b/config/eeprom/inputs.json @@ -0,0 +1,10 @@ +{ + "format": 1, + "repository": "https://github.com/raspberrypi/rpi-eeprom", + "commit": "2fee426f27b6c54d3f5b6f36efd9a2fe1286a45d", + "files": [ + {"path": "rpi-eeprom-config", "name": "rpi-eeprom-config", "sha256": "dfa8e2a819e0922fc7fd948ef4fa55f44066fa04ccf5da210d880f0b35ad9d1b"}, + {"path": "firmware-2712/latest/pieeprom-2026-09-12.bin", "name": "pieeprom.bin", "sha256": "b49adc90c380f3b9bec9e1ab21111571d7cfdbfe8f2dc5974f294bdb3eb7c39a"}, + {"path": "LICENSE", "name": "LICENSE", "sha256": "594b7565fd3ccf8acd4711a2ec1b199181aafbc3426d0bacaa50ef40edbf7c4a"} + ] +} diff --git a/config/eeprom/production.conf b/config/eeprom/production.conf new file mode 100644 index 0000000..5c004a4 --- /dev/null +++ b/config/eeprom/production.conf @@ -0,0 +1,8 @@ +# Pi 5 production: internal NVMe only, no firmware network-install scanning. +[all] +BOOT_ORDER=0xf6 +BOOT_UART=0 +NET_INSTALL_ENABLED=0 +NET_INSTALL_AT_POWER_ON=0 +POWER_OFF_ON_HALT=1 +WAIT_FOR_POWER_BUTTON=0 diff --git a/config/host-tools.conf b/config/host-tools.conf new file mode 100644 index 0000000..dea6d80 --- /dev/null +++ b/config/host-tools.conf @@ -0,0 +1,4 @@ +# Versioned upstream archive; never silently advance to a 'latest' download. +XBPS_STATIC_URL=https://repo-default.voidlinux.org/static/xbps-static-static-0.60.4_1.x86_64-musl.tar.xz +XBPS_STATIC_SHA256=603b3c55e9cabd5af79b461b929b14e1556a443c97b5714d188681c2172d9e28 +# Source: https://repo-default.voidlinux.org/static/sha256sums.txt diff --git a/config/machine/bays.toml b/config/machine/bays.toml new file mode 100644 index 0000000..046e443 --- /dev/null +++ b/config/machine/bays.toml @@ -0,0 +1,2 @@ +# No physical bay wiring has been measured yet. Calibrate with fds topology. +# Add explicit USB 2 and USB 3 aliases following docs/cartridges.md. diff --git a/config/machine/hardware-catalog.toml b/config/machine/hardware-catalog.toml new file mode 100644 index 0000000..3b1190d --- /dev/null +++ b/config/machine/hardware-catalog.toml @@ -0,0 +1,2 @@ +# Recognition data only. Entries never authorize commands or startup actions. +device = [] diff --git a/config/machine/machine.toml b/config/machine/machine.toml new file mode 100644 index 0000000..823f6c4 --- /dev/null +++ b/config/machine/machine.toml @@ -0,0 +1,2 @@ +format = 1 +name = "FP-85" diff --git a/config/pi-firmware.conf b/config/pi-firmware.conf new file mode 100644 index 0000000..eaac0ed --- /dev/null +++ b/config/pi-firmware.conf @@ -0,0 +1,4 @@ +# Exact signed Void package matching the template at VOID_PACKAGES_COMMIT. +PI_FIRMWARE_VERSION=20260715_1 +PI_FIRMWARE_SHA256=a75788f92527310bde3c6433f6988d9e6bdd1219a1f625440c16849971769b82 +PI_FIRMWARE_REPOSITORY=https://repo-default.voidlinux.org/current/aarch64 diff --git a/config/vm-test.conf b/config/vm-test.conf new file mode 100644 index 0000000..82504e4 --- /dev/null +++ b/config/vm-test.conf @@ -0,0 +1,6 @@ +# Signed Void package used only as a generic ARM virtual-machine test kernel. +# The Pi production kernel is deliberately not part of M2. +VM_KERNEL_PACKAGE=linux6.12 +VM_KERNEL_VERSION=6.12.110_1 +VM_KERNEL_SHA256=219e60cc05f41e0813beb2c9026bc89f9abb5d4beed72a9528f68b8ebfcde0ba +VM_REPOSITORY=https://repo-default.voidlinux.org/current/aarch64 diff --git a/config/xbps-src.conf b/config/xbps-src.conf new file mode 100644 index 0000000..f525263 --- /dev/null +++ b/config/xbps-src.conf @@ -0,0 +1,4 @@ +# Host build container only. This is NOT an FDS target rootfs. +XBPS_CHROOT_CMD=bwrap +XBPS_MIRROR=https://repo-default.voidlinux.org/current +XBPS_MAKEJOBS=4 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8b0b682 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,86 @@ +# Documentation guide + +FDS/OS builds bootable ARM SYSTEM images, an internal NVMe disk, independent +recovery, and a development environment. Native s6 manages the base system, +including Dasung support; cartridge tools manage DATA, programs and optional +desktop/network activation. M0–M12 software checks and local release +verification have passed. Physical Pi validation is deferred. The guides +below explain how to build, use and test the implemented system. + +## If this is your first visit + +1. Read the [project overview](../README.md) for the intended computer and current capabilities. +2. Follow [Your first build](getting-started.md) to create and inspect the working artifacts. +3. Read [Architecture](architecture.md) to understand the relationship between the build host, Linux packages, FDS tools, and cartridges. +4. Use the [Development guide](development.md) for subsequent changes and rebuilds. + +The [Glossary](glossary.md) explains terms such as ABI, sysroot, EROFS, and +masterdir. No prior Void or s6 experience is assumed by the first-build guide. + +## Working with the repository today + +| Guide | Question it answers | +| --- | --- | +| [Workstation and emulator](workstation.md) | How do I build software, create a 1+m cartridge image, boot FDS, insert/remove it and write USB? | +| [Software cartridge format](software-format.md) | What goes in metadata, payload partitions and xz tarballs, and how does the guest run them? | +| [Workstation validation](workstation-validation.md) | Which host builds, whole-image writes and public QEMU lifecycle tests actually passed? | +| [Your first build](getting-started.md) | What do I install, what do I run, and how do I know it worked? | +| [M1 root filesystem](rootfs.md) | How do I build the OS filesystem, run its ARM shell, inspect packages, and test the archive? | +| [FDS Rust tooling](tooling.md) | How do I build and run the static FDS command and boot-discovery tools? | +| [Clap migration validation](clap-validation.md) | Which parser, complete-image, VM and startup checks passed after standardizing the Rust command lines? | +| [Internal storage](internal-storage.md) | How do I build the full NVMe disk, persist bay settings and save diagnostics? | +| [Release signatures](releases.md) | How do I create a signing key, sign artifacts and verify a download with a trusted key? | +| [Frozen inputs and offline rebuilds](reproducible-builds.md) | How do I preserve inputs, build without network access, compare results and assemble a local release? | +| [Boot images](boot.md) | How do I create the Pi boot partition, initramfs and SYSTEM cartridge, and test insertion/recovery? | +| [Desktop, PROGRAM and Ethernet](desktop.md) | How do I start WindowMaker, run cartridge programs, and enable networking? | +| [Media tools](media-tools.md) | How are images created, inspected and confirmed before cartridge writes? | +| [Pi 5 EEPROM configuration](eeprom.md) | How do I prepare, review and restore bootloader settings without flashing the build host? | +| [Twelve-bay stress tests](stress-testing.md) | How do I run the twelve-device VM checks and prepare physical tests? | +| [Shutdown and reboot](power.md) | How does shutdown protect DATA, and what should I do when it is blocked? | +| [Writable DATA](data.md) | Where do user files live, how do managed programs work, and when is removal safe? | +| [Implementation through M12](implementation-status.md) | What is complete, pending, and deferred to physical hardware? | +| [M12 software evidence](m12-validation.md) | Which checks, offline comparisons and signed local artifacts establish M12 software acceptance? | +| [Native init and ARM VM](init.md) | How do I boot the system, open its shell, and start or stop a service? | +| [M1 validation](m1-validation.md) | What did the original rootfs milestone verify? | +| [M2 validation](m2-validation.md) | Which full ARM boot and service-control checks passed? | +| [M3 validation](m3-validation.md) | Which static tooling and ARM parsing/discovery checks passed? | +| [M4 software validation](m4-validation.md) | Which image, module, stage0, insertion and recovery tests passed? | +| [M5 software validation](m5-validation.md) | Which ordinary-console, trace, emulation and optimization checks passed? | +| [M6 software validation](m6-validation.md) | Which virtual USB, mapping, metadata, mount, IPC and eject checks passed? | +| [M7 DATA validation](m7-validation.md) | Did sustained writes survive safe eject, reinsertion and independent filesystem checks? | +| [M8 desktop/network validation](m8-validation.md) | Did desktop transitions, PROGRAM execution, fonts and Ethernet work in the ARM VM? | +| [M9 media-tool validation](m9-validation.md) | Did ARM image creation, confirmed cartridge writes, verification and boot of a newly written SYSTEM pass? | +| [M10 shutdown validation](m10-validation.md) | Which ordered halt/reboot, busy refusal, crash and writeback-error tests passed? | +| [M11 stress validation](m11-validation.md) | Which twelve-device and media failure tests have passed, and what remains? | +| [Build host reference](build-host.md) | Which versions, dependencies, environment settings, and checks does the pipeline use? | +| [Development](development.md) | How do I rebuild only the component I changed, configure the build, and prepare a change? | +| [Dasung monitor](dasung.md) | How is the existing monitor daemon built, packaged, configured, and tested for the base system? | +| [Dasung validation](dasung-validation.md) | Which builds, simulator cases, and native s6 checks actually passed? | +| [Packages](packages.md) | What is an XBPS package, and how do I inspect the one I built? | +| [Troubleshooting](troubleshooting.md) | Why did a command fail, and what should I check next? | +| [M0 validation](m0-validation.md) | What has actually been built and verified on the development host? | + +## Understanding the operating system + +These pages explain the implemented behavior and distinguish software evidence +from properties that still need physical hardware tests. + +| Guide | Subject | +| --- | --- | +| [Architecture](architecture.md) | Machine model, two library strategies, host/target split, writable state | +| [Boot](boot.md) | From internal NVMe to a removable SYSTEM and the first console | +| [Cartridges](cartridges.md) | Cartridge classes, physical bays, insertion, ejection, and manifests | +| [Services](services.md) | Native s6, service dependencies, readiness, and shutdown | +| [Performance](performance.md) | Timing targets, measurement boundaries, and regression reporting | +| [Recovery](recovery.md) | Build and enter independent recovery, inspect cartridges, check/repair DATA and prepare replacement SYSTEM media | + +## Planning and reference + +- [Roadmap](roadmap.md): current milestone, remaining milestones, and acceptance criteria. +- [Master plan](master-plan.md): the complete 67-section specification; examples describe the final design unless stated otherwise. +- [Project instructions](../AGENTS.md): implementation scope and conventions. + +A `sh` code block in a current-workflow guide is intended to be run from the +repository root unless the surrounding text says otherwise. Planned runtime +examples use `text` blocks and are labeled as future behavior. Run build commands +sequentially; they share one Void build directory. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..afc66b4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,167 @@ +# Understanding FDS/OS + +[Documentation index](README.md) · [Roadmap](roadmap.md) · [Glossary](glossary.md) + +FDS/OS is designed for a Raspberry Pi 5 portable computer whose operating system, +user data, applications, and optional environments can be carried on removable +cartridges. Its central design question is: what must stay with the machine, and +what should move with the user? + +**Status:** M0–M12 software checks cover the cross-build, configured ARM rootfs, +native init, Pi kernel/initramfs/SYSTEM images, ordinary-user console, cartridge +management, writable DATA, desktop/network activation and verified media writing. +Ordered shutdown, twelve-device virtual stress and independent recovery have +passed software acceptance. M12 internal storage, development tools and signature +verification have also passed software checks. The signed local 0.1.0 release +passed two complete offline builds and archive verification. Physical Pi boot +and wiring remain deferred. Use +[Your first build](getting-started.md) and [Boot images](boot.md) to try the VM. + +## Three things to keep separate + +1. **The build workstation** is an x86_64 Arch Linux computer. It downloads tools, + compiles ARM software, checks artifacts, assembles the rootfs, and boots the ARM VM. +2. **The machine firmware layer** lives on the Pi's internal NVMe. It supplies the + kernel, early boot program, recovery environment, and hardware configuration. +3. **The removable operating environment** comes from cartridges. SYSTEM supplies + the OS root filesystem; DATA supplies persistent user files; other cartridges + supply programs, configuration, or physical USB hardware. + +```text +Build workstation + Arch x86_64 + ├── Void build container → aarch64 glibc packages + └── Rust toolchain → static AArch64 FDS executables + │ + └── Pi kernel + initramfs + SYSTEM → ARM VM checks + +Pi 5 machine (planned) + Internal NVMe Removable bays + ├── FDS_BOOT ───────────────→ SYSTEM: read-only OS + ├── FDS_RECOVERY DATA: user files + └── FDS_INTERNAL PROGRAM / ENVIRONMENT / hardware + machine configuration +``` + +The Void build container on the workstation is **not** the Pi's root filesystem. +It may contain build dependencies that must never appear in the final FDS image. +Likewise, a successful ARM package build does not prove that a Pi can boot it. + +## Why cartridges change the operating model + +A conventional writable root filesystem combines the installed OS, local changes, +and often user data on one disk. FDS separates their lifetimes: + +- A SYSTEM image is built and validated as a unit. Updating means preparing and + swapping to a new SYSTEM cartridge; retaining the previous one enables rollback. +- A DATA cartridge follows the user. With no DATA cartridge, the home is + temporary memory-backed storage, so it does not survive power loss. +- A PROGRAM cartridge supplies a self-contained application image. It does not + install arbitrary files into SYSTEM. +- An ENVIRONMENT cartridge selects a known configuration such as WindowMaker. + In version 1, the desktop software itself is already present in SYSTEM. + +This requires explicit safe-eject behavior: removable does not mean safe to pull +while an application is writing. See [Cartridges](cartridges.md). + +## Two library strategies + +Both software groups target **Linux on AArch64**. They differ in how they obtain +the C runtime and other libraries, not in which processor runs them. + +| Software group | Build target | Runtime approach | Reason | +| --- | --- | --- | --- | +| Ordinary Linux packages | Void `aarch64` | glibc, usually shared libraries | Compatibility with the GNU userland, Xorg, WindowMaker, compilers, and third-party packages | +| FDS-owned control tools | Rust `aarch64-unknown-linux-musl` | Static musl by default | Early boot and control programs can run without the installed glibc loader or version | + +M0 demonstrates these with GNU hello and `fds-smoketest`, respectively. A static +FDS executable still needs a compatible Linux kernel; static linkage does not +turn it into firmware or a standalone OS. Future dependencies using C/C++ FFI +will need their own musl cross-build assessment. + +The project does not switch the whole distribution to musl. XBPS target packages +remain `aarch64`, not `aarch64-musl`. The downloaded **host** XBPS tools happen to +be static musl for x86_64 so they can run on Arch; their libc is independent of +the target package ABI. See [Packages](packages.md). + +## Workstation software and emulation + +Native Linux `fds-cartridge` builds software from trusted recipes, packages xz +tarballs, and creates a complete GPT image with metadata partition 1 plus payload +partitions. It verifies that image before a separately confirmed whole-USB write. +Native `fds-emulator` boots the FDS kernel/initramfs/SYSTEM in QEMU and exposes +twelve virtual USB bays. Writable DATA uses separate overlays; source images +stay unchanged. These tools are host programs, independent of the target's ABI. + +The guest performs bounded verification and temporary extraction, then runs a +selected software command as UID 1000. It does not compile software or run +installation hooks from cartridges. See [the walkthrough](workstation.md) and +[format contract](software-format.md). + +## Storage and writable state + +M4 builds the actual FAT32 boot partition and GPT/EROFS SYSTEM cartridge. M5 +provides the temporary home. M6 adds managed read-only cartridge mounts. The +M12 internal BOOT/RECOVERY/INTERNAL builder and persistent settings are described +in [Internal storage](internal-storage.md). + +| Location | Intended content | Write policy | +| --- | --- | --- | +| Internal `FDS_BOOT` (FAT32) | Pi boot files, kernel, DTBs, initramfs | Managed machine boot content | +| Internal `FDS_RECOVERY` (EROFS) | Independent recovery OS | Read-only recovery image | +| Internal `FDS_INTERNAL` (ext4) | Bay map, hardware configuration, diagnostics | Persistent machine state; no ordinary user files | +| Cartridge `FDS_SYSTEM` (EROFS) | OS root filesystem | Read-only; replace image instead of in-place updates | +| Software `FDS_METADATA` + `FDS_PAYLOAD02`… (EROFS) | Catalogue and xz software archives | Read-only; extracted executable caches are temporary | +| Cartridge `FDS_DATA` (ext4) | User home and/or `/data` | Persistent user writes | +| `/run`, `/tmp`, `/var/tmp`, `/var/log` | Runtime state, temporary files, logs | tmpfs; volatile | +| Home without DATA | Temporary user session | tmpfs; volatile | + +Writable DATA uses `/data`; `/home/fds` stays temporary. +The table describes the final storage roles. Use the image builders rather than +applying this layout to an existing workstation disk. + +## Boot and service responsibilities + +The Pi boots its kernel and small initramfs from internal NVMe. A static Rust +`fds-stage0` locates a partition named `FDS_SYSTEM`, mounts its EROFS filesystem, +and transfers control to native s6 in the new root. + +The console should become usable as soon as its own prerequisites are ready. +It must not wait for the network, desktop, all 12 USB devices, or a complete +cartridge catalog. `fds-cartridged` handles hotplug, physical bay mapping and +metadata, and activates known desktop profiles from validated ENVIRONMENT media. + +See [Boot](boot.md) for the sequence, [Services](services.md) for responsibilities, +and [Performance](performance.md) for targets and how they will be measured. + +## Trust and ownership boundaries + +Cartridge metadata describes a cartridge; it is not permission to run supplied +scripts as root. Known profiles and hardware mappings belong to the OS. Ordinary +programs should run with ordinary user privileges. An unrecognized device should +not become trusted merely because it appears in a USB bay. + +Package customization belongs in project-owned `packages/fds-*` overlays. +Upstream Void tracked files remain unchanged at a recorded source commit. +M0 checks this boundary automatically. Source pins and artifact hashes improve +traceability. Exact selected input snapshots, offline reproduction and local +release signing have passed [M12 acceptance](m12-validation.md). The +independent [recovery environment](recovery.md) is implemented and tested. + +## Where implementation starts and stops + +Today the active sources also include `rust/dasungd/`, its package overlay, and +`s6/source/` for the base monitor service. `make dasung` builds its static ARM +executable, XBPS package, and a service database for validation. The monitor +service is independent of GUI and cartridge profiles. M2 includes these in a rootfs with native s6 init and a generic ARM VM test. +M3 adds the FDS CLI and shared Rust contracts. M4 adds the Pi kernel, Rust +initramfs, GPT/EROFS SYSTEM and FAT32 boot partition. Its actual stage0 handoff +and media insertion/recovery paths run in a generic ARM VM. Physical Pi +validation remains deferred. M5 adds the ordinary console and boot tracing; M6 +adds the cartridge daemon. M7–M11 add writable DATA, desktop/network profiles, +verified media writing, ordered shutdown and virtual USB stress. M12 adds recovery, +internal machine storage and release preparation. + +Use [Boot images](boot.md) for the current builders and their acceptance boundary. +See [M1 rootfs](rootfs.md) to use the current filesystem archive. Consult the [roadmap](roadmap.md) for acceptance +boundaries and the [master plan](master-plan.md) for the complete specification. diff --git a/docs/boot.md b/docs/boot.md new file mode 100644 index 0000000..aba4785 --- /dev/null +++ b/docs/boot.md @@ -0,0 +1,178 @@ +# Boot images and stage0 + +[Documentation index](README.md) · [First build](getting-started.md) · [Implementation status](implementation-status.md) + +FDS keeps the machine's boot files on internal NVMe and its operating system on a +removable SYSTEM cartridge. The Pi firmware loads the kernel and a small initial +filesystem. Its Rust `fds-stage0` program finds the partition named `FDS_SYSTEM`, +mounts it read-only, and hands control to native s6. Device names such as `sda` +are never used as cartridge identities. + +The software boot path has passed on an ARM virtual machine using the actual +FDS Pi kernel. That does not verify Pi firmware, NVMe, RP1, display output, USB +power or physical boot speed. Those checks are deferred until the machine is +assembled. M5 now provides an ordinary-user `FDS>` console with a temporary home +and measured boot events. Password login remains locked; this is an intentional +local single-user session. The M2 test VM separately offers a temporary root +development shell. See [native init](init.md) and [performance reports](performance.md). + +## Build on the workstation + +Use the [Arch host setup](build-host.md) first. In addition to its original tools, +install host `lz4`, `zstd`, `util-linux`, `e2fsprogs` and `libarchive` if missing. +They supply initramfs compression, independent GPT inspection and the initial +VM's filesystem/archive tools; these are build/test dependencies. +Python 3.14+ reads zstd-compressed XBPS archives. Filesystem builders are obtained +as verified Void packages in `.host/image-tools`, separate from the build container. + +From the repository root, run sequentially: + +```sh +make bootstrap +make rootfs PROFILE=cli +make initramfs +make system-card PROFILE=cli +make boot-volume +make rootfs-test +make init-test +make boot-test +``` + +The rootfs build includes the FDS CLI, base Dasung daemon, native s6 database, and +the kernel's matching modules. Its first kernel cross-build is substantial; +progress is recorded in `out/logs/xbps-fds-kernel.log`. `make kernel` also builds +that kernel independently. Later calls reuse its package only when recorded +source inputs and every exported payload hash still match. To force a kernel +rebuild, run `./tools/build-kernel --rebuild`. This source/payload cache is not +yet a complete lock of rolling binary build dependencies. +No command here opens or writes a physical target disk. +On a new build host, `make init-test` installs QEMU's system emulator into the +project-local Void environment before `make boot-test` uses it. + +| Output | Meaning | +| --- | --- | +| `out/kernel/boot/kernel_2712.img` | Pi 5 kernel with boot-critical drivers built in | +| `out/kernel/usr/lib/modules/` | Matching optional drivers | +| `out/fds-initramfs.img` | Uncompressed default initramfs; a symlink to a versioned build | +| `out/initramfs/initramfs.cpio*` | Uncompressed, gzip, legacy-LZ4 and Zstandard variants | +| `out/rootfs-aarch64.tar` | Configured userspace archive with target ownership and capabilities | +| `out/fds-system-cli.img` | Whole GPT cartridge image, containing an EROFS `FDS_SYSTEM` partition | +| `out/fds-boot.img` | **512 MiB FAT32 partition image**, labeled `FDS_BOOT`; not a whole NVMe disk | +| `out/boot-volume/manifest.json` | Boot image hash, firmware input and every boot file hash | +| `out/m4-vm-latest/` | Logs and exact inputs from the last passing full boot matrix | + +`make internal-image` combines BOOT, independent RECOVERY and INTERNAL settings +into `out/fds-internal.img`; see [Internal storage](internal-storage.md) for the +full procedure. The FAT partition image alone is not that complete disk layout. +Use the [media tools](media-tools.md) for confirmed removable-cartridge writes. + +## What stage0 does + +The initial filesystem contains static-musl stage0 and Dasung executables, +configuration, the monitor's EDID, mountpoint directories and `/dev/console`. +There is no shell, BusyBox, Python, glibc, or udev in it. Stage0 mounts proc, sysfs +and devtmpfs itself and binds its kernel-event socket before scanning devices. + +If a single SYSTEM partition is present, stage0 verifies its block-device identity, +mounts EROFS read-only, and checks the FDS identity and native init executables. +It stops and reaps the early Dasung process before the base service starts, +moves the kernel filesystems into the new root, removes the old in-memory root, +and executes the s6 launcher. It refuses this path outside root PID 1 on ramfs/tmpfs. + +Without SYSTEM, the console displays a missing-media prompt and keeps processing +device events. Inserting media resumes boot without rebooting or polling sleeps. +When more than one matching partition is visible, stage0 refuses to choose one. +Its waiting console accepts: + +```text +list Show the discovered kernel partition identities +rescan Repeat discovery immediately +recovery Select FDS_RECOVERY instead of FDS_SYSTEM +reboot Reboot from early userspace +poweroff Power off from early userspace +``` + +An invalid filesystem or incomplete FDS root remains at the prompt. `list` is a +diagnostic view and can show technical device paths; physical bay labels arrive +with the cartridge daemon. Version 1 uses explicit recovery rather than an +interactive choice between multiple SYSTEMs. Detection applies to devices visible +at selection time; a cartridge inserted after root handoff cannot replace root. + +## Pi display and development output + +`image/pi5/config.txt` selects the Pi 5 kernel, initramfs, external PCIe and VC4 +KMS, disables HDMI audio, and avoids automatic camera/DSI setup. It does not +overclock the Pi or PCIe link. The pinned Pi kernel retains 16 KiB pages and +disables the CH341 SPI driver required by the Dasung protection policy. + +The production command line uses the display console and quiet kernel output. +It loads the Paperlike 13K's recorded EDID from the initramfs before SYSTEM exists. +The unqualified `drm.edid_firmware` setting applies this EDID to connected DRM +outputs: this image is intended for the user's dedicated Dasung display. The +actual adapter, connector, mode acceptance and picture still need Pi validation. +Early USB control and the later base daemon share the same monitor identity. + +For serial diagnosis, build: + +```sh +make boot-volume BOOT_MODE=development +``` + +This replaces the convenience output link with a separate development build; +previous versioned directories remain. Development enables kernel/daemon output +and makes the Pi 5 `ttyAMA10` debug UART the primary userspace console. That is +the Pi 5 debug connector, not an assumption about the 40-pin GPIO UART. Rebuild +with `BOOT_MODE=production` to restore the display-console configuration. + +`fds.boot=recovery` on the kernel command line selects `FDS_RECOVERY` immediately. +`fds.emulator=1` is reserved for the [workstation emulator](workstation.md): +the SYSTEM loader requires QEMU virt and its fixed xHCI controller before +enabling the twelve virtual bay mappings. Default Pi boot is unchanged. +`fds.debug=1` exposes the early controller's diagnostics. Duplicate or unknown +`fds.*` options fail clearly rather than being silently ignored. + +## What the automated test proves + +`make boot-test` appends a test-only checker to a copy of the exported rootfs; +the original stage-2 script and compiled service graph still run. It checks: + +- All four initramfs formats reach native s6 on read-only EROFS. +- Missing SYSTEM waits; a virtual USB insertion resumes boot. +- Two SYSTEM partitions remain unselected; the console can enter recovery. +- Explicit recovery mode selects RECOVERY even when SYSTEM is also present. +- Invalid filesystems and foreign OS roots are refused; recovery remains usable. +- Kernel page size, root ownership, ping capability, temporary filesystems, + single Dasung ownership, service start/restart/stop and orderly power-off. +- Input disk hashes remain unchanged; the VM has no network or host-device access. + +QEMU uses its `max` ARM CPU because the Pi kernel needs 16 KiB page support; +the earlier M2 Cortex-A72 fixture cannot run this kernel. These are compatibility +and behavior checks, not Pi performance measurements. Test RECOVERY is a relabeled +fixture for selection; the independent recovery product is tracked separately. + +Reference material: [Pi kernel builds](https://www.raspberrypi.com/documentation/computers/linux_kernel.html), +[boot configuration](https://www.raspberrypi.com/documentation/computers/config_txt.html), +[Pi UARTs](https://www.raspberrypi.com/documentation/computers/configuration.html#configuring-uarts), +and [Linux initramfs](https://docs.kernel.org/filesystems/ramfs-rootfs-initramfs.html). + +## Use the ordinary FDS console + +After building the CLI rootfs, initramfs and SYSTEM image: + +```sh +make console-vm +``` + +The ARM virtual machine boots the real stage0 and read-only SYSTEM image. At +`FDS>` you are the ordinary `fds` user (UID 1000), with a temporary writable home. +Try `fds info`, `fds inspect /FDS/CARTRIDGE.TOML`, and `fds boot-profile`. +The console deliberately uses local autologin; both account passwords are locked. +Use `fds poweroff` for an orderly shutdown through native s6. Enter `exit` to +restart the console. Press **Ctrl-a, then x** to close QEMU immediately. +Closing QEMU this way ends the test immediately; it is not a graceful-shutdown test. +No host USB devices or physical disks are attached to this VM. + +Run `make console-test` for automated console/readiness checks and +`make performance-test` for the software optimization comparison. Read +[M5 validation](m5-validation.md) and [measurement boundaries](performance.md) +before interpreting VM timing as hardware evidence. diff --git a/docs/build-host.md b/docs/build-host.md new file mode 100644 index 0000000..89c9cc6 --- /dev/null +++ b/docs/build-host.md @@ -0,0 +1,236 @@ +# M0: Arch Linux x86_64 build host + +[Documentation index](README.md) · [First build](getting-started.md) · [Troubleshooting](troubleshooting.md) + +This is the technical reference for the build foundation used by later milestones. +The [rootfs guide](rootfs.md) adds assembly, Python and isolated ARM execution requirements. If you are +setting up the repository for the first time, follow [Your first build](getting-started.md) +for an ordered walkthrough with expected output. For editing, rebuilding, and +configuration changes after setup, use [Development](development.md). + +## Scope and pinned inputs + +M0 validates the cross-build environment. The scoped [Dasung integration](dasung.md) +adds a controller package and service definitions without implementing a full OS. +M1 adds the configured FDS rootfs; M2 adds native init and the generic ARM VM. +M3 adds static FDS tools. M4 cross-builds the Pi kernel and creates initramfs, +SYSTEM GPT/EROFS and a FAT32 boot partition; see [Boot images](boot.md). +M6 adds cartridge management and M7 adds writable DATA. M8 adds the optional +WindowMaker stack and on-demand Ethernet. M12 adds the [complete internal disk](internal-storage.md), +independent recovery and persistent machine settings. The +[signature tools](releases.md) have passed acceptance; the +[frozen-input workflow](reproducible-builds.md) and signed local 0.1.0 release +have passed two complete offline builds and artifact/archive verification. The [desktop guide](desktop.md) explains the +new dependencies and the development image used for virtual display tests. +See [Native init](init.md) for the additional test dependencies. An ARM host +and a connected Pi are not required. + +| Input | Pin | +| --- | --- | +| void-packages | Git submodule and `VOID_PACKAGES_COMMIT`: `02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1` | +| Static XBPS host tools | Versioned `0.60.4_1.x86_64-musl` URL and a SHA-256 digest stored in this repository | +| Rust | `rust-toolchain.toml`: 1.98.0, minimal profile, rustfmt | +| Rust target | `aarch64-unknown-linux-musl` | +| Rust crate dependencies | Smoketest has none; all workspace dependencies, including shared Clap CLI parsing, are pinned in `Cargo.lock`, fetched at bootstrap, and built with `--locked --offline` | +| XBPS test package | `srcpkgs/hello/template` in the pinned Void tree: GNU hello 2.12.3_1 | +| Pi kernel | `packages/fds-kernel/template`: 6.12.87, pinned Raspberry Pi source archive and FDS config delta | +| Pi firmware | `config/pi-firmware.conf`: signed 20260715_1 package with a pinned SHA-256 | + +The musl ABI of the static host tools does not determine the target ABI. Scripts +explicitly use `XBPS_ARCH=x86_64` for the glibc build container, then run +`./xbps-src -a aarch64 pkg hello` to build the ARM glibc package. Local ARM package +indexing also explicitly sets `XBPS_ARCH=aarch64`. + +## Separate generic Linux workstation tools + +`make workstation` builds native `fds-cartridge` and `fds-emulator` without the +Void/Arch bootstrap. Their runtime dependencies are QEMU (`qemu-system-aarch64` +and `qemu-img`), erofs-utils, xz and bubblewrap. A software recipe chooses its +cross compiler. The shared archive reader adds Rust `tar` 0.4.46 and its +`filetime` dependency; both are locked. No new daemon or host build tools are +installed in the Pi base image. Follow [the workstation guide](workstation.md). +The full OS build prerequisites below retain their existing scope. + +## Host dependencies + +Run as an ordinary user on x86_64 Arch Linux with unprivileged user namespaces. +Rootfs construction now requires Linux 6.7+ with namespaced `binfmt_misc` support. +The M5 build uses a private handler and static QEMU for child execution; it does +not register an ARM interpreter in the workstation's global registry. +The checkout path must not contain whitespace, a limitation of upstream +xbps-src. Allow several GB of disk space and provide a network connection. + +```sh +sudo pacman -S --needed bash coreutils binutils git curl make file tar xz gzip zstd \ + bubblewrap rustup ca-certificates findutils diffutils grep sed gawk util-linux +``` + +Alternatively, run `./tools/bootstrap-host --install-deps`. Only this explicit +option invokes sudo/pacman. Default `make bootstrap` checks dependencies and +exits nonzero when one is missing, without installing host system packages. + +Dependency rationale: + +- bash, coreutils, findutils, diffutils, grep, sed, gawk, and util-linux provide + the build scripts and upstream build system with their required shell tools. +- git pins upstream source; curl and CA certificates download HTTPS inputs. +- tar, xz, gzip, and zstd extract archives and XBPS packages. +- file and binutils inspect ELF artifacts; make provides the build entry points. +- bubblewrap runs the build container as an ordinary user. +- rustup installs the pinned Rust toolchain and target. rustfmt checks source + formatting and is not a runtime dependency. + +Rust's bundled `rust-lld` and target-provided static musl/CRT are sufficient for +the current pure-Rust smoketest. No Zig, Docker, OpenSSL crate, or additional +musl runtime is required. The imported Dasung controller bundles libusb and uses a Void musl C cross +compiler inside the container; see [its dependency rationale](dasung.md#dependencies-and-source-provenance). +Glibc C libraries cannot simply be reused in static-musl binaries. + +## Exact commands + +Run sequentially from the repository root: + +```sh +make bootstrap +make smoke-test +make check +``` + +Bootstrap initializes the pinned submodule, verifies and extracts XBPS, checks +the tools' x86_64 static ELF properties, installs the pinned Rust toolchain and +target, fetches the locked workspace crates, copies `config/xbps-src.conf` into the local Void checkout, then runs the +equivalent of: + +```sh +export PATH="$PWD/.host/xbps/usr/bin:$PATH" +export XBPS_ARCH=x86_64 +cd vendor/void-packages +./xbps-src -A x86_64 binary-bootstrap +``` + +`binary-bootstrap` installs upstream **base-chroot** as a compilation environment. +It is separate from the FDS `fds-base` target rootfs built in M1. Builds run through +bubblewrap without starting Void services on Arch. Repeated bootstrap reuses the +masterdir and verified downloads. The script checks the container architecture, +installed base-chroot package, and execution of GCC inside the container. + +To reproduce the two builds individually from the repository root: + +```sh +./tools/cargo-build --locked --offline --release \ + --target aarch64-unknown-linux-musl -p fds-smoketest +./tools/build-package hello +``` + +The latter command sets the local XBPS PATH, checks the Void pin, and executes +this command within the submodule: + +```sh +./xbps-src -a aarch64 pkg hello +``` + +xbps-src installs the aarch64 GNU cross toolchain that runs on x86_64, together +with the target glibc sysroot. No Pi compilation is needed. The pinned template's +checksum verifies the package source archive. + +## Validation + +```sh +file target/aarch64-unknown-linux-musl/release/fds-smoketest +readelf -hW target/aarch64-unknown-linux-musl/release/fds-smoketest +readelf -lW target/aarch64-unknown-linux-musl/release/fds-smoketest +readelf -dW target/aarch64-unknown-linux-musl/release/fds-smoketest +readelf -VW target/aarch64-unknown-linux-musl/release/fds-smoketest +ldd target/aarch64-unknown-linux-musl/release/fds-smoketest +``` + +Rust acceptance requires ELF64, little endian, AArch64, executable type EXEC/DYN, +static linkage reported by `file`, no `PT_INTERP`, no `DT_NEEDED`, and no +`GLIBC_*` version requirements. Static PIE is also acceptable. The pinned Rust +target, self-contained linking configuration, and compile-time target checks +establish musl provenance; an ELF's static label alone does not identify its libc. + +`ldd` is supplemental diagnostic output. A static executable normally produces +`not a dynamic executable` with exit status 1. **An x86_64 host's ldd alone cannot +prove that an ARM executable is static.** + +XBPS validation checks architecture=aarch64 in the local package index, then +extracts `usr/bin/hello` from the generated package. It requires AArch64, +`/lib/ld-linux-aarch64.so.1`, and `libc.so.6`. An architecture suffix in a package +filename is not sufficient evidence. + +`make check` verifies rejection of the wrong architecture, dynamic executables +passed as static, a static-musl executable passed as a glibc package, non-ELF +input, missing artifacts, invalid arguments, Void pin drift, and modified +upstream files. It also tests overlay protection and runs rustfmt. Pipelines +enable pipefail so `tee` cannot hide a failed build. + +Optionally install `sudo pacman -S --needed qemu-user` to execute the ARM Rust +binary during smoke-test and compare its output. When qemu-aarch64 is absent, +the script explicitly reports SKIP. M0's required acceptance is actual +cross-compilation plus ELF/package inspection. Static inspection is not an ARM +runtime or Pi boot test. + +## Inputs, caches, and overlays + +- `.host/xbps/`: project-local host tools, not installed into `/usr`. +- `vendor/void-packages/masterdir-x86_64/`: glibc build container. +- `vendor/void-packages/hostdir/`: download caches, cross packages, and build output. +- `out/packages/`: exported aarch64 test package and its repository index. +- `out/manifests/void-build-packages.txt`: packages remaining installed after the + build. Cross toolchains and temporary dependencies removed by xbps-src remain + traceable through cache digests and build logs. +- `out/manifests/void-package-inputs.sha256`: digests of cached `.xbps` inputs. +- `out/manifests/artifacts.sha256`: validation artifact digests. +- `out/logs/`: actual command logs, including logs from failed attempts. + +Place FDS package overlays at `packages/fds-*/template`. `tools/prepare-void` +copies only directories containing a template and refuses to replace tracked +upstream packages. These copies are visible inside the build container. Base, +base-files, init, CLI, kernel and Dasung overlays are active; future directories +without templates remain inactive. + +If an overlay changes and preparation reports a stale copy, inspect and preserve +any unique edits before moving the specific generated copy out of `srcpkgs/` and +retrying. Modified upstream tracked files cause immediate failure. Conflicting +local build configuration also causes failure and must be reconciled explicitly. +See [configuration and overlay troubleshooting](troubleshooting.md#configuration-or-overlay-conflict) +for the procedure. + +Updating Void requires an explicit change to both the submodule gitlink and +`VOID_PACKAGES_COMMIT`, followed by validation and a commit containing both. +Do not fetch master and silently change the pin. Replacing the static host XBPS +archive also requires updating both its versioned URL and checksum. + +## Known limitations + +1. Source templates, Rust, and XBPS tools are pinned; **the complete Void binary + repository is not snapshotted**. Initial bootstrap, xbps-src build-container + updates, and missing build dependencies still use signed rolling repositories. + Cached inputs, version inventories, and digests support auditing but cannot + guarantee future upstream retention. M12 preserves the exact selected + packages, sources and build tools through the accepted + [snapshot and offline workflow](reproducible-builds.md). Its two fresh builds + produced 27 matching artifacts. This freezes the release inputs, not the + entire rolling Void repository; the original M0 check alone does not + establish reproducibility. +2. Bootstrap supports x86_64 Arch Linux. Other host distributions and ARM hosts + have not been validated. Unprivileged user namespaces must be available. +3. M4 tests the actual Pi kernel and initramfs-to-EROFS handoff in a generic ARM + VM. This does not emulate Pi firmware, RP1, physical bays or the display. + Media burning and physical boot-performance measurements remain separate work. +4. `opt-level=z` is the plan's initial setting, not a proven fastest setting. + Compare optimization levels later using real programs and hardware. +5. During the initial upstream bootstrap, bubblewrap's mounted `/tmp` produced + `rm: cannot remove '//tmp': Device or resource busy` in the cleanup phase. + Upstream returned success; the subsequent container execution, cross build, + and public bootstrap command passed. The upstream source was left unchanged. + +## Upstream references + +- [Pinned Void xbps-src usage and foreign host documentation](https://github.com/void-linux/void-packages/blob/02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1/README.md) +- [Pinned aarch64 cross profile](https://github.com/void-linux/void-packages/blob/02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1/common/cross-profiles/aarch64.sh) +- [Void static XBPS](https://docs.voidlinux.org/xbps/troubleshooting/static.html) +- [Official static tool SHA-256 checksums](https://repo-default.voidlinux.org/static/sha256sums.txt) +- [Rust aarch64-unknown-linux-musl](https://doc.rust-lang.org/rustc/platform-support/aarch64-unknown-linux-musl.html) +- [Rust self-contained linking](https://doc.rust-lang.org/rustc/codegen-options/index.html#link-self-contained) diff --git a/docs/cartridges.md b/docs/cartridges.md new file mode 100644 index 0000000..16d65c4 --- /dev/null +++ b/docs/cartridges.md @@ -0,0 +1,197 @@ +# Using cartridges and configuring bays + +[Documentation index](README.md) · [Boot images](boot.md) · [Services](services.md) + +M6 adds the base `fds-cartridged` service, twelve bay states, USB hardware +recognition, strict metadata inspection, read-only storage mounts and eject. +Its [ARM VM acceptance checks passed](m6-validation.md). [Writable DATA integration](data.md) has passed M7 software acceptance; +[desktop/program activation](desktop.md) passed M8 and [media creation tools](media-tools.md) +passed M9 software acceptance. [M10 ordered shutdown](power.md) has passed software acceptance. + +## Inspect the current machine + +At the FDS console: + +```sh +fds bays +fds bay 3 +fds cartridge 3 +fds --json bays +``` + +The ordinary `fds` user can use these commands. `bays` shows all twelve bays, +including empty and unconfigured ones. `cartridge` includes validated metadata +and the current mount path. `--json` exposes a versioned machine-readable report. +`fds rescan` explicitly refreshes inventory; normal insert/remove events trigger +refresh automatically. The console does not wait for the inventory scan. + +| State | Meaning and next action | +| --- | --- | +| UNCONFIGURED | No measured controller/port mapping exists for this bay | +| EMPTY | Configured bay with no detected USB device | +| HARDWARE | USB device without storage; catalog name or VID/PID is shown | +| UNRECOGNIZED STORAGE | Storage exists but no named FDS partition is available | +| MOUNTED READ ONLY | Manifest validated; filesystem available at the displayed path | +| MOUNTED READ WRITE | Active DATA is writable at `/data`; eject before removal | +| PROTECTED | The active SYSTEM root; eject is refused | +| AMBIGUOUS | Multiple devices or named partitions match; nothing is selected arbitrarily | +| ERROR | Inspection, mounting or metadata validation failed; read the diagnostic | +| SAFE | The requested unmount succeeded; remove the cartridge | + +An unrecognized storage state may briefly appear while the kernel discovers its +partitions. It is not permission to remove a device being used elsewhere. +Errors and ambiguous media never produce SAFE. An additional mount of the same filesystem also blocks managed eject until +it is unmounted. + +## Calibrate the physical bay map + +The shipped `/etc/fds/bays.toml` is deliberately empty: no Pi bay wiring has yet +been measured. This produces UNCONFIGURED states, not invented assignments. + +1. On the assembled machine, insert one known USB device into one physical bay. +2. Run `fds topology`. This diagnostic explicitly shows controller/port paths; + ordinary bay commands omit those implementation details. +3. Record that path and repeat for each bay. Test both USB 2 and USB 3 devices, + because their companion root hubs can have different logical port paths. +4. Put the measured map in your machine configuration directory and build the + [internal image](internal-storage.md), or use `fds machine install DIRECTORY` + from recovery and reboot. Machine settings persist independently of SYSTEM. + The packaged `/etc/fds/` files are fallback defaults for missing/invalid internal + storage; `fds machine status` shows which source is active. +5. Verify all twelve devices together, in different insertion orders and after + reboot. That physical acceptance remains deferred. + +A **syntax example only**, using identities that must be replaced by observations: + +```toml +[front] +hub = "platform/example-controller:usb2/1" +[front.ports] +1 = 1 +2 = 2 +3 = 3 +4 = 4 +5 = 5 +6 = 6 + +[front_superspeed] +hub = "platform/example-controller:usb3/1" +[front_superspeed.ports] +1 = 1 +2 = 2 +3 = 3 +4 = 4 +5 = 5 +6 = 6 +``` + +`hub` names the controller and parent port chain. Its numbered `ports` map the +next downstream port to a bay number from 1 through 12. Additional groups can +map the rear hub. Explicit USB 2/3 aliases may refer to the same bay; duplicate +ports, overlapping parent/child mappings, and duplicate bays within one hub are +rejected. If two devices simultaneously match aliases for one bay, it becomes +AMBIGUOUS. A hub inserted into a cartridge bay with multiple downstream devices +also needs an explicit future composite-device policy; M6 refuses to guess. + +The identity excludes the Linux USB bus number and `/dev/sdX` enumeration order. +It includes the controller's sysfs path, USB protocol generation and port chain. +Moving a hub to another controller or changing wiring requires recalibration. + +## Storage formats and metadata + +| Class | GPT partition name | Filesystem and current behavior | +| --- | --- | --- | +| SYSTEM | FDS_SYSTEM | EROFS; current root is protected, additional media read-only | +| DATA | FDS_DATA | ext4; inspected read-only first, then activated at `/data` under the M7 policy | +| PROGRAM | FDS_METADATA + FDS_PAYLOAD02… | Metadata and xz bundles in `1+m` EROFS partitions; see [software format](software-format.md) | +| Legacy PROGRAM | FDS_PROGRAM | Existing single EROFS application tree; still readable | +| ENVIRONMENT | FDS_ENVIRONMENT | EROFS; declarative selection of a trusted built-in profile | +| UTILITY | FDS_UTILITY | EROFS; inspection only, no automatic actions | +| HARDWARE | None required | VID/PID, optional serial, device/interface class and bay | + +Storage contains `/FDS/CARTRIDGE.TOML`. For example: + +```toml +format = 1 +[cartridge] +id = "fds.windowmaker" +name = "WINDOW SYSTEM" +class = "environment" +version = "0.1" +[media] +writable = false +[activation] +profile = "windowmaker" +``` + +The manifest class must match the GPT partition name. SYSTEM, PROGRAM and +ENVIRONMENT are read-only; DATA declares writable media during the initial read-only inspection. Unknown keys, root commands, unsafe identifiers, control +characters, symlink metadata and nonregular metadata files are rejected. +The parser accepts at most 64 KiB. No `/FDS/autorun.sh` is run. + +Inspect a standalone manifest with `fds inspect /path/to/CARTRIDGE.TOML`. +For mounted storage, `fds cartridge N` reports the daemon's validated copy. +Media is inspected in a root-only staging directory, then published at +`/run/fds/media/NN` after validation, with nodev, nosuid and noexec. Device +major/minor and kernel disk sequence are checked before mounting an opened +block-device descriptor, preventing stale enumeration names from selecting a +replacement disk. + +## Eject a mounted cartridge + +```sh +fds eject 2 +``` + +Leave any shell working directory inside that cartridge and close files first. +SAFE is emitted only after an ordinary unmount succeeds. Busy mounts return an +error; forced or lazy unmount is never used to declare safe removal. Once SAFE, +the daemon keeps that insertion unmounted until removal and reinsertion, including +across a service restart. Its root-owned volatile marker is tied to the kernel +disk sequence, so a replacement disk is not mistaken for ejected media. +The active SYSTEM cannot be ejected. Swap SYSTEM only after shutting down. + +Pulling a mounted cartridge without eject is surprise removal. The daemon clears +its inventory and detaches a vanished read-only mount where needed; that cleanup +is not a successful eject. [M7 DATA handling](data.md) extends ejection to writable DATA, consumer +tracking and syncfs. The temporary `/home/fds` remains usable without DATA and is lost at +power-off. + +## Hardware recognition + +Edit `packages/fds-cartridged/files/hardware-catalog.toml`, then rebuild SYSTEM. +Entries name devices, never executable actions: + +```toml +[[device]] +name = "MY SERIAL ADAPTER" +vendor = "1234" +product = "5678" +# Optional exact restrictions: +serial = "UNIT-1" +class = "02" +``` + +Replace these example IDs with observed values. Device or interface class may +match `class`. Multiple matching catalog entries produce an error; unknown +hardware remains identified by VID/PID without being mistaken for empty media. +Dasung control remains in the independent base monitor service. + +## Implementation and diagnostics + +The daemon subscribes to kernel USB/block events before taking its first snapshot. +A receive-buffer overflow causes a fresh snapshot. IPC uses a bounded Unix socket +at `/run/fds/control.sock`, mode 0660 and group `fds`, with peer UID checks for +root and the ordinary FDS user. Slow clients have individual deadlines and do not +block other clients. No asynchronous runtime or new external Rust dependency is +introduced: the existing std, libc, serde, serde_json and toml components suffice. + +Root diagnostics: `s6-svstat /run/service/cartridged` and +`cat /run/log/cartridged/current`. Logs are bounded and volatile. Restart through +`s6-rc -l /run/s6-rc -d change cartridged`, then the corresponding `-u` command. +Close users of cartridge mounts first; restart cleanup refuses busy leftovers. + +On the build host, `make cartridge-test` runs fixtures and full virtual USB tests. +These exercise actual kernel events and mounts but cannot establish physical +wiring, USB power stability, or Pi port behavior. M11 expands to twelve-device +stress, and physical calibration must be recorded separately. diff --git a/docs/clap-validation.md b/docs/clap-validation.md new file mode 100644 index 0000000..596b696 --- /dev/null +++ b/docs/clap-validation.md @@ -0,0 +1,128 @@ +# Clap command-line migration validation + +[Documentation index](README.md) · [Command usage](tooling.md) · [M12 evidence](m12-validation.md) + +Software acceptance completed on 2026-09-21. Every Rust command-line interface +uses the shared, locked Clap 4.6.7 dependency. The migration adds no third-party +crate versions or shared runtime libraries. Dasung already used Clap; its +workspace dependency now shares the same definition. The dependency-free +smoketest has no argument interface. + +## Implemented behavior + +- Typed commands cover `fds`, its inspect/eject/power aliases, stage0, boottrace, + cartridge/profile services, media creation/writing, and release signing. +- `fds burn` and `fds-burn` share one grammar. Format options are specific to + the selected class, with duplicate and conflicting options rejected. +- Clap generates command-specific help, versions, required-option errors and + typed bay/size validation. Global `--json` works around nested commands. +- `fds run BAY -- EXECUTABLE ARGS...` preserves every child argument, including + `--help`, `--json`, empty strings, spaces and an additional `--`. +- Private s6 flags and default daemon/PID-1 behavior are preserved. Stage0 + enters its emergency path on argument errors as PID 1 instead of exiting. +- VM client and write/stress helpers now build through Cargo and use Clap. + No project-owned Rust source reads or slices a raw process argument vector. + Kernel command-line files and metadata remain separate domain parsers. + +A regression test caught that Clap's broad argument/subcommand conflict rule +also rejected `fds-inspect --json image IMAGE`. The final implementation uses +Clap's typed conversion hook to reject only simultaneous target/image forms; +it accepts global options before or after the image subcommand. This behavior +passed host parser tests and execution of the actual ARM binary. + +## Build and runtime evidence + +The final tooling run passed **48 Rust unit tests**, ELF checks for every ARM +executable/alias, real ARM help/version/invalid-input tests, strict metadata +checks and stage0 diagnostic fixtures. Evidence: `out/logs/clap-tooling-test.log`. +The independent Dasung simulator/service and host/ARM/OpenSSL signing suites +passed in `out/logs/clap-dasung-test.log` and +`out/logs/clap-signing-test.log` (`out/m12-signing.9xgbool5/`). + +`make bootstrap`, `make smoke-test`, and `make check` passed. The complete +`make all` build passed in `out/logs/clap-all-final.log`; the earlier +`clap-all.log` attempt was intentionally stopped before fixing the inspection +option-placement regression. The final `make check` also passed in +`out/logs/clap-check-final.log`. + +All suites below passed against the rebuilt images. Each has its own +`out/logs/clap-.log` and retained VM/image evidence: + +| Make target | Evidence directory | +| --- | --- | +| `init-test` | `out/m2-vm.lsptD3/` | +| `boot-test` | `out/m4-vm.4VJHfm/` | +| `console-test` | `out/m5-vm.ktln7za0/` | +| `cartridge-test` | `out/m6-vm.nv3oq41q/` | +| `data-test` | `out/m7-vm.yv53pjkk/` | +| `desktop-test` | `out/m8-vm.q8nwbhgc/` | +| `media-image-test` | `out/m9-images.9ts4w_qb/` | +| `media-test` | `out/m9-vm.06yddrko/`, `out/m9-system.a9n8xxb_/` | +| `power-test` | `out/m10-vm.yk62sod6/` | +| `stress-test` | `out/m11-vm.vg5dicrw/`, `out/m11-faults.pd52oxs7/` | +| `recovery-test` | `out/m12-recovery.0604mqgm/` | +| `internal-test` | `out/m12-internal.uj0rbf4i/` | +| `clock-test` | `out/m12-clock.f54asrop/` | +| `development-test` | `out/m12-development.mu29x4mr/` | + +These checks include actual SYSTEM creation/write/readback/boot, confirmed DATA +repair, interrupted-checker quarantine, persistent settings across reboot, +window manager and DHCP control, sustained writes, all twelve overlapping I/O +intervals, and injected write/flush/readback/worker/daemon failures. They use +regular-file images and isolated ARM VMs. No physical media was written. + +## Executable size and startup measurements + +Clap increases executable sizes. All eight production Rust programs (including +Dasung) remain statically linked and contain no personal build-path prefix. +`out/clap-binary-sizes.json` records the final hashes and these sizes: + +| Executable | Before, bytes | After, bytes | +| --- | ---: | ---: | +| `fds` | 900,720 | 1,097,960 | +| `fds-burn` | 821,344 | 990,312 | +| `fds-stage0` | 474,336 | 633,880 | +| `fds-boottrace` | 442,152 | 606,672 | +| `fds-cartridged` | 917,328 | 1,064,832 | +| `fds-profile` | 527,880 | 687,904 | +| `fds-release` | 434,720 | 595,776 | + +Five alternating QEMU TCG boot pairs compare the previous complete image set +with the rebuilt images, using the same Pi kernel. The previous images also +precede Rust source-path normalization; this is an image-level comparison, +not an isolated microbenchmark of the parser. Each measurement uses Linux +CLOCK_BOOTTIME from kernel start to the first ordinary-user console. + +| Image set | Five samples, ms | Median, ms | +| --- | --- | ---: | +| before | 1958.754852, 2031.634456, 1998.790409, 1951.843302, 1961.052514 | 1961.052514 | +| clap | 1917.447815, 1909.301394, 1923.776234, 1955.579658, 2053.676925 | 1923.776234 | + +The median change was **−37.276280 ms**, below the +100 ms regression threshold. +This does not establish a speedup on the Pi. Input hashes, all sample reports +and serial logs are in `out/clap-console-pairs.qyyoppcj/`; the replay script is +`out/clap-boot-comparison.py`. + +The updated stage0 was also built at all three optimization levels and booted +three times per level (`out/rust-profiles.8hnnusn8/comparison.json`): + +| Optimization | Stage0 bytes | Median kernel-to-console, ms | +| --- | ---: | ---: | +| `z` | 633,880 | 1922.658965 | +| `2` | 730,568 | 1938.769685 | +| `3` | 757,168 | 1950.010662 | + +Retain `z`; these VM samples do not establish a physical-hardware winner. The +separate twelve-device test observed a +180.457 ms median versus empty bays. +That comparison adds twelve emulated USB devices and is not the before/after +Clap comparison. Its readiness-gate test still proved that cartridge service +readiness does not block the console. Physical Pi boot and shutdown targets, +USB electrical behavior, display recovery and flash durability remain deferred. + +## Release status + +The requested CLI migration is complete and included in the signed local +0.1.0 release. Fresh v5 offline builds produced 27 byte-identical artifacts, +which also match the normal images used by every runtime suite above. The old +v4 candidate was stopped before acceptance. See [final M12 evidence](m12-validation.md#final-local-release-acceptance) +and the [67-section implementation ledger](implementation-status.md). diff --git a/docs/dasung-validation.md b/docs/dasung-validation.md new file mode 100644 index 0000000..9fbb0e4 --- /dev/null +++ b/docs/dasung-validation.md @@ -0,0 +1,69 @@ +# Dasung integration validation + +[Documentation index](README.md) · [Integration and usage](dasung.md) + +Validated on 2026-09-20 on the x86_64 Arch development host. This records the FDS +port of the existing local `dasungd`; it is separate from the historical live +monitor validation in [the imported source](../rust/dasungd/VALIDATION.md). + +This is the original integration record. The subsequent rootfs build, emulated +ARM execution, and integration rerun are recorded in [M1 validation](m1-validation.md). + +## Commands completed successfully + +```sh +make bootstrap +make smoke-test +make check +make dasung +make dasung-test +``` + +Bootstrap now fetches the locked workspace dependencies. A fresh empty Cargo +cache had exposed that resolving the new workspace member also affects an offline +build of the dependency-free smoketest; the bootstrap fetch addresses that setup +requirement. The original dependency versions were retained; the workspace lock +adds the smoketest member to the imported controller lock. + +## Checks performed + +- Static AArch64 ELF validation of both the built daemon and the executable + extracted from its `aarch64` XBPS package. No dynamic interpreter, shared-library + requirements, or glibc symbol requirements were present. +- Six imported unit tests: framing/reassembly, malformed streams, parameter and + IPC validation, saved-setting reload, and exact EDID identity/checksum/clock. +- Pseudo-terminal monitor tests: late attachment, keepalive, fragmented replies, + invalid settings, saved-setting replay, unplug/replug, duplicate-instance + exclusion, reply watchdog recovery, restart, and forgetting settings. +- Native s6 execution of the packaged service graph in an isolated container with + a read-only root and no exposed physical USB or display devices. Startup, + logging, socket permissions, restart, and clean stop passed with no monitor. + This used the x86_64 test build of the same source, not ARM emulation. +- Package contents, exact config/profile, mandatory base package selection, + base boot-bundle membership, and compilation of packaged service sources. +- The original M0 ELF/source/overlay protections and Rust formatting checks. + +## Artifact identities + +```text +7a3daf7e7beadde7d4b0991a08af04952495e1ec155bd4a2e3605fb903dcc782 out/dasungd +9c33dc3808549016f388f409658abc3ba37ff0b8d05973dcf1d5f9f8dbd89e94 out/packages/fds-dasungd-0.1.0_1.aarch64.xbps +``` + +Package dependencies are coreutils, execline, s6, s6-rc, and eudev. The static +executable does not add a shared musl, libusb, or libudev runtime dependency. +The packet keepalive/pacing and confirmed 304210 kHz EDID are retained. + +Local generated evidence: [build log](../out/logs/dasung-build-final.log), +[test log](../out/logs/dasung-test-final.log), and +[artifact manifest](../out/manifests/dasung-artifacts.sha256). These outputs are +ignored by Git and must be regenerated in a fresh checkout. + +## Not verified + +There is no complete rootfs, native PID 1 deployment, Pi kernel/initramfs, or +bootable image yet. ARM runtime execution, Pi video/cabling, physical image quality, +early-boot display handoff, and corrected cold-power recovery were not tested. +The workstation's live display service and configuration were not changed. +Saved overrides use volatile `/run` until persistent machine storage is integrated. +The service test is not a boot or shutdown benchmark. diff --git a/docs/dasung.md b/docs/dasung.md new file mode 100644 index 0000000..d8de682 --- /dev/null +++ b/docs/dasung.md @@ -0,0 +1,185 @@ +# Dasung Paperlike 13K base-system integration + +[Documentation index](README.md) · [First build](getting-started.md) · [Services](services.md) + +FDS includes the Rust **dasungd** controller from the project's earlier +**Fix Dasung monitor black screen** task. It targets the user's **Paperlike 13K +grayscale**, serial `L56051794302`, with the confirmed 3200 × 2400 timing at about +37 Hz. This is core display support: its package is required for the base SYSTEM +image, and its service belongs to the base boot bundle, including console-only use. +It does not require an ENVIRONMENT cartridge, WindowMaker, or a graphical session. + +**Implementation boundary:** the daemon, ARM package, and s6 service definitions +are included by the [rootfs assembler](rootfs.md). [M2 native init](init.md) starts +the controller in the base boot graph, including in the ARM VM with no monitor. +The [M4 boot path](boot.md) starts an early copy before SYSTEM exists, then stops +and reaps it before base s6 starts the normal daemon. Software handoff is tested +in a VM; physical Pi display and monitor recovery remain unverified. The final +s6 database is compiled at image build time. + +## Build it on the workstation + +Complete `make bootstrap` as described in [Your first build](getting-started.md), +then run from the repository root, as your regular user: + +```sh +make dasung +make dasung-test +``` + +The first command prepares the additional C cross compiler inside the existing +Void build container, fetches the crates pinned in `Cargo.lock`, and cross-builds +static-musl `dasungd`. It verifies the ELF, builds the `fds-dasungd` XBPS overlay, +indexes the output, and compiles the actual s6 source into a validation database. +The second runs unit tests and a simulated serial monitor, then inspects the +package and compiles the service definitions extracted from that package. It also +runs that s6 graph with a native test binary, a read-only container root, and no +physical device access to check startup, logging, permissions, restart, and stop. + +Python 3 is needed **only on the host for the simulator**. If absent on Arch, +install `python` with pacman. No Python interpreter is shipped for the daemon. +The first additional toolchain download is approximately 243 MB and needs roughly +1 GB of extra container space. Builds still depend on rolling Void binary inputs. +Run these commands sequentially with other FDS builds; they share the container. + +| Output | Purpose | +| --- | --- | +| `out/dasungd` | Static AArch64 executable; no target glibc, musl, libusb, or libudev shared library dependency | +| `out/packages/fds-dasungd-0.1.0_1.aarch64.xbps` | Target package with executable, config, exact EDID, s6 source, udev rule, and licenses | +| `out/manifests/dasung-s6-database.txt` | Path to the service database compiled on the host for validation | +| `out/manifests/dasung-artifacts.sha256` | Executable and package hashes | +| `out/manifests/dasung-build-packages.txt` | Installed build-container input versions | +| `out/logs/dasung-build.log`, `dasung-checks.log` | Build and test evidence | + +The standalone validation database contains only the Dasung subset. The M2 +image assembler merges it with the init package and compiles the full current +graph while building the rootfs. No daemon is started on the workstation by these targets; +the integration test uses its own temporary socket and pseudo-terminal. + +## What goes into the base system + +`image/base-packages.list` makes `fds-dasungd` a mandatory hardware package. +`packages/fds-dasungd/template` is the active FDS-owned XBPS overlay. The upstream +Void tree remains pinned and unchanged. Source is maintained at `rust/dasungd/`; +the original task directory is not required for future builds. + +The package installs: + +- `/usr/bin/dasungd` and `/etc/dasungd.toml`. +- `/usr/lib/firmware/edid/dasung-paperlike13k-37hz.bin`. +- `/etc/s6-rc/source/dasungd`, its runtime-directory and log services, and the + `boot/contents.d/dasungd` membership entry. +- `/usr/lib/udev/rules.d/99-dasung-spi.rules`, adapted to eudev syntax. +- Documentation and the controller/libusb licenses. + +s6 supervises the foreground daemon. A short oneshot creates private writable +runtime/log directories; it does not wait for the display. The daemon starts with +no monitor attached and retries discovery. Its initial service transition does +not wait for a monitor reply, a desktop, the network, or all cartridge bays. +The log service rotates a small log under `/run/log/dasungd`. + +The init package adds a dependency on `runtime-fs` so the controller starts with +its kernel interfaces mounted. The service runs as root because it claims USB interfaces. Its socket +is under a root-owned mode-0750 directory; runtime control is restricted to root. +A future FDS CLI can expose selected operations through an explicit policy. +No world-writable USB rule, systemd unit, or runit service is installed. + +## Confirmed profile and Pi-specific choices + +| Property | Recorded value | +| --- | --- | +| Monitor | Paperlike 13K grayscale; `L56051794302` | +| Resolution | 3200 × 2400 | +| Pixel clock | **304210 kHz** (304.21 MHz) | +| Horizontal active / sync start / sync end / total | 3200 / 3248 / 3280 / 3360 | +| Vertical active / sync start / sync end / total | 2400 / 2423 / 2427 / 2447 | +| Sync polarities | Positive horizontal and vertical | +| EDID SHA-256 | `b6c1e0a8d315d9cf5c2fb83784a177530bcc085c2d0724dc7478a9c12449e4f4` | +| Control transport | USB UART `1a86:7523`, with companion SPI bridge `1a86:5512` | +| Startup parameters | Raw mode 1 and contrast 4, previously read back on this unit | +| Keepalive | `20 01` every two seconds; minimum 150 ms between command writes | + +The video timing was confirmed on the original AMD workstation, **not on a Pi**. +The original USB-C video connection is not a Pi cabling prescription. Pi connector, +cable/adapter, mode acceptance, and picture stability require hardware validation. +Desktop 2× scaling is separate from EDID timing; a console uses its own font size. + +FDS configures `display.enabled=false` and `hotplug="none"`: the daemon handles +USB control without applying the source project's AMD debugfs disconnect/reconnect +procedure. The Pi boot/display integration must supply the appropriate KMS/firmware +EDID configuration for its real connector. The Pi boot-volume builder now supplies +an unqualified firmware EDID override for this dedicated monitor; it does not +assume `DP-2` or a numbered HDMI connector. See the [boot guide](boot.md) for +the exact command line and the remaining physical mode test. + +The daemon requires the configured monitor's DRM EDID and the matching USB +companion topology before claiming a device. Multiple matching UARTs are rejected; +`usb_path` can disambiguate a verified Pi topology. Do not substitute a blanket +match for all CH340 adapters or copy this unit's identity to a different monitor. + +The original monitor's SPI driver interaction caused a dark picture after a power +cycle. `image/kernel/dasung.config` requires `CONFIG_SPI_CH341=n`; the Pi kernel +builder enforces this before boot, including the initramfs environment. The scoped +udev rule is secondary protection: it cannot prevent a built-in or already-loaded +SPI driver from probing. The daemon also reserves the companion interface without +sending SPI data and refuses to take it from an already-bound kernel driver. + +## Runtime usage once installed in an FDS image + +These are target commands, **not commands to run against the workstation's live +monitor during the build**: + +```text +dasungd status +dasungd query +dasungd refresh +dasungd set contrast 4 +dasungd set mode 1 --save +dasungd forget mode +``` + +`status` returns JSON including `connected`, `responsive`, reply age, and cached +parameters. A successful set means a packet was sent; use query/status to check +observed values. It is not proof of physical image quality. Color-model mode names +are not assigned to this grayscale monitor's raw numeric modes. + +The default socket is `/run/dasungd/control.sock`. External programs can use its +newline-delimited JSON protocol; the daemon remains the only USB owner: + +```json +{"op":"status"} +{"op":"set","parameter":"contrast","value":4,"save":true} +``` + +In FDS's current payload, saved overrides live in `/run/dasungd/settings.json`. +They survive a daemon restart **within the same boot**, but not power loss. +Mode 1/contrast 4 are persistent image configuration defaults. Once persistent +machine-state mounts are implemented, point `state_file` at that internal writable +storage to retain user overrides across boots. Do not put it on read-only SYSTEM +or make the base display daemon depend on a removable DATA cartridge. + +## Dependencies and source provenance + +The imported Rust dependencies are retained and pinned by the workspace lockfile: +`rusb` wraps USB access; `libc` supports Linux serial/locking calls; `ctrlc` handles +termination; `clap` provides CLI parsing; `serde`, `serde_json`, and `toml` support +configuration and the local socket; `anyhow` supplies contextual errors. +Transitive versions are recorded in `Cargo.lock`. + +The `vendored` feature builds libusb from the pinned `libusb1-sys` crate. The Void +`cross-aarch64-linux-musl` toolchain supplies C headers/compiler for that library; +Rust's bundled musl target alone was enough for the old pure-Rust smoke-test, but +is insufficient for this C dependency. `LIBUSB_NO_PKG_CONFIG` and +`LIBUDEV_NO_PKG_CONFIG` prevent accidental host/shared-library linkage. libusb uses +its Linux netlink backend. No libusb/libudev shared runtime package is needed. + +The target package depends on GNU coreutils for directory preparation, execline +for service launch, s6/s6-rc for supervision, and eudev for device rules. These +match the planned base userspace. The container's s6-rc is a build-time compiler, +not a target PID 1 running on Arch. + +[Import provenance](../rust/dasungd/IMPORT.md) records the original source hashes. +[Original validation](../rust/dasungd/VALIDATION.md) records the live handoff and +remaining physical test. Protocol and reconnect tests use simulated hardware; +Pi boot, image quality, physical disconnect/reconnect, and corrected cold-power +recovery must still be verified on the actual target. No boot benchmark is claimed. diff --git a/docs/data.md b/docs/data.md new file mode 100644 index 0000000..ef2fbb1 --- /dev/null +++ b/docs/data.md @@ -0,0 +1,138 @@ +# Writable DATA and managed programs + +[Documentation index](README.md) · [Cartridges](cartridges.md) · [Services](services.md) + +M7 implements writable DATA. Its sustained-write, safe-eject and surrounding +image regression checks [passed in the ARM VM](m7-validation.md). A DATA +cartridge uses a GPT partition named `FDS_DATA`, ext4, and a validated +`/FDS/CARTRIDGE.TOML` declaring class `data` and `writable = true`. +Use the [M9 media tools](media-tools.md) to create new DATA media. The virtual +tests construct disposable image files and never format a workstation disk. + +## Where your files live + +`/home/fds` remains a temporary home, whether DATA is present or absent. Its +contents disappear at power-off. Persistent user files belong under `/data`. +Separating these paths lets you eject DATA without hiding the active shell's +home directory or substituting another cartridge underneath it. + +After inserting a DATA cartridge into a configured bay: + +```sh +fds bays +fds cartridge 2 +ls /data +``` + +If exactly one valid DATA candidate is available and none is active, the daemon +activates it at `/data`. The filesystem must grant UID/GID 1000 the intended +write permissions; insertion does not recursively change ownership of user files. +M9's DATA formatter supplies that ownership for fresh media. + +An active DATA session stays attached to its current cartridge when another is +inserted. If a discovery snapshot contains multiple candidates with none active, +they remain read-only until you select one: + +```sh +fds data use 4 +``` + +Eject the existing active DATA before selecting a replacement. The daemon never +changes `/data` underneath an active session. EMPTY, ERROR and SAFE states retain +the meanings in the [cartridge guide](cartridges.md); MOUNTED READ WRITE means +that the displayed DATA filesystem is available for user writes. + +## Run a program that participates in eject + +Ordinary console commands work as usual. They are not automatically killed when +you request eject: an open file or working directory may make eject fail as busy. +For a background task that should stop when its DATA is ejected, use: + +```sh +fds run 2 -- /usr/bin/bash -c 'date > /data/managed-example.txt' +``` + +This starts a **background** managed program and prints its process ID. It has no +interactive input. Output goes to the bounded, volatile cartridge service log: + +```sh +tail /run/log/cartridged/current +fds cartridge 2 +``` + +The executable path must be absolute. The child starts with `/data` as its working +directory, the ordinary FDS UID/GID, no supplementary groups, no effective +capabilities, a small explicit environment, and `no_new_privs`. Programs cannot +gain privileges through setuid executables. The limit is 256 managed processes +per bay. This interface accepts healthy writable DATA; the [desktop guide](desktop.md) +also explains M8's explicit PROGRAM-media launch interface. + +A root-owned Linux cgroup tracks the program and its descendants, including +children that outlive their parent. Cgroups are kernel process tracking, not a +new init system or runtime package. The configured kernel already includes the +required cgroup v2 and process-limit support. The daemon sets up its hierarchy +independently of the console. See the [kernel cgroup interface](https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html). + +## Eject and remove DATA + +```sh +cd "$HOME" +fds eject 2 +``` + +Eject checks for additional mounts, stops managed consumers, flushes the DATA +filesystem with `syncfs`, and performs a normal unmount. Only successful completion +produces SAFE. A retained filesystem descriptor observes writeback errors during +the session. M10 additionally makes the filesystem read-only and checks writeback +again before closing that descriptor for unmount, preventing an error-reporting +gap when a busy mount needs a later retry. +Linux's [syncfs interface](https://man7.org/linux/man-pages/man2/syncfs.2.html) +reports filesystem errors that must not be ignored. + +Managed programs receive TERM first. The daemon waits on actual cgroup exit +notifications; programs that have not exited after a one-second deadline are +killed as a group, including newly forked descendants. A further bounded exit +check must succeed. These are failure deadlines, not delays imposed on a program +that has already stopped. Requesting eject therefore ends those background jobs; +it does not promise that unfinished application work is completed. + +An unmanaged busy shell/file, extra mount, failed flush or failed unmount prevents +SAFE. Close the reported use and retry. A recorded DATA writeback fault requires +investigation or recovery; a later successful call is not used to erase it and +pretend the failed write succeeded. The daemon never uses lazy/forced unmount to +claim successful eject. A successfully ejected insertion remains unmounted across +a daemon restart until removal/reinsertion. + +M10 records writable sessions and faults outside the daemon process. If it crashes +before verified unmount, the same insertion stays quarantined and read-only on +restart. A later flush cannot reconstruct the lost error history. See +[Shutdown and recovery records](power.md#data-recovery-records) for the current +implementation and its acceptance status. + +Pulling DATA during writes is an error. The daemon stops its managed consumers, +cleans up the vanished mount and logs that writes may have been lost. This path +never produces SAFE and makes no filesystem-cleanliness promise. Use recovery +inspection/repair before trusting media that was removed during I/O. + +## Build and verify on the workstation + +After changing DATA code or image configuration, build fresh inputs in order: + +```sh +make rootfs PROFILE=cli +make initramfs +make system-card +make data-test +``` + +The test boots disposable virtual media, writes continuously, verifies ordinary +user privileges and descendant shutdown, checks busy and additional mounts, +reopens persisted data, tests multiple candidates and simulates surprise removal. +After safe eject it extracts the ext4 partition into an ordinary file, runs +read-only `e2fsck -fn`, and compares the sustained-write payload byte-for-byte. +No host mount or physical USB device is used. + +Run `make cartridge-test`, `make init-test`, `make boot-test` and +`make console-test` for the surrounding regressions. Test logs and VM evidence +are written under `out/`. Real flash-controller caches, battery loss, USB power +and physical Pi eject/shutdown latency remain hardware acceptance work. diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..2e7bac6 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,212 @@ +# Desktop, programs, and networking + +[Documentation index](README.md) · [Cartridge guide](cartridges.md) · [DATA guide](data.md) + +M8 software checks passed in the ARM virtual machine. The [validation report](m8-validation.md) +records desktop, program and networking evidence. Raspberry Pi video output and +Dasung display quality remain deferred physical checks. + +## What starts when you boot + +FDS opens its ordinary-user console first. Xorg, WindowMaker, and the DHCP client +are installed in SYSTEM but are absent from the boot bundle. Dasung control +remains in the base boot bundle, including when no desktop is running. + +The default graphical backend is Xorg on virtual terminal 2, using its built-in +modesetting driver. The physical keyboard and pointer use libinput. Console +access remains on the original terminal. There is no display manager. Physical +VC4 output, monitor resolution, and E-Ink appearance still require Pi testing. + +## Start and stop the desktop + +At `FDS>`: + +```sh +fds profiles +fds profile activate windowmaker +fds profiles +``` + +Activation is asynchronous. The second status command reports the selected +profile and an activation-to-ready duration once WindowMaker is ready. This +measurement starts at the activation request, so time spent deciding when to +start the desktop is excluded. `fds boot-profile` also records the first desktop +readiness event for the boot. Neither measure includes firmware or power-on. + +The desktop opens an FDS terminal. Right-click its white background to open the +menu, launch another terminal, enable/disable Ethernet, or return to the console. +To stop it from either a terminal or the console: + +```sh +fds profile deactivate +``` + +Stopping the desktop closes its terminal and all processes descended from its +session. Save work first. Files in `/home/fds` survive desktop restarts during the +same boot; the home directory is still temporary and disappears at shutdown. +Use [DATA](data.md) for persistent files. Removing an active DATA cartridge also +stops the desktop before flushing DATA, because desktop applications may use it. + +`fds-profile status`, `fds-profile activate windowmaker`, and +`fds-profile deactivate` provide the standalone static helper interface. + +## ENVIRONMENT cartridges + +An ENVIRONMENT cartridge is a read-only EROFS partition named `FDS_ENVIRONMENT`. +Its `FDS/CARTRIDGE.TOML` uses the [documented manifest format](cartridges.md), with: + +```toml +[activation] +profile = "windowmaker" +``` + +When exactly one eligible ENVIRONMENT cartridge is present, the daemon requests +WindowMaker after console readiness. The graphical runtime comes from SYSTEM; +the cartridge provides a declarative request, never a privileged executable. +Unknown profile names do not execute anything. If several eligible cartridges +arrive together, select a profile explicitly instead of relying on bay ordering. + +Eject its bay with `fds eject N`. The desktop stops before SAFE is reported. +Surprise removal also stops its associated desktop. Manually deactivating while +the cartridge remains inserted suppresses automatic reactivation until removal +and reinsertion. A manually started desktop is independent of ENVIRONMENT media. +A cartridge-service restart reevaluates mounted media and retires old optional +sessions; this is a maintenance operation, not a way to preserve desktop jobs. + +## E-Ink defaults + +`fds-eink` installs the WindowMaker policy and Terminus fonts: a white background, +black text and borders, grayscale controls, outline movement/resizing, no +animations, no blinking decorations, no app-icon bounce, no dock, no compositor, +and no periodically updating clock. X11 compositing is disabled. The terminal +uses black on white and a nonblinking cursor. + +Defaults are copied into the ordinary user's `~/GNUstep/Defaults` at the first +session. Later sessions preserve edits there. They are temporary unless you +explicitly store a copy on DATA. The authoritative image defaults live in +`/usr/share/fds/eink`; edit their package sources and rebuild SYSTEM to change the +machine-wide defaults. + +Font caches and X11 font indexes are generated while building the image. +An active X server's keyboard map is session state, generated only on desktop +activation. That is separate from global boot-time cache generation. + +## Launch a software cartridge + +Build current software cartridges on a [Linux workstation](workstation.md). They +contain metadata plus xz software bundles in payload partitions. In FDS: + +```sh +fds bay 4 +fds run 4 -- demo.hello:hello +fds eject 4 +``` + +Use the software id and command listed by `fds bay`. The guest verifies and +extracts the bundle into a read-only temporary cache, then starts it as UID 1000. +There are no guest software build hooks. + +### Legacy PROGRAM compatibility + +Existing PROGRAM media contains a read-only EROFS partition named `FDS_PROGRAM`, metadata +in `FDS/CARTRIDGE.TOML`, and its self-contained application files: + +```text +app/bin/editor +app/lib/ +app/share/ +``` + +FDS validates and mounts it at `/run/fds/apps/`. Insertion does not +run anything, and the mount initially disallows execution. To launch an executable +named `editor` from bay 4: + +```sh +fds cartridge 4 +fds run 4 -- editor +``` + +Use a simple executable name from `app/bin`, followed by that program's arguments. +The daemon enables read-only execution and starts it as UID 1000, with no added +privileges. Executable paths must remain within the cartridge's `app` directory. +Programs receive `FDS_APP`, a PATH including `app/bin`, `LD_LIBRARY_PATH` including +`app/lib`, and `XDG_DATA_DIRS` including `app/share`. Bundled libraries can also use +an executable-relative RPATH. FDS does not resolve dependencies across cartridges. + +Programs are background jobs. Graphical programs can use the active authenticated +X display. Standard output/error goes to the bounded, temporary cartridge log: + +```sh +tail /run/log/cartridged/current +fds bay 4 +fds eject 4 +``` + +The bay status reports managed processes. Eject stops the whole process group, +including forked descendants, before unmounting. The existing DATA form remains +`fds run N -- /absolute/system/command arguments`, using `/data` as its working +directory. DATA itself remains mounted with execution disabled. + +## Ethernet on demand + +External networking is off until explicitly requested or a mapped USB Ethernet cartridge +produces a real network interface. Interfaces are associated with their USB +ancestors; a descriptive label alone does not start networking. Wi-Fi setup is +not implemented by this Ethernet policy. The local loopback interface is always +up for applications on the same machine; it does not enable external traffic. + +```sh +fds network on +fds profiles +ip -brief address +ip route +fds network off +``` + +Enabling networking starts DHCP without waiting for a lease. An absent server +cannot delay `FDS>`. Explicit activation selects available Ethernet interfaces; +automatic activation selects interfaces under mapped cartridge USB devices. +Stopping networking stops DHCP, brings the managed interfaces down, and clears +temporary DNS settings. This initial profile uses IPv4 DHCP; it disables kernel +IPv6 autoconfiguration on those interfaces. After an explicit stop, an inserted Ethernet +cartridge remains suppressed until it is removed, or you enable networking again. + +Leases, DNS settings, and logs live under `/run`; nothing makes SYSTEM writable. +`/etc/resolv.conf` points to `/run/fds/resolv.conf`. Inspect +`/run/log/network/current` when an address is missing. + +## Development and troubleshooting + +The production console image includes the real desktop stack. The development +image adds Xvfb and X11 diagnostics for a virtual display without a connected GPU: + +```sh +make rootfs PROFILE=development +make desktop-test +``` + +The test creates its own disposable image selecting Xvfb; the normal development +image still selects Xorg. Never mistake an Xvfb pass for Pi graphics verification. +Changing the SYSTEM image profile requires building that rootfs first; the image +builder rejects a profile label that disagrees with its embedded identity. + +If desktop activation fails, `fds profiles` reports it. Read +`/run/log/xserver/current` and `/run/log/desktop/current`, then deactivate before +retrying. Xorg's detailed log is `/run/log/xserver/Xorg.0.log`. X access requires +an authority cookie readable only by root and the FDS group; do not use `xhost +` +or disable access control as a workaround. + +Dependency rationale: Xorg supplies the display server and modesetting driver; +libinput supplies keyboard/pointer input; WindowMaker supplies window management; +Terminus and fontconfig supply the typography; xterm supplies a terminal; the static FDS helper +observes X11 property events for readiness without polling delays; xset/xsetroot apply the +static session settings. Xvfb, xdotool, xwd, xwininfo, xdpyinfo, xprop, and xauth are +optional development diagnostics. `fds-dhcpcd` builds the pinned upstream DHCP +client with privilege separation and volatile state paths, omitting its runit +service files. All FDS control helpers remain static Rust/musl binaries. + +The readiness observer uses the [X11 core protocol](https://xorg.freedesktop.org/archive/X11R7.7/doc/xproto/x11protocol.html) +and establishes its property subscription before launching the window manager. +Font caches are generated with the image's normalized timestamp, following +[fontconfig's reproducible-cache support](https://fontconfig.pages.freedesktop.org/fontconfig/fontconfig-user.html), +so exporting the filesystem does not invalidate their directory timestamps. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..bed244a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,309 @@ +# Working on FDS/OS + +[Documentation index](README.md) · [First build](getting-started.md) · [Troubleshooting](troubleshooting.md) + +This guide is for someone who has completed the first build and wants to change +or inspect the working code. Run commands from the repository root, as a normal +user. M0–M12 software acceptance is complete; consult [AGENTS.md](../AGENTS.md) +and the [roadmap](roadmap.md) before adding components. + +## Choose the smallest useful build + +| You changed or want to do | Run | +| --- | --- | +| See the available entry points | `make help` | +| Prepare a new checkout or restore missing host tools | `make bootstrap` | +| Edit the Rust smoketest | The Rust build and validation sequence below | +| Build/test the base display controller | `make dasung`, then `make dasung-test`; see [Dasung](dasung.md) | +| Change the CLI, shared contracts or stage0 | `make tooling`, then `make tooling-test`; rebuild initramfs and run `make boot-test` for boot-path changes | +| Change kernel configuration | `make kernel`, rebuild rootfs and boot images, then `make boot-test`; see [Boot images](boot.md) | +| Change USB discovery, mapping or media writing | Build CLI images, then `make stress-test`; see [Stress tests](stress-testing.md) | +| Change shutdown or DATA safety | Rebuild both profiles; run `make power-test` against development, plus `make data-test` and `make init-test`; see [Shutdown](power.md) | +| Change init or boot services | `make rootfs PROFILE=cli`, `make rootfs-test`, then `make init-test`; see [Native init](init.md) | +| Change base packages or rootfs assembly | `make rootfs PROFILE=cli`, `make rootfs-test`, then `make init-test`; see [Rootfs](rootfs.md) | +| Build the native development toolset | `make rootfs PROFILE=development`, `make rootfs-test`, `make system-card PROFILE=development`, then `make development-test` | +| Change internal storage or machine settings | Rebuild CLI and recovery, then `make internal-test`; see [Internal storage](internal-storage.md) | +| Change release signing or verification | `make signing-test`; see [Release signatures](releases.md) | +| Rebuild the test XBPS package | `./tools/build-package hello` | +| Verify an M0 build-system change | `make bootstrap`, then `make smoke-test`, then `make check` | +| Check Rust formatting and validation protections | `make check`, after building the Rust program | +| Read or edit documentation | Check examples, links, and claims against the implementation | + +Run builds sequentially. The scripts share one Void masterdir and package cache; +starting bootstrap and package builds concurrently is not a supported workflow. +`make packages` builds every FDS base package. `make all` builds the complete +artifact set; two independent frozen offline builds passed with matching output. See +[Frozen inputs and offline rebuilds](reproducible-builds.md). There is no +destructive `make clean` or generic `make test` target. + +## Edit and rebuild the Rust program + +The active program is [rust/fds-smoketest/src/main.rs](../rust/fds-smoketest/src/main.rs). +It is deliberately tiny: it establishes that the intended target and static +linking work. `rust/dasungd/`, `rust/fds-common/`, `rust/fds-cli/`, and +`rust/fds-stage0/`, `rust/fds-boottrace/`, and `rust/fds-cartridged/` are also active. Directories absent from the workspace member +list remain reserved. Use the [tooling guide](tooling.md) for the FDS programs, +[Cartridges](cartridges.md) for bay and metadata changes, and [DATA](data.md) +for writable storage and managed jobs. + +Use the shared `clap` workspace dependency for every Rust command-line interface. +Declare commands and options with `Parser`, `Subcommand`, `Args`, and typed value +parsers; do not match or consume raw argument vectors. Shared media syntax lives +in `rust/fds-burn/src/cli.rs`; installed FDS aliases use Clap's multicall support +in `rust/fds-cli/src/cli.rs`. Keep authorization and filesystem validation in the +operation layer after parsing. Add parser tests when changing required options, +conflicts, private s6 flags, aliases, or child-argument forwarding. + +The disposable Rust VM workloads in `tests/helpers/` are a Cargo workspace +member and use the same locked Clap dependency. They are never installed in +the base packages. Their stress workload uses release optimization level 2; +production executables retain the size-oriented release settings. + +After editing the program: + +```sh +cargo fmt --all +cargo build --locked --offline --release \ + --target aarch64-unknown-linux-musl -p fds-smoketest +./tools/verify-elf target/aarch64-unknown-linux-musl/release/fds-smoketest aarch64 static +make check +``` + +Cargo writes to `target/aarch64-unknown-linux-musl/release/`. It does **not** refresh +`out/fds-smoketest` or its manifest. Run `make smoke-test` when you need a freshly +exported artifact and matching checksums. + +If you installed optional QEMU, execute the freshly built file with: + +```sh +qemu-aarch64 target/aarch64-unknown-linux-musl/release/fds-smoketest +``` + +The smoke-test contract expects exactly `FDS/OS M0: aarch64 static-musl OK`. +Changing that text requires updating the runtime assertion and documentation as +part of the same change. The crate also rejects the wrong architecture, libc, +or missing static CRT at compile time. Do not bypass those checks to make an +x86_64 build appear to pass. + +The release profile and target configuration live in [Cargo.toml](../Cargo.toml) +and [.cargo/config.toml](../.cargo/config.toml). Rust and target versions are +selected through [rust-toolchain.toml](../rust-toolchain.toml). This M0 crate has +no external crate dependencies. `--offline` works after bootstrap has installed +the toolchain; it is not a promise that the complete build pipeline is offline. + +## Build and inspect a package + +```sh +./tools/build-package hello +``` + +This checks the Void source pin, prepares configuration and overlays, invokes +xbps-src's aarch64 cross build, and copies the resulting main package into +`out/packages/`. Its log is `out/logs/xbps-hello.log`. Follow +[Packages](packages.md) for indexing, querying, and extracting the output. + +`hello` is the verified M0 package. The helper accepts other template names, but +its exporter currently looks only for that main package in the top-level +`hostdir/binpkgs/`. Packages emitted into subrepositories and separately named +subpackages need additional handling; the helper is not yet a universal package +export interface. + +For diagnosis, this is the underlying command in its correct host environment: + +```sh +( + export PATH="$PWD/.host/xbps/usr/bin:$PATH" + export XBPS_ARCH=x86_64 + cd vendor/void-packages + ./xbps-src -a aarch64 pkg hello +) +``` + +The subshell keeps the directory and environment changes local. Prefer the FDS +helper for normal work: it also checks the pin and exports the output. The raw +command does neither. Do not run `xbps-install` against the Arch host or install +an ARM package into the x86_64 build container. + +## Change build configuration deliberately + +The source of truth is [config/xbps-src.conf](../config/xbps-src.conf). +`XBPS_MAKEJOBS` controls package build parallelism; the current value is 4. +`XBPS_CHROOT_CMD=bwrap` selects the supported container backend. + +`tools/prepare-void` copies this file to the ignored +`vendor/void-packages/etc/conf`. If the two differ, it stops instead of silently +overwriting local settings. After intentionally editing the checked-in file, +review the difference: + +```sh +diff -u vendor/void-packages/etc/conf config/xbps-src.conf +``` + +A difference makes `diff` exit 1; that is expected. Once the checked-in version +contains every setting you intend to keep, synchronize and prepare: + +```sh +cp config/xbps-src.conf vendor/void-packages/etc/conf +./tools/prepare-void +``` + +This overwrites the local generated configuration, so reconcile any intentional +local edits before copying. Re-run the three M0 validation commands after a build +configuration change. A mirror change does not lock the packages served by that +mirror; see [input limitations](build-host.md#known-limitations). + +## How future package overlays fit + +FDS-owned package sources belong in `packages/fds-*/`, outside the upstream +submodule. An overlay becomes active only when its directory contains a +`template`. `fds-base`, `fds-base-files`, `fds-init`, `fds-dasungd`, `fds-cli`, `fds-kernel`, +`fds-cartridged`, `fds-eink`, and `fds-dhcpcd` are active. + +For active overlays and future templates, `tools/prepare-void` copies each +active directory into `vendor/void-packages/srcpkgs/`. It allows an identical +repeat copy, rejects a name that collides with a tracked upstream package, and +stops if a previous generated copy differs. Edit the source under `packages/`, +then reconcile the specific stale generated copy. Never put the only copy of an +FDS change inside `vendor/`. + +The [troubleshooting guide](troubleshooting.md#configuration-or-overlay-conflict) +explains how to preserve and inspect a conflicting copy. Empty future directories +are intentional; adding a fake template to silence a missing-package error does +not implement that milestone. + +## Preserve logs and identify inputs + +The normal log filenames are reused. Before reproducing a failure that you need +to retain, save the existing logs: + +```sh +log_archive=$(mktemp -d "$PWD/out/saved-logs.XXXXXX") +cp -a out/logs/. "$log_archive/" +printf 'Saved logs to %s\n' "$log_archive" +``` + +After a successful smoke-test: + +```sh +cat VOID_PACKAGES_COMMIT +rustc --version +cargo --version +cat out/manifests/void-build-packages.txt +sha256sum -c out/manifests/artifacts.sha256 +``` + +The package inventory lists what remains installed in the build container after +xbps-src cleans up temporary dependencies. Cached package digests and build logs +provide additional input records. These records do not constitute a complete, +immutable upstream package repository snapshot. + +## Source updates and changes for review + +Keep upstream tracked files unchanged. Updating Void is a deliberate change to +both the submodule gitlink and `VOID_PACKAGES_COMMIT`, followed by the full M0 +checks. Updating host XBPS requires a matching archive URL and checksum. Updating +Rust requires considering the pinned toolchain, target, and generated artifacts +together. Do not use an unreviewed `git pull` inside the submodule as a repair. + +For a change report, state what changed, which commands actually passed, and what +was skipped. Include the failing command and relevant log when reporting a bug. +Do not describe ELF inspection as execution, QEMU userspace execution as a Pi +boot, or a target timing as a measured result. Use English for documentation, +comments, diagnostics, and project discussions. + +## Desktop verification + +Build `make rootfs PROFILE=development`, then run `make desktop-test`. This +executes the actual ARM WindowMaker, terminal, cartridge daemon, and DHCP client +in disposable VMs. Its Xvfb backend and root test console exist only in generated +fixtures. The normal SYSTEM configuration uses Xorg and the ordinary FDS user. + +VM helpers place QEMU in a private network namespace. The test's virtual DHCP +server can advertise DNS while remaining unable to reach host interfaces or the +Internet. QEMU diagnostics have separate `*.stderr.log` files, keeping host +emulator messages out of guest serial/JSON responses. Screenshots and measured +activation timestamps are retained under `out/m8-vm.*`. + +To return to a console image after desktop development, rebuild +`make rootfs PROFILE=cli` and `make system-card PROFILE=cli`. A SYSTEM image cannot +be relabeled as another profile without rebuilding the matching filesystem. + +## Use the development SYSTEM + +`profiles/development.list` extends the same immutable base with native aarch64 +**glibc** development tools. FDS control programs retain their static-musl build. +Build and verify the profile on the x86_64 workstation: + +```sh +make rootfs PROFILE=development +make rootfs-test +make system-card PROFILE=development +make development-test +``` + +These commands do not install compilers on the Pi's read-only SYSTEM at runtime. +The profile must be built into a new SYSTEM image. Store source code and build +outputs on an active DATA cartridge, or under `/tmp` for disposable experiments. +The ordinary user's home is temporary in this profile too. + +At native startup, FDS advances an older wall clock to the image's fixed source +timestamp in `/usr/share/fds/build-epoch`. A newer kernel/RTC clock is retained. +This prevents immutable compiler and CMake inputs from appearing to be in the +future when a machine boots without a usable RTC. It requires no network, +time server or waiting. `/run/fds/clock.json` records which case occurred. +The floor is an approximate lower bound, **not the current date**. Set the date +explicitly from a trusted time source when accurate timestamps or certificate +validity matter. Physical Pi RTC behavior remains to be tested. Boot-duration +measurements continue to use `CLOCK_BOOTTIME`, independent of wall-clock changes. +`make development-test` checks the older-clock case while compiling real projects; +`make clock-test` checks preservation of a newer clock in a disposable VM. + +| Selected package(s) | Purpose | +| --- | --- | +| `gcc`, `glibc-devel` | Native C/C++ compilation, libc headers and startup files; GCC selects its matching support libraries and C++ headers | +| `make`, `cmake`, `meson`, `ninja`, `pkg-config` | Common project build systems and dependency discovery; Meson brings its Python runtime | +| `rust`, `cargo` | Native Rust development and package builds; Rust selects its standard library and LLVM runtime dependencies | +| `git` | Local source history and optional remote checkout when networking is explicitly enabled | +| `gdb`, `strace` | Interactive debugging and syscall inspection | +| `vim` | Terminal editing, with no graphical session required | +| Xvfb and the existing X11 diagnostic tools | Virtual desktop integration tests; they remain opt-in | + +No development package is added to the boot service bundle. The profile uses +GCC for C/C++ and Rust for Rust; additional compilers and physical performance +profilers can be considered separately when there is a concrete need. + +For a disposable first C program at `FDS>`: + +```sh +mkdir -p /tmp/hello +cd /tmp/hello +printf '#include \nint main(void) { puts("Hello from FDS"); }\n' >hello.c +cc -g -o hello hello.c +./hello +gdb ./hello +``` + +Within GDB, use `break main`, `run`, `next`, and `quit`. For persistent work, +use `/data/projects` after selecting a healthy DATA cartridge. Stop programs and +leave that directory before `fds eject BAY`. + +For a dependency-free Rust example: + +```sh +cargo new --vcs none /tmp/hello-rust +cd /tmp/hello-rust +cargo run --offline +``` + +`--offline` avoids contacting a registry. Projects with third-party crates need +those sources prepared beforehand or an explicit network connection. To preserve +Cargo downloads, put `CARGO_HOME` on DATA and stop related builds before ejecting. +An ordinary development program uses the target's glibc environment; rebuilding +the FDS distribution and its static control plane still follows the documented +x86_64 cross-build workflow. + +`make development-test` runs native C, C++ and Rust compiler/linker executions, +CMake/Meson/Ninja/Make builds, GDB and strace, a local Git commit, and a headless Vim +edit in a disposable ARM VM. It verifies read-only SYSTEM, absent optional +services and native shutdown. Test results are recorded in +[M12 validation](m12-validation.md); they do not establish Pi compiler throughput. diff --git a/docs/eeprom.md b/docs/eeprom.md new file mode 100644 index 0000000..8f8ae34 --- /dev/null +++ b/docs/eeprom.md @@ -0,0 +1,149 @@ +# Pi 5 EEPROM configuration + +[Boot images](boot.md) · [Recovery](recovery.md) · [Implementation ledger](implementation-status.md) + +M12 now provides an offline configuration workflow using the pinned official +Raspberry Pi tool and a real Pi 5 firmware image. The host checks passed; +application to a physical Pi, boot order, PMIC behavior and timing remain deferred. +`tools/configure-pi-eeprom` only creates files. It never reads or writes a hardware +EEPROM, invokes a firmware updater, or reboots a machine. + +## Preview a profile on the build workstation + +From the repository root: + +```sh +./tools/configure-pi-eeprom --profile production +./tools/configure-pi-eeprom --profile development +make eeprom-test +``` + +The first invocation downloads three checksum-pinned inputs into +`.host/eeprom/`: the official configuration script, the Pi 5 preview firmware and +its upstream license. Later calls verify and reuse them. `FDS_OFFLINE=1` requires +all inputs to exist and refuses a network download. + +The command prints its new `out/eeprom-PROFILE.*` directory. It contains: + +| File | Purpose | +| --- | --- | +| `base.bin` | Exact input firmware retained unchanged | +| `original.conf` | Configuration to restore | +| `configured.conf` | Reviewed profile merged with the supplied settings | +| `configured.bin` | Real EEPROM image with the new configuration | +| `rollback.bin` | Same base firmware with `original.conf` restored | +| `review.diff` | Human-readable configuration changes | +| `manifest.json` | Input identity, selected settings and output SHA-256 hashes | +| `LICENSE` | Complete upstream tool and firmware notices | + +Default inputs are **preview defaults, not a backup of your Pi**. A rollback image +restores the configuration supplied to this command on the supplied base firmware. +It cannot reconstruct a different firmware version or settings that were never +provided. Retain the actual machine's configuration and matching firmware input +before applying anything to that machine. + +## Profile behavior + +| Setting | Production | Development | +| --- | --- | --- | +| `BOOT_ORDER` | `0xf6`: NVMe, repeat | `0xf16`: NVMe, SD, repeat | +| `BOOT_UART` | `0` | `1` | +| `NET_INSTALL_ENABLED` | `0` | `0` | +| `NET_INSTALL_AT_POWER_ON` | `0` | `0` | +| `POWER_OFF_ON_HALT` | `1` | `1` | +| `WAIT_FOR_POWER_BUTTON` | `0` | `0` | + +Boot order is read from the right. Both profiles avoid scanning the twelve USB +cartridges for firmware boot. Development retains an SD rescue path. Both disable +network-install keyboard detection; Raspberry Pi documents that this detection +adds USB initialization and enumeration work. These settings do not establish a +measured FDS boot improvement. [Official bootloader configuration](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#bootloader-configuration). + +On Pi 5, the power-off setting requests PMIC standby on halt; the dedicated power +button remains the wake mechanism. The wait setting leaves cold power-on boot +enabled. Whether the assembled computer and attached hardware behave as intended +still needs a physical test. [Official power settings](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#POWER_OFF_ON_HALT). + +The source profiles are [production.conf](../config/eeprom/production.conf) and +[development.conf](../config/eeprom/development.conf). No GPIO wake option or +unmeasured HDMI tuning is added. + +## Preserve the machine's settings + +In a Raspberry Pi maintenance environment with the official EEPROM utilities, +collect its configuration and version information before changing firmware: + +```sh +rpi-eeprom-config > pi-current.conf +rpi-eeprom-update > pi-eeprom-status.txt +``` + +Retain the matching original firmware image separately. The status report helps +identify it; it is not a binary backup. Copy the saved inputs to the workstation, +then prepare a new directory: + +```sh +./tools/configure-pi-eeprom \ + --profile production \ + --base-image /absolute/path/to/original-pi5-firmware.bin \ + --current-config /absolute/path/to/pi-current.conf \ + --output-directory /absolute/path/to/new-eeprom-review +``` + +The base must be a regular 2 MiB Pi 5 image, not a device node. The output directory +must not already exist. The helper preserves unrelated configuration lines and +conditional sections. It replaces all occurrences of the six managed settings, +including conditional overrides, with the selected profile's final `[all]` +settings. Review that change in `review.diff`; the exact original conditional +configuration remains in `original.conf` and `rollback.bin`. + +The upstream parser reads the generated images back before success is reported. +This checks file construction and configuration roundtrip, not hardware +compatibility. No secure-boot key, fuse, customer signature or OTP setting is +modified by this helper. + +## Apply and roll back during physical testing + +This step is deliberately outside the host/VM acceptance run. Use the Pi's +maintenance environment and the official installed EEPROM utilities. Once the +reviewed files and saved original inputs are available there, the upstream +configuration interface is: + +```sh +sudo rpi-eeprom-config --apply ./configured.conf ./base.bin +``` + +The updater may program EEPROM immediately or schedule an update depending on +its platform and update method. Follow its output and verify the resulting +configuration/version after the required restart. To restore the supplied +configuration on the same supplied firmware: + +```sh +sudo rpi-eeprom-config --apply ./original.conf ./base.bin +``` + +If the firmware version itself changed, use the saved matching original firmware +for the rollback operation. Keep the maintenance SD and an external EEPROM rescue +route available; internal recovery cannot repair an EEPROM that prevents internal +boot. The official [EEPROM update and recovery guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#raspberry-pi-boot-eeprom) +describes that physical workflow. FDS has not yet tested it on your Pi. + +## Inputs, dependencies and software evidence + +The input lock is [inputs.json](../config/eeprom/inputs.json), pinned to +`raspberrypi/rpi-eeprom` commit `2fee426f27b6c54d3f5b6f36efd9a2fe1286a45d` and +Pi 5 preview firmware `pieeprom-2026-09-12.bin`. Every download is SHA-256 checked; +a changed cached input is rejected. The +[official source tool](https://github.com/raspberrypi/rpi-eeprom/blob/2fee426f27b6c54d3f5b6f36efd9a2fe1286a45d/rpi-eeprom-config) +is stored unchanged in the cache. Void's tracked source remains unchanged. + +The host workflow uses existing Python and curl. It does not add a target daemon, +package or Rust dependency. The upstream parser's optional signing dependencies +are not needed for configuration-only operations. + +`make eeprom-test` passed production/development roundtrips, unchanged firmware +payloads, repeatable binary output, refusal to overwrite an existing directory, +preservation of custom settings, exact rollback of conditional settings, and +rejection of malformed images, device nodes and oversized configuration files. +Initial evidence is `out/m12-eeprom.zi144zib/`, with log +`out/logs/m12-eeprom-check.log`. The complete local release acceptance is recorded in [M12 validation](m12-validation.md). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..2476797 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,232 @@ +# Your first build + +[Documentation index](README.md) · [Troubleshooting](troubleshooting.md) + +This walkthrough takes you from an FDS Git checkout to two verified ARM Linux +artifacts. It explains the output as you go. You do not need a Raspberry Pi. + +At the end you will have a small Rust program built with static musl and GNU hello +packaged for aarch64 glibc. This proves that the workstation can build the two +kinds of software FDS/OS needs. It does not produce a bootable OS image. + +## 1. Check the machine and checkout + +Use an x86_64 Arch Linux workstation as a normal user. Check it with: + +```sh +uname -m +cat /etc/os-release +id -u +``` + +Expect `x86_64`, `ID=arch`, and a user ID other than `0`. The bootstrap script +currently rejects other host platforms and root execution. + +Enter your FDS checkout. For the existing development workspace: + +```sh +cd /home/felis/source/fds +pwd +git status --short +``` + +On another workstation, substitute its checkout directory. It must contain +`Makefile`, `Cargo.toml`, `tools/`, `.gitmodules`, and Git metadata. Avoid spaces +in the path. There is no configured public remote in this workspace; obtain a +Git checkout from the project owner rather than using an invented clone URL. + +You need HTTPS access to GitHub, Void repositories, Rust distribution servers, +and package source hosts. Initial downloads total hundreds of MB. Plan for +several GB of free disk space; the verified workstation used roughly 1.6 GB for +the Void masterdir and 455 MB for its hostdir cache, before Rustup and other +outputs. These are observations, not fixed requirements or a build-time promise. + +```sh +df -h . +``` + +## 2. Install host prerequisites + +On an otherwise maintained Arch installation, install the required packages: + +```sh +sudo pacman -S --needed bash coreutils binutils git curl make file tar xz gzip zstd \ + bubblewrap rustup ca-certificates findutils diffutils grep sed gawk util-linux +``` + +This is the host package installation step. Subsequent build commands run as your +normal user. The alternative `./tools/bootstrap-host --install-deps` runs the same +package installation and then continues bootstrap; choose one route. + +The [dependency reference](build-host.md#host-dependencies) explains why each +package is needed. This project uses Rustup to select its pinned Rust compiler, +not an arbitrary system Rust version. If pacman reports an existing Rust package +conflict, resolve the host package choice before continuing; do not force file +replacement. + +Check that bubblewrap can create a user namespace: + +```sh +bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true +``` + +Success produces no output and exits normally. If it fails, use +[Troubleshooting](troubleshooting.md#bubblewrap-or-user-namespace-failure). +This is a host capability requirement, not something a Pi can fix. + +## 3. Prepare the build environment + +From the repository root: + +```sh +make bootstrap +``` + +This performs these steps: + +1. Initializes `vendor/void-packages` at the recorded Git commit. +2. Downloads the pinned static XBPS archive, verifies its SHA-256, and extracts + the host tools into `.host/xbps/`. +3. Copies the checked-in Void build configuration into the local upstream checkout. +4. Installs Rust 1.98.0, rustfmt, and `aarch64-unknown-linux-musl` through Rustup. +5. Fetches the locked workspace crates needed for Cargo to resolve the Dasung member, + including when building the dependency-free smoketest offline. +6. Creates the x86_64 glibc Void build container and verifies that GCC runs inside it. + +The Void build container supplies compilers and package tools; it is not the +future FDS root filesystem. Rustup changes your user toolchain installation. +XBPS host tools stay inside this repository, and no Pi storage is written. + +The final success marker is: + +```text +PASS: M0 host bootstrap; run make smoke-test to build both aarch64 artifacts +``` + +The command saves its output in `out/logs/bootstrap.log`. Re-running bootstrap +reuses the checkout and downloads, but still checks their pins and integrity. +If it exits nonzero, stop here and resolve that failure before running smoke-test. + +## 4. Cross-build both artifacts + +```sh +make smoke-test +``` + +First Cargo builds `rust/fds-smoketest` for ARM, using the static musl libraries +and linker bundled with the Rust toolchain. Then xbps-src cross-compiles GNU +hello and packages it for aarch64 glibc. The first package build downloads the +cross compiler, target libraries, and build dependencies such as texinfo. + +The script inspects actual ELF files and local package metadata. It checks: + +| Artifact | Required result | +| --- | --- | +| Rust smoketest | AArch64, static linkage, no dynamic interpreter or shared library requirement | +| GNU hello in the XBPS package | AArch64, glibc loader and `libc.so.6` dependency | + +A successful run contains these messages; other compiler output appears between them: + +```text +PASS: aarch64 static ELF +PASS: aarch64 glibc ELF +PASS: XBPS package hello-2.12.3_1 architecture=aarch64 (glibc) +PASS: M0 smoke test complete +``` + +Without QEMU, it also reports: + +```text +SKIP: ARM execution (optional qemu-aarch64 not installed); ELF verification passed +``` + +That skip means the ARM program was compiled and inspected, but not executed. +It does not hide a failed compiler or ELF check. If QEMU is installed, execution +must pass; an emulator failure is a real smoke-test failure. + +## 5. Run the protection and formatting checks + +```sh +make check +``` + +Run this after smoke-test: it expects the built Rust executable to exist. It +checks that bad inputs are rejected, that the Void pin and upstream files are +protected, that overlays cannot replace upstream packages, and that Rust source +is formatted. Several `PASS: rejects ...` lines are expected; they mean the +negative tests worked. The final commands include: + +```text +PASS: M0 guardrail checks complete +cargo fmt --all -- --check +``` + +Rustfmt is normally silent on success. `make check` does not rebuild the ARM +program. After a source change, rebuild first, as described in +[Development](development.md#edit-and-rebuild-the-rust-program). + +## 6. Inspect what you built + +```sh +ls -lh out/fds-smoketest out/packages/ +file out/fds-smoketest +./tools/verify-elf out/fds-smoketest aarch64 static +sha256sum -c out/manifests/artifacts.sha256 +``` + +`file` should include `ARM aarch64` and `statically linked` (or `static-pie linked`). +The checksum command should report `OK` for both artifacts. Hashes verify the +files against this run's manifest; they are not release signatures. + +For ELF details, use: + +```sh +readelf -hW out/fds-smoketest +readelf -lW out/fds-smoketest +readelf -dW out/fds-smoketest +``` + +The header names AArch64. There should be no `INTERP` segment or `NEEDED` library. +An x86_64 host's `ldd` may say `not a dynamic executable` and return 1; that message +is expected here, but is insufficient by itself to prove a foreign ELF is static. +The [package guide](packages.md#inspect-the-built-package) walks through the +corresponding inspection of GNU hello. + +## 7. Optionally execute the ARM Rust program + +The [Arch qemu-user package](https://archlinux.org/packages/extra/x86_64/qemu-user/) +provides user-mode emulation. Install it if you want to execute the ARM program +on the x86_64 workstation: + +```sh +sudo pacman -S --needed qemu-user +qemu-aarch64 out/fds-smoketest +``` + +Expected output: + +```text +FDS/OS M0: aarch64 static-musl OK +``` + +Because this executable is static, this command needs no ARM rootfs or glibc +sysroot. Explicitly invoking `qemu-aarch64` also avoids needing automatic binfmt +registration. Re-running `make smoke-test` records its own QEMU check when the +emulator is on PATH. + +This executes one Linux userspace program. It does not emulate the Pi's firmware, +USB bays, display, battery, or operating-system boot. QEMU execution was not +performed during the initial M0 validation; see the [recorded results](m0-validation.md). + +## 8. Decide what to do next + +- To build the configured OS filesystem and try its ARM programs, follow [Rootfs](rootfs.md): `make rootfs PROFILE=cli`, then `make rootfs-test`. +- To boot native s6 and open a development console, follow [Native init and ARM VM](init.md): `make init-test`, then `make vm`. +- To build the included Dasung monitor package, follow [Dasung](dasung.md). +- To edit the existing Rust program, follow [Development](development.md). +- To understand the `.xbps` artifact, follow [Packages](packages.md). +- To understand the intended removable operating system, read [Architecture](architecture.md) and [Cartridges](cartridges.md). +- To see when bootable images become possible, read [Roadmap](roadmap.md). + +Bootstrap, smoke-test, and check complete the M0 foundation. Continue with the +M1 guide for a configured rootfs archive. There is still no flashing step. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..710d48f --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,52 @@ +# Glossary + +[Documentation index](README.md) + +| Term | Meaning in this repository | +| --- | --- | +| **M0** | Milestone zero: prepare and verify the workstation's cross-build environment. M1 adds the rootfs; M2 adds native init and ARM VM boot. | +| **Host / build workstation** | The x86_64 Arch Linux computer running the build. | +| **Target** | The platform being built for: AArch64 Linux on the Pi. | +| **x86_64** | The processor architecture of the supported build workstation. | +| **AArch64 / aarch64 / arm64** | Names commonly used for 64-bit ARM. Void uses `aarch64` for its glibc package target. | +| **Cross-compilation** | Compiling on one platform to produce code for another. An x86_64 build can produce an ARM executable that does not run directly on its host. | +| **Toolchain** | Compiler, linker, and related tools used to build software. | +| **Sysroot** | Target headers and libraries made available to a cross compiler. It is not necessarily a bootable filesystem. | +| **ABI** | Application binary interface: the machine-level conventions that compiled programs and libraries must agree on. Matching CPU architecture alone is insufficient. | +| **libc** | The C runtime library. FDS uses glibc for conventional packages and musl inside its static Rust tools. | +| **glibc** | GNU's C library, used by the main FDS userspace and Void `aarch64` packages. | +| **musl** | An alternative C library used for static FDS-owned programs. It does not replace the system's glibc package set. | +| **Static linking** | Including needed library code in an executable at build time, so it does not need those shared libraries at startup. It still needs the operating system/kernel. | +| **Dynamic linking / loader** | Loading shared libraries when a program starts. The ARM glibc loader is `/lib/ld-linux-aarch64.so.1`. | +| **ELF** | The executable/library file format inspected by `file` and `readelf`. The validator checks architecture and runtime requirements. | +| **Rust target triple** | A name such as `aarch64-unknown-linux-musl` specifying the compiler's target platform and library environment. | +| **Cargo / Rustup** | Cargo builds Rust projects; Rustup installs/selects Rust toolchains and target components. | +| **QEMU user mode** | Executes a foreign-architecture Linux program on the host. It does not boot or emulate the entire Pi in the M0 example. | +| **XBPS** | Void's package-management tools and package format, reused by FDS. It is separate from Arch's pacman. | +| **xbps-src** | Void's source-package build system. It creates packages using recipes called templates. | +| **Template** | An xbps-src package recipe describing source, dependencies, build actions, and packaging. | +| **Submodule / gitlink** | A separate Git repository embedded at a path; the parent records the exact upstream commit it expects. | +| **Pin** | An explicit version, commit, or digest chosen for an input. A source pin alone does not freeze every downloaded dependency. | +| **Overlay** | An FDS-owned package directory copied into the build tree without modifying tracked upstream packages. | +| **masterdir** | The generated Void build-container filesystem under `vendor/void-packages/masterdir-x86_64/`. This is not the target OS root. | +| **hostdir** | xbps-src's generated cache/output directory under `vendor/void-packages/hostdir/`. | +| **bubblewrap / bwrap** | The host tool used to isolate the build environment with Linux namespaces and mounts. No target init system runs inside this M0 container. | +| **Manifest** | A generated record of package versions or file checksums used to inspect a particular build. | +| **Rootfs** | The root filesystem used by a running OS, including programs, libraries, and configuration. `make rootfs` builds its archive. | +| **Initramfs** | A small early userspace filesystem loaded with the kernel, used before switching to the main root filesystem. | +| **PID 1 / init** | The first userspace process and the foundation of system startup, supervision, and shutdown. FDS uses native s6. | +| **s6 / s6-rc** | FDS's process-supervision and service-dependency tools. The service database is compiled while building the image. | +| **EROFS** | The read-only filesystem format used for SYSTEM and recovery images. | +| **tmpfs** | A memory-backed filesystem for temporary state. Its contents do not persist through power loss. | +| **GPT / PARTNAME** | Disk partition metadata and a partition's name. Stage0 discovers SYSTEM using `FDS_SYSTEM`, rather than a changing `/dev/sdX` name. | +| **NVMe** | The internal solid-state storage interface/device. The FDS internal image holds boot, recovery, and machine configuration. | +| **SYSTEM / DATA** | Cartridge roles: operating-system image and persistent user files, respectively. | +| **PROGRAM / ENVIRONMENT** | Cartridge roles: application payload and activation/configuration of a known environment. | +| **Bay topology** | Mapping a physical USB hub/port path to the user-facing BAY 01–12 labels. | +| **Hotplug** | Inserting or removing a device while the machine is running. Detection does not by itself make removal safe. | +| **sysfs / netlink** | Linux interfaces used by FDS to inspect devices and receive cartridge events. | +| **Profile** | A known OS configuration or service selection activated by a cartridge. It is not an arbitrary root script supplied by media. | +| **Safe eject** | Stopping users of a cartridge, flushing pending writes, unmounting it, then reporting it safe to remove. Use `fds eject BAY`. | + +For how these pieces fit together, read [Architecture](architecture.md). For +commands that currently work, follow [Your first build](getting-started.md). diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..99e8d49 --- /dev/null +++ b/docs/implementation-status.md @@ -0,0 +1,134 @@ +# Implementation through M12 + +This is the software acceptance ledger for the 67 sections of the +[master plan](master-plan.md). The user authorized implementation through M12 +and deferred tests requiring the physical Raspberry Pi or attached hardware. +The current release checkpoint is recorded in [M12 validation](m12-validation.md). + +**M0–M12 software acceptance is complete.** The signed local 0.1.0 release +passed two fresh network-isolated builds with all 27 artifacts identical. +Those artifacts also match the complete post-Clap VM acceptance. Signature +verification and actual source/input archive round-trips passed; see +[final release evidence](m12-validation.md#final-local-release-acceptance). + +The final command-line migration uses shared, locked Clap for every Rust +command-line interface, including the VM helpers. Its 48 Rust tests, actual ARM +CLI checks and complete runtime acceptance are recorded in +[Clap validation](clap-validation.md). The dependency-free smoketest has no +argument interface. Kernel command-line files and cartridge metadata use their +own strict format parsers; they are not process argument parsers. + +## Workstation software and emulator extension + +Current sources extend the frozen 0.1.0 checkpoint with native Linux +`fds-cartridge` and `fds-emulator` tools. The software format uses metadata plus +`m` payload partitions containing xz tarballs. Software builds and complete +image creation run on the workstation; the guest verifies, extracts and runs +selected commands as UID 1000. See [usage](workstation.md), +[format](software-format.md), and [current acceptance](workstation-validation.md). +Historical M0–M12 and Clap reports describe their recorded artifacts; they do +not by themselves validate these new sources. + +## Requirement coverage + +“Verified” below means software, artifact or isolated ARM VM evidence. It does +not mean the assembled Pi has been tested. The detailed milestone reports retain +the commands, artifacts, failures that led to fixes, and measured results. + +| Section | Implementation and evidence | Physical or later boundary | +| --- | --- | --- | +| 1. Project objective | Pi 5 images, all cartridge classes, native s6, Dasung base support and 12-device virtual stress; [overview](../README.md), [stress evidence](m11-validation.md) | Assembled FP-85, battery, display and all physical bays deferred | +| 2. ABI strategy | Every profile uses aarch64 glibc packages; all eight production Rust programs are static ARM musl; rootfs and ELF audits pass | Actual Pi execution deferred | +| 3. Limited musl scope | No target musl runtime package or second general-purpose package repository; glibc compiler/desktop acceptance passes | None in software scope | +| 4. Native init | s6-linux-init hands PID 1 to s6-svscan; s6-rc database compiled during image construction; [native init evidence](m2-validation.md) | Pi startup timing deferred | +| 5. Packages | Nine FDS base packages selected and audited; forbidden init/runtime packages rejected; [packages](packages.md) | None in software scope | +| 6. Build host | x86_64 Arch bootstrap, glibc cross-package and static Rust checks; [build host](build-host.md) | Other host distributions are not validated | +| 7. Repository layout | Complete build/runtime components and public interfaces; location choices documented below | No placeholder component claims | +| 8. Void upstream | Pinned commit, unchanged tracked upstream files, protected overlays, exact frozen package inputs; [offline builds](reproducible-builds.md) | Normal online bootstrap still uses rolling repositories | +| 9. Rust workspace | All required tools and aliases built; release/static settings and shared dependencies; [tooling](tooling.md) | None in software scope | +| 10. Dependencies | Locked synchronous dependencies, Clap policy and all 68 third-party crate notices; z/2/3 builds and repeated boot comparison | Physical optimization winner deferred | +| 11. Boot architecture | Pi kernel → static stage0 → read-only SYSTEM → native s6 and ordinary-user console; [boot evidence](m4-validation.md) | Firmware, RP1 and physical NVMe/USB handoff deferred | +| 12. Internal NVMe | BOOT/RECOVERY/INTERNAL GPT layout; root-private atomic settings and bounded diagnostic snapshots persist across VM reboot; [internal storage](internal-storage.md) | Actual NVMe and power-loss durability deferred | +| 13. SYSTEM | Named GPT partition and EROFS, strict manifest/architecture checks, immutable update by replacement; [media evidence](m9-validation.md) | Physical cartridge write/boot deferred | +| 14. Stage0 | Direct Linux/sysfs/netlink discovery, mount and root handoff; mandatory early Dasung process; nine-case real-kernel VM matrix | Physical display keepalive/recovery deferred | +| 15. Missing SYSTEM | Explicit wait state, event-driven insertion/resume and independent recovery verified | Physical insertion latency deferred | +| 16. Multiple SYSTEMs | Ambiguity enters recovery instead of choosing enumeration order; explicit recovery verified | Interactive selector is a later-version option | +| 17. Kernel | Pinned Pi kernel package, built-in critical driver delta verified after Kconfig resolution, modules/DTBs packaged | Actual RP1, PCIe, graphics and USB support deferred | +| 18. Initramfs | Minimal static early userspace; raw/gzip/lz4/zstd construction, extraction and boot checks | Pi decompression/timing comparison deferred | +| 19. Firmware | Production/development EEPROM previews, exact readback and rollback preparation; [EEPROM](eeprom.md) | No EEPROM was read from or applied to a physical Pi | +| 20. Filesystems | SYSTEM stays read-only; volatile logs, temporary directories and state; root ownership is explicitly root:root 0755 | Physical persistence/durability deferred | +| 21. User data | Persistent DATA at `/data`, temporary `/home/fds`, unprivileged sessions; [DATA evidence](m7-validation.md) | Home persistence is not claimed | +| 22. Service graph | Native boot bundle includes console, devices, cartridge and mandatory Dasung services; optional bundles excluded | Hardware service behavior deferred | +| 23. Fast boot | Console depends on runtime filesystem/hostname, not cartridge scan, DHCP or desktop; blocked-service VM tests pass | Pi timing targets deferred | +| 24. eudev | Native supervision and trigger without settle barriers; console opens with eudev blocked | Physical hotplug races deferred | +| 25. Cartridge daemon | Static event-driven daemon, private metadata probes, bounded IPC, mount lifecycle and restart behavior; [cartridge evidence](m6-validation.md) | Real device-controller behavior deferred | +| 26. Bay topology | Controller/protocol/port identity, strict map validation, 12 virtual bays independent of block/bus enumeration | User must calibrate the physical wiring | +| 27. Metadata | Strict bounded TOML, six class values, architecture and payload validation, unknown fields rejected | None in software scope | +| 28. Hardware cartridges | Filesystem-free VID/PID/class catalog and input/Ethernet recognition; no catalog autorun | Catalog starts empty until physical devices are identified | +| 29. ENVIRONMENT | Validated declarative activation of the trusted WindowMaker profile | GUI runtime on the cartridge itself is a later-version option | +| 30. WindowMaker | SYSTEM includes Xorg, WindowMaker, packaged caches/fonts; authenticated X11 and transitions verified; [desktop evidence](m8-validation.md) | Real graphics/input path deferred | +| 31. E-Ink | White/black/grayscale profile, Terminus and reduced decorations/animations; mandatory Dasung in base and early boot | Picture quality, refresh and power-cycle recovery deferred | +| 32. PROGRAM | Metadata-first GPT software bundles plus legacy reading; xz verification, read-only runtime caches and UID 1000 consumers; see [current evidence](workstation-validation.md) | Physical application cartridge behavior deferred | +| 33. CLI | Complete typed Clap command hierarchy, aliases, nested JSON, help/errors and unchanged child-argument forwarding; [usage](tooling.md) | None in software scope | +| 34. Media writer | Static create/inspect/burn/eject, insertion-bound confirmation, protected disks and full readback; actual virtual write then boot | Physical throughput, cache and power-loss behavior deferred | +| 35. Installation model | Immutable SYSTEM images contain packages; replacement cartridge provides update/rollback | Physical swap/boot deferred | +| 36. Profiles | CLI, development and independent recovery rootfs/images; profile-specific package audits and runtime checks | None in software scope | +| 37. Build interface | Complete `make all`, packages, kernel, initramfs, rootfs, SYSTEM, boot and internal outputs; [offline builds](reproducible-builds.md) | No command silently flashes hardware | +| 38. Cross compilation | Static Rust, glibc XBPS and Pi kernel cross-built on x86_64; generated target files inspected/executed under emulation | Native Pi build performance deferred | +| 39. QEMU | Packaged ARM OS, real kernel, disposable USB/NVMe media and serial evidence across all runtime suites | QEMU does not emulate the Pi firmware/display/electrical system | +| 40. Boot targets | Actual CLOCK_BOOTTIME reports and repeated comparisons; [performance](performance.md) | No physical cold-boot target is claimed | +| 41. Boottrace | T0–T6 event model, first-console event retention, reports and persisted diagnostic exports | Firmware timing and physical display events deferred | +| 42. Regression rule | +100 ms investigation threshold, alternating before/after boots and z/2/3 comparison; [Clap measurements](clap-validation.md) | VM samples are not Pi performance claims | +| 43. Boot blacklist | No systemd, runit, BusyBox, shell PID 1, boot-time cache compilation, settle or sleep-based readiness barriers | Dasung protocol pacing/backoff is not a console readiness barrier | +| 44. Network | Ethernet recognition and on-demand native DHCP bundle; console remains usable without DHCP | Physical adapter/link testing deferred | +| 45. Logging | Runtime logs stay volatile; explicit bounded diagnostics can be stored on internal media or exported; [recovery](recovery.md) | Physical storage durability deferred | +| 46. Shutdown | Ordered desktop/jobs/network/DATA/services stop with native halt/reboot; busy and writeback-error paths remain powered on; [power evidence](m10-validation.md) | Real power removal deferred | +| 47. Shutdown timing | Idle/workload/error-path VM measurements retained without artificial waits | Sub-second Pi/battery/flash targets deferred | +| 48. Eject | Consumers stopped, DATA syncfs/unmount before SAFE, quarantine across failure/restart and insertion identity checks | Physical LEDs are optional later work; electrical/cache durability deferred | +| 49. Recovery | Independent EROFS boots without usable SYSTEM, read-only DATA defaults, root-confirmed repair, media replacement and diagnostics; [M12 evidence](m12-validation.md) | Physical recovery/EEPROM path deferred | +| 50. Security | No cartridge root autorun; unprivileged managed applications, strict metadata/IPC, protected media operations and tested failure paths | Distribution signatures do not implement secure boot | +| 51. Initial UI | Direct ordinary-user FDS console without artificial delays, display manager, splash or automatic desktop | Physical presentation/input latency deferred | +| 52. Development | Native GCC/C++, Rust/Cargo, Make/CMake/Meson/Ninja, Git, GDB, strace, Vim and display tools execute in the development image | Optional example tools such as LLVM are not mandatory; Pi throughput deferred | +| 53. M0 | [Host validation](m0-validation.md), repeated after Clap | Optional host `qemu-aarch64` smoke execution skipped; separate ARM execution suites pass | +| 54. M1 | [Rootfs validation](m1-validation.md), all three current profile audits | Physical boot deferred | +| 55. M2 | [Native s6 validation](m2-validation.md), repeated after Clap | Physical Dasung behavior deferred | +| 56. M3 | [Tooling acceptance](tooling.md), complete Clap migration and static ARM tests | None in software scope | +| 57. M4 | [Boot matrix](m4-validation.md), repeated after Clap | Physical Pi boot deferred | +| 58. M5 | [Console/performance evidence](m5-validation.md), current repeated Clap comparison | Physical speed targets deferred | +| 59. M6 | [Cartridge acceptance](m6-validation.md), repeated after Clap | Real bay calibration deferred | +| 60. M7 | [DATA acceptance](m7-validation.md), repeated after Clap | Physical storage durability deferred | +| 61. M8 | [Desktop/network acceptance](m8-validation.md), repeated after Clap | Physical display/input/link behavior deferred | +| 62. M9 | [Media acceptance](m9-validation.md), repeated after Clap | Physical write/readback deferred | +| 63. M10 | [Shutdown acceptance](m10-validation.md), repeated after Clap | Physical power/timing deferred | +| 64. M11 | [Twelve-device stress and fault injection](m11-validation.md), repeated after Clap | Simultaneous physical devices and electrical tests deferred | +| 65. M12 | [Final release evidence](m12-validation.md), signatures, input lock, independent offline builds, recovery, internal settings and guides | Hardware qualification and publication are separate actions | +| 66. Implementation rules | Buildable milestones, justified dependencies, strict ABI/init policy, stable topology and measured regressions | No fabricated physical benchmarks | +| 67. First task | Original M0 setup/negative checks completed and repeated; complete scope explicitly authorized afterward | Historical M0-only gate was superseded by the user's through-M12 request | + +## Interface and policy decisions + +| Design sketch | Implemented interface | +| --- | --- | +| `image/build-rootfs` | `tools/build-rootfs`, exposed by `make rootfs` | +| `tools/qemu-test` | `make init-test` for native init and `make boot-test` for the Pi-kernel/stage0 matrix | +| `tools/burn` | Packaged static `fds-burn` and `fds burn` | +| `profiles/cli.toml`, `development.toml`, `recovery.toml` | `profiles/*.list` select packages; immutable profile marker selects recovery policy | +| `profiles/windowmaker.toml` | Built-in `windowmaker` runtime profile with a native s6 desktop bundle | + +DATA uses `/data`, one of the two alternatives permitted by section 21. +`/home/fds` remains temporary, avoiding replacement of a live shell's home on +insertion/eject. Internal boot reports and cartridge inventories are explicit +diagnostic snapshots, not authoritative live state or automatic boot writes. +An interactive multi-SYSTEM selector, moving GUI runtime onto ENVIRONMENT media, +and optional eject LEDs remain later-version options in the specification. + +## Hardware work intentionally deferred + +The next acceptance phase needs the assembled computer: EEPROM backup/application +and rollback, NVMe-to-USB boot, RP1/PCIe/USB behavior, actual bay calibration and +all twelve devices, display picture/refresh/cold-power recovery, removable battery +and power behavior, physical storage durability, cold boot and shutdown timing. +Use [stress testing](stress-testing.md), [Dasung](dasung.md), [EEPROM](eeprom.md), +[internal installation](internal-storage.md) and [performance](performance.md) +to collect that evidence. No physical target disk was flashed, and no Pi EEPROM +or monitor was changed by the software acceptance work. diff --git a/docs/init.md b/docs/init.md new file mode 100644 index 0000000..677f0b1 --- /dev/null +++ b/docs/init.md @@ -0,0 +1,187 @@ +# M2: native s6 init and the ARM development VM + +[Documentation index](README.md) · [Rootfs build](rootfs.md) · [Services](services.md) + +M2 adds the system's real init path. The kernel enters the generated execline +launcher at `/sbin/init`; that immediately executes `s6-linux-init`, which prepares +`/run` and replaces itself with `s6-svscan`. The supervisor remains +PID 1. Bash is used for some stage-2 configuration tasks, as an ordinary child +process. No shell loop, systemd, runit, or BusyBox owns PID 1. + +You can boot and inspect this system on the x86_64 workstation using a complete +ARM QEMU machine. This is a generic development VM, not a Raspberry Pi image. +It does not test Pi firmware, a cartridge boot path, or the physical monitor. + +## Build and verify + +First complete the [build-host setup](getting-started.md) and the +[rootfs prerequisites](rootfs.md#1-prepare-the-workstation). M2 additionally uses +the Arch `e2fsprogs` and `libarchive` packages to create an ext4 VM disk directly +from the tar, without mounting it or requiring root: + +```sh +sudo pacman -S --needed e2fsprogs libarchive +make rootfs PROFILE=cli +make rootfs-test +make init-test +``` + +`make init-test` installs `qemu-system-aarch64` inside the project-local Void build +container when missing. Void packages its emulator with display, audio, and other +libraries, even though this test uses only a serial console. Those dependencies +stay in the build container; they do not become target packages or host services. + +The test also downloads a signed generic ARM kernel, pinned by version and SHA-256 +in `config/vm-test.conf`. Its AHCI, ext4, serial console and PCI drivers are built +in and devtmpfs is automounted by the kernel, so no test initramfs or replacement stage 0 is needed. The kernel package is +approximately 161 MB; only its boot files are extracted. Its module collection is +not installed into FDS. The separate [M4 boot test](boot.md) uses the actual +FDS Pi kernel and its initramfs-to-EROFS handoff. + +The VM uses software CPU emulation, 1 GiB of RAM, two virtual CPUs, no network, +and no host USB, display, filesystem sharing, or block-device passthrough. +Its root filesystem is mounted read-only. QEMU discards any block changes in a +temporary disk overlay, preserving the generated test disk. + +Successful output includes: + +```text +FDS_M2_PID1: /usr/bin/s6-svscan +FDS_M2_FILESYSTEM: read-only root, writable tmpfs, root ownership and capabilities +FDS_M2_BOOT: getty, eudev and Dasung supervised without a monitor +FDS_M2_SERVICE: s6-rc start, echo, supervised restart, stop and start passed +FDS_M2_PASS +PASS: M2 ARM boot, native s6 PID 1, service control, and orderly power-off +``` + +The test must finish within 180 seconds. This is a failure deadline, not a boot +performance target. A panic, failed assertion, missing marker, timeout, or missing +power-off message makes the command fail. Retained logs explain the result. + +## Open a real booted ARM shell + +After `make init-test` succeeds: + +```sh +make vm +``` + +The same guest checks run, then the VM opens a temporary root shell on its serial +console. Try: + +```sh +readlink /proc/1/exe +findmnt / +findmnt /run +s6-rc -l /run/s6-rc -a list +s6-svstat /run/service/dasungd +dasungd status +``` + +The rootfs's ordinary getty still requires authentication and root remains locked. +The VM hook stops getty before opening its shell so they do not compete for the +serial console; `s6-svstat` therefore reports getty down during that shell session. +The password-free shell exists only in the disposable VM test overlay, when +`make vm` explicitly requests it. The checker and shell hook do not ship in the +normal rootfs archive. Type `exit` to request orderly VM power-off. Ctrl+C stops +the emulator immediately if the guest is stuck; it does not shut down your host. + +The shell's `/run`, `/tmp`, `/var/tmp`, and `/var/log` writes disappear at power-off. +Changes to `/etc` or `/usr` fail because SYSTEM is read-only. Rebuild the rootfs +and rerun `make init-test` after changing packages or init configuration; `make vm` +otherwise boots the previous successful test disk. + +## Control the test service + +The `test-echo` service is an actual Unix-socket echo server, supervised by s6. +It is included in the service database but excluded from the normal boot bundle. +In the VM shell: + +```sh +s6-rc -l /run/s6-rc -u change test-echo +s6-svstat -o up,ready,pid /run/service/test-echo +printf 'hello from ARM\n' | s6-ipcclient /run/fds-test/echo.sock s6-ioconnect -t 3000 +s6-svc -wR -T 5000 -r /run/service/test-echo +s6-rc -l /run/s6-rc -d change test-echo +``` + +Its socket is root-only, under `/run/fds-test` with mode 0700. The server sends an +actual readiness notification when it is listening. The automated test checks +the reply, confirms the PID changes on restart, stops the service, and starts it +again. No arbitrary boot delay substitutes for readiness. + +## What starts at boot + +| Component | Role and dependencies | +| --- | --- | +| Native stage 1 | Consume the kernel-provided `/dev`, mount `/run`; start s6 supervision and the catch-all logger | +| `runtime-fs` | Mount proc, sysfs, devpts and volatile temporary/log filesystems | +| `hostname` | Apply the image's `/etc/hostname` | +| `console` | Create volatile login accounting files after runtime mounts | +| `getty` | Offer a login on `/dev/console`, after console and hostname setup | +| `eudevd` | Process device events; readiness requires a successful control request | +| `udev-trigger` | Request initial subsystem and device events after eudev readiness | +| `dasungd-runtime`, `dasungd-log`, `dasungd` | Prepare private state/log paths and supervise the base monitor controller | + +Console startup does not depend on eudev enumeration, a monitor connection, the +network, or a cartridge scan. No global `udevadm settle` is used. The eudev package +has no native s6 notification, so `s6-notifyoncheck` performs a bounded check of +its control channel. It signals readiness only after a successful response. + +Cartridge discovery, the FDS CLI, desktop activation and on-demand Ethernet were +added after the original M2 milestone. The current image includes their real +implementations; see [Services](services.md), [Cartridges](cartridges.md) and +[Desktop](desktop.md). This page focuses on the native init mechanism and its +generic test fixture. SSH remains outside the default boot bundle. + +## Files and build-time work + +| Location | Meaning | +| --- | --- | +| `packages/fds-init/` | Init skeleton, runtime mount/accounting helpers, and package template | +| `s6/source/` | Complete current service graph, split between init, Dasung and cartridge/desktop packages | +| `/etc/s6-linux-init/current/` | Generated native init environment and stage scripts in the image | +| `/etc/s6-rc/compiled/` | Compiled service database, produced during image construction | +| `/run/service/`, `/run/s6-rc/` | Writable supervision and live service state | +| `/run/uncaught-logs/` | Catch-all service log, also copied to the console | +| `/run/log/dasungd/` | Rotating monitor-controller log | +| `out/m2-vm-latest/serial.log` | Last successful full ARM boot test | +| `out/m2-vm-latest/rootfs.ext4` | Disposable test disk; not a Pi or SYSTEM cartridge image | + +`s6-linux-init-maker` and `s6-rc-compile` run during the rootfs build under ARM +emulation. Boot only reads their output. The VM test preserves that exact compiled +database. Its only init hook runs the original stage-2 script to completion, then +runs the checker as a child process. Normal `out/rootfs-aarch64.tar` contains +neither the checker nor the development-shell hook. + +The s6-generated template contains FIFOs. Archive validation permits them only +inside its `run-image` directory; those templates are copied into writable `/run` +at boot. Before entering `/sbin/init`, the kernel or later stage 0 must provide +a mounted devtmpfs at `/dev`. Stage 1 deliberately does not mount it twice. +Services never place their live FIFOs or status files on read-only SYSTEM. + +M10 adds cartridge-aware preparation before s6-rc stops the remaining services. +Its native shutdown hook stays alive if DATA cannot be verified and unmounted. +`s6-linux-init-shutdownd` still performs final process cleanup and the kernel +power action. See [Shutdown and reboot](power.md) for usage, blocked-request +recovery, and the current M10 acceptance status. + +## Troubleshooting + +- **`make vm` asks you to run `make init-test`:** no successful VM disk has been + published yet. Build the rootfs, then run the test. +- **Kernel pin/hash failure:** do not substitute an unverified kernel. The exact + configured package must remain available or be deliberately updated and retested. +- **Disk creation reports missing tar support:** use an `e2fsprogs` build with + libarchive support and install `libarchive`. No loop mount or sudo fallback is used. +- **No guest success marker:** read `out/logs/init-checks.log` and the printed + `out/m2-vm.*` directory's `serial.log`. Failed test workspaces are retained. +- **Root login is denied on the ordinary getty:** this is expected with the locked + base accounts. Use the explicit development VM shell, not a default password. +- **Dasung says disconnected:** the VM intentionally has no physical monitor. + Use [Dasung integration](dasung.md) for its eventual hardware validation. + +Upstream references: [s6-linux-init](https://skarnet.org/software/s6-linux-init/overview.html), +[init generation](https://skarnet.org/software/s6-linux-init/s6-linux-init-maker.html), +[s6-rc initialization](https://skarnet.org/software/s6-rc/s6-rc-init.html), and +[ext4 creation](https://man7.org/linux/man-pages/man8/mke2fs.8.html). diff --git a/docs/internal-storage.md b/docs/internal-storage.md new file mode 100644 index 0000000..92c2cd4 --- /dev/null +++ b/docs/internal-storage.md @@ -0,0 +1,273 @@ +# Internal storage and machine settings + +[Documentation index](README.md) · [Boot images](boot.md) · [Recovery](recovery.md) + +The internal NVMe supplies the Pi's firmware boot files, independent recovery, +and machine settings. SYSTEM remains a separate removable cartridge. Ordinary +user files belong on DATA, not internal storage. No physical disk is written by +any build command below; Pi/NVMe boot and power-loss tests remain deferred. + +## Build the complete disk image + +Build the component images first, in this order: + +```sh +make kernel +make initramfs +make boot-volume BOOT_MODE=production +make recovery +make internal-image +``` + +The result is `out/fds-internal.img`, a **complete GPT disk image**. Its versioned +build directory contains `layout.json`, source payload checksums, and readback +verification. This differs from `out/fds-boot.img` (a raw FAT partition) and +`out/fds-recovery.img` (a raw EROFS partition). + +| Partition | Format | Default allocation | Purpose | +| --- | --- | --- | --- | +| 1: FDS_BOOT | FAT32, EFI system type | 512 MiB | Pi firmware, kernel, DTBs, configuration and initramfs | +| 2: FDS_RECOVERY | EROFS | 1 GiB | Complete independent maintenance system | +| 3: FDS_INTERNAL | ext4 | 256 MiB | Machine settings and explicitly saved diagnostics | + +The builder pads the recovery partition without changing its EROFS contents, +aligns partitions to 1 MiB, writes both GPT copies, verifies each payload, and +checks the table independently with `sfdisk`. Internal ext4 is fully initialized +before deployment. It contains root-owned `config/` and private `diagnostics/`. +Unused capacity beyond this initial layout is not automatically expanded. + +For a 2 GiB recovery allocation or larger settings partition: + +```sh +./image/build-internal --recovery-mib 2048 --internal-mib 512 +``` + +Output directories supplied with `--output-directory` must already exist and be +empty. The builder accepts ordinary image files, never a host block-device +output. Final hardware provisioning and identity checks belong to the physical +acceptance procedure; do not confuse a partition payload with the complete disk. + +## Install the internal disk when hardware is available + +This is a **deferred physical procedure**. The image and virtual NVMe workflow +are software-tested; writing and booting the user's actual NVMe still require +the hardware. Installation erases the selected disk, including any existing +machine settings. Retain backups before replacing an existing installation. + +Use an NVMe enclosure or another Linux machine that can access the target drive +while the Pi is off. First verify a downloaded release using a separately trusted +public key as described in [Release signatures](releases.md). Its complete disk +is named `fds-internal-0.1.0.img`; a local build uses `out/fds-internal.img`. +Use the complete disk image, not the separate BOOT or RECOVERY payload. + +List disks before and after connecting the intended drive: + +```sh +lsblk -d -o NAME,PATH,MODEL,SERIAL,SIZE,TRAN,LOG-SEC +ls -l /dev/disk/by-id/ +``` + +Match the physical model, serial and capacity. Choose a persistent **whole-disk** +`/dev/disk/by-id/` path without a `-partN` suffix; never guess a `/dev/sdX` name. +Unmount every target partition and disable any swap on it. Do not select the +workstation's system disk. The current images require 512-byte logical sectors +and a disk at least as large as the image; 4 KiB logical-sector media is not +supported by this layout. + +Open Bash (`bash`), replace both paths below, then run the block. It checks the +selected disk again, requires a typed confirmation, writes and reads back the +complete image, and relocates the backup GPT when the disk is larger than the +image. It does not expand any partition or filesystem. + +```bash +( + set -euo pipefail + fds_image="$PWD/out/fds-internal.img" + fds_disk=/dev/disk/by-id/REPLACE_WITH_THE_TARGET_DISK + [[ -f "$fds_image" && -s "$fds_image" && -b "$fds_disk" ]] + [[ $(lsblk -dnro TYPE "$fds_disk") == disk ]] + [[ $(sudo blockdev --getss "$fds_disk") == 512 ]] + fds_bytes=$(stat -Lc %s "$fds_image") + (( $(sudo blockdev --getsize64 "$fds_disk") >= fds_bytes )) + lsblk -p -o NAME,TYPE,MODEL,SERIAL,SIZE,MOUNTPOINTS "$fds_disk" + if lsblk -nrpo MOUNTPOINTS "$fds_disk" | grep '[^[:space:]]' >/dev/null; then + echo 'Target has mounted filesystems or swap; stop and release them first.' >&2 + exit 1 + fi + if lsblk -nrpo TYPE "$fds_disk" | grep -Ev '^(disk|part)$' >/dev/null; then + echo 'Target has device-mapper or other active descendants; stop.' >&2 + exit 1 + fi + read -r -p "Type ERASE $fds_disk to erase this disk: " fds_confirmation + [[ "$fds_confirmation" == "ERASE $fds_disk" ]] + sudo dd if="$fds_image" of="$fds_disk" bs=4M conv=fsync status=progress + sudo blockdev --flushbufs "$fds_disk" + sudo cmp -n "$fds_bytes" "$fds_image" "$fds_disk" + sudo sfdisk --lock=yes --relocate gpt-bak-std "$fds_disk" + sudo sfdisk --verify "$fds_disk" + sudo blockdev --flushbufs "$fds_disk" +) +``` + +Every command must succeed. The byte comparison occurs before relocating GPT, +because relocation intentionally changes disk-table headers. GNU `dd`'s `fsync` +flushes output before it returns; `sfdisk`'s `gpt-bak-std` moves the backup header +to the end of the target. See the [GNU dd manual](https://www.gnu.org/s/coreutils/manual/html_node/dd-invocation.html) +and [sfdisk manual](https://man7.org/linux/man-pages/man8/sfdisk.8.html). +These commands use the already documented coreutils, diffutils and util-linux +host tools; they do not add a target daemon. + +Confirm that the disk shows `FDS_BOOT`, `FDS_RECOVERY` and `FDS_INTERNAL` in that +order using `lsblk -o NAME,PARTLABEL,FSTYPE,MOUNTPOINTS`. Unmount anything the +desktop automatically mounted, then safely disconnect the enclosure and install +the NVMe in the powered-off Pi. Follow [EEPROM preparation](eeprom.md) for the +separately reviewed NVMe boot settings. + +Prepare the **first SYSTEM cartridge on the workstation** using the same guarded +write block, with its two path assignments changed to the chosen SYSTEM image +and a different, empty USB cartridge disk. For a local build select +`out/fds-system-cli.img` or `out/fds-system-development.img`; release files are +`fds-system-cli-0.1.0.img` and `fds-system-development-0.1.0.img`. They are +alternative complete GPT images, each containing one `FDS_SYSTEM` partition. +Readback and backup-GPT relocation apply to this disk too. Check its partition +label, safely disconnect it, and insert exactly one SYSTEM cartridge in the Pi +before normal boot. Subsequent cartridge creation and updates can use FDS's +confirmed [media workflow](media-tools.md) after bay calibration. + +On the first physical boot, check `fds info`, `fds machine status`, and +`fds bays`. The supplied bay map is empty until calibration. Follow +[physical acceptance](stress-testing.md) to measure ports, test the Dasung +display, and record actual boot/shutdown behavior before relying on the machine. + +## Configure the machine before building + +Copy `config/machine/` to your own directory. It contains three files: + +- `machine.toml`: `format = 1` and a short human-readable `name`. +- `bays.toml`: the measured controller/port map described in + [Cartridges and bay calibration](cartridges.md). +- `hardware-catalog.toml`: optional USB identification names using the same schema + as the base catalog. Entries are data and cannot run commands. + +The supplied bay map is deliberately empty because the physical wiring has not +been measured. Do not invent Pi USB paths. USB 2 and USB 3 companion ports need +explicit aliases for the same bay. + +```sh +cp -a config/machine out/my-machine +# Edit the three files in out/my-machine using your editor. +make internal-image MACHINE_CONFIG=out/my-machine +``` + +The builder compiles a native host copy of `fds` and uses the same strict parser +as the target system. You can also validate or pack settings yourself: + +```sh +cargo build --locked --offline --release --target x86_64-unknown-linux-gnu -p fds-cli +./target/x86_64-unknown-linux-gnu/release/fds machine validate out/my-machine +./target/x86_64-unknown-linux-gnu/release/fds machine pack out/my-machine out/my-machine.json +``` + +The three source files become one atomic `config/machine.json` document on +FDS_INTERNAL. Names, lengths, bay aliases, catalog fields and unknown keys are +validated before use. Do not put passwords or private keys in this configuration: +its active snapshot is readable by the local FDS user. + +## What happens at boot + +The `machine-config` native s6 oneshot precedes `cartridged`. It does **not** +precede the console or Dasung controller. It accepts exactly one non-removable +NVMe disk with the three named partitions in the order above. USB lookalikes are +ignored; multiple eligible NVMe disks are rejected instead of choosing by name. + +The settings partition must be clean ext4 with the expected label. Loading uses +`ro,noload,nosuid,nodev,noexec` in a private mount namespace, validates the entire +settings bundle, copies it into `/run/fds/machine/`, and unmounts. No filesystem +repair, journal replay, cache compilation or persistent write occurs during +normal boot. The cartridge service uses that snapshot throughout this boot, +including after a service restart. + +If internal storage is missing, unclean, invalid or ambiguous, the service records +an explanation and uses the immutable image's `/etc/fds/` defaults. The console +still opens. Check the actual source before treating bays as calibrated: + +```sh +fds machine status +fds --json machine status +fds machine export /tmp/current-machine +``` + +Export creates a new directory with the three editable source files. It never +overwrites an existing directory. The status source is `internal_nvme` or +`image_defaults`. A temporarily unavailable NVMe is not adopted later in the same +boot; resolve the issue and reboot to load its settings. This prevents changing +bay identities underneath active cartridge operations. + +## Update settings from recovery + +Bring the edited source directory on a DATA cartridge. At the local root +`RECOVERY#` console, identify its bay with `fds bays`; healthy DATA is mounted +read-only under `/run/fds/media/NN`. For example, if it is in BAY 02: + +```sh +fds machine validate /run/fds/media/02/my-machine +fds machine install /run/fds/media/02/my-machine +fds reboot +``` + +Installation is restricted to root in the recovery image. It writes the complete +validated bundle atomically, saves the old bytes as `config/previous.json`, +flushes and unmounts the internal filesystem, and reports success only after +those steps. A reboot activates the new settings. The currently running bay map +is unchanged, so active media does not move to a different bay mid-operation. +Keep a copy of your previous source directory on DATA or the build host to +reinstall it if the new calibration is wrong. + +An invalid existing JSON document can be replaced this way. Wrong ownership, +symlinks, an unclean filesystem or a damaged directory require offline filesystem +maintenance first. Recovery does not automatically repair internal NVMe; its +`fds recovery repair` command is deliberately limited to DATA cartridges. + +## Save and retrieve diagnostics + +Logs and boot records stay in RAM by default. Root may explicitly save a file +of up to 16 MiB. Saved names cannot contain paths, and existing names are refused. +These operations mount internal ext4 only for the operation and then unmount it. +For example, from recovery: + +```sh +fds --json boot-profile >/tmp/boot.json +fds machine store boot-first.json /tmp/boot.json +fds --json bays >/tmp/cartridge-inventory.json +fds machine store cartridges-first.json /tmp/cartridge-inventory.json +bash /usr/share/fds/capture-hardware /tmp/hardware-capture +tar -C /tmp -czf /tmp/hardware-capture.tar.gz hardware-capture +fds machine store hardware-first.tar.gz /tmp/hardware-capture.tar.gz +fds machine fetch boot-first.json /tmp/retrieved-boot.json +``` + +Use distinct names for subsequent sessions. A failed flush or unmount is an +error, not a successful save. These are machine diagnostics; do not use this +facility as ordinary user storage. The saved cartridge inventory is a persistent +diagnostic snapshot, including metadata already inspected during this boot. +Retrieve it with `fds machine fetch cartridges-first.json /tmp/saved-inventory.json`. +The daemon's live metadata cache remains volatile and is rebuilt from currently +attached devices; saved snapshots are never used to authorize media actions. +Together, explicit boot reports, inventory snapshots and hardware captures provide +the boot history, cached metadata and diagnostics assigned to FDS_INTERNAL in +master-plan section 12, without adding internal writes to startup or shutdown. + +## Dependencies and validation + +No new target package or Rust crate is required. The host image-tool prefix adds +`e2fsprogs` for ext4 creation, inspection and validation; it already supplies FAT +and EROFS tools. A private unprivileged user namespace gives created files root +ownership without requiring a root build session. + +`make internal-test` exercises the actual packaged runtime in ARM VMs with +virtual NVMe. Acceptance evidence belongs in [M12 validation](m12-validation.md); +a passing VM does not verify the Pi EEPROM, PCIe path or physical flash durability. + +The Linux [ext4 mount documentation](https://www.kernel.org/doc/html/latest/admin-guide/ext4.html) +explains why read-only loading also disables journal replay. Filesystem creation +options follow the upstream [mke2fs manual](https://man7.org/linux/man-pages/man8/mke2fs.8.html). diff --git a/docs/m0-validation.md b/docs/m0-validation.md new file mode 100644 index 0000000..949d184 --- /dev/null +++ b/docs/m0-validation.md @@ -0,0 +1,191 @@ +# M0 validation report + +[Documentation index](README.md) · [First build](getting-started.md) · [Roadmap](roadmap.md) + +This is the initial acceptance record, not a setup tutorial. Later documentation +improvements do not change the build results or skips recorded here. Linked logs +and artifacts are generated local files and are not included in a fresh checkout. + +Validated on 2026-09-20 in `/home/felis/source/fds` on an x86_64 Arch Linux +Framework host, running Linux 7.2.6-arch2-1. + +**M0 is complete:** the host bootstrapped an upstream Void build container, +cross-built an aarch64 glibc XBPS package, and built a static-musl AArch64 Rust +executable. M1 remains a proposal. + +All project-owned documentation, source comments, diagnostics, and ongoing +development context use English. The complete master plan retains its 67 +numbered sections. The pinned upstream submodule has no tracked modifications. + +## Changes + +- Added the repository skeleton, milestone instructions, master plan, architecture + contracts, and build documentation. +- Pinned `vendor/void-packages` to + `02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1` using a Git submodule and + `VOID_PACKAGES_COMMIT`. +- Added versioned, checksum-verified static XBPS host tools and a bubblewrap + bootstrap workflow for ordinary Arch users. +- Added FDS overlay preparation with upstream collision and stale-copy checks. +- Added the Rust workspace, Rust 1.98.0 pin, static musl linking configuration, + dependency-free `fds-smoketest`, and release settings from the plan. +- Added `make bootstrap`, `make smoke-test`, `make check`, package export, + ELF inspection, failure-path checks, and build input manifests. +- Reserved future component directories without implementing later milestones. + +The initial M0 file inventory appears at the end of this report; guides added +after acceptance are not included in that historical inventory. + +## Exact build commands + +These public entry points all completed with exit status 0: + +```sh +cd /home/felis/source/fds +make bootstrap +make smoke-test +make check +``` + +The underlying cross-build commands are: + +```sh +cargo build --locked --offline --release \ + --target aarch64-unknown-linux-musl -p fds-smoketest + +export PATH="$PWD/.host/xbps/usr/bin:$PATH" +export XBPS_ARCH=x86_64 +cd vendor/void-packages +./xbps-src -A x86_64 binary-bootstrap +./xbps-src -a aarch64 pkg hello +``` + +The first bootstrap created the build environment on this host. The final +public command runs reused verified downloads and that environment. Large +archives were prefetched from official servers with SHA-256 verification; +XBPS also verified package signatures. This was not a test of a pristine clone +on a second machine. + +## Smoke-test output + +Actual excerpts from `out/logs/smoke-test.log`: + +```text +Void commit: 02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1 +rustc 1.98.0 (88d9e12ae 2026-08-18) +cargo 1.98.0 (797e8a9bc 2026-08-05) +PASS: x86_64 static ELF +PASS: aarch64 static ELF +ldd (host diagnostic, exit 1): not a dynamic executable +PASS: aarch64 package exported to out/packages/ +PASS: aarch64 glibc ELF +PASS: XBPS package hello-2.12.3_1 architecture=aarch64 (glibc) +SKIP: ARM execution (optional qemu-aarch64 not installed); ELF verification passed +PASS: M0 smoke test complete +``` + +Rust `file` output identifies an `ELF 64-bit LSB executable, ARM aarch64`, +`statically linked, stripped`. `readelf` confirms no interpreter, shared library +dependency, or glibc symbol version requirement. The extracted GNU hello ELF +uses `/lib/ld-linux-aarch64.so.1` and requires `libc.so.6`. + +`make check` passed its ABI rejection, invalid-input, Void pin, dirty-upstream, +overlay collision, stale-overlay, configuration protection, and formatting +checks. An initial package-index architecture error was corrected by explicitly +setting `XBPS_ARCH=aarch64` when indexing the exported package; the full +smoke-test then passed. + +Actual logs and inventories: + +- [Bootstrap log](../out/logs/bootstrap.log) +- [Smoke-test log](../out/logs/smoke-test.log) +- [Package build log](../out/logs/xbps-hello.log) +- [Guardrail and formatting checks](../out/logs/check.log) +- [Build package inventory](../out/manifests/void-build-packages.txt) +- [Cached package input digests](../out/manifests/void-package-inputs.sha256) +- [Artifact digests](../out/manifests/artifacts.sha256) + +Generated output is ignored by Git. These links refer to this validated checkout; +rerun the commands to recreate the output in another checkout. + +## Artifacts + +| Artifact | Verified ABI | SHA-256 | +| --- | --- | --- | +| `out/fds-smoketest` | AArch64, static musl | `3c719a386cfef5b67f90488d9c67075c835c1a42d4db0efac0b475267dfc25ee` | +| `out/packages/hello-2.12.3_1.aarch64.xbps` | aarch64 package, glibc executable | `2703e81cff78d3d11b1a36f323c0af25691b258effcd60fe7f6d074ca8cdf1d8` | + +## Known limitations + +- QEMU execution was skipped because qemu-aarch64 is not installed. No ARM + hardware, Pi boot, bay topology, E-Ink, or boot/shutdown timing was tested. +- Void source, Rust, and host XBPS are pinned. Rolling binary dependencies are + audited through cached digests and version inventories, but a complete binary + repository snapshot and reproducible release are still future work. +- This is a cross-build foundation; there is no bootable FDS image yet. +- Initial upstream bubblewrap cleanup reported a busy `/tmp` mount point while + returning success. Subsequent container execution and cross-build checks + passed. See [the build guide](build-host.md#known-limitations). + +## Next milestone proposal + +M1 should implement `fds-base` and `fds-base-files`, assemble an aarch64 glibc +rootfs with GNU userspace and XBPS, and generate `out/rootfs-aarch64.tar`. +Acceptance should audit package contents, confirm glibc/coreutils/binutils, +require no musl runtime package, and exclude BusyBox, systemd, and runit. +Begin M1 only after an explicit request. + +## Initial M0 changed-file inventory + +```text +.cargo/config.toml +.gitignore +.gitmodules +AGENTS.md +Cargo.lock +Cargo.toml +Makefile +README.md +VOID_PACKAGES_COMMIT +config/host-tools.conf +config/xbps-src.conf +docs/architecture.md +docs/boot.md +docs/build-host.md +docs/cartridges.md +docs/m0-validation.md +docs/master-plan.md +docs/packages.md +docs/performance.md +docs/recovery.md +docs/services.md +image/.gitkeep +out/.gitkeep +packages/fds-base-files/.gitkeep +packages/fds-base/.gitkeep +packages/fds-cartridged/.gitkeep +packages/fds-cli/.gitkeep +packages/fds-eink/.gitkeep +packages/fds-kernel/.gitkeep +profiles/.gitkeep +rust-toolchain.toml +rust/fds-boottrace/.gitkeep +rust/fds-burn/.gitkeep +rust/fds-cartridged/.gitkeep +rust/fds-cli/.gitkeep +rust/fds-common/.gitkeep +rust/fds-smoketest/Cargo.toml +rust/fds-smoketest/src/main.rs +rust/fds-stage0/.gitkeep +s6/source/.gitkeep +tests/hardware/.gitkeep +tests/integration/m0-checks +tests/unit/.gitkeep +tools/bootstrap-host +tools/build-package +tools/lib.sh +tools/prepare-void +tools/smoke-test +tools/verify-elf +vendor/void-packages +``` diff --git a/docs/m1-validation.md b/docs/m1-validation.md new file mode 100644 index 0000000..6243e1a --- /dev/null +++ b/docs/m1-validation.md @@ -0,0 +1,125 @@ +# M1 root filesystem validation + +[Documentation index](README.md) · [Build and usage](rootfs.md) · [Roadmap](roadmap.md) + +Validated on **2026-09-21 (Asia/Shanghai)** on the x86_64 Arch workstation. +The archive build timestamp is `2026-09-20T16:58:35Z`. This is userspace and +archive validation, not a Pi boot or native PID 1 result. + +## Artifact produced + +| Item | Recorded result | +| --- | --- | +| Output | `out/rootfs-aarch64.tar` | +| Archive size | 346,705,920 bytes | +| Configured packages | 108 | +| ARM ELF paths inspected | 1,167 | +| SHA-256 | `b2f0472d49aeb1565d124bc30f250cb304dc265be4d049f1ac9e8cb533742a34` | +| Void source pin | `02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1` | +| Emulation tools | PRoot 5.2.0_1 and QEMU aarch64 userspace 11.1.1_1 | + +The successful build is retained in `out/rootfs-build.Ewfkl2/`. Convenience links +point to its archive, staging root, and manifests. Future rebuilds may replace +the convenience links; this report describes the result above. + +Selected package versions: + +```text +fds-base-0.1.0_1 +fds-base-files-0.1.0_1 +fds-dasungd-0.1.0_1 +glibc-2.41_1 +coreutils-9.4_3 +binutils-2.44_2 +xbps-0.60.7_1 +s6-2.15.0.0_1 +s6-rc-0.6.1.1_1 +``` + +Every selected package archive is retained with its SHA-256 in the build +directory. Remote packages were verified against Void's RSA keys; local FDS +packages are unsigned development builds. The package archives total 71,371,822 +bytes, excluding signature files. This is input retention, not a complete +upstream repository snapshot or a reproducible-release claim. + +## Acceptance completed + +```sh +make rootfs PROFILE=cli +make rootfs-test +``` + +Both commands exited successfully. Checks established: + +- `fds-base` selects GNU userspace, glibc, XBPS and the mandatory Dasung package. + Neither the Void `base-system` nor its `base-files` package is installed. +- Every installed package is `aarch64` or `noarch` and configured. All scanned + ELF files are little-endian 64-bit ARM. Dynamic interpreters resolve to glibc. + Dasung is statically linked and has no dynamic interpreter or shared-library + dependency. No BusyBox, systemd, runit, or musl runtime package is present. +- The target's package setup ran under QEMU. English UTF-8, CA certificates, + the udev hardware database, the dynamic linker cache, and the Dasung s6 database + were generated before export. `_uuidd` was created and root remains locked. +- ARM GNU coreutils/binutils, glibc, shell pipelines, locale lookup, XBPS queries, + package integrity checking, Dasung configuration/EDID checking, and s6 database + queries succeeded. Tests do not open a real USB or DRM device. +- Exported ownership and permissions were checked, including locked account + files, sticky temporary directories, the tty group for `wall`/`write`, the + xbuilder group for `xbps-uchroot`, and portable `CAP_NET_RAW` for ping. +- GNU tar restored the exported ping capability successfully inside a private + user namespace, verifying the archive's actual extended-attribute encoding. +- A fresh extraction of the exported archive passed the same userspace checks. +- Failure tests rejected a forbidden package, an unconfigured package, a musl + package ABI, an injected x86_64 executable, a missing Dasung boot entry, and an + unsupported image profile. + +The build leaves upstream Void tracked files unchanged. The FDS layout manifest +avoids changing upstream lint rules. The tests use the exported tar rather than +trusting the original staging tree alone. + +## Regression checks + +The following also exited successfully after the M1 changes: + +```sh +make bootstrap +make smoke-test +make check +make dasung-test +``` + +The Dasung rerun passed all six Rust unit tests, the serial simulator cases, +package inspection, and the s6 boot/logger/socket/restart/stop check with a +read-only root and no monitor. Its physical-hardware checks remain skipped. +The existing M0 smoke script still reports its optional host-PATH QEMU skip: +the emulator is installed only inside the project-local build container. M1's +explicit container-based ARM runtime tests passed independently. + +Shell syntax checks were updated to recognize shell files by their interpreter, +so adding Python image tools does not make the older test suites parse Python +as shell. Python syntax, documentation file links, English project context, +and upstream tracked-file cleanliness were also checked. + +## Emulation and rootless-build limitations addressed + +The packaged PRoot version does not translate every modern syscall reliably. +The helper therefore makes the ARM filesystem the actual root with bubblewrap, +and uses direct QEMU for XBPS integrity checking and capability operations. +PRoot is used for child-process execution during package configuration and shell +sessions, not as evidence of successful ownership or capability changes. + +Rootless configuration emits expected group-change failures for `wall`, `write`, +and `xbps-uchroot`. Archive generation restores their intended numeric groups +and the archive tests verify the result. Other package payload ownership comes +from the retained XBPS headers. No host user ID is exported as the target root. + +## Remaining boundaries + +- M2: native s6 PID 1, complete boot graph, runtime mounts and booted service control. +- M4 and later: Pi firmware/kernel/initramfs, EROFS SYSTEM image, console, and media creation. +- Hardware: monitor picture, cold-power recovery, USB hotplug under Pi boot, and 12-bay behavior. +- Performance: no boot, shutdown, or monitor readiness benchmark was measured. + +The rootfs is not flashable, does not provide `fds` or a cartridge daemon, and +does not provision a login password. See [Rootfs](rootfs.md) for the working +emulated-shell workflow and the [roadmap](roadmap.md) for later acceptance gates. diff --git a/docs/m10-validation.md b/docs/m10-validation.md new file mode 100644 index 0000000..65ff840 --- /dev/null +++ b/docs/m10-validation.md @@ -0,0 +1,111 @@ +# M10 shutdown validation + +[Shutdown usage](power.md) · [Implementation ledger](implementation-status.md) + +M10 software acceptance passed on 2026-09-21 in isolated ARM virtual machines. +The complete shutdown suite, CLI packaging and earlier-milestone regressions +passed. Physical Pi timing, flash-controller durability, +power rails and battery behavior remain deferred. + +## Verified shutdown behavior + +The ordinary FDS user can request `fds poweroff` or `fds reboot`. New cartridge +operations freeze before the daemon stops the desktop, managed programs and +networking. The original DATA filesystem descriptor checks writeback, the +filesystem becomes read-only, a second check catches remount writeback, and normal +unmount completes before native s6 stops the remaining services and halts or +reboots. Dasung remains supervised until that final service stop. + +`make power-test` passed all six cases in `out/m10-vm.tituqzh7/`: + +| Case | Observed result | VM preparation-to-kernel-action time | +| --- | --- | --- | +| Idle | Ordinary-user poweroff; unprivileged internal hook refused | 540.733 ms | +| Active workload | GUI, DHCP and TERM-ignoring descendants stopped; DATA verified; actual reboot | 1835.329 ms | +| Direct native poweroff | Busy DATA blocks the native hook until resolved; cancellation refused | 414.091 ms | +| Active media preview | Shutdown and new confirmation refused; cancellation leaves disk unchanged | 289.099 ms after retry | +| Crashed DATA service | Same insertion stays quarantined across further restarts; no false SAFE | 281.307 ms after removing fixture | +| Attached-device writeback error | Real EIO retained across restarts; SAFE and shutdown refused | 344.777 ms after removing fixture | + +These are measurements inside generic QEMU with the Pi kernel, not Pi or +physical power-off benchmarks. They do not establish the sub-second idle or +1.5-second normal-DATA hardware targets. The workload deliberately ignores TERM, +so the manager must wait its bounded termination deadline and confirm actual +exit before proceeding. + +Each case records ordered `CLOCK_BOOTTIME` events and captures the final native +shutdown record. The test waits for the kernel's halt/reboot message and QEMU's +successful exit; closing the emulator does not count as a completed shutdown. +The full command log is `out/logs/m10-final-power.log`. + +## DATA integrity and failure handling + +The workload writes at least 128 MiB through a rotating 64 MiB file. After reboot, +independent host `e2fsck` finds a clean filesystem, SHA-256 matches all 64 MiB, and +an ordinary-user marker retains its exact contents. Open writable descriptors +and a shell working directory both block shutdown. Explicit resume enables new +operations but does not recreate stopped jobs. + +Before writable activation, a root-private insertion record is created under +`/run/fds/data-sessions`. A service crash cannot discard the original descriptor's +writeback history and then issue SAFE based on a fresh descriptor. The same +insertion becomes read-only and quarantined; a known error retains its diagnostic. +Records survive service restarts during this boot, not power loss or a different +insertion. The separate DATA regression passed in `out/m7-vm.vmhkmyqk/`. + +The writeback test uses QEMU's `blkdebug` to return persistent EIO for writes to a +known allocated file block. GPT, manifest and journal metadata remain outside +that injected sector. The device stays attached; Linux reports a real writeback +error and `syncfs` fails. The daemon refuses eject, keeps the fault after restart, +and blocks shutdown through another restart. No filesystem-cleanliness claim is +made for this deliberately damaged image. Removing the test fixture merely lets +the remaining system shut down; it never turns the failed insertion SAFE. + +See [QEMU's fault-injection documentation](https://www.qemu.org/docs/master/devel/testing/blkdebug.html). +A one-shot fault is insufficient evidence by itself because the guest may retry +it successfully; the acceptance case verifies the actual guest-visible EIO. + +## Native guard and dependencies + +The native shutdown hook remains alive while preparation is blocked, including +when an administrator calls the upstream `poweroff` directly. A nonzero hook +exit would let native shutdown continue, so watcher failure uses a blocked +signal wait. Normal waiting uses inotify and process/cgroup exit events, with no +sleep-based assumptions. Once native shutdown starts it cannot be cancelled. + +`fds-power` is a static alias of `fds`. The CLI now directly declares the already +locked `libc` crate for inotify, identity and signal operations. No new external +crate or target package is introduced. `fds-init` explicitly depends on +`fds-cartridged`, since the native hook requires its DATA checks. DHCP and its +privilege-separated children now share a managed cgroup. The final native grace +period is zero after managed exits and DATA verification have completed. + +## Regression evidence + +The development archive passed rootfs rejection checks, native PID 1/service +control and actual poweroff, desktop/network transitions, tooling/static-ELF +checks and `make check`. Logs are `out/logs/m10-final-*.log`; desktop evidence is +`out/m8-vm.1c20rcpb/`, native init is `out/m2-vm.5ARJNL/`. The final CLI image also passed native init (`out/m2-vm.ysuhZ5/`), all nine +stage0 cases (`out/m4-vm.e3SBsX/`), and the ordinary-user console checks +(`out/m5-vm.4l796uhp/`). Cartridge regression passed in `out/m6-vm.p7gfwnj2/`, DATA regression in +`out/m7-vm.82fqc9wa/`, and confirmed media writes in `out/m9-vm.5a58kou6/`. + +The development runtime tested here is `out/rootfs-build.WEtGnr/`, with +191 packages and 1406 AArch64 ELF files. Its rootfs SHA-256 is +`9de03100b998cac2a754fc8e05acbad0bd3f45846dd637ba4c597ea3ba61f5bb`. +Its SYSTEM is `out/system-build.dd3e74_2/system.img`, SHA-256 +`bef3b01bc8bc68ca673aa6a0ec73892ddaa2901864eed204745749915ee34b18`. +The subsequent explicit init-to-cartridge package dependency was verified in the +final CLI build; the development archive already includes that daemon through +its base package. Runtime binaries and service behavior are identical. +The CLI archive has 180 packages and 1394 AArch64 ELF files, and is 499527680 bytes. + +Kernel-to-console was 1821.039788 ms versus M9's 1854.858231 ms: **−33.818443 ms**. +The actual ARM comparison passed without a regression exception; see +`out/logs/m10-boot-comparison.log`. This is a VM observation, not a Pi target result. + +| Artifact | Location | SHA-256 | +| --- | --- | --- | +| CLI rootfs | `out/rootfs-build.0AzReg/rootfs-aarch64.tar` | `ebfc58171aeee6f737c2a93a0f3a318d4f02eb915edb3b5a3cf0f51848863930` | +| CLI SYSTEM | `out/system-build.c7q19iil/system.img` | `81dc8083777b497f02a290a15eeb246465689d0deaff97d761aeb882d140b44b` | +| FAT32 boot partition | `out/boot-build.42abbf_h/boot.fat` | `0972b5973d1fefc377ec4c800043fb8e4aa3013140f95218cdebcf38dcd22a49` | diff --git a/docs/m11-validation.md b/docs/m11-validation.md new file mode 100644 index 0000000..de34aba --- /dev/null +++ b/docs/m11-validation.md @@ -0,0 +1,107 @@ +# M11 stress validation + +[Stress-test usage and physical procedure](stress-testing.md) · [Implementation ledger](implementation-status.md) + +M11 software acceptance passed on 2026-09-21. The expanded twelve-device suite +passed in `out/m11-vm.o1c_tqiv/`, including the explicit readiness gate and +diagnostic collector. The public `make stress-test` passed all six initial media +failure cases in `out/m11-faults.g5uw_jao/`. The strengthened final +`make media-fault-test` passed in `out/m11-faults.hfyqoui3/`, verifying the actual +virtual-device byte counter before each interruption. `make check` then passed. Physical Pi/hub/battery tests remain deferred. + +## Twelve-device evidence + +The complete runs used the packaged M10 CLI rootfs and actual Pi kernel in +an isolated generic ARM VM. A protected virtual SYSTEM root and twelve USB disks +were present: one DATA and eleven distinct PROGRAM cartridges. Every identity +matched its bay through cold boot, three simultaneous hotplug/removal cycles, +rotated placements and reverse device insertion order. + +Eleven direct readers each verified 32 MiB while DATA wrote and flushed 64 MiB. +All twelve measured I/O intervals overlapped. QEMU block counters confirmed the +corresponding reads/writes, so cached file reads alone could not satisfy the test. +A subsequent managed writer and its TERM-ignoring descendant ran during native +reboot. Independent host `e2fsck` passed; the entire 64 MiB payload matched its +expected SHA-256. Another boot with a different device declaration order retained +correct mapping and data, and completed native poweroff. Read-only cartridge +image hashes were unchanged. + +Kernel logs from normal stress/reboot contained no detected unexpected USB reset, +controller failure, I/O error or filesystem error. This only describes the virtual +hardware used in the test. It says nothing about physical voltage, hub firmware, +cables, RP1 or a storage controller's cache guarantees. + +## Startup observations + +Three paired samples used the same SYSTEM and virtual controller, with either +zero or twelve USB disks. The first complete run observed: + +| USB population | Kernel-to-console samples | Median | +| --- | --- | --- | +| Empty | 1907.961445, 1761.020483, 1847.460910 ms | 1847.460910 ms | +| Twelve | 1968.638224, 1998.194820, 1927.705171 ms | 1968.638224 ms | + +The median device-load difference was **+121.177314 ms**, above the 100 ms review +threshold. Stage0 discovery remained approximately 23–27 ms in both scenarios; +the increased work appears mainly between s6 start and console readiness. +The unchanged getty graph has no cartridge-readiness dependency. Concurrent USB +and cartridge processing competes with startup in this two-vCPU emulator. This +is an explanation of the controlled device-load comparison, not a claim that a +code change has been proven faster or that Pi timing targets passed. + +Earlier complete cold-boot pairs from interrupted test attempts observed ++122.544124 ms and +76.064782 ms; their raw reports remain in +`out/m11-vm.jcvwpxel/` and `out/m11-vm.w4oyzwje/`. They are not full acceptance runs. +The spread is retained rather than discarding slower observations. The expanded +suite then passed with medians 1900.735990 ms (empty) and 1957.588750 ms (twelve): +**+56.852760 ms**. All samples and the actual ARM comparison are stored in +`out/m11-vm.o1c_tqiv/boot-comparison.json` and `boot-review.log`. The blocked-daemon +case reached the real UID-1000 prompt with no cartridge control socket, then +completed enumeration and native shutdown after an explicit event released the +daemon. This verifies console independence under the same twelve-device load. + +## Test-harness findings + +The AArch64 Linux `O_DIRECT` flag differs from the x86_64 value. An initial +fixture incorrectly requested directory-only opens and failed before I/O. The +corrected ARM-only test helper performs aligned direct reads; byte checks and +block counters then passed. No target runtime change was needed. + +A burst of asynchronous device-deletion events could arrive while a new QMP +monitor connection was being greeted. The shared VM helper now accepts those +events before the greeting, without treating them as command responses. Checked +serial capture executes a command once and retries only a corrupted transfer. +It preserves kernel diagnostics instead of suppressing error messages. + +## Media failure acceptance + +Actual-device tests passed for write EIO, flush EIO and readback EIO. +Each leaves a failed operation across daemon restart, with no SAFE marker. +Cancellation, worker death and daemon death during confirmed writing also passed +in `out/m11-faults.g5uw_jao/`. The strengthened run in +`out/m11-faults.hfyqoui3/` additionally requires at least 64 MiB in QEMU's actual +write counter while the transfer remains incomplete, retaining that counter and +job state in an interruption record. All three strengthened interruption cases +passed: each had accepted 128 MiB into the guest write path and delivered 64 MiB +to the virtual disk, with a 270532608-byte source still incomplete. Cancellation +retained its diagnostic, worker death retained a failed-completion error, and +daemon death retained a restart error. Each remained failed after another +restart, with the daemon usable and no SAFE marker. + +Final logs are `out/logs/m11-final-stress-retry.log`, +`out/logs/m11-final-faults.log` and `out/logs/m11-final-check.log`. The final failure +run records input hashes and QEMU 11.1.1 identity beside its images. Runtime +packages and boot artifacts remain the verified M10 builds; M11 adds tests and +physical-test tooling rather than changing the target service behavior. + +## Physical acceptance + +The [physical procedure](stress-testing.md#prepare-the-physical-session) covers +bay calibration, cold boot, rotated placement, concurrent I/O, orderly reboot and +poweroff, power behavior and external timing. `tools/capture-hardware` collects +read-only local diagnostics with per-command exit statuses and checksums. It +reports collection success separately from hardware acceptance. All 17 capture +commands returned success in the ARM VM, and the resulting private directory +and checksums passed inspection. The blank physical session template remains +explicitly `not_run`. None of those +physical tests has been performed in this milestone. diff --git a/docs/m12-validation.md b/docs/m12-validation.md new file mode 100644 index 0000000..8f4b219 --- /dev/null +++ b/docs/m12-validation.md @@ -0,0 +1,407 @@ +# M12 production preparation: software evidence + +[Implementation ledger](implementation-status.md) · [EEPROM](eeprom.md) · [Recovery](recovery.md) + +M12 **software acceptance is complete**, including the signed local FDS/OS +0.1.0 release. Physical Pi tests, EEPROM application, display behavior and +storage power-loss behavior are deferred. Earlier checkpoints below preserve +the development history; the final acceptance identifies the released bytes. + +## Final local release acceptance + +Completed on 2026-09-21. The local release is +[`out/fds-os-0.1.0/README.md`](../out/fds-os-0.1.0/README.md). +It contains 38 signed manifest entries, versioned images and packages, +profile inventories, source and complete build-input archives, guides and +reproducibility evidence. It has not been published or installed on hardware. + +- Frozen source SHA-256: `8947e57552f5db834fbb6bcce6d79623bf0605072b42b4ccf04d1e4fe7bb9f29`. +- Input-lock SHA-256: `6fa630446f91a7a0a2b85324b7a258ea6351978ff8f8a4cf9970f73f8d24fe2c`. +- Manifest SHA-256: `0c223b4996258efbe33db780ab0d05019c35e36be291499a9548c27371c451a4`. +- Signing public-key fingerprint (decoded 32 bytes): `d5c95e8c1a079ed523b859c0ee78b8e849bbf387b3719dcdae1661ae4f7eb267`. +- Signing public-key text-file SHA-256: `30d832747e95dca2c7ad3f5a117d2069a2d94e6d3eee2dd385caea688159e677`. + +Two newly restored trees, `out/rebuild-m12-v5-a` and `out/rebuild-m12-v5-b`, +ran `tools/in-frozen-build make all` sequentially with no network access. +Both started without FDS build outputs. The complete 27-artifact comparison +passed, covering all five installable images, three rootfs tars, four initramfs +formats, kernel/DTB, four EEPROM preview files and all nine FDS packages. +Logs: `out/logs/m12-offline-v5-{a,b}.log`; comparison: +[`out/m12-reproducibility-v5.json`](../out/m12-reproducibility-v5.json). + +Every one of those artifacts also matches the normal build used by the complete +[Clap runtime acceptance](clap-validation.md). The identity proof is +[`out/m12-tested-to-release-v5.json`](../out/m12-tested-to-release-v5.json), +and `out/m12-clap-acceptance-index.json` records the 21 acceptance logs and hashes. +This establishes exact-byte coverage of the released images without repeating +the same VM suite against identical images. Physical tests remain deferred. + +The first restored tree additionally passed `make smoke-test`, `make check` +and `make signing-test` inside the isolated wrapper. A second full comparison +after those checks still passed. Logs are `out/logs/m12-v5-{smoke-test,check,signing-test}.log`; +the comparison is `out/m12-reproducibility-v5-after-checks.json`. +The optional direct host `qemu-aarch64` smoke run is reported as skipped when +absent; the separate ARM signing and OS runtime suites execute real ARM code. + +The assembler verified source identity and rootfs build inputs before signing. +Verification against the separately retained public key checked every manifest +entry. OpenSSL independently verified the actual release signature. Both actual +release archives were extracted with mode-preserving flags; the complete input +lock and standalone source identity passed. Evidence: +[`out/m12-release-acceptance-v5.json`](../out/m12-release-acceptance-v5.json), +with logs in `out/logs/m12-v5-*.log` and extracted files under +`out/m12-release-acceptance.mzkfsvev`. + +The private signing key is outside the checkout, snapshot and release output. +Its corresponding trusted public key is +`/home/felis/.local/share/fds/signing-0.1.0-20260921/release-01.pub`. +On this workstation, from the checkout: + +```sh +out/rebuild-m12-v5-a/target/x86_64-unknown-linux-gnu/release/fds-release verify \ + out/fds-os-0.1.0 \ + --key /home/felis/.local/share/fds/signing-0.1.0-20260921/release-01.pub +``` + +| Image | Bytes | SHA-256 | +| --- | ---: | --- | +| `fds-boot-0.1.0.img` | 536,870,912 | `1466bbac3f782115bce78b48376ab90a7364416379700e944c529280b7253703` | +| `fds-internal-0.1.0.img` | 1,881,145,344 | `5197cd5edbbb9f689f12d233371cf0079ac50b36a4d3ca3cfe4c38cd320fc468` | +| `fds-recovery-0.1.0.img` | 491,593,728 | `4b0c9dad4336535d5b90c35cacd1500528836008be3384bdcd22b078b8a25f5b` | +| `fds-system-cli-0.1.0.img` | 493,879,296 | `03233a1118471cd8550ef2813fa99d371ef00b23edde5a3ade161d371e36c177` | +| `fds-system-development-0.1.0.img` | 1,989,148,672 | `9110f966212b59186e60ae7f88b6500fb0909478e6cbbb17735eebc969092c1e` | +| `kernel_2712-0.1.0.img` | 26,751,488 | `9e811bc55aa8e0b6a7233c6c84978244f2affee7bc808f3913dcd2fab3cd8013` | + +The [67-section ledger](implementation-status.md) records implementation, +evidence and physical deferrals for the entire master plan. Main-checkout status +documentation was updated after acceptance; the frozen trees and signed archives +retain the exact checkpoint documentation used for their source identity. +Consequently, their historical “verification in progress” wording predates this +record. Runtime code, configuration and image bytes were not changed by this +final documentation update. Use the preserved inputs to reproduce the exact +release images. The signed `RELEASE-NOTES.md` also corrects an archived guide's +fingerprint terminology: the tool hashes decoded key bytes, not the text file. +The standalone release verification guide includes that correction. The original +assembler output is retained at `out/fds-os-0.1.0-checkpoint/`; final packaging +adds these signed release notes and updated standalone guidance without changing +any image, package, source archive or input archive. + +## EEPROM configuration and rollback + +`make eeprom-test` passed against the actual pinned 2 MiB Pi 5 EEPROM image. +Evidence is in `out/m12-eeprom.5b97ph36/`; the public run is recorded in +`out/logs/m12-eeprom-final.log`. `make check` also passed afterward. + +The tests cover both production and development settings, exact configuration +readback, unchanged firmware payloads, identical repeated outputs, preservation +of unrelated conditional settings, exact rollback configuration, existing-output +refusal, and rejection of invalid firmware/device/config inputs. The tool never +invokes an update/apply command. The default firmware is a preparation input, +not a backup of the user's Pi. + +See the [EEPROM guide](eeprom.md) for the source commit, hashes, complete profile +settings, generated files, and the deferred physical application/rollback steps. + +## Independent recovery + +The recovery implementation has its own rootfs profile, EROFS builder, local +maintenance console, read-only cartridge defaults, and explicit DATA check/repair +commands. It retains native s6 and the base Dasung controller. No additional Rust +crate or target package was needed: repair uses the existing e2fsprogs and kernel +loop support while preserving the physical disk reservation. + +`make recovery`, `make rootfs-test` and the complete `make recovery-test` passed. +The final runtime evidence is `out/m12-recovery.rlvmytrz/`, linked from +`out/m12-recovery-latest`. Logs are +`out/logs/m12-recovery-identity-build.log`, +`out/logs/m12-recovery-identity-rootfs.log`, and +`out/logs/m12-recovery-final-acceptance.log`. + +| Artifact | Verified result | +| --- | --- | +| Recovery rootfs | `out/rootfs-build.D5TCGs/rootfs-aarch64.tar`, 499,609,600 bytes; 180 packages and 1,394 AArch64 ELF files | +| Rootfs SHA-256 | `0611c0f8f24d04a733401063f9f52c7bc08f6389cb707601df083390fe063ae1` | +| Recovery EROFS | `out/recovery-build.9ukiaknh/recovery.erofs`, 490,598,400 bytes | +| EROFS SHA-256 | `7a219ca803325e4d60c3acf1b4cffe8717e7a7ec4e65377f48c900c131910bcd` | + +The tests use the packaged recovery image and Pi kernel. The only rootfs change +for virtual cartridge tests is the observed virtual bay map; no replacement +runtime binaries or simulated success responses are inserted. + +Verified cases: + +- Two independent EROFS constructions from the same tar produce identical bytes. +- Recovery boots with SYSTEM absent and with an unreadable SYSTEM. Its root is + read-only, PID 1 is native s6, the maintenance console is root, and Dasung is + supervised without needing the physical monitor. +- DATA remains read-only; ENVIRONMENT and Ethernet insertion do not automatically + start the desktop or DHCP. An ordinary user cannot invoke recovery repairs. +- A real ext4 inode reference-count error is detected without changing DATA. + Wrong confirmations and busy mounts are rejected. Confirmed repair fixes the + error, flushes/invalidate caches, and passes another read-only check. The + original test file survives, and a separate host e2fsck also passes. +- Failed checks survive daemon restart as quarantine. Confirmation from a previous + insertion is rejected. Clean checks preserve every byte of the DATA image. +- Recovery writes a complete known-good SYSTEM image to a disposable virtual USB + device, verifies it by readback, inspects it, and excludes it from DATA repair. + A separate VM boots that written image to the ordinary UID 1000 FDS console. + The source SYSTEM is the previously verified M10 CLI image; this proves the + recovery replacement workflow, not a new release-signing claim. +- Killing the daemon during a real checker kills the child and preserves DATA. + An explicit retry succeeds. Twenty partition-table rereads and a subsequent + service restart preserve the unmounted SAFE state; temporary loops are gone. +- The diagnostic collector completes all 17 commands and verifies its output + checksums. Successful cases power off through native s6. + +The interruption/rescan case also passed three consecutive focused runs in +`out/m12-recovery-rescan.rlr9u2aq/`, `out/m12-recovery-rescan.cvzye8z0/`, and +`out/m12-recovery-rescan.wj0jlfkq/`. This caught and fixed a real bug: temporary +partition disappearance during a reread previously cleared the eject record. +The daemon now retains that record while the same physical disk insertion is +present; a new insertion still invalidates it. + +The normal CLI rebuild and regression checks also passed: `make rootfs PROFILE=cli`, +`make rootfs-test`, `make system-card PROFILE=cli`, `make init-test`, +`make console-test`, `make data-test`, `make tooling-test`, and `make check`. +Evidence is in `out/logs/m12-cli-*.log`, `out/logs/m12-tooling.log`, and +`out/logs/m12-check.log`. The normal-image root check emitted +`FDS_M12_RECOVERY_BOUNDARY`, confirming that recovery repair remains unavailable +outside the recovery profile, even to root. + +That CLI rootfs is `out/rootfs-build.HqBtpz/rootfs-aarch64.tar` +(SHA-256 `af132511a6784eced9e52e971f010df6717e3caed751d14017ca9e924196e8b3`), +and its SYSTEM image is `out/system-build.psymtwbr/system.img` +(SHA-256 `5bc69f31d74a4c07596b696a72138cd24327ff4bc467f428717a56ce44c6bd27`). +Native init passed in `out/m2-vm.GyJsNg`, console checks in `out/m5-vm.h5ygfe1j`, +and DATA checks in `out/m7-vm.nt_tsdfs`. Console readiness was 1,853.221941 ms, +32.182153 ms above the earlier M10 CLI baseline of 1,821.039788 ms; this is below +the 100 ms investigation threshold. These artifacts precede the internal-settings +implementation below. No physical timing or media-durability claim follows from +these VM measurements. + +## Internal NVMe and machine settings + +Implementation and current usage are described in [Internal storage](internal-storage.md). +`make internal-test` passed in `out/m12-internal.vqr74fcs/`, linked from +`out/m12-internal-latest`. The public log is +`out/logs/m12-internal-final-acceptance.log`; rootfs evidence is in +`out/logs/m12-machine-final-rootfs.log`. The test uses packaged CLI and recovery +images, the actual Pi kernel, and disposable virtual NVMe devices. No runtime +binary or configuration file is injected into the booted OS. + +Verified cases: + +- Two complete 1,881,145,344-byte internal GPT images are byte-identical, with + independent FAT, EROFS, ext4, GPT and payload checks. Their SHA-256 is + `0d464ba18b0988a79a75ea54b6d81c5fa01acfea567b3d0a79f9faf2a428dfc6`. +- `make internal-image` publishes the same hash at `out/internal-build.xxzvw_3l/internal.img`, + linked from `out/fds-internal.img`; its log is `out/logs/m12-internal-published.log`. +- The actual loader selects one correctly laid-out NVMe and leaves every byte + unchanged during read-only loading. The private mount is gone afterward. +- Root recovery installs validated settings atomically, preserves previous bytes, + flushes/unmounts storage, and keeps this boot's active mapping unchanged even + across a cartridge-daemon restart. +- A separate reboot loads the saved machine name, bay map and hardware catalog. + The real virtual keyboard is recognized in its assigned bay with its saved name. +- Boot diagnostics survive reboot and are retrieved byte-for-byte. Non-root + writes, path traversal, duplicates, existing retrieval outputs and oversized + diagnostic files are rejected. Independent post-write e2fsck passes. +- BOOT and RECOVERY payload hashes remain unchanged after settings/diagnostic + writes. Ordinary user data is not placed on internal storage. +- A complete USB lookalike cannot supply settings. Two eligible NVMes boot the + normal UID 1000 console with explicit ambiguity/fallback status. +- Invalid JSON, writable settings, symlinks and unclean ext4 leave the disk bytes + unchanged, record an error and retain a usable recovery console with image + defaults. No automatic filesystem repair or journal replay is performed. + +An earlier test used unsupported PCI hotplug on QEMU's root PCI bus; the final +ambiguity fixture instead boots the ordinary SYSTEM with both NVMes present. +Earlier partial runs remain available, but are not counted as acceptance. + +The settings trust checks exposed omitted `/` metadata in the rootfs archive. +EROFS had synthesized a host-owned mode-0777 root directory. The archive now +records root:root mode 0755 explicitly, both OS builders reject old archives, +and rootfs/VM tests check the result. `out/m12-root-metadata-repro-audit.json` +compares two otherwise-identical recovery builds; only the new `.` entry differs. +The build-specific auxiliary linker cache has been removed. + +The tested recovery rootfs is `out/rootfs-build.P4WWwz/rootfs-aarch64.tar` +(SHA-256 `9d0e54533399da79616853e8233d2abb8efbc6b2d51772bd86f2669134c957b4`), +and its EROFS is `out/recovery-build.1ukd0xgl/recovery.erofs` +(SHA-256 `1c1930c86960f8e208408f4a0c244e4e5ae876263bab234b25307d4154ef3f91`). +The updated CLI rootfs is `out/rootfs-build.BP1Ylv/rootfs-aarch64.tar` +(SHA-256 `58eb153416972dca1c29a87d7b356638b8b1c08df56231470c18ee50fbad2d29`), +and SYSTEM is `out/system-build.o9d6affu/system.img` +(SHA-256 `cb544eb25f18b6572f78fc245818bd2cbfa18423b44d57b5af251069f2e6498e`). + +The complete recovery regression also passed against these updated artifacts in +`out/m12-recovery.idrwxslu/` (`out/logs/m12-machine-recovery-regression.log`). It +repeats the missing/damaged SYSTEM, actual DATA repair, interrupted-checker, +partition-reread, full SYSTEM burn/readback, diagnostics and replacement-boot +checks described above. This time the source SYSTEM is the updated CLI image +listed here, including the settings loader and corrected filesystem-root metadata. + +The updated normal CLI has already passed rootfs, SYSTEM construction, native +init, console independence and DATA regression checks. Logs are +`out/logs/m12-machine-cli-*.log`; native init evidence is `out/m2-vm.5FHPFv`, +console evidence is `out/m5-vm.ifuo9ei6`, and DATA evidence is +`out/m7-vm.6xcpnj97`. The measured VM console time was 1,865.948488 ms, +44.908700 ms above the earlier M10 CLI baseline, below the 100 ms review threshold. + +## Build-host regression + +After the host image-tool changes, `make bootstrap`, `make smoke-test`, and +`make check` all passed. Logs are `out/logs/m12-foundation-bootstrap.log`, +`out/logs/m12-foundation-smoke.log`, and `out/logs/m12-foundation-check.log`. +The smoke test cross-built and inspected GNU hello as aarch64 glibc and the Rust +smoketest as static aarch64-musl. Optional direct ARM execution was skipped because +the workstation lacks `qemu-aarch64`; the separate ARM VM/runtime suites above +still executed the actual OS tools. Upstream tracked Void files remain unchanged. + +## Development profile + +The native GNU C/C++ and Rust toolchains, build systems, Git, GDB, strace and Vim +are selected and documented in [Development](development.md). The expanded image +passed `make development-test` in `out/m12-development.r6mrgm0b/`. +The rootfs is `out/rootfs-build.p8DKvd/rootfs-aarch64.tar` (2,012,047,360 bytes; +245 packages and 1,731 AArch64 ELF files), and its SYSTEM is +`out/system-build.jnubdw1v/system.img`. Logs are +`out/logs/m12-development-clock-{build,rootfs,system,acceptance}.log`. + +The ordinary UID 1000 console compiled and ran C/C++ with CMake/Ninja, C with +Meson/Ninja and Make, and a dependency-free Rust project with offline Cargo. +GDB stopped at `main`, reported a backtrace and continued; strace captured the +program's write; a local Git commit and headless Vim edit also succeeded. +SYSTEM remained read-only and unchanged, optional services stayed off, and +native shutdown completed. Desktop/network acceptance then passed in +`out/m8-vm.4c6_jj4h/`, and the complete shutdown/DATA error suite passed in +`out/m10-vm.q_ewzl4g/`; see `out/logs/m12-development-{desktop,power}.log`. + +This test found a real startup issue: the VM's wall clock started at Unix second +1 while CMake's immutable inputs had the source timestamp 1,789,909,701. +Ninja consequently tried to regenerate its manifest repeatedly. Native startup +now advances only an older clock to the fixed image timestamp, without a time +server or waiting. The compiler acceptance verifies that path; a separate +`make clock-test` passed in `out/m12-clock.l39g3uhx/`, preserving a guest clock +set to 2030 before the actual startup code runs. Non-root clock changes are +rejected and boot measurements still use `Linux CLOCK_BOOTTIME`. +`out/logs/m12-clock-final.log` records that check. This is not physical RTC +validation and the floor does not supply the accurate current date. + +The first corrected-clock run also exposed a test transport mistake: interactive +Bash consumed the literal tab in a generated Makefile. Test files now cross the +serial console as base64, preserving their exact bytes. + +## Release signature tool + +`make signing-test` passed in `out/m12-signing.md4r2kbo/`, recorded in +`out/logs/m12-signing-public.log`. It builds both x86_64 and static-musl ARM +executables, checks the RFC 8032 Ed25519 vector, and independently signs/verifies +the exact manifest domain with OpenSSL. Host and ARM signatures match OpenSSL's +bytes. Wrong and weak keys, altered signature/manifest/artifact bytes, wrong +sizes, missing files, symlinks, FIFOs, path traversal, duplicates, unknown fields, +oversized manifests, insecure private-key permissions and output overwrites are +rejected. These are disposable test keys, not a signed OS release. + +The verifier is included in the base CLI package for all profiles. Its guide and +dependency rationale are in [Release signatures](releases.md), and the build now +collects license notices for all 68 resolved third-party ARM workspace crates, +including the vendored libusb notices. `make bootstrap`, `make smoke-test` and +`make check` passed after adding the signing dependencies, with logs under +`out/logs/m12-signing-{bootstrap,smoke,check}.log`. The later package-notice and +offline-build interface changes are being rebuilt and checked separately. + +## Repeated console timing checkpoint + +After the clock floor, packaged signer/notices and deterministic package epoch +changes, one console run measured 2,103.545197 ms. This exceeded the 100 ms +investigation threshold against the earlier single M10 sample. Five alternating +pairs then compared the preceding internal-settings CLI image with the updated +CLI image, using the same kernel and initramfs for every run. Evidence and image +hashes are in `out/m12-console-pairs.yuoz7unr/comparison.json`; all ten serial logs +are retained beside it. The execution log is `out/logs/m12-console-pairs.log`. + +| Image | Five console-ready samples, ms | Median, ms | +| --- | --- | ---: | +| Before | 1884.195231, 1909.087577, 1960.273690, 2055.400846, 1908.411769 | 1909.087577 | +| Updated | 1938.522887, 1913.188789, 1935.906888, 1958.748315, 1943.363749 | 1938.522887 | + +The median increased by 29.435310 ms, below the threshold. The initial updated +sample was outside the repeated updated range. This is workstation/QEMU evidence +including scheduling noise, not physical Pi performance; no artificial delay or +boot barrier was added. This checkpoint precedes Rust source-path normalization. + +## Complete build and release interface checkpoint + +`make packages` now uses the same complete base-package builder as rootfs +assembly. It passed in `out/logs/m12-packages-public.log`, including a fresh +cross-build of the Pi kernel. The preceding `make bootstrap`, `make smoke-test` +and `make check` passed in `out/logs/m12-offline-foundation-{bootstrap,smoke,check}.log`. +`make all` now sequences the complete image set. Its first complete run passed +in `out/logs/m12-all-first.log`. Subsequent rootfs and foundation checks passed, +as did the complete boot matrix (`out/m4-vm.kn9Hku/`), native development tools +(`out/m12-development.hpk59nul/`) and internal-storage acceptance +(`out/m12-internal.8z6nffpa/`). The corresponding logs are +`out/logs/m12-all-{recovery-rootfs,check,boot,development,internal}.log`. +These results precede source-path normalization in Rust release builds. + +The frozen-input, restore, network-isolation, comparison and signed-assembly +interfaces are documented in [Offline rebuilds](reproducible-builds.md). +`make release-contract-test` passed in `out/m12-release-contracts.yg33ry5r/` +(`out/logs/m12-release-contracts.log`). Its small fixtures verify rejection of +changed input bytes/modes/paths/symlinks, invalid lock metadata, differing or +missing artifacts, comparison of one tree with itself, changed input locks, +existing output reports/directories and private-key symlinks. Snapshot creation +also refuses an active base-package build. These contract checks do not stand +in for two actual complete offline builds. + +A settings-size regression test also passed: individually valid TOML comments +can expand during JSON escaping. The machine configuration now rejects an +encoded bundle exceeding its loader limit before installation writes any data. +All 15 shared-contract tests passed in `out/logs/m12-machine-encoding-tests.log`. + +## Earlier release checkpoints + +The user-requested [Clap migration](clap-validation.md) is complete: 48 Rust +tests, ARM CLI/alias execution, the complete image build, all runtime suites, +signing and Dasung checks, and repeated startup measurements passed. All Rust +command-line handling now uses the shared locked Clap dependency. The v4 +offline candidate was deliberately stopped during kernel compilation to include +this change; its second tree was not built and it has no accepted comparison. +The accepted v5 pair uses this updated source checkpoint. + +`make release-contract-test` also passed with the actual archive writer and the +documented extraction flags in `out/logs/m12-release-contracts-v4.log`: repeated +archives match, and extraction preserves ordinary executable modes, sticky +directories, symlinks and the complete frozen lock. Default tar extraction had +stripped those permissions in a small diagnostic fixture. The v3 candidate was +stopped during kernel compilation before acceptance so that the corrected +archive instructions and the completed user-guide audit could be frozen +together. It is not a failed runtime test or an accepted release build. + +The first real snapshot exposed two restore defects, now corrected: shallow +Void bundles need their Git boundary file, and xbps-src's cross repository +configuration retains only custom filenames containing `local`. The dependency +that revealed the second issue was already captured; no network fallback was +added. `out/logs/m12-offline-v1-a.log` and `out/logs/m12-offline-v2-a.log` retain +the failed attempts. The resumed v2 tree is a diagnostic probe, not a clean +release acceptance run. + +The fresh offline v2 compilation matched the normal build byte-for-byte for all +seven core FDS binaries, Dasung, the Pi kernel/DTB, and the kernel/Dasung packages. +Records are `out/m12-offline-v2-rust-comparison.json` and +`out/m12-offline-v2-native-comparison.json`. Personal absolute build paths are +absent from all eight release executables after path normalization; see +`out/m12-remap-binaries.json`. Bootstrap, smoke-test and checks passed in +`out/logs/m12-remap-{bootstrap,smoke,check}.log`. + +The documented larger-disk GPT relocation was checked on a disposable regular +file in `out/m12-internal-relocation.j29f1rx6/`. After extending the image by +512 MiB and relocating its backup GPT, independent partition-table verification +passed and every partition's start, size and payload SHA-256 remained unchanged. +This verifies the image operation only; the physical write/readback and Pi boot +procedure in [Internal storage](internal-storage.md) remains deferred. + +Those checkpoints preceded the final clean v5 pair. The acceptance record at +the top of this page closes the software release work with actual complete +builds, comparisons, signing, archive verification and the master-plan audit. diff --git a/docs/m2-validation.md b/docs/m2-validation.md new file mode 100644 index 0000000..58c4293 --- /dev/null +++ b/docs/m2-validation.md @@ -0,0 +1,142 @@ +# M2 native init validation + +[Documentation index](README.md) · [Boot and usage](init.md) · [Roadmap](roadmap.md) + +Validated on **2026-09-21 (Asia/Shanghai)** on the x86_64 Arch workstation. +M2's acceptance criteria passed in a complete ARM virtual machine: native s6 +owns PID 1, and `s6-rc change` controls an actual test service. This is a generic +ARM boot result, not a Raspberry Pi boot or performance measurement. + +## Recorded environment and outputs + +| Item | Result | +| --- | --- | +| Workstation | Arch Linux x86_64, Linux 7.2.6-arch2-1, Python 3.14.7 | +| Void source pin | `02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1` | +| Init packages | s6-linux-init 1.2.0.1_1, s6 2.15.0.0_1, s6-rc 0.6.1.1_1 | +| Emulation | QEMU system/user ARM 11.1.1_1; PRoot 5.2.0_1 for build-time child execution | +| VM | ARM `virt`, Cortex-A72 software emulation, 2 CPUs, 1 GiB RAM, AHCI disk, serial console | +| Generic test kernel | Signed Void `linux6.12-6.12.110_1.aarch64.xbps`; SHA-256 pinned in `config/vm-test.conf` | +| Image creator | mke2fs 1.47.4, libarchive tar import; no privileged mount | +| Final rootfs build | `out/rootfs-build.gSQUvr/`, built `2026-09-20T18:14:46Z` | +| Rootfs archive | 346,808,320 bytes; 109 configured packages; 1,167 ARM ELF paths | +| Successful boot test | `out/m2-vm.wYgNtQ/`; stable link `out/m2-vm-latest/` | + +Final rootfs SHA-256: + +```text +81113e027b5e65c7885aac9d4b88541de92470aaffb1fefd9508de30b97f0cad +``` + +VM disk SHA-256, verified unchanged after the automated boot: + +```text +00b48913a157de7a1107bf1bfff2c96db83dab48599fbe76be2ab433142d27d6 +``` + +The test directory retains `serial.log`, `command-line`, `test-environment.txt`, +`test-inputs.sha256`, `disk-before.sha256`, and `artifacts.sha256`. The rootfs build +retains its package inventory, exact package archives, build inputs, and hashes. +Convenience links may change after future builds; this report names the retained +successful directories. Rolling Void repositories are not a complete release +snapshot, and local FDS packages remain unsigned development artifacts. + +## What the ARM guest proved + +The VM booted the exported filesystem with its generated native init launcher. +The test overlay preserves all 54 entries in the actual compiled service database +and the original stage-2 script. Its hook runs the original stage 2 to completion, +then runs the checker as a child. It neither substitutes a shell PID 1 nor compiles +a different service graph. The checker is absent from the normal rootfs archive. + +The serial log records: + +```text +FDS_M2_PID1: /usr/bin/s6-svscan +FDS_M2_FILESYSTEM: read-only root, writable tmpfs, root ownership and capabilities +FDS_M2_BOOT: getty, eudev and Dasung supervised without a monitor +FDS_M2_SERVICE: s6-rc start, echo, supervised restart, stop and start passed +FDS_M2_PASS +``` + +The guest verified a read-only ext4 root, rejected a write under `/etc`, found +writable tmpfs at `/run`, `/tmp`, `/var/tmp`, and `/var/log`, and checked hostname, +root ownership, protected shadow permissions, and ping's restored capability. +Eudev answered its control-channel readiness request; getty and Dasung were up. + +`test-echo` started down. The checker brought it up through s6-rc, waited for +actual listener readiness, received an echo over its private Unix socket, restarted +it under supervision and observed a new PID, stopped it, started it again, and +stopped it before shutdown. Native s6 then completed power-off. The VM had no +network, host filesystem share, physical USB/display access, or host disk passthrough. +QEMU's disposable overlay preserved the backing disk. + +The interactive `make vm` workflow was also exercised: native PID 1, read-only +mounts, active-service listing, Dasung status, manual echo-service control, and +exit-to-power-off. Its transcript is `out/logs/m2-vm-shell.log`. Dasung returned +valid status JSON with `connected: false`, as expected without a monitor. The +explicit VM shell stops getty to take over the serial console; normal base +accounts remain locked. + +## Checks run + +These commands completed successfully during M2 validation: + +```sh +make bootstrap +make smoke-test +make check +make rootfs PROFILE=cli +make rootfs-test +make init-test +make dasung-test +make vm +``` + +Rootfs acceptance checks cover package integrity, ARM glibc execution, static +Dasung ELF properties, ownership/capability restoration, and rejection of wrong +ABIs, forbidden packages, incomplete configuration, host executables, missing +boot members, a shell init replacement, and unsupported profiles. + +Dasung regressions passed all six Rust unit tests, the serial simulator, package +inspection, and its independent s6 logger/socket/restart/stop test on a read-only +root. The final device-trigger guard changed only the init service definition; +the rootfs and full ARM boot checks were repeated afterward. + +Shell/Python syntax, local documentation links, English project-owned text, +recorded build/test source hashes, and unchanged upstream Void tracked files +were checked. The source pin was preserved. + +## Issues caught and fixed + +- The test kernel already mounts devtmpfs. Asking native init to mount `/dev` + again caused a genuine kernel panic. The generated launcher now consumes the + existing mount and leaves `/run` creation to s6-linux-init. +- The generated init template contains FIFOs. Auditing/export now permits them + only within its `run-image` subtree, and avoids reading them as ordinary files. +- The upstream CA installer hides updater failures. One build lacked the bundle + despite package configuration reporting success. Finalization now runs the + updater explicitly, checks its exit status, verifies the resulting cache, and + reports the failing command if any required finalization step fails. +- VM image creation uses a UTF-8 locale for libarchive's Unicode paths. AHCI uses + a QEMU snapshot overlay, with temporary files in the retained test directory. +- Initial device triggering now propagates a failed subsystem trigger instead + of continuing and reporting only the final command's result. + +The two essential internal s6-rc helpers remain until native shutdown's final +cleanup; warnings that the ordinary service-stop phase leaves them running are +expected. Known rootless `wall`, `write`, and `xbps-uchroot` ownership adjustments +remain handled and verified by archive export, as documented for M1. + +## Explicit skips and limits + +The M0 smoke script still skips its optional host-PATH `qemu-aarch64` execution +because QEMU is installed inside the project-local container. The separate ARM +userspace and complete ARM VM tests both passed. + +No Pi firmware, NVMe-to-cartridge root transition, production kernel/initramfs, +physical Dasung picture or cold-power recovery, bay mapping, DATA safety, +networking, or graphical profile was tested. There is no installable Pi image. +The VM kernel is only a test fixture. Its log timestamps and 180-second test +deadline are not boot or shutdown benchmarks. M3 and later implementation remain +outside this change. diff --git a/docs/m3-validation.md b/docs/m3-validation.md new file mode 100644 index 0000000..ab64250 --- /dev/null +++ b/docs/m3-validation.md @@ -0,0 +1,40 @@ +# M3 static Rust tooling validation + +Validated on 2026-09-21 (Asia/Shanghai), x86_64 Arch Linux with pinned Rust 1.98.0. +This records the M3 diagnostic foundation before M4 adds the real boot handoff. +See [tooling usage](tooling.md) and the [through-M12 ledger](implementation-status.md). + +Commands completed successfully: + +```sh +make tooling +make tooling-test +make bootstrap +make smoke-test +make check +``` + +Both exported binaries were AArch64 static ELF executables without a dynamic +interpreter, shared-library requirements or GLIBC symbol requirements. Supplemental +`ldd` printed `not a dynamic executable`. At this milestone: + +| Artifact | Bytes | SHA-256 | +| --- | --- | --- | +| `out/fds` | 537424 | `04052554eef3c592230aa912d6dbcaec9bc37c168c0e240b924a4fb215a68815` | +| `out/fds-stage0` | 375824 | `930afd537aaf8e90715e5ec73d70480b44fbe9dfe3c0615014ed78b36239bd7c` | + +Six shared-library tests passed. Actual ARM execution then verified tool identity, +JSON output, the master-plan manifest example, strict/bounded manifest rejection, +missing/unique/ambiguous SYSTEM selection, device-path rejection, explicit recovery +selection, and duplicate boot-option rejection. All filesystem fixtures were +temporary; no physical block device or monitor was accessed. + +Build/source hashes are in `out/manifests/fds-tools*.sha256`; logs are in +`out/logs/m3-tooling.log`, `m3-tooling-test.log`, `m3-bootstrap.log`, +`m3-smoke-test.log`, and `m3-check.log`. Later milestones will replace these tool +artifacts; the hashes above describe the validated M3 version. + +The M0 smoke script's optional host-PATH QEMU execution was skipped, as before; +the M3 integration script explicitly ran both ARM tools using container QEMU. +No physical Pi result or boot-speed claim is made. M3's stage0 refused normal +boot invocation rather than claiming its diagnostic skeleton could switch roots. diff --git a/docs/m4-validation.md b/docs/m4-validation.md new file mode 100644 index 0000000..18acba2 --- /dev/null +++ b/docs/m4-validation.md @@ -0,0 +1,93 @@ +# M4 software boot validation + +Validated on the x86_64 Arch workstation on 2026-09-21 (Asia/Shanghai). +Physical Pi testing is deferred by the user. This report establishes the +software boot machinery, not the physical NVMe/RP1/display path or performance +targets. M5 supplies the ordinary-user FDS console and timing instrumentation. + +## Verified path + +The cross-built Pi 5 kernel loaded the actual Rust-only initramfs, discovered +GPT partition identity, mounted EROFS read-only, transferred proc/sys/dev mounts, +stopped and reaped the early Dasung controller, and executed native s6. Guest +checks confirmed 16 KiB pages, s6-svscan as PID 1, the static packaged FDS CLI, +matching module release, loading/unloading the `dummy` kernel module, and one +base Dasung process. Ownership, ping capabilities, volatile filesystems and +start/restart/stop of the test service also passed. + +The final VM matrix, `out/m4-vm.flkV6j/`, passed nine cases: + +1. Uncompressed initramfs. +2. Gzip initramfs. +3. Legacy-LZ4 initramfs. +4. Zstandard initramfs. +5. No SYSTEM at startup, followed by virtual USB insertion and automatic boot. +6. Two SYSTEMs refused, followed by explicit console recovery. +7. Recovery selected by kernel option while SYSTEM is also present. +8. Invalid SYSTEM filesystem refused, with recovery still usable. +9. Foreign OS root refused, with recovery still usable. + +Each case reached the guest checks and orderly power-off. Input image hashes +were unchanged. The test uses QEMU 11.1.1, `virt`, `max` CPU, TCG, 1 GiB RAM and +two virtual CPUs, without networking or host-device passthrough. Its recovery +disk is a selection fixture, not the independent M12 recovery product. + +The FAT32 boot builder produced a 512 MiB `FDS_BOOT` partition, checked it with +`fsck.fat`, extracted it with mtools and compared every file hash. Two consecutive +builds matched byte-for-byte. Two full kernel builds also produced identical +kernel Image, Pi 5 DTB and XBPS package hashes. These narrow reproducibility +results do not establish a complete offline release rebuild. + +## Commands and artifacts + +The completed sequence included `make kernel`, `make tooling`, `make tooling-test`, +`make rootfs PROFILE=cli`, initramfs construction, and these final regression gates: + +```text +make bootstrap +make smoke-test +make check +make rootfs-test +make init-test +make system-card +make boot-volume +make boot-test +make dasung-test +``` + +Final logs are `out/logs/m4-final-*.log`; rootfs construction is recorded in +`out/logs/m4-rootfs.log`. The configured rootfs is `out/rootfs-build.p0HSQv/`: +111 packages, 1,168 inspected ARM ELF files, and a 407,009,280-byte tar. + +| Artifact | SHA-256 | +| --- | --- | +| Rootfs tar | `ba06bdcb8f4fc537c8cb4ce9d31404d3cd1d14f01b41cb597a53ec48f0ba6710` | +| SYSTEM GPT image | `d15af6feccbaeba77920e0b5ee13076dbdc80a076c973068a1f68e86bb599bf1` | +| Pi kernel Image | `78f027ad3124da7b06d4559f49ea7f63c312cd24e653d0f8e29ee2391f520bc4` | +| Kernel XBPS package | `6c361df5bf31ef517b6bad7e44220a8126f7c2fac341379e8c1d6b39fcbe8c20` | +| Uncompressed initramfs | `bc8905c5ca40fe827a44f7dd73f88aa1c99a3b84fa474535ccb36a212682b514` | +| Production FAT boot partition | `940b7d62ba5923a3f92db6939aca1534f0f2061376c1fec7604a50e6d3b3f0b8` | + +The kernel is 6.12.87-fds1, built from Raspberry Pi commit +`ae4d75fb36deba0fe1a986d9bfae65755e082dd0` with the FDS configuration delta. +Void remains pinned to `02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1` with no tracked +upstream changes. Firmware package/version/hash are pinned in +`config/pi-firmware.conf`. Detailed package inventories and source inputs remain +beside each versioned build. + +## Findings and limits + +The original Cortex-A72 VM CPU did not support the retained 16 KiB kernel page +configuration; `max` successfully runs the Pi kernel. A repeated VM test exposed +getty's shutdown hangup discarding pending serial output. The checker now stops +getty after verifying it and reopens the console before final service checks; +no boot delay was added. + +The M0 host-PATH QEMU execution remains an optional skip. Actual ARM userspace +and full-system VM execution passed using project-local emulators. Physical +Pi firmware boot, EEPROM changes, NVMe/RP1, monitor EDID acceptance, image quality, +cold-power display recovery, bay wiring/power and real boot/shutdown timings +remain explicitly untested. + +Use the [boot guide](boot.md) to reproduce these artifacts and understand the +distinction between a boot partition, SYSTEM cartridge and complete internal disk. diff --git a/docs/m4-work.md b/docs/m4-work.md new file mode 100644 index 0000000..27e996e --- /dev/null +++ b/docs/m4-work.md @@ -0,0 +1,57 @@ +# M4 boot integration development record + +The user has authorized the remaining software through M12. This page records +M4 development decisions. Software validation is complete; see [M4 evidence](m4-validation.md). Physical Pi validation remains deferred. + +The Pi kernel overlay starts from the exact source commit and archive checksum in +Void's pinned `rpi5-kernel` recipe (6.12.87). It uses `bcm2712_defconfig` plus the +small FDS delta, verifies Kconfig actually applied that delta, and exports the Pi 5 +Image, DTBs, overlays, and modules without modifying upstream Void files. +The upstream Pi 5 16 KiB page configuration is retained. + +Critical storage, EROFS, keyboard, console and VC4 display support are built in. +VC4 also requires the SND/SND_SOC core to be built in; optional sound devices can +remain modules. CH341 SPI is disabled for the Dasung companion bridge. Generic +ARM VM drivers are built into this same kernel so we can exercise software boot +without claiming to emulate Pi firmware or RP1. Debug/BTF artifacts are omitted; +module authentication is part of immutable image/release authentication rather +than random build-generated module keys. + +`make kernel` uses the existing Void build container and cross compiler. Its +additional host dependencies are Perl and Python for kernel generation, bc for +build-time arithmetic, Bison/Flex for Kconfig, kmod for module indexes, OpenSSL +headers for kernel build utilities, and archive tools. OpenSSL is a host build +dependency, not a new Rust runtime dependency. The source and checksum are recorded +in `packages/fds-kernel/template`. + +Image builders use a separate project-local prefix at `.host/image-tools` for +signed Void x86_64 `erofs-utils`, `dosfstools` and `mtools`, plus their resolved +libraries. They create filesystem images without privileged loop mounts and do +not mutate the shared kernel build container. Versions and cached inputs are +recorded under `out/manifests/image-tool-*`; full input locking is M12 work. + +Stage0 now has a Rust PID-1 implementation: direct filesystem +syscalls, authenticated kernel uevents, explicit normal/recovery partition +selection, early Dasung supervision, EROFS validation, mount transfer, and exec +of native s6. Its normal path refuses to run outside root PID 1 on ramfs/tmpfs. +The implementation reuses the locked `libc` crate for these Linux syscalls. +The full initramfs matrix passed all four formats, missing-media USB insertion, +ambiguous SYSTEM recovery, explicit recovery, invalid filesystems and foreign +root rejection. A refreshed base package/rootfs build is being completed. +QEMU uses its `max` CPU because the retained 16 KiB Pi kernel cannot execute +on the old Cortex-A72 VM model. + +The FAT32 boot builder verifies every file after reading it back with mtools. +Directory timestamps are normalized as well as file timestamps; two consecutive +builds from the same inputs produced the same complete image SHA-256. This narrow +result is not a claim of complete offline or release reproducibility. +Two complete kernel builds also produced identical kernel Image, Pi 5 DTB and +XBPS package digests. The helper now reuses a build after checking recorded +source inputs, its package and all exported files. `--rebuild` bypasses reuse. + +The [boot guide](boot.md) documents usable build commands, image boundaries, +waiting-console commands, display policy, and remaining physical checks. + +References: [Raspberry Pi cross-compilation](https://www.raspberrypi.com/documentation/computers/linux_kernel.html#cross-compile-the-kernel), +[Linux initramfs](https://docs.kernel.org/filesystems/ramfs-rootfs-initramfs.html), +and [root handoff semantics](https://man7.org/linux/man-pages/man8/switch_root.8.html). diff --git a/docs/m5-validation.md b/docs/m5-validation.md new file mode 100644 index 0000000..72c70d2 --- /dev/null +++ b/docs/m5-validation.md @@ -0,0 +1,69 @@ +# M5 software validation + +M5's ordinary-user console and software timing tools passed on the x86_64 Arch +build host using the actual AArch64 kernel, initramfs and SYSTEM image in QEMU +11.1.1. Physical Pi boot, power-on, display and timing acceptance is deferred. +These runs completed on 2026-09-20 UTC (2026-09-21 in Asia/Shanghai). + +## Usable console and event evidence + +`make console-test` passed in `out/m5-vm.fuml66z0/`: + +- Native s6 starts the local autologin console as `fds`, UID/GID 1000. Commands + execute at `FDS>`, HOME and working directory are `/home/fds`, the temporary + home accepts writes, and SYSTEM rejects writes. +- Reports contain stage0 entry, SYSTEM discovery, root mount, root switch, + s6 stage-2 entry and first-console readiness for the current boot. Desktop + readiness is absent. No missing event is replaced with a zero timestamp. +- Exiting and restarting the console preserves the first readiness event. +- Holding eudev startup behind a test FIFO does not prevent the usable prompt. + The test releases that gate after executing a console command. +- Saved-report comparison rejects an unexplained regression greater than + 100 ms, permits an explicit explanation, and rejects incompatible platforms. + +The normal run measured 1,669.027 ms kernel-clock-to-console and 664.032 ms +s6-stage-2-to-console. The blocked-eudev run measured 1,659.990 ms and +581.113 ms. These are individual emulator observations, not physical budgets. +Nine further boots compare Rust optimization levels in +`out/rust-profiles.x_wgx1kp/`; see [Performance](performance.md) for the table, +raw report locations, clock boundaries and decision to retain `opt-level=z`. +`make console-vm` was also opened interactively and verified UID 1000. + +## Build execution correction + +PRoot intermittently aborted during ARM subprocess execution on this host. One +failure also returned a misleading zero status. Runtime checks now require an +explicit final child acknowledgement, and finalization propagates failures. +The image builder now uses a private user-namespace `binfmt_misc` registration +with static QEMU, requiring Linux 6.7 or newer. It leaves the workstation's +registration table unchanged. The existing Void `util-linux` package provides +mount and capability-drop helpers; no target package was added for emulation. + +`tests/integration/rootfs-emulation.py` verified nested ARM execution, exact +nonzero exit propagation, isolation from the host registry and removal of +setup capabilities before target execution. Only namespace-local SETFCAP remains +for package configuration. A fresh complete rootfs build passed afterward. +Historical M1/M2 reports retain the older PRoot workflow as historical evidence. + +## Final artifacts + +| Artifact | SHA-256 | +| --- | --- | +| Rootfs TAR, `out/rootfs-build.9kTEAH/` | `eef5764034d315b5e012679ef7ed165c6961b69aa88f9514c77cf08ef67bf8f7` | +| SYSTEM GPT/EROFS, `out/system-build.o948uzwd/` | `d2e00f56f1ecc689de455a9066259cefa95bd0bbd271e7f2d3d4cd3235dffd91` | +| Uncompressed initramfs, `out/initramfs-build.dzggpetk/` | `2a6009800f4c73707b165a3a3142b5baff688c685b7ea9fcd196e9ab6079a4de` | +| Production FAT32 boot partition, `out/boot-build.bzakcn8s/` | `cd242813ded7c860d291c1dbf984bf69b48cc3b58da93f9bdebf83068139abf1` | +| Kernel Image, unchanged from M4 | `78f027ad3124da7b06d4559f49ea7f63c312cd24e653d0f8e29ee2391f520bc4` | + +The rootfs contains 111 packages and 1,169 AArch64 ELF files. Its TAR is +407,480,320 bytes. Per-build package/input inventories accompany the image. +The boot FAT is a partition image, not a complete internal NVMe disk. + +The final sequential regression run exited zero: `make bootstrap`, +`make smoke-test`, `make tooling-test`, `make check`, `make rootfs-test`, +`make init-test`, `make system-card`, `make boot-volume`, `make boot-test`, +`make console-test`, and `make performance-test`. Logs are +`out/logs/m5-verified-*.log`. The native-init VM is `out/m2-vm.sYk3Hu/`; +the nine-case stage0 matrix is `out/m4-vm.RHUJX5/`. Shared Rust tests passed. +M0 still reports its optional host-PATH QEMU skip; project-local ARM execution +and full-system emulation actually ran. No physical boot benchmark is claimed. diff --git a/docs/m6-validation.md b/docs/m6-validation.md new file mode 100644 index 0000000..dde5b90 --- /dev/null +++ b/docs/m6-validation.md @@ -0,0 +1,74 @@ +# M6 cartridge software validation + +Verified on 2026-09-21 on the x86_64 Arch build host. These results use actual +AArch64 binaries, kernel USB events, s6 services and filesystem mounts in QEMU +11.1.1. Physical Pi wiring, bay calibration, simultaneous physical devices and +USB electrical behavior remain deferred. Twelve-device virtual stress follows +in M11; this milestone does not claim that test has already run. + +## Behavior verified + +`make cartridge-test` passed in `out/m6-vm.u4w9b7pl/`: + +- The unconfigured production image reports twelve UNCONFIGURED bays. A probe + discovers the virtual controller's actual topology; the fixture then maps its + USB 2/3 companion ports explicitly. No `/dev/sdX` name selects a bay. +- The SYSTEM image boots from virtual USB and is reported PROTECTED. Eject fails. +- A virtual keyboard is recognized by the hardware catalog without a filesystem. +- The ordinary FDS user can query the daemon. A different UID is rejected even + when the test temporarily relaxes socket file permissions. Slow and oversized + clients do not prevent another control request from completing. +- Inserting ENVIRONMENT media triggers a validated read-only mount. Writes fail. + Metadata is inspected privately before the mount becomes publicly accessible. +- A shell inside the mount and an additional bind mount each prevent SAFE. + After those uses end, eject unmounts successfully and reports SAFE. A service + restart preserves that ejected state until removal and reinsertion. +- Reinsertion into another port follows that bay. Wrong manifest classes, + symlink metadata, and multiple named partitions are rejected without choosing + a first candidate. Surprise removal clears inventory; restart remounts valid, + nonejected read-only media. +- Holding cartridged before its readiness notification does not prevent the + ordinary UID-1000 FDS console from accepting a command. + +Every VM disk used read-only remained byte-identical. Tests use disposable image +files and virtual USB devices, never host disks or physical USB passthrough. +VM test helpers and the temporary root test console do not ship in SYSTEM. + +Shared unit tests also cover USB bus renumbering, mapping aliases and overlapping +mappings, manifest symlinks/FIFOs, malformed contracts, and absent USB subsystems. +The older native-init VM revealed the absent-USB case; its corrected empty-inventory +behavior passed a new unit test and the complete native-init boot afterward. + +## Final artifacts and regression evidence + +| Artifact | SHA-256 | +| --- | --- | +| Rootfs TAR, `out/rootfs-build.FpSNiJ/` | `00fc851affe0f1c42d289cc05ae376788beae9a6b0dce39c6a1f3b86687d1baf` | +| SYSTEM, `out/system-build.df52fwqj/` | `908645b47bfe207470eeb82436f8a644d34cf3703156a7bdcbf2a3534e9346fe` | +| Production boot FAT, `out/boot-build.bka9vs1y/` | `fb54582a9bd3bb393299002a65a5b0035e806e6733e81a437369a0b152b297e2` | + +The rootfs contains 112 packages and 1,170 AArch64 ELF files; its TAR is +408,248,320 bytes. `fds-cartridged` is statically linked, installed by its own +mandatory base package and supervised by native s6. No external Rust dependency +or locked external version changed; the lockfile change only adds the workspace +member. Existing libc, serde, serde_json and toml provide the required interfaces. + +The final pipeline exited zero: fresh rootfs and initramfs, tooling tests, +`make check`, rootfs checks, native-init boot, SYSTEM/boot-volume construction, +the nine-case stage0 matrix, console tests and cartridge tests. Logs are +`out/logs/m6-verified-*.log`. M0 bootstrap and smoke-test also passed in +`out/logs/m6-final-bootstrap.log` and `out/logs/m6-final-smoke-test.log`. +The optional M0 host-PATH emulator skip remains explicit; project-local ARM +execution and full-system tests actually ran. + +Native-init evidence is in `out/m2-vm.S16Noo/`; stage0 cases are in +`out/m4-vm.XBTVEW/`; ordinary-console traces are in `out/m5-vm.51n612y3/`. +The normal trace measured 1,753.878 ms from the kernel clock to console, versus +1,669.027 ms in the final M5 normal trace: +84.851 ms, below the 100 ms explanation +threshold. The actual ARM comparison tool passed; see +`out/logs/m6-boot-regression.log`. These are individual emulator observations, +not proof of physical Pi latency or a statistically established timing change. + +Writable DATA, consumer shutdown and syncfs follow in M7; desktop/profile +activation follows in M8. Read [Cartridge usage](cartridges.md) for exact commands, +configuration syntax, current states and safe-eject boundaries. diff --git a/docs/m7-validation.md b/docs/m7-validation.md new file mode 100644 index 0000000..1495cc6 --- /dev/null +++ b/docs/m7-validation.md @@ -0,0 +1,75 @@ +# M7 writable DATA validation + +Software acceptance passed on 2026-09-21 using the x86_64 Arch build workstation, +QEMU 11.1.1, the actual AArch64 Pi kernel and static FDS tools. Physical Pi, +removable-media power loss and real shutdown/eject latency remain deferred. + +## DATA acceptance workload + +`make data-test` passed in `out/m7-vm.drg2a4ml/`: + +- Without DATA, the ordinary UID-1000 console can write its temporary home. + Inserting a valid ext4 DATA cartridge activates `/data`; user writes persist + through eject and reinsertion while the temporary home remains separate. +- An unmanaged shell working directory and an extra bind mount each prevent + SAFE. The additional-mount check runs before stopping managed programs. +- A managed writer and its child run as UID/GID 1000 with no effective + capabilities and `no_new_privs`. At least 128 MiB is written while repeatedly + overwriting a 64 MiB file. Both processes intentionally ignore TERM. +- Eject escalates to kernel cgroup termination after its exit deadline, observes + that the complete group has exited, runs syncfs, closes its writeback handle, + unmounts normally and only then reports SAFE. The managed process count is zero. +- SAFE persists across a daemon restart until removal/reinsertion. Reinsertion + reads the previously saved user file, followed by another successful eject. +- Two DATA candidates in one discovery snapshot remain read-only. Explicit + selection activates the requested bay; another request cannot replace an + active session underneath its users. +- Pulling DATA during dirty writes produces actual kernel storage errors. The + daemon stops the remaining managed descendants and clears the vanished mount. + Eject afterward fails; this path never claims SAFE or a clean filesystem. + +After the VM releases its disks, the test extracts each safely ejected ext4 +partition into an ordinary file. Host e2fsprogs 1.47.4 runs `e2fsck -fn` on both; +both exit zero. `debugfs` extracts the 64 MiB stress payload, whose size and SHA-256 +match the complete expected byte pattern. No host filesystem mount is involved. +See `primary-fsck.log`, `secondary-fsck.log`, `filesystem-tools.log`, and +`surprise-removal-kernel.log` in the evidence directory. + +During development, removal exposed partially disappearing USB sysfs entries. +Discovery now skips incomplete individual entries, retains a still-present +mounted disk identity, and does not restart the daemon merely because another +USB device is being removed. Fixture checks and the final hotplug workload passed. +Kernel diagnostics sharing the test serial console are preserved in logs and +excluded only from the CLI JSON parser; errors are not treated as successful I/O. + +## Final image and regressions + +| Artifact | SHA-256 | +| --- | --- | +| Rootfs TAR, `out/rootfs-build.wFuKoS/` | `cdd7c2c8809bf144234470839cc082014f9757276e1d2f769496511c056fe127` | +| SYSTEM, `out/system-build.y7aiuhbi/` | `1675f9225ccca746045f7f999fed5b9b48d7bdd23869aa65a90f97a4219aafab` | +| Production boot FAT, `out/boot-build.abgq4799/` | `fb54582a9bd3bb393299002a65a5b0035e806e6733e81a437369a0b152b297e2` | + +The rootfs has 112 packages, 1,170 AArch64 ELF files and a 408,309,760-byte TAR. +M7 adds no external Rust dependency or target package. Its process tracking uses +already-enabled Linux cgroup v2 and PID-limit support. cgroup setup belongs to +the cartridge daemon and adds no console dependency. Test filesystem tools are +the host e2fsprogs tools already used by the M2 workflow. + +The final sequential pipeline exited zero: fresh rootfs and initramfs, tooling +checks, formatting/negative checks, rootfs checks, native-init VM, SYSTEM and boot +volume, all nine stage0 cases, ordinary console tests, cartridge tests and DATA +tests. Logs are `out/logs/m7-verified-*.log`. Native-init evidence is +`out/m2-vm.1sPKEy/`; the cartridge regression is `out/m6-vm._cgfbexe/`. +The temporary root test console and writer binary are confined to disposable +fixtures and do not ship in the production rootfs. + +The ordinary-console trace in `out/m5-vm.6_55lhge/` measures 1,769.585 ms from the +kernel clock, versus M6's 1,753.878 ms: +15.707 ms. The ARM regression comparison +passes the 100 ms explanation threshold (`out/logs/m7-boot-regression.log`). +These individual VM observations do not establish Pi performance targets. + +Attached-media writeback fault injection, broader stress and complete shutdown +ordering remain part of M10/M11. Physical flash-controller cache and power-loss +behavior require hardware. Read [DATA usage](data.md) for the implemented user +workflow and [the ledger](implementation-status.md) for remaining milestones. diff --git a/docs/m8-validation.md b/docs/m8-validation.md new file mode 100644 index 0000000..220ba65 --- /dev/null +++ b/docs/m8-validation.md @@ -0,0 +1,119 @@ +# M8 desktop and network validation + +[Usage guide](desktop.md) · [Implementation ledger](implementation-status.md) + +M8 software acceptance passed on 2026-09-21, including corrected CLI and +development images and the complete M0–M7 regression suite. These results concern +actual ARM software under QEMU, not a physical Raspberry Pi or E-Ink display. + +## Functional acceptance + +`make rootfs PROFILE=development`, `make desktop-test`, `make rootfs-test`, and +`make system-card PROFILE=development` passed. Evidence is retained in +`out/m8-vm.kf5ohhq8/` and `out/logs/m8-verified-*.log`. + +The test verified: + +- No X server, WindowMaker, or DHCP client runs by default. +- The ordinary FDS user can request and stop the graphical session. +- X11 requires its authority cookie; a client without the cookie is rejected. +- WindowMaker manages a real xterm, and injected X11 keyboard input executes a + command inside that terminal as the ordinary user. +- The screenshot shows the shipped grayscale controls, Terminus font, white + background, and FDS terminal prompt. It is `desktop.png` in the evidence folder. +- No user font cache is generated when the session starts. Image construction + normalizes directory timestamps before generating the packaged cache. +- Stopping the desktop leaves no Xvfb, WindowMaker, or xterm process; Dasung + remains running. Starting a second session succeeds without a reboot. +- Each activation gets its own request/readiness times. The boot report retains + the first desktop-ready event instead of replacing it on restart. +- A recognized ENVIRONMENT cartridge starts the desktop. Safe eject and surprise + removal stop it. Manual deactivation remains effective while media is inserted; + removal/reinsertion permits automatic activation again. Unknown profiles do not + start a session. +- A PROGRAM cartridge initially disallows execution and never autoruns. Explicit + launch runs as UID 1000 with the application/library paths set. A symlink + escaping `app` and a path traversal name are rejected. Eject stops managed + consumers before unmounting and reporting SAFE. +- A virtual USB Ethernet adapter activates DHCP and receives a real virtual + lease. DHCP-provided DNS reaches the volatile resolver file. Explicit network + off stops the client and leaves the managed interfaces administratively down; + restarting networking obtains an address again. +- With Ethernet present but no DHCP server, the console and service controls + remain usable. DHCP readiness is never a lease wait. + +The measured first activation was **1048.946380 ms** from request to WindowMaker +readiness in this software-emulated VM. This is a measurement of this run, not a +Pi performance claim. `activation.json` contains the raw CLOCK_BOOTTIME values. + +## Retained development artifacts + +| Artifact | Location | SHA-256 | +| --- | --- | --- | +| Development rootfs, 499210240 bytes | `out/rootfs-build.eTfJu2/rootfs-aarch64.tar` | `d3ee3b622c2ce9c34783a72a6b40d6a29b624768ac978b6db840347e32ea46c0` | +| Development SYSTEM GPT/EROFS | `out/system-build.p61o8p_o/system.img` | `e7c5c2e716260969eacbab3cd3b0a6bae16005826de1a90f0c325f3f60125855` | + +The rootfs audit counted 191 packages and 1402 AArch64 ELF files. Its package +archives, signatures, versions, scripts, ownership, and hashes are retained +beside the rootfs. The graphical test uses a disposable root-console fixture with +Xvfb; the normal image still selects Xorg and the ordinary-user console. + +## Regression acceptance + +The corrected CLI rootfs contains 180 packages and 1390 AArch64 ELF files. +`make tooling-test`, `make check`, `make rootfs-test`, `make init-test`, +`make boot-test`, `make console-test`, `make cartridge-test`, and `make data-test` +all passed. Bootstrap and smoke-test also passed after the namespace-wrapper +change; their logs are `out/logs/m8-final-bootstrap.log` and +`out/logs/m8-final-smoke.log`. + +| Artifact | Location | SHA-256 | +| --- | --- | --- | +| CLI rootfs, 495953920 bytes | `out/rootfs-build.0ayTj2/rootfs-aarch64.tar` | `1b82905789d00ba78f4474ad8613303c1d5684ba7f5f55a30cdde785a35cae06` | +| CLI SYSTEM | `out/system-build.jjgm8xox/system.img` | `be530d8076fffe1ca0de7951952e1bcec763bf6e811d068e40f75961ca3e46e1` | +| FAT32 boot partition | `out/boot-build.newd2icx/boot.fat` | `518deb1b8bee9f013d60c4cd22bea4380357e001db558a74026e2511da2c50a0` | + +The boot matrix is in `out/m4-vm.TPiKHf/`, console checks in +`out/m5-vm.f9612amj/`, cartridge checks in `out/m6-vm.cnvl_fxp/`, and sustained +DATA writes plus independent filesystem checks in `out/m7-vm.rko_14_q/`. +The corrected archive and booted guest both verify the terminal-accounting +helper's `root:utmp` ownership, and the guest verifies loopback is up. + +The normal kernel-to-console observation was 1854.724813 ms, compared with +1769.584786 ms in `out/m5-vm.6_55lhge/`: **+85.140027 ms**. The actual static ARM +`fds-boottrace compare` passed without a regression exception; output is in +`out/logs/m8-verified-boot-comparison.log`. This single software-emulated comparison +is below the 100 ms review threshold and does not establish Pi performance. + +## Test corrections and build findings + +The original `xprop -spy` approach could miss readiness when the EWMH atom was +created during startup. The final static helper speaks the small required part +of X11 directly: it installs its event subscription before starting WindowMaker, +then observes the supporting-window property. It adds no external Rust crate or +Xlib dependency to the FDS executable. + +Terminus needed its supplied fontconfig acceptance rule enabled. Font caches +also needed the same normalized timestamp as the exported archive; otherwise +image creation invalidated directory timestamps and caused a user cache rebuild. +The final VM test checks the resulting behavior. + +QEMU's restricted user network omitted DNS from its DHCP offer. The test now +runs QEMU in a separate network namespace and uses its normal virtual DHCP/DNS +backend there. That namespace has no host or Internet route. QEMU diagnostics +are saved separately from guest serial output, so emulator messages cannot be +mistaken for a CLI JSON response. No workstation network interface, physical +USB device, or physical block device is passed to the guest. + +## Deferred acceptance + +Pi DRM/VC4 output, virtual-terminal switching on the physical machine, monitor +resolution and E-Ink legibility/refresh quality, physical keyboard/pointer input, +and real CLI-to-desktop latency remain untested. The base Dasung daemon is still +mandatory and independent of optional services. Its physical recovery behavior +remains subject to the [Dasung hardware procedure](dasung.md). + +The development profile currently adds X11 diagnostics. The broader development +toolchain, independent recovery, image signing, complete offline input lock, and +release packaging remain part of M12. Full shutdown ordering and broader fault +injection remain M10/M11 work. diff --git a/docs/m9-validation.md b/docs/m9-validation.md new file mode 100644 index 0000000..e6a3385 --- /dev/null +++ b/docs/m9-validation.md @@ -0,0 +1,158 @@ +# M9 media-tool validation + +[Media-tool usage](media-tools.md) · [Implementation ledger](implementation-status.md) + +M9 software acceptance passed on 2026-09-21. Both packaged profiles, ARM image +construction, confirmed virtual USB writes, SYSTEM creation/write/boot, and the +earlier-milestone regression checks passed. All devices in these tests are +disposable regular files exposed only to isolated ARM virtual machines. Physical +Pi, media-controller and display tests remain deferred. + +## Verified behavior + +`make media-image-test` created SYSTEM, DATA, PROGRAM and ENVIRONMENT images with +the actual static ARM `fds-burn`. Independent `sfdisk`, Python SHA-256, +`fsck.erofs` and `e2fsck` checked the outputs. Invalid GPT extents, extra partitions, +internal partition labels and inconsistent backups were rejected even when the +fixture recomputed valid CRCs. Existing output files and recursive source/output +layouts were refused. Evidence: `out/m9-images.zqojr8hc/`. + +`make media-test` verified these cases through the ordinary FDS user's CLI: + +- Inspection reports the bay's capacity, logical sector size, model, serial and + kernel insertion identity. The running SYSTEM and mounted media refuse writes. +- Preview and cancellation leave the target unchanged. A confirmation is bound + to its operation, bay, insertion and image hash; a wrong phrase cannot proceed. +- Changing source bytes after preview is detected before writing. Replacing the + device in the same bay invalidates the old confirmation and leaves the new + insertion untouched. +- A PROGRAM image is written, flushed, read back and marked SAFE. Its backup GPT + moves to the end of a larger device. SAFE survives a daemon restart; a new + insertion mounts normally and supports explicit unprivileged execution. +- PROGRAM, DATA and ENVIRONMENT images are created on the running ARM machine. + DATA permits ordinary-user writes, retains data across removal/reinsertion and + passes independent host `e2fsck` after safe eject. +- Cartridge status remains usable while a separate write worker is active. + Source DATA cannot be ejected while the worker holds its image, including + across the worker's private mount namespace. +- Killing the cartridge daemon during an awaiting-confirmation operation leaves + a failed record on restart. It never becomes SAFE or automatically remounts. +- A valid EROFS/GPT containing an approximately 60 KiB malformed manifest is + rejected before writing. Its diagnostic stays bounded and printable, the + daemon retains the same process ID, and subsequent status requests work. + +The final virtual USB evidence is `out/m9-vm.cdlj6_r7/`. The crash case uses native s6 +supervision controls; see the [upstream s6-svc interface](https://skarnet.org/software/s6/s6-svc.html). + +## SYSTEM creation through boot + +`tests/integration/m9-system.py` mounts a complete prepared SYSTEM after boot, +then runs the real ARM filesystem/image builder against that read-only tree. +The ordinary user previews and confirms a write to another virtual cartridge. +After device flush and readback verification, a fresh VM boots the written disk +through the actual Pi kernel, stage0 and native s6 to the ordinary FDS prompt. +The test verifies UID 1000, read-only SYSTEM behavior, Dasung, the preserved +`root:utmp` terminal-helper ownership, and packaged Terminus caches without a +new user cache. Final evidence: `out/m9-system.ej8oz_av/`. + +This demonstrates creation and boot of a usable SYSTEM on ARM, not just a valid +partition header. It does not emulate Raspberry Pi firmware, physical NVMe/RP1, +USB power behavior or Dasung video output. + +## Implementation and dependencies + +`fds-burn` is static-musl Rust. `fds-inspect` and `fds-eject` are static aliases of +`fds`; the daemon starts a bounded worker for each media operation. The worker +opens the exact disk with exclusive access, checks live identity again before +writing, validates the image's filesystem and manifest, and verifies actual +readback after flushing and invalidating the device cache. A root-owned record +prevents interrupted operations from acquiring a false SAFE status after restart. + +Protection checks cover mounts, swap, kernel holders and the three reserved +internal partition names. Existing primary and backup GPTs are also inspected +directly, so protection does not depend solely on partition uevent timing. Native +4 KiB logical-sector disks are explicitly unsupported by this writer. + +The eight new locked Rust dependency entries are explained in the +[media guide](media-tools.md#dependencies-and-current-evidence). Filesystem tools +and GNU coreutils already belong to the base image; their package dependencies +are now declared explicitly. No init replacement, background network service, +new native library dependency of an FDS executable, or boot-time cache builder +was introduced. Void's tracked source files remain unchanged. + +## Findings resolved during validation + +A private checker device node initially failed to open because `/run` is mounted +with `nodev`. The final code creates an inaccessible private directory on +`devtmpfs`, opens its read-only device inode, and immediately unlinks it. The +filesystem checker receives that handle and runs as UID 1000; global `/dev/loopN` +permissions are unchanged. Image mounts exist only in the worker's private mount +namespace and disappear when it exits. + +The namespace review also identified the need to block eject until the worker +exits: unmounting only the daemon's namespace could otherwise report a source +DATA cartridge SAFE while the worker retained its mount. The VM test now covers +that exact case. + +Malformed TOML can include most of its source text in an error message. Worker +diagnostics are now limited to 2048 UTF-8 bytes plus a truncation suffix, with +control characters replaced by spaces. This prevents cartridge contents from +overflowing the daemon's status protocol or injecting terminal controls. + +The sustained-write regression initially failed because kernel I/O diagnostics +interrupted CLI JSON in the middle of a serial line during deliberate removal. +The test now executes each command once, saves its stdout, and transfers it with +base64 and a SHA-256 checksum. A damaged transfer is retried without rerunning +the command. Kernel output remains in the serial log and a separate diagnostic +capture. The corrected test passed in `out/m7-vm.t5d5fd_4/`, including independent +ext4 checks and the exact 64 MiB write payload. + +## Packaged artifacts and regressions + +The rebuilt CLI rootfs contains 180 packages and 1393 AArch64 ELF files. All FDS +executables remain static-musl. Package archives, input hashes, ownership and +capability records are retained beside the rootfs. + +| Artifact | Location | SHA-256 | +| --- | --- | --- | +| CLI rootfs, 498616320 bytes | `out/rootfs-build.gs9ZTA/rootfs-aarch64.tar` | `4abbc4e4e1823ef78acde2645d915160aa8e1c0f8b3173e8bc73f8d6b923d502` | +| CLI SYSTEM | `out/system-build.a74aryls/system.img` | `b7a71dad45c259c92ec5a5ac72b87356132f642df5c33d74d20e2e6ca564dee6` | +| FAT32 boot partition | `out/boot-build.oapz3fuf/boot.fat` | `ac3ee864c6293dd30a640fbbc0514b08acb92cda2d25d05128d234229308ac8f` | + +The earlier-milestone regression run passed tooling, formatting/build checks, +rootfs rejection checks, native init, the stage0 boot matrix, console and +cartridge tests. Its logs are `out/logs/m9-final-*.log`; the corrected DATA run +is `out/logs/m9-data-retry3.log`. The final diagnostic change was then rebuilt +into the CLI archive and passed the complete media write/boot and archive checks +in `out/logs/m9-hardened-*.log`. + +Native init evidence is `out/m2-vm.ijzHZC/`, the boot matrix is +`out/m4-vm.sxNb8D/`, console checks are `out/m5-vm.6suv4qjj/`, and cartridge +checks are `out/m6-vm.o5sjhatc/`. Kernel-to-console was 1854.858231 ms in this +VM run, compared with M8's 1854.724813 ms: **+0.133418 ms**. The actual static ARM +comparison passed without an exception; see `out/logs/m9-boot-comparison.log`. +This is below the 100 ms review threshold and is not a Pi performance result. + +The development rootfs contains 191 packages and 1405 AArch64 ELF files. +`make rootfs-test`, `make desktop-test` and development SYSTEM construction passed, +followed by the final tooling and formatting/build checks. Evidence is +`out/m8-vm.498wsxsv/` and `out/logs/m9-hardened-*.log`. The screenshot was inspected +and retains the grayscale Terminus terminal. First desktop activation was +1111.300247 ms in this VM; physical display timing remains unmeasured. + +| Artifact | Location | SHA-256 | +| --- | --- | --- | +| Development rootfs, 501882880 bytes | `out/rootfs-build.sQCq5V/rootfs-aarch64.tar` | `87e9a35829432f07e4a4bfa4c69eabd5ba0f30a25eadfed05ea9393bd1fb2afa` | +| Development SYSTEM | `out/system-build.l5e3j1pg/system.img` | `bcb8dfa7ec6e3207080f5c68074cc0c27d1bea63a0b51561c8ec628691e03611` | + +The generic rootfs convenience links were restored to the CLI build after the +development checks. `out/rootfs-cli.tar` and `out/rootfs-development.tar` retain +explicit pointers to these two verified archives. + +## Deferred and remaining acceptance + +Physical media throughput, flash-controller write-cache behavior, USB brownouts, +power-loss durability and Pi boot remain deferred. No throughput or hardware +latency claim is made. Broader I/O failure injection and twelve-device stress +belong to M11; ordered power-off and persistent DATA error handling belong to M10; +signing, independent recovery and complete release inputs belong to M12. diff --git a/docs/master-plan.md b/docs/master-plan.md new file mode 100644 index 0000000..ce31aac --- /dev/null +++ b/docs/master-plan.md @@ -0,0 +1,2564 @@ +# FDS/OS MASTER IMPLEMENTATION PLAN + +This is the complete design specification, including future components and +command interfaces. **M0–M12 software acceptance is complete, including Dasung base +integration and the signed local 0.1.0 release.** +Tests requiring physical Pi hardware are deferred; software and VM checks remain +in scope. See the [implementation ledger](implementation-status.md). A command appearing +here is not evidence that it exists in the checkout. Start with the +[project overview](../README.md) and [first-build walkthrough](getting-started.md) +for current usage, or the [roadmap](roadmap.md) for implementation status. +The 67 numbered sections below retain the full project requirements. + +## 1. Project objective + +Implement an independent Linux distribution for Raspberry Pi 5: + +**FDS/OS — Felis Data Systems Operating System** + +FDS/OS targets a retro portable computer with these characteristics: + +- Raspberry Pi 5 +- aarch64 +- 12 USB cartridge bays +- removable SYSTEM / DATA / PROGRAM / ENVIRONMENT cartridges +- Dasung Paperlike 13K grayscale E-Ink display +- removable battery +- Very fast boot +- Very fast shutdown +- native s6 init +- Void/XBPS package ecosystem +- glibc as the system ABI +- Rust + static musl for FDS-owned programs wherever practical + +Core design principle: + +> Preserve compatibility with modern Linux software while making FDS-owned software as independent of distribution runtime libraries as practical. + +--- + +# 2. ABI and libc strategy + +The system must have two distinct userspace domains. + +## 2.1 General userspace + +Use: + +```text +glibc +``` + +Applies to: + +```text +GNU coreutils +GNU binutils +bash +grep +sed +gawk +findutils +util-linux +procps-ng + +Xorg +WindowMaker + +XBPS packages + +Python +GCC +LLVM +third-party software +``` + +The system package architecture is: + +```text +aarch64 +``` + +Do not use: + +```text +aarch64-musl +``` + +--- + +## 2.2 FDS control plane + +FDS-maintained programs default to: + +```text +Rust ++ +aarch64-unknown-linux-musl ++ +static linking +``` + +Include at least: + +```text +fds-stage0 +fds +fds-cartridged +fds-burn +fds-inspect +fds-eject +fds-profile +fds-power +fds-boottrace +``` + +Principle: + +```text +FDS-owned runtime program + ↓ +static musl where practical + ↓ +Linux syscall ABI +``` + +These programs should not depend on the system's glibc version. + +The following checks must pass: + +```bash +file /usr/bin/fds +ldd /usr/bin/fds +``` + +Expected output resembles: + +```text +statically linked +not a dynamic executable +``` + +--- + +# 3. Do not convert the entire system to musl + +Do not force the following components to use musl: + +```text +Xorg +WindowMaker +general XBPS packages +GNU toolchain +graphics stack +plugin-heavy applications +``` + +Do not attempt to maintain two complete repositories: + +```text +glibc repository +musl repository +``` + +The purpose of musl is limited to: + +> Providing a stable, self-contained static linking environment for FDS-owned control-plane executables. + +--- + +# 4. Init architecture + +FDS/OS must be a native s6 system. + +Final architecture: + +```text +Linux + ↓ +/sbin/init + ↓ +s6-linux-init + ↓ +s6-svscan + ↓ +s6-rc +``` + +Prohibited: + +```text +systemd → s6 +runit → s6 +OpenRC → s6 +custom shell PID1 → s6 +``` + +PID 1 must ultimately belong to the s6 architecture. + +Use: + +```text +skalibs +execline +s6 +s6-linux-init +s6-rc +``` + +The s6-rc source database resides at: + +```text +/etc/s6-rc/source +``` + +compiled database: + +```text +/etc/s6-rc/compiled +``` + +live database: + +```text +/run/s6-rc +``` + +**The compiled database must be generated during image construction.** + +Normal boot must never run: + +```text +s6-rc-compile +``` + +--- + +# 5. Package architecture + +Use Void Linux only for: + +```text +upstream package ecosystem +XBPS +xbps-src +glibc packages +GNU packages +kernel packaging reference +third-party packages +``` + +FDS defines its own: + +```text +fds-base +``` + +Do not directly depend on an all-inclusive Void base metapackage. + +Initial dependencies of `fds-base`: + +```text +glibc +glibc-locales + +coreutils +binutils +findutils +diffutils +grep +sed +gawk +bash +tar +gzip +xz + +util-linux +procps-ng +shadow +kbd + +e2fsprogs +dosfstools +erofs-utils + +eudev +kmod + +iproute2 +iputils + +pciutils +usbutils + +ca-certificates +tzdata + +xbps + +skalibs +execline +s6 +s6-linux-init +s6-rc + +fds-base-files +fds-cartridged +fds-cli +fds-dasungd +``` + +FDS-owned static-musl binaries must not require: + +```text +musl runtime package +``` + +musl is already statically linked into each executable. + +--- + +# 6. Build host + +Primary build machine: + +```text +x86_64 Arch Linux +``` + +Target: + +```text +aarch64 Raspberry Pi 5 +``` + +Normal development **must not require an ARM build host**. + +The Pi 5 serves as: + +```text +deployment target +hardware validation target +performance target +``` + +It is not the primary build machine. + +Void's xbps-src supports foreign Linux hosts. Cross-build with: + +```bash +./xbps-src -a aarch64 pkg PACKAGE +``` + +This performs a cross build. + +Rust FDS components use: + +```text +aarch64-unknown-linux-musl +``` + +This Rust target explicitly supports cross-compilation from other hosts. + +--- + +# 7. Repository layout + +Create: + +```text +fds-os/ +│ +├── README.md +├── Makefile +├── Cargo.toml +│ +├── docs/ +│ ├── architecture.md +│ ├── boot.md +│ ├── cartridges.md +│ ├── services.md +│ ├── packages.md +│ ├── performance.md +│ └── recovery.md +│ +├── vendor/ +│ └── void-packages/ +│ +├── packages/ +│ ├── fds-base/ +│ ├── fds-base-files/ +│ ├── fds-kernel/ +│ ├── fds-cartridged/ +│ ├── fds-cli/ +│ └── fds-eink/ +│ +├── rust/ +│ ├── fds-common/ +│ ├── fds-stage0/ +│ ├── fds-cli/ +│ ├── fds-cartridged/ +│ ├── fds-burn/ +│ └── fds-boottrace/ +│ +├── s6/ +│ └── source/ +│ +├── profiles/ +│ ├── cli.toml +│ ├── development.toml +│ ├── windowmaker.toml +│ └── recovery.toml +│ +├── image/ +│ ├── build-rootfs +│ ├── build-system-cartridge +│ ├── build-boot-volume +│ ├── build-initramfs +│ └── build-recovery +│ +├── tools/ +│ ├── bootstrap-host +│ ├── build-package +│ ├── qemu-test +│ ├── burn +│ └── configure-pi-eeprom +│ +├── tests/ +│ ├── unit/ +│ ├── integration/ +│ └── hardware/ +│ +└── out/ +``` + +--- + +# 8. Managing the Void upstream + +Do not maintain a long-lived fork with changes throughout void-packages. + +Use: + +```text +vendor/void-packages +``` + +Pin it to a specific commit. + +Record: + +```text +VOID_PACKAGES_COMMIT +``` + +Inject FDS packages through an overlay. + +Required relationship: + +```text +same FDS commit ++ +same Void commit += +same package input +``` + +Do not drift with daily changes to the Void rolling repository. + +--- + +# 9. Rust workspace + +Create the workspace: + +```toml +[workspace] +members = [ + "rust/fds-common", + "rust/fds-stage0", + "rust/fds-cli", + "rust/fds-cartridged", + "rust/fds-burn", + "rust/fds-boottrace", +] +``` + +Default release profile: + +```toml +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true +``` + +Benchmark performance-critical binaries with: + +```text +opt-level = z +vs +opt-level = 2 +vs +opt-level = 3 +``` + +Do not assume that the smallest executable starts fastest. + +--- + +# 10. Rust dependency policy + +Use Clap for every Rust command-line interface, including test helpers. Declare +typed commands, options, validation, and generated help; do not hand-parse the +argument vector. The workspace shares the version already used by Dasung and +pins it in `Cargo.lock`. Kernel command-line files and cartridge metadata remain +separate domain formats with their own strict parsers. + +FDS core programs should avoid: + +```text +tokio +openssl +glibc-specific crates +libudev-sys +large asynchronous runtimes +``` + +Prefer: + +```text +std +libc crate +rustix +nix +serde +toml +small synchronous event loop +netlink/sysfs directly where reasonable +``` + +Do not introduce a large runtime merely to listen for a few uevents. + +--- + +# 11. Boot architecture + +The machine's internal NVMe belongs to the: + +```text +machine firmware layer +``` + +The SYSTEM cartridge contains the: + +```text +operating system userspace +``` + +Boot sequence: + +```text +Raspberry Pi EEPROM + ↓ +internal NVMe + ↓ +kernel + ↓ +tiny FDS initramfs + ↓ +fds-stage0 + ↓ +locate SYSTEM cartridge + ↓ +mount EROFS + ↓ +switch_root + ↓ +s6 + ↓ +READY +``` + +Do not make Raspberry Pi firmware search all 12 USB cartridges for a boot device. + +--- + +# 12. Internal NVMe layout + +Recommended layout: + +```text +GPT + +p1 FDS_BOOT + FAT32 + 512 MB + +p2 FDS_RECOVERY + EROFS + 1–2 GB + +p3 FDS_INTERNAL + ext4 +``` + +## FDS_BOOT + +Contains: + +```text +Pi firmware +kernel +DTBs +config.txt +cmdline.txt +FDS initramfs +``` + +## FDS_RECOVERY + +Contains a complete, independent recovery environment. + +## FDS_INTERNAL + +Record: + +```text +hardware configuration +Bay topology +boot performance history +cached cartridge metadata +diagnostics +``` + +Do not store ordinary user files here. + +--- + +# 13. SYSTEM cartridge + +SYSTEM cartridge: + +```text +GPT ++ +partition name FDS_SYSTEM ++ +EROFS +``` + +For example: + +```text +/dev/??? +└── GPT PARTNAME=FDS_SYSTEM +``` + +Never depend on: + +```text +/dev/sda +/dev/sdb +``` + +With 12 USB devices, these names are completely unreliable identifiers. + +root: + +```text +read-only +``` + +--- + +# 14. fds-stage0 + +`fds-stage0` is a static-musl Rust executable. + +Target: + +```text +single executable initramfs control program +``` + +Avoid including the following wherever practical: + +```text +shell +BusyBox +glibc +Python +udev +``` + +Its responsibilities are limited to: + +```text +mount /proc +mount /sys +mount devtmpfs + +enumerate block devices + +find GPT PARTNAME FDS_SYSTEM + +mount EROFS root + +move /proc /sys /dev + +switch_root + +exec /sbin/init +``` + +Prefer direct use of: + +```text +mount(2) +openat +read +ioctl +netlink/sysfs +pivot_root/switch_root equivalent +``` + +Do not invoke a shell or external commands. + +--- + +# 15. Missing SYSTEM cartridge + +For the selected Dasung monitor, early userspace must provide the display's +control/keepalive and correct video mode before showing this prompt. The base +SYSTEM daemon starts later and cannot satisfy missing-SYSTEM display requirements +by itself. Implement and verify this handoff as part of Pi boot integration. + +When no SYSTEM cartridge is present: + +Display: + +```text +FELIS DATA SYSTEMS +PORTABLE COMPUTER FP-85 + +BOOT ROM 0.1 + +SYSTEM MEDIA NOT PRESENT + +INSERT SYSTEM CARTRIDGE +``` + +Then: + +```text +continue listening for block device changes +``` + +When a SYSTEM cartridge is inserted: + +**Resume boot immediately.** + +A reboot must not be required. + +--- + +# 16. Multiple SYSTEM cartridges + +When multiple partitions are found with: + +```text +FDS_SYSTEM +``` + +Do not choose one arbitrarily. + +Display: + +```text +MULTIPLE SYSTEM CARTRIDGES + +BAY 02 FDS/OS 0.1 +BAY 08 FDS/OS DEVELOPMENT + +SELECT SYSTEM: +``` + +Version 1 may enter a recovery/debug console. + +Add interactive selection in version 2. + +--- + +# 17. Kernel strategy + +Start the first version from Void's Raspberry Pi 5 kernel packaging. + +Do not maintain a large kernel fork from day one. + +Create: + +```text +fds-kernel +``` + +Maintain only: + +```text +config delta +packaging +version policy +``` + +Build boot-critical drivers into the kernel wherever practical: + +```text +BCM2712 +RP1 +PCIe +USB host +USB hub +USB mass storage +SCSI disk +NVMe +devtmpfs +EROFS +ext4 +FAT +HID keyboard +console +DRM/KMS +``` + +optional: + +```text +Wi-Fi +Bluetooth +USB Ethernet +USB Serial +audio +SDR +``` + +These optional drivers may be modules. + +--- + +# 18. Initramfs + +Target: + +```text +As close as practical to: + +/init -> fds-stage0 +``` + +Test these formats: + +```text +uncompressed cpio +lz4 +zstd +gzip +``` + +Benchmark on a real Pi 5. + +The following observation: + +```text +zstd image smaller +``` + +does not establish that boot will be faster. + +NVMe capacity is inexpensive. + +Boot milliseconds matter. + +--- + +# 19. Raspberry Pi firmware optimisation + +production EEPROM: + +Disable unnecessary network installation behavior. + +In particular: + +```text +NET_INSTALL_ENABLED=0 +NET_INSTALL_AT_POWER_ON=0 +``` + +Raspberry Pi's official guidance states that USB controller initialization and device enumeration to detect a network-install keyboard add about one second to boot, making these features worth disabling on embedded devices that need fast startup. + +Provide: + +```text +tools/configure-pi-eeprom +``` + +Together with: + +```text +development.conf +production.conf +``` + +All EEPROM changes must be: + +```text +documented +reversible +``` + +--- + +# 20. Filesystem model + +SYSTEM: + +```text +EROFS +read-only +``` + +runtime: + +```text +/run tmpfs +/tmp tmpfs +/var/tmp tmpfs +/var/log tmpfs +``` + +Do not run root filesystem fsck during boot. + +Do not write logs to SYSTEM. + +Do not update SYSTEM in place. + +--- + +# 21. User data + +DATA cartridge: + +```text +GPT PARTNAME=FDS_DATA +filesystem=ext4 +``` + +After insertion: + +```text +/data +``` + +Alternatively: + +```text +/home/fds +``` + +Mount this path from the DATA cartridge. + +When no DATA cartridge is present: + +```text +ephemeral tmpfs home +``` + +Therefore: + +> The machine itself does not own user data. + +--- + +# 22. Native s6 service graph + +Minimum boot bundle: + +```text +boot +├── runtime-fs +├── hostname +├── console +├── eudevd +├── udev-trigger +├── cartridged +├── getty +└── dasungd +``` + +Explicitly excluded from the boot bundle: + +```text +network +DHCP +NTP +sshd +D-Bus +Xorg +WindowMaker +Bluetooth +Wi-Fi +``` + +--- + +# 23. Fast boot principle + +The interactive shell should depend only on: + +```text +root mounted +console ready +/dev minimally usable +``` + +It must not depend on: + +```text +all USB devices enumerated +cartridge catalog scan +network +clock synchronization +GUI +``` + +The intended sequence is: + +```text +power on + ↓ +kernel + ↓ +root + ↓ +s6 + ↓ +FDS> +``` + +Everything else proceeds in parallel afterward. + +--- + +# 24. eudev + +The normal system uses: + +```text +eudev +``` + +However, do not use: + +```text +udevadm settle +``` + +as a global boot barrier. + +Correct sequence: + +```text +start eudevd + ↓ +trigger existing devices + ↓ +continue boot +``` + +Services that need a particular device must handle their own readiness. + +--- + +# 25. cartridged + +Implement: + +```text +fds-cartridged +``` + +static-musl Rust. + +Responsibilities: + +```text +listen for USB/block events +identify physical hub/port +map device → BAY 01..12 +inspect storage metadata +mount/unmount cartridges +activate FDS profiles +communicate state to fds CLI +``` + +Do not depend on: + +```text +/dev/sdX ordering +``` + +Use: + +```text +sysfs USB topology +``` + +to identify physical bays. + +--- + +# 26. Bay topology + +Configuration: + +```text +/etc/fds/bays.toml +``` + +Example: + +```toml +[front] +hub = "usb-topology-id" + +[front.ports] +1 = 1 +2 = 2 +3 = 3 +4 = 4 +5 = 5 +6 = 6 + +[rear] +hub = "usb-topology-id" + +[rear.ports] +1 = 7 +2 = 8 +3 = 9 +4 = 10 +5 = 11 +6 = 12 +``` + +Users see: + +```text +BAY 01 +... +BAY 12 +``` + +They must not see: + +```text +1-2.4.1 +``` + +except in debug mode. + +--- + +# 27. Cartridge metadata + +At the root of a storage cartridge: + +```text +/FDS/CARTRIDGE.TOML +``` + +Format: + +```toml +format = 1 + +[cartridge] +id = "fds.windowmaker" +name = "WINDOW SYSTEM" +class = "environment" +version = "0.1" + +[media] +writable = false + +[activation] +profile = "windowmaker" +``` + +classes: + +```text +system +data +program +environment +hardware +utility +``` + +--- + +# 28. Hardware cartridges + +USB hardware without a filesystem includes: + +For example: + +```text +USB Ethernet +USB Serial +USB Wi-Fi +USB Audio +``` + +Identify it using: + +```text +VID +PID +serial +USB class +physical bay +``` + +Use these identifiers for recognition. + +Maintain: + +```text +/etc/fds/hardware-catalog.toml +``` + +Do not require every hardware cartridge to contain storage. + +--- + +# 29. ENVIRONMENT cartridge v1 + +Do not place the entire Xorg and WindowMaker runtime on cartridges in version 1. + +Implement the activation logic first: + +The SYSTEM image already contains: + +```text +Xorg +WindowMaker +fonts +theme +``` + +The ENVIRONMENT cartridge initially provides only: + +```text +capability key ++ +configuration +``` + +On insertion: + +```text +WINDOW SYSTEM + ↓ +cartridged + ↓ +fds-profile activate windowmaker + ↓ +s6-rc change +desktop +``` + +On removal: + +```text +desktop down + ↓ +return CLI +``` + +Move the GUI runtime onto the cartridge in version 2. + +--- + +# 30. WindowMaker stack + +Display managers are prohibited. + +Do not use: + +```text +GDM +SDDM +LightDM +``` + +Use: + +```text +s6 + ↓ +Xorg + ↓ +WindowMaker +``` + +A single-user machine may use: + +```text +autologin FDS console +``` + +Activating a GUI profile starts the user's graphical session directly. + +--- + +# 31. E-Ink profile + +The monitor controller is base hardware support, independent of the desktop +profile. Include the existing Rust `dasungd` for the user's Paperlike 13K grayscale +in every SYSTEM image and the native s6 boot bundle. Maintain the confirmed +profile, static-musl target, reconnect handling, and SPI-driver protection. +See [Dasung integration](dasung.md) for implementation, usage, and the remaining +Pi and physical recovery checks. It must not require an ENVIRONMENT cartridge. + +Create: + +```text +fds-eink +``` + +Configuration: + +```text +no compositor +no transparency +no fade +no desktop animation +no smooth window animation +outline move +outline resize +static wallpaper +high contrast +``` + +WindowMaker should prioritize: + +```text +black +white +2–4 grayscale levels +``` + +Fonts: + +```text +Terminus +other high-contrast bitmap-friendly fonts +``` + +Do not update the clock every second. + +--- + +# 32. PROGRAM cartridge + +Do not design a complex dependency resolver across cartridges in version 1. + +New PROGRAM software cartridges use GPT with exactly `1 + m` read-only EROFS +partitions. Partition 1 (`FDS_METADATA`) contains `FDS/CARTRIDGE.TOML` and +`FDS/SOFTWARE.TOML`. The catalogue describes every software bundle in the next +`m` partitions, named `FDS_PAYLOAD02` onward. Each software bundle is an +xz-compressed tarball; multiple bundles may share a payload partition. + +Software compilation, archive packaging and complete cartridge image creation +run on a generic Linux workstation. The confirmed USB writer writes and verifies +that complete disk image. QEMU workstation tooling boots the real FDS kernel, +initramfs and SYSTEM and inserts/removes these images as virtual USB cartridges. + +The guest validates metadata and mounts payloads read-only, then verifies and +extracts a selected bundle to a bounded temporary read-only cache. It executes +only an explicitly requested `SOFTWARE-ID:COMMAND` as the ordinary user. It never +runs software build recipes or installation hooks. Eject, physical removal, +service restart and shutdown release caches and stop managed consumers. + +Legacy single-partition `FDS_PROGRAM` media with `app/bin`, `app/lib`, and +`app/share` remain readable. The metadata-first format supersedes that layout +for newly built software cartridges. See [Workstation usage](workstation.md) and +[the format contract](software-format.md). + +Where necessary, use: + +```text +RPATH +wrapper environment +``` + +to resolve shared libraries. + +Do not modify the SYSTEM root. + +--- + +# 33. FDS CLI + +`fds` is a static-musl Rust binary. + +Implement at least: + +```text +fds info + +fds bays + +fds bay 3 + +fds cartridge 3 + +fds eject 3 + +fds profiles + +fds profile activate windowmaker + +fds poweroff + +fds reboot + +fds boot-profile +``` + +Output should be concise, fast, and suitable for E-Ink. + +For example: + +```text +FDS> fds bays + +01 SYSTEM FDS/OS 0.1 ONLINE +02 DATA FELIS ONLINE +03 ENVIRONMENT WINDOW SYSTEM ONLINE +04 PROGRAM TEXT TOOLS ONLINE +05 EMPTY +06 EMPTY + +07 ETHERNET EC-1 ONLINE +08 EMPTY +09 EMPTY +10 EMPTY +11 EMPTY +12 EMPTY +``` + +--- + +# 34. fds-burn + +The target media client remains static-musl Rust. New software creation and +writing use native Linux workstation `fds-cartridge`: build bundles, construct +and verify the complete metadata-first GPT image, preview the USB target, then +write and verify that full image. Never assemble software partitions piecemeal +on the destination drive. See [the workstation workflow](workstation.md). + +Responsible for: + +```text +inspect target +partition media +write image +verify +write cartridge metadata +``` + +Typical usage: + +```text +fds burn system fds-system-cli.img BAY04 +``` + +Alternatively: + +```text +fds burn data BAY04 --label FELIS +``` + +Destructive operations require explicit confirmation. + +--- + +# 35. Package installation model + +A running SYSTEM cartridge must not use: + +```text +sudo xbps-install -Su +``` + +to modify itself in place. + +Use XBPS for: + +```text +building images +resolving package dependencies +resolving libraries for workstation-built PROGRAM bundles +development profile +``` + +SYSTEM update sequence: + +```text +build new SYSTEM image + ↓ +write new cartridge + ↓ +swap cartridge +``` + +rollback: + +```text +put old cartridge back +``` + +--- + +# 36. Image builder + +Implement: + +```text +make rootfs PROFILE=cli +``` + +Internal workflow: + +```text +create empty root + ↓ +xbps-install -r ROOT ... + ↓ +install FDS packages + ↓ +apply profile + ↓ +generate locales + ↓ +generate caches + ↓ +compile s6-rc database + ↓ +remove build junk + ↓ +make EROFS +``` + +All of the following must happen at build time: + +```text +locale generation +font cache generation +s6-rc compilation +ldconfig +package configuration +``` + +Do not perform them during boot. + +--- + +# 37. Build interface + +Eventually support: + +```text +make bootstrap + +make packages + +make rootfs PROFILE=cli + +make rootfs PROFILE=development + +make system-card PROFILE=cli + +make system-card PROFILE=development + +make initramfs + +make kernel + +make boot-volume + +make recovery + +make all +``` + +output: + +```text +out/ +├── fds-boot.img +├── fds-initramfs.img +├── fds-recovery.img +├── fds-system-cli.img +├── fds-system-development.img +└── packages/ +``` + +--- + +# 38. Cross compilation policy + +All FDS Rust binaries must support building on x86_64 with: + +```bash +cargo build \ + --release \ + --target aarch64-unknown-linux-musl +``` + +All FDS XBPS packages should support the following wherever practical: + +```bash +./xbps-src -a aarch64 pkg PACKAGE +``` + +For cross-built packages, xbps-src selects the corresponding cross environment and exposes variables such as `XBPS_CROSS_BASE` and `XBPS_RUST_TARGET`. + +If a package cannot be cross-built: + +Fallback priority: + +```text +1. Use an official Void aarch64 binary +2. Fix the cross build +3. QEMU native-aarch64 build +4. Build natively on the Pi only as a last resort +``` + +The normal build pipeline must not require a Pi. + +--- + +# 39. Host-side QEMU testing + +Set up this workflow on the Framework: + +```text +x86_64 Arch + ↓ +cross build + ↓ +aarch64 image + ↓ +QEMU smoke test + ↓ +real Pi test +``` + +Use QEMU to test: + +```text +s6 graph +shell +package contents +static-musl binaries +config parsing +filesystem layout +shutdown logic +cartridge metadata +``` + +Use a real Pi to test: + +```text +firmware +RP1 +USB topology +12 bays +E-Ink +power behaviour +actual boot timing +``` + +--- + +# 40. Boot performance targets + +Define: + +```text +T0 power applied +T1 kernel entered +T2 fds-stage0 entered +T3 switch_root +T4 s6 running +T5 CLI accepts input +T6 WindowMaker accepts input +``` + +Target: + +```text +kernel → CLI: +< 1.5 seconds target +< 2.0 seconds maximum target + +s6 → CLI: +< 400 ms target + +cold power → CLI: +< 3 seconds stretch goal +< 5 seconds hard goal + +CLI → WindowMaker: +< 2 seconds +``` + +These are targets, not claimed performance results. + +Actual measurements are required. + +--- + +# 41. fds-boottrace + +Implement: + +```text +fds-boottrace +``` + +Record: + +```text +kernel timestamp +stage0 start +SYSTEM found +root mounted +switch_root +s6 start +console ready +desktop ready +``` + +Output: + +```text +FDS BOOT PROFILE + +KERNEL .............. 0.713 s +STAGE0 .............. 0.094 s +SYSTEM DISCOVERY .... 0.082 s +ROOT SWITCH ......... 0.031 s +S6 .................. 0.047 s +CONSOLE ............. 0.073 s + +KERNEL → READY ...... 1.040 s +``` + +Save benchmarks in: + +```text +docs/performance.md +``` + +--- + +# 42. Performance regression rule + +Any commit that causes: + +```text +boot regression > 100ms +``` + +must: + +```text +explain it +or fix it +``` + +Do not add an artificial delay for a retro splash screen: + +```text +sleep 1 +``` + +If FDS can boot in 800 ms, let it boot in 800 ms. + +--- + +# 43. Fast boot blacklist + +Prohibited on the critical path: + +```text +DHCP +DNS +NTP +sshd +Wi-Fi setup +Bluetooth setup +package updates +package verification +font cache generation +locale generation +s6-rc compilation +global udev settle +GUI login manager +Python +filesystem fsck for read-only root +persistent log flush +``` + +These activities must run: + +```text +build time +on demand +or parallel after READY +``` + +--- + +# 44. Networking + +network bundle: + +```text +network +``` + +By default, it is not part of: + +```text +boot +``` + +When an Ethernet cartridge is present or the user explicitly requests networking: + +```text +s6-rc change +network +``` + +DHCP must never delay the appearance of: + +```text +FDS> +``` + +The prompt must remain independent of DHCP. + +--- + +# 45. Logging + +Normal logging location: + +```text +/run/log +``` + +tmpfs. + +Persist only: + +```text +important errors +explicit debug captures +boot performance +``` + +Do not persist tens of megabytes of journal data as a server distribution might. + +FDS does not use a journal service. + +This is intentional. + +--- + +# 46. Shutdown + +Provide: + +```text +fds poweroff +``` + +Sequence: + +```text +freeze new cartridge operations + ↓ +stop GUI + ↓ +stop program services + ↓ +stop network + ↓ +sync DATA filesystems + ↓ +unmount DATA + ↓ +stop remaining services + ↓ +poweroff +``` + +SYSTEM root: + +```text +read-only +``` + +It therefore does not need to wait for journal writes to SYSTEM. + +--- + +# 47. Shutdown performance + +Target: + +```text +idle poweroff: +< 1 second target + +normal writable DATA: +< 1.5 seconds target +``` + +Do not use: + +```text +sleep 5 +``` + +to wait for daemons. + +s6 must base shutdown on actual process exit and readiness. + +--- + +# 48. Eject semantics + +Users cannot safely unplug writable DATA without ejecting it first. + +Command: + +```text +fds eject 02 +``` + +Behavior: + +```text +identify dependent programs + ↓ +stop them + ↓ +syncfs() + ↓ +unmount + ↓ +mark SAFE +``` + +Output: + +```text +BAY 02 + +DATA CARTRIDGE FELIS + +SAFE TO REMOVE +``` + +A physical LED or eject mechanism may be integrated later. + +--- + +# 49. Recovery environment + +FDS_RECOVERY must not depend on a SYSTEM cartridge. + +Include at least: + +```text +bash +GNU coreutils +util-linux +filesystem tools +XBPS tools + +fds +fds-burn +fds-inspect +``` + +FDS tools in recovery remain static-musl binaries. + +Recovery supports: + +```text +inspect SYSTEM +burn SYSTEM +repair DATA +diagnose USB bays +inspect boot log +``` + +--- + +# 50. Security + +Strictly prohibited: + +```text +/FDS/autorun.sh +``` + +executed automatically as root when a cartridge is inserted. + +Cartridge manifests must be declarative. + +For example: + +```text +profile = "windowmaker" +``` + +Instead of: + +```text +run = "/media/card/haha.sh" +``` + +PROGRAM cartridges run with ordinary user privileges by default. + +--- + +# 51. Initial boot UI + +Production boot should suppress Linux kernel console noise. + +Display: + +```text +FELIS DATA SYSTEMS +PORTABLE COMPUTER FP-85 + +FDS BOOT ROM 0.1 + +MEMORY ........ READY +SYSTEM ........ FDS/OS 0.1 +CARTRIDGE BUS . READY + +READY. +_ +``` + +However: + +**Do not delay startup to display these lines one at a time.** + +A single repaint is sufficient. + +A retro appearance does not require a four-second delay. + +--- + +# 52. Development SYSTEM cartridge + +The development profile may include a large toolset: + +```text +gcc +clang +binutils +make +cmake +meson +ninja +pkg-config + +rust +cargo + +git +gdb +strace +perf + +vim/neovim +``` + +Provided that these tools: + +```text +do not start daemons +``` + +they should not significantly slow boot. + +Disk usage is not boot latency. + +--- + +# 53. Milestone M0 — Host build environment + +Implement: + +```text +repo skeleton +pin void-packages +install/use XBPS host tools +xbps-src bootstrap +aarch64 cross toolchain +Rust musl target +``` + +Acceptance: + +On the x86_64 Framework, it must be possible to: + +```text +build aarch64 package +build static-musl hello-world +``` + +Verify that the executable is static. + +--- + +# 54. M1 — FDS base rootfs + +Implement: + +```text +fds-base +fds-base-files +glibc system +GNU userland +XBPS +``` + +Acceptance: + +Generate: + +```text +out/rootfs-aarch64.tar +``` + +Verify: + +```text +glibc present +GNU coreutils present +GNU binutils present + +musl runtime package not required +BusyBox absent +systemd absent +runit absent +``` + +--- + +# 55. M2 — Native s6 + +Implement the complete stack: + +```text +s6-linux-init +s6-svscan +s6-rc +``` + +Acceptance: + +In the booted rootfs: + +```text +PID1 belongs to s6 architecture +``` + +Also verify that: + +```text +s6-rc change +``` + +can control a test service. + +--- + +# 56. M3 — static-musl FDS tooling + +Implement: + +```text +fds-common +fds CLI skeleton +fds-stage0 skeleton +``` + +All components use: + +```text +Rust +aarch64-unknown-linux-musl +static +``` + +Acceptance: + +```text +readelf +file +ldd +``` + +must demonstrate the absence of a glibc runtime dependency. + +--- + +# 57. M4 — Pi boot + +Implement: + +```text +FDS_BOOT +kernel +initramfs +fds-stage0 +SYSTEM EROFS +switch_root +s6 +``` + +Acceptance: + +Pi: + +```text +power on + ↓ +internal NVMe kernel + ↓ +USB SYSTEM cartridge + ↓ +FDS> +``` + +--- + +# 58. M5 — Fast CLI + +Optimize: + +```text +firmware +kernel +initramfs +stage0 +s6 graph +getty +shell startup +``` + +Acceptance: + +```text +kernel → FDS> <2 sec +``` + +Create the first measured version of: + +```text +docs/performance.md +``` + +--- + +# 59. M6 — Cartridge daemon + +Implement: + +```text +udev/netlink events +hub discovery +12 bay topology +metadata +mounting +eject +``` + +Acceptance: + +With 12 USB devices inserted simultaneously: + +```text +every device maps to the correct physical BAY +``` + +--- + +# 60. M7 — DATA cartridge + +Implement: + +```text +mount +home/data integration +safe eject +``` + +Acceptance: + +After a sustained write test: + +```text +fds eject +``` + +must leave the filesystem clean. + +--- + +# 61. M8 — WindowMaker + +Implement: + +```text +Xorg +WindowMaker +E-Ink theme +environment profile +``` + +Acceptance: + +On insertion of an ENVIRONMENT cartridge: + +```text +CLI → WindowMaker +``` + +On cartridge removal or profile deactivation: + +```text +WindowMaker → CLI +``` + +No reboot is required. + +--- + +# 62. M9 — Cartridge image tools + +Implement: + +```text +fds burn +fds inspect +fds format +``` + +Acceptance: + +The machine itself can create: + +```text +SYSTEM +DATA +PROGRAM +ENVIRONMENT +``` + +cartridge. + +--- + +# 63. M10 — Shutdown optimisation + +Complete: + +```text +service stop graph +DATA sync +DATA unmount +fast halt +``` + +Acceptance: + +```text +idle shutdown <1s target +normal shutdown <1.5s target +``` + +Filesystem corruption is unacceptable. + +--- + +# 64. M11 — 12-device stress testing + +Hardware: + +```text +12 USB devices +``` + +Test: + +```text +cold boot +hot insert +hot remove +simultaneous enumeration +simultaneous IO +reboot +shutdown +``` + +Check: + +```text +bay mapping stability +USB resets +kernel errors +boot regression +power issues +``` + +--- + +# 65. M12 — Production hardening + +Complete: + +```text +release signing +image versioning +package pinning +reproducible build work +recovery +documentation +production EEPROM config +``` + +Release: + +```text +FDS/OS 0.1 +``` + +--- + +# 66. Codex implementation rules + +Codex must follow these rules: + +1. Implement only the current milestone at a time. +2. Do not implement later features ahead of schedule. +3. Keep the repository buildable at every milestone. +4. Default every custom FDS binary to static musl. +5. Keep general userspace on glibc. +6. Do not introduce systemd. +7. Do not introduce runit. +8. Do not use BusyBox to implement early userspace. +9. Do not use `sleep` to hide races. +10. Do not let networking block the console. +11. Do not generate caches during boot when they can be generated at build time. +12. Do not depend on `/dev/sdX` ordering. +13. Do not automatically execute arbitrary root scripts from cartridge contents. +14. Explain the necessity of each new dependency. +15. Support every boot optimization with benchmarks. +16. Support x86_64 to aarch64 cross builds for all FDS packages wherever practical. +17. Use native Pi builds only as a documented fallback. +18. Keep documentation synchronized with the implementation. + +--- + +# 67. First Codex task + +Implement only M0 now. + +Specific requirements: + +```text +A. Create the repository skeleton. + +B. Add vendor/void-packages, + pinned to a specific commit. + +C. Write tools/bootstrap-host, + supporting Arch Linux x86_64. + +D. Verify the static XBPS host tools. + +E. bootstrap xbps-src build environment. + +F. Verify: + + ./xbps-src -a aarch64 pkg + +G. Initialize the Rust workspace. + +H. Configure: + + aarch64-unknown-linux-musl + +I. Build: + + rust/fds-smoketest + +J. Verify: + + - architecture = AArch64 + - static executable + - no glibc dependency + +K. Provide: + + make bootstrap + make smoke-test + +L. Create docs/build-host.md, + accurately documenting all dependencies and commands. + +M. Add automated checks that exit nonzero on failure. + +N. Do not begin s6, kernel, GUI, or cartridge daemon implementation. + +On completion, report: + +1. changed files +2. exact build commands +3. smoke-test output +4. known limitations +5. next milestone proposal +``` + +The original M0 gate above has been satisfied. The user explicitly authorized +M1; its workflow and evidence are in [M1 rootfs](rootfs.md) and +[M1 validation](m1-validation.md). The user subsequently authorized M2; see [Native init](init.md). +The user subsequently authorized implementation through M12, with physical Pi +testing deferred. The implementation ledger above records the current evidence +and remaining work. diff --git a/docs/media-tools.md b/docs/media-tools.md new file mode 100644 index 0000000..a751ace --- /dev/null +++ b/docs/media-tools.md @@ -0,0 +1,155 @@ +# Cartridge image tools + +[Documentation index](README.md) · [Cartridge usage](cartridges.md) + +For new software cartridges, use the [Linux workstation workflow](workstation.md): +`fds-cartridge` builds xz bundles and a complete metadata-first `1+m` GPT image, +then previews and verifies a whole-drive write. Software image creation is not +an on-Pi operation. The commands below cover existing target media management +and legacy compatibility. + +Historical M9 software acceptance verified image creation for all four classes, +confirmed virtual USB writes, and boot of a newly written SYSTEM. Physical media +and Pi testing remain deferred; see [validation evidence](m9-validation.md). +Images built before M9 do not contain these commands. Build a new image to use +them: + +```sh +make rootfs PROFILE=cli +make rootfs-test +make system-card PROFILE=cli +make media-image-test +make media-test +``` + +Run these build commands on the x86_64 host from the repository root, after the +[first-build prerequisites](getting-started.md). The integration checks use +disposable virtual disks. The `fds` examples below run at the ARM FDS console; +the static ARM executables do not run directly on an x86_64 shell. For an +interactive console, use `make console-vm`; its initial bay map is deliberately +unconfigured, so use the tests for the complete virtual write workflow. + +## Images and cartridge writes + +An image is a regular file containing a complete GPT and its filesystems. Legacy +classes use one partition; current software media use metadata plus payload +partitions. Creating +one does not touch a disk. Writing one selects a physical bay and requires a +confirmation tied to that insertion, image hash and operation identifier. + +Configure the physical bay map first using the [cartridge guide](cartridges.md). +Unconfigured or ambiguous bays are refused. At the FDS console, inspect the target and eject any existing filesystem first: + +```sh +fds inspect BAY04 +fds eject BAY04 +fds burn system /data/fds-system-cli.img BAY04 +``` + +Do not eject a blank disk: it has no mounted filesystem. The running SYSTEM, +internal BOOT/RECOVERY/INTERNAL partitions, mounts, swap and device-mapper holders +are protected. The tools support 512-byte logical sectors. A larger target keeps +the image's filesystem size; GPT backup metadata moves to the actual disk end. +Unused space is not a secure erase of old data. + +All cartridge eject requests are refused while a media operation is active. This +keeps a source DATA filesystem attached while the worker holds its image file. +Finish or cancel the operation first, then eject the source normally. + +The preview shows the bay, model, serial when available, kernel insertion number, +capacity, image class and SHA-256. Type the complete displayed phrase to proceed. +There is no generic `--yes` switch. A wrong phrase, changed image or different +insertion is rejected. Completion requires a flush, cache invalidation, readback, +and valid final partition tables. A failed or interrupted write never reports SAFE. + +For scripts, `fds --json burn CLASS IMAGE BAY04` stops at the prepared preview. +Use its exact `id` and `confirmation` fields with: + +```sh +fds burn confirm OPERATION_ID 'EXACT PHRASE FROM PREVIEW' +fds burn status OPERATION_ID +fds burn wait OPERATION_ID +fds burn cancel OPERATION_ID +``` + +`wait` waits for a terminal result; confirm or cancel from another console if +the operation is still awaiting confirmation. Cartridge +status remains available while the worker hashes or writes. Confirmation expires +after five minutes. Removing/reinserting media invalidates its insertion identity. +A service restart records an unfinished operation as failed, leaving it unmounted. + +## Existing DATA and ENVIRONMENT creation + +Use a writable working directory with enough free space for both a filesystem +image and its GPT wrapper. `/home/fds` is temporary RAM storage; `/data` is better +for large images, provided the source DATA is not the cartridge being overwritten. + +```sh +cd /home/fds +fds format data BAY04 --label FELIS +fds format environment BAY04 --profile windowmaker --label 'WINDOW SYSTEM' +``` + +Each command first creates an image, then presents the same explicit confirmation. +Labels are the display names shown by FDS; fixed GPT partition names identify +the cartridge class. DATA uses ext4 and normally fills the target's available whole MiB. Use +`--size-mib 512` to choose a smaller filesystem. Its root belongs to the ordinary +FDS user. Create PROGRAM software bundles on the workstation using the linked +workflow; target PROGRAM creation now reports that requirement. +ENVIRONMENT stores only a declarative profile. See [Desktop usage](desktop.md). + +`fds burn data BAY04 --label FELIS` is the same creation workflow. `fds-inspect` +and `fds-eject` are static aliases of the corresponding `fds` commands. + +## Build a regular image from a prepared directory + +Prepare a directory containing a valid `FDS/CARTRIDGE.TOML`, plus the class's +payload. The manifest format is described in [Cartridges](cartridges.md). Then: + +```sh +fds-burn create data data-tree data.img --size-mib 128 +fds-burn create environment environment-tree environment.img +fds-burn create system prepared-root system.img +fds inspect image system.img +``` + +The output must be outside the source directory, and existing output files are +never replaced. SYSTEM requires a complete prepared FDS root, including native +init, cartridge management and Dasung; preserve its intended ownership, file +modes, capabilities and prebuilt caches. On the build host, the normal supported +SYSTEM workflow remains `make rootfs PROFILE=cli` followed by `make system-card`. +`fds format system prepared-root BAY04` combines prepared-root construction with +the confirmed write workflow. It does not install packages or change the running +read-only SYSTEM. + +SYSTEM construction must be able to read every source file, including protected +account files, while preserving root ownership and capabilities. Run that +construction step from a privileged maintenance session with a correctly +prepared tree. The normal FDS console is UID 1000 and does not gain access to +protected source files merely by invoking `fds-burn`. The ordinary user's +supported workflow is to burn an already-built SYSTEM image. The VM acceptance +test separately exercises privileged SYSTEM creation and ordinary-user confirmed +writing, then boots the result. + +## Dependencies and current evidence + +The new static Rust crate uses pinned `sha2` 0.10.9 for streaming image hashes. +Its digest/block-buffer/crypto-common/generic-array/typenum dependencies provide +the hashing primitives; cpufeatures selects CPU implementations and version_check +is a build helper. These eight additions are locked in `Cargo.lock`; no glibc, +OpenSSL, async runtime or network client is added to an FDS executable. +[Upstream SHA-2 API](https://docs.rs/sha2/0.10.9/sha2/). + +Filesystem construction uses the already-installed glibc `erofs-utils` and +`e2fsprogs`; the retained legacy preparation code uses GNU `cp` from `coreutils`. These are explicit +`fds-cli` package dependencies, and `fds-cartridged` depends on `fds-cli` for its +worker. They add no packages to the existing base selection. The static tool creates and verifies GPT itself. A read-only loop +inside a private mount namespace lets the worker validate the filesystem and +manifest before confirmation. The filesystem checker runs as UID 1000 through a +private, already-open read-only device handle; cartridge code is never executed. + +The [M9 validation report](m9-validation.md) records the actual ARM construction, +virtual USB writes, independent GPT/filesystem checks and SYSTEM boot evidence. +Writer unit tests also cover source mutation, readback corruption, cancellation +and overlapping backup relocation on slightly larger targets. Physical flash +behavior and power-loss testing remain deferred. diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..4de8aba --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,151 @@ +# Building and inspecting packages + +[Documentation index](README.md) · [Development](development.md) · [Glossary](glossary.md) + +FDS uses Void's XBPS package format and xbps-src build machinery for ordinary +Linux software. M0 verifies this route by cross-compiling GNU hello 2.12.3_1 for +**aarch64 glibc** on the x86_64 Arch workstation. + +This guide assumes `make bootstrap` and `make smoke-test` have succeeded. All +commands run from the repository root. They inspect a local package; they do not +install it on Arch or create a Pi root filesystem. + +## What each tool does + +| Tool or file | Role | +| --- | --- | +| `vendor/void-packages/srcpkgs/hello/template` | Pinned upstream recipe: sources, checksums, dependencies, build/install steps | +| `xbps-src` | Creates the build environment, cross-compiles, and packages files | +| `.host/xbps/usr/bin/xbps-query` | Reads package metadata and dependencies | +| `.host/xbps/usr/bin/xbps-rindex` | Creates or updates a package repository index | +| `.host/xbps/usr/bin/xbps-install` | Installs packages into a selected root; bootstrap uses it for the build container | +| `*.aarch64.xbps` | Package archive with metadata and installed file contents | +| `aarch64-repodata` | Index allowing XBPS to find packages in an output directory | + +Arch's pacman manages workstation packages. XBPS manages Void/FDS packages. An +XBPS archive cannot be passed to pacman, and an ARM package cannot be executed as +native x86_64 software just because it was built on this workstation. + +## Rebuild the verified package + +```sh +./tools/build-package hello +``` + +The helper runs `xbps-src -a aarch64 pkg hello` and exports the main package to: + +```text +out/packages/hello-2.12.3_1.aarch64.xbps +``` + +It reuses upstream's cache and build state; an already built package may not need +recompiling. The helper's log is `out/logs/xbps-hello.log`. The package version in +this guide comes from the current source pin, not a promise about a future pin. + +Standalone `tools/build-package` does not index exported output. To refresh the +local index for this package: + +```sh +XBPS_ARCH=aarch64 .host/xbps/usr/bin/xbps-rindex \ + -a out/packages/hello-2.12.3_1.aarch64.xbps +``` + +`make smoke-test` already performs that indexing step. Explicit `XBPS_ARCH` +matters: a host tool's default architecture is not the architecture of the +package you want to inspect. + +## Inspect the built package + +Query only the local exported repository, avoiding a remote prebuilt package: + +```sh +XBPS_ARCH=aarch64 .host/xbps/usr/bin/xbps-query \ + -i --repository "$PWD/out/packages" -p pkgver hello +XBPS_ARCH=aarch64 .host/xbps/usr/bin/xbps-query \ + -i --repository "$PWD/out/packages" -p architecture hello +XBPS_ARCH=aarch64 .host/xbps/usr/bin/xbps-query \ + -i --repository "$PWD/out/packages" -x hello +``` + +The first two commands should print `hello-2.12.3_1` and `aarch64`. The third lists +runtime dependencies recorded by the package. `-i` ignores configured repositories, +so the explicit output directory supplies the lookup here. + +List the archive contents and extract just the executable into a new directory: + +```sh +tar -tf out/packages/hello-2.12.3_1.aarch64.xbps +extract_dir=$(mktemp -d "$PWD/out/hello-inspect.XXXXXX") +tar -xf out/packages/hello-2.12.3_1.aarch64.xbps \ + -C "$extract_dir" ./usr/bin/hello +./tools/verify-elf "$extract_dir/usr/bin/hello" aarch64 glibc +readelf -lW "$extract_dir/usr/bin/hello" +readelf -dW "$extract_dir/usr/bin/hello" +printf 'Extracted program: %s/usr/bin/hello\n' "$extract_dir" +``` + +The check should print `PASS: aarch64 glibc ELF`. The program headers name +`/lib/ld-linux-aarch64.so.1`, and dynamic entries include `libc.so.6`. This checks +the program inside the archive, not just its filename or metadata. The scratch +folder remains under `out/` for inspection. + +Running this dynamic ARM program also needs ARM glibc and its loader. M0 verifies +its package and ELF structure, not hello execution under QEMU. The static Rust +program can be run with the simpler [QEMU example](getting-started.md#7-optionally-execute-the-arm-rust-program). + +## Host, build container, and target architectures + +```text +Host utility: x86_64 static-musl XBPS runs on Arch +Build container: x86_64 glibc Void supplies native build tools +Target package: aarch64 glibc GNU hello runs on ARM Linux +FDS Rust program: aarch64 static-musl executable runs on ARM Linux +``` + +The wrapper selects `XBPS_ARCH=x86_64` for build-container operations and +`-a aarch64` for the target cross build. Package indexing/query examples override +`XBPS_ARCH=aarch64`. Using `aarch64-musl` would select the wrong general userspace +ABI for FDS, even though FDS-owned Rust executables use musl internally. + +## FDS packages and overlays + +| Package directory | Responsibility and status | +| --- | --- | +| `packages/fds-base/` | Implemented: base system package selection and dependencies | +| `packages/fds-base-files/` | Implemented: FDS identity, filesystem layout, and base configuration | +| `packages/fds-init/` | Implemented: native init skeleton, runtime mounts, console and service graph | +| `packages/fds-kernel/` | Implemented: pinned Pi kernel, DTBs, overlays and matching modules | +| `packages/fds-cartridged/` | Cartridge daemon packaging | +| `packages/fds-cli/` | Implemented: verified static-musl FDS CLI | +| `packages/fds-eink/` | E-Ink configuration and related integration | + +All nine FDS base packages, including cartridged, E-Ink configuration and the +bounded DHCP client, are built by `make packages` or `make rootfs PROFILE=cli`. +The separate `packages/fds-dasungd/` overlay is active: +use `make dasung` to prepare its verified +static binary and build the package. It is required by `image/base-packages.list` +and belongs to the base boot bundle; see [Dasung](dasung.md). + +The build deliberately ignores +directories without a `template`. [Development](development.md#how-future-package-overlays-fit) +explains how active overlays are copied without changing upstream files. + +The builder assembles a configured rootfs tar from this explicit set; see [Rootfs](rootfs.md). +M2 adds native init and verifies it with a full [ARM VM boot](init.md). +`make system-card` now packages it as read-only EROFS inside GPT. Runtime updates +replace SYSTEM media; PROGRAM media uses self-contained images instead of +modifying the root. See [Media tools](media-tools.md) for those implemented +workflows; the small package smoke-test only checks the build foundation. + +## What the records prove + +`out/manifests/artifacts.sha256` records the output hashes from the most recent +successful smoke-test. `void-package-inputs.sha256` records cached XBPS archive +hashes, and `void-build-packages.txt` records packages left installed after the +build. Logs retain additional build dependency information. + +The Void source commit, Rust toolchain, and static XBPS archive are pinned. +These ordinary build records alone do not freeze the rolling Void repository. +M12's separate [frozen-input workflow](reproducible-builds.md) captures selected +packages and build dependencies and compares independent offline builds. +See its acceptance status before making a reproducibility claim. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..0a79c89 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,177 @@ +# Performance targets and measurement + +[Documentation index](README.md) · [Boot](boot.md) · [Services](services.md) + +**Physical Pi performance remains unmeasured.** The target budgets below are +requirements. M5 now records actual software boot events and provides full-system +ARM VM measurements; these are kept separate from physical targets. The numerical +example in the master plan remains illustrative, not a benchmark. + +## Target budgets + +| Transition | Target | Current measurement | +| --- | --- | --- | +| Kernel entry → usable CLI | Under 1.5 s desired; under 2.0 s maximum target | Not measured | +| s6 running → usable CLI | Under 400 ms | Not measured | +| Cold power → usable CLI | Under 3 s stretch goal; under 5 s hard goal | Not measured | +| CLI → usable WindowMaker | Under 2 s | Not measured | +| Idle power-off | Under 1 s | Not measured | +| Normal power-off with writable DATA | Under 1.5 s | Not measured | + +“Usable” means accepting input, not merely drawing a banner or starting a process. +A quick screen followed by a blocked console does not meet the console target. +Shutdown must finish required data operations; losing writes is not an optimization. + +## Timestamp boundaries + +| Marker | Event | +| --- | --- | +| T0 | Power applied | +| T1 | Kernel entered | +| T2 | `fds-stage0` entered | +| T3 | Root transition (`switch_root` or equivalent) | +| T4 | s6 running | +| T5 | CLI accepts input | +| T6 | WindowMaker accepts input | + +Kernel-to-console is T5 − T1; s6-to-console is T5 − T4; cold boot is T5 − T0. +For desktop activation after the console is already usable, record a separate +activation-request timestamp and measure through T6. Do not include arbitrary +user delay at the prompt as desktop startup time. + +The implemented `fds-boottrace` records stage0 entry, SYSTEM discovery, root mount, +root switch, s6 stage-2 entry and first-console readiness. Desktop readiness is +recorded by the M8 desktop session after its startup checks and remains absent +when no desktop has started. Events use Linux +`CLOCK_BOOTTIME`; its origin is the kernel boot clock, not external power-on. +The s6 marker is the start of stage 2, after native PID 1 exists. Console readiness +is recorded immediately before Bash presents its first prompt; the VM test then +sends real commands and verifies their results. These instrumentation boundaries +are explicit approximations to T1/T4/T5, not external electrical/display measurements. + +## Collect and compare a report + +On FDS, as the ordinary console user: + +```sh +fds boot-profile +fds --json boot-profile > ~/boot-profile.json +``` + +The report lists measured intervals and missing events. A direct-root M2 VM has +no stage0 events; they remain absent rather than becoming zero-duration samples. +The default home is temporary until DATA integration, so export any report you +want to retain before powering off. Host tests save their reports under `out/`. + +To compare two saved reports on FDS: + +```sh +fds-boottrace compare baseline.json current.json +``` + +The command requires the same platform and clock and returns nonzero for a +kernel-to-console regression greater than 100 ms. A justified regression can be +recorded with `--explain "Reason for the measured regression"`; keep the explanation +and raw reports with the change. This does not make unlike scenarios comparable. + +On the workstation, run the actual ARM tool through the project emulator: + +```sh +./tools/in-void qemu-aarch64 "$PWD/out/fds-boottrace" compare \ + "$PWD/baseline.json" "$PWD/current.json" +``` + +`make console-test` boots the ordinary-user console, verifies temporary home and +read-only SYSTEM, collects a trace, and tests the regression threshold. A second +boot holds eudev behind a FIFO gate: the test uses the prompt successfully before +releasing the gate. There is no sleep or artificial readiness delay in the target. +The gate exists only in a disposable test image. + +## Physical measurement procedure + +Each benchmark record should contain: + +1. Hardware identity: Pi revision, firmware/EEPROM version, NVMe and SYSTEM media, + USB hubs/devices, power supply/battery conditions, and display setup. +2. Software identity: FDS revision, kernel configuration/version, image digest, + package/input records, active profile, and relevant boot configuration. +3. Scenario: cold power-on versus reboot, number and classes of inserted devices, + DATA presence, network state, and whether media or caches were already warm. +4. Instrumentation: where each marker was captured, timestamp resolution, clock + source, and how external power-on timing aligns with kernel timestamps. +5. Repeated raw samples, sample count, a typical value and observed range, with + failed or incomplete runs recorded rather than silently discarded. + +Use a monotonic time source for intervals within the running OS. T0 occurs before +the kernel can timestamp anything, so cold-power timing needs external capture +or another explicitly justified measurement method. Do not subtract unrelated +clock readings and present the result as a precise interval. + +Boot with one SYSTEM, with DATA, and with all 12 bays populated should be separate +scenarios. Missing SYSTEM followed by insertion is also a separate readiness test. +A single favorable run cannot establish behavior under the hardware stress cases. + +## Regression and optimization rules + +The master plan requires any boot regression greater than 100 ms to be explained +or fixed. Compare like-for-like hardware, images, scenarios, and measurement +boundaries; keep the raw measurements with the change report. + +Optimization should remove work from the console's dependency path or make that +work faster. Prepare service databases and caches during image construction. +Start optional networking and desktop services when needed. Handle device readiness +through events and explicit dependencies. Do not add sleeps for a retro effect, +wait for all USB devices before the console, or hide unfinished work behind READY. + +Rust's current `opt-level=z` is a starting configuration, not evidence that it is +the fastest choice for future control programs. Real programs and hardware must +guide later comparisons. + +## Recorded VM comparison + +The final M5 comparison is stored in `out/rust-profiles.x_wgx1kp/`, with nine +serial logs, nine raw reports, executable digests and `comparison.json`. +It uses the real Pi kernel and SYSTEM EROFS, QEMU 11.1.1 TCG, `virt`, `max` CPU, +1 GiB RAM and two virtual CPUs. Only stage0's Rust optimization level changes; +the kernel and SYSTEM image are held constant. Each level has three measured boots. + +| Stage0 optimization | Static executable size | Median kernel-clock → console | Median s6 stage 2 → console | +| --- | ---: | ---: | ---: | +| `z` | 474,128 bytes | 1,788.575 ms | 714.436 ms | +| `2` | 517,688 bytes | 1,692.750 ms | 665.673 ms | +| `3` | 533,224 bytes | 1,714.568 ms | 702.154 ms | + +These are **emulator measurements on a development workstation**, including its +scheduling noise. Three samples do not establish a hardware winner. Keep `z` +as the current default and repeat on the assembled Pi before selecting an +optimization level for physical boot speed. In particular, these numbers do not +establish either the physical 2-second console target or the 400 ms s6 target. + +Reproduce the comparison after building the current SYSTEM and boot inputs: + +```sh +make performance-test +``` + +The helper creates separate build directories and initramfs files for each level; +it does not overwrite the normal tool or initramfs outputs. Read +`out/rust-profiles-latest/comparison.json` for the measured artifact identities. +The uncompressed initramfs remains the default. All four formats boot in the M4 +matrix, but compression speed must still be compared on physical NVMe/Pi hardware. + +M10 records shutdown events and VM observations in [its validation report](m10-validation.md). +M11 records repeated empty/twelve-device VM comparisons, including the +reviewed greater-than-100 ms samples, in [its validation report](m11-validation.md). +No physical firmware, cold-power, desktop or shutdown timing is claimed here. + +M12's [repeated console checkpoint](m12-validation.md#repeated-console-timing-checkpoint) +compares five alternating pairs with fixed kernel/initramfs inputs after the +startup clock floor and release-tool packaging changes. The median increased +29.435310 ms; the initial greater-than-100 ms single-sample difference was +investigated and its raw result retained. + +The subsequent [Clap migration checkpoint](clap-validation.md#executable-size-and-startup-measurements) +passed five alternating before/after boot pairs with a −37.276280 ms median +change and repeated the three-sample `z`/`2`/`3` comparison on the updated +stage0. These are image-level VM observations; the default remains `z` and +physical Pi measurements are still deferred. diff --git a/docs/power.md b/docs/power.md new file mode 100644 index 0000000..0c2e96d --- /dev/null +++ b/docs/power.md @@ -0,0 +1,132 @@ +# Shutdown and reboot + +[Documentation index](README.md) · [DATA usage](data.md) · [Media tools](media-tools.md) + +M10 software acceptance passed; see the [validation report](m10-validation.md). +Physical timing and durability targets remain unmeasured. Rebuild the rootfs to +obtain these commands; images built before M10 do not contain them. + +## Normal use + +At the ordinary FDS console: + +```sh +fds poweroff +fds reboot +``` + +Use one command for the action you want. `fds-power poweroff` and `fds-power +reboot` are equivalent static aliases. No root password or setuid executable is +needed: the root cartridge daemon authenticates the local FDS user and performs +the controlled operation. + +Preparation freezes new cartridge operations, stops the desktop, stops managed +PROGRAM/DATA jobs and their descendants, then stops networking and all DHCP +children. It flushes DATA through the filesystem descriptor retained since +activation, changes the filesystem to read-only, checks writeback again, and +unmounts it normally. Other cartridges are unmounted next. Only after those +steps succeed does native s6 stop remaining services and request kernel halt or +reboot. Dasung remains part of the base service set until that final service stop. + +The read-only transition prevents new writes while the error-tracking descriptor +is closed for unmount. An open writer blocks that transition. A working +directory or unexpected additional mount can block unmount. No lazy unmount is +used to turn a busy or faulty cartridge into a successful shutdown. + +## If shutdown is blocked + +The machine stays running and the command reports the cause. Inspect it with: + +```sh +fds power status +fds --json power status +``` + +The JSON response includes the current phase, requested action, error, whether +native shutdown has begun, and actual `CLOCK_BOOTTIME` timestamps for preparation +steps. It is a software diagnostic, not a physical power-off measurement. + +Common cases: + +| Reported problem | What to do | +| --- | --- | +| Active media operation | Let an existing write finish, or use `fds burn cancel OPERATION_ID`. A preview awaiting confirmation must be cancelled. New confirmations are refused while shutdown is frozen. Then retry shutdown. | +| Busy DATA filesystem | Close files and programs that were started outside FDS management. Leave `/data` in every shell with `cd /`. Remove any extra mounts you deliberately created. Then retry shutdown. | +| DATA I/O error or interrupted session | Do not interpret this as SAFE. Inspect the read-only data and recover/check the cartridge in a maintenance environment before writable use. Restarting the daemon does not clear this fault. | +| Service transition failure | Inspect the corresponding volatile service logs under `/run/log`, resolve the failure, and retry. | + +If native shutdown has **not** begun, you can abandon preparation: + +```sh +fds power resume +``` + +This enables new cartridge operations again. It does not restart stopped jobs. +Start the desktop or network explicitly when needed, using `fds profile activate +windowmaker` and `fds network on`. DATA faults remain quarantined. + +If native shutdown has already begun, it cannot be cancelled. Its guard remains +alive while preparation is blocked. Resolve the cause, leave busy directories, +then retry `fds poweroff` to complete preparation; the original native action +continues. This also protects direct administrative use of the native `poweroff` +or `reboot` commands. The final native hook never treats an unsuccessful +preparation command as permission to kill processes and power down. + +Flushing real storage may take time. A client timeout is an error, never proof +that a filesystem was unmounted or a request completed. There is no automatic +force-poweroff path after a timeout. + +## DATA recovery records + +Before enabling writable DATA, FDS creates a root-owned session record under +`/run/fds/data-sessions`. Successful sync and unmount clear it. A daemon crash +can lose the original descriptor's writeback error history; a remaining record +therefore quarantines the same kernel insertion even if a later flush appears +successful. A known I/O fault is also recorded there. + +The record survives daemon restarts during this boot. It is tied to the kernel +insertion identity and does not pretend to survive complete power loss or a new +insertion. Filesystem recovery and physical durability still require separate +acceptance. Shutdown preparation itself is recorded under `/run/fds/power`, so +restarting the daemon does not silently unfreeze a blocked shutdown. + +## Build and virtual tests + +After the [boot-image prerequisites](boot.md), run on the build host: + +```sh +make rootfs PROFILE=development +make rootfs-test +make media-image-test +make power-test +make data-test +make desktop-test +``` + +The development profile supplies Xvfb for the graphical shutdown case. The +tests use private ARM virtual machines and disposable image files, with no +physical disks or USB devices passed through. They exercise native halt/reboot, +busy refusal, explicit resume, active-write protection, daemon crashes, and +independent filesystem/payload checks, plus an attached-device writeback EIO +that must survive daemon restarts. M11 extends media-writer fault injection and +twelve-device stress. Physical Pi timing, power rails, battery and +flash-controller behavior remain deferred. + +## Dependencies and native init behavior + +No new package or external Rust crate is introduced. `fds-init` explicitly +depends on `fds-cartridged` because native shutdown requires its DATA checks. The CLI now declares the +already-locked `libc` crate directly for its native shutdown guard's identity, +inotify and signal-wait syscalls. DHCP children use the existing cgroup v2 +controller and native FDS helpers. `fds-power` is another static copy of `fds`, +with behavior selected by its executable name. + +Native s6 still owns shutdown and PID 1. Its configurable final grace period is +set to zero after FDS has verified DATA and the service manager has waited for +managed service exits. Process groups use real exit events and bounded TERM/KILL +deadlines, rather than sleeping before assuming a process has stopped. + +References: [s6 shutdown](https://skarnet.org/software/s6-linux-init/s6-linux-init-shutdown.html), +[native init configuration](https://skarnet.org/software/s6-linux-init/s6-linux-init-maker.html), +[Linux remount behavior](https://man7.org/linux/man-pages/man2/mount.2.html), and +[filesystem error reporting](https://man7.org/linux/man-pages/man2/syncfs.2.html). diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 0000000..ddee7d3 --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,207 @@ +# Recovery and rollback + +[Documentation index](README.md) · [Boot images](boot.md) · [Media tools](media-tools.md) · [EEPROM](eeprom.md) + +Recovery is a separate, read-only FDS image. It contains native s6, Bash, GNU +utilities, XBPS, filesystem tools, the static FDS tools, and the base Dasung +controller. It does not need files or programs from a SYSTEM cartridge. +The software workflow is covered by `make recovery-test`; physical Pi recovery remains deferred. +The [complete internal disk](internal-storage.md) includes this recovery image; +its guide separates verified image construction from deferred physical +installation. The build commands below create ordinary files and do not write a +host disk. + +## Build the recovery image + +Use the prepared x86_64 build host described in [Your first build](getting-started.md). +From the repository root, run these commands sequentially: + +```sh +make recovery +make rootfs-test +make recovery-test +``` + +The full VM test also needs the Pi kernel/initramfs and a known-good CLI SYSTEM +image. On a fresh checkout, build those first with `make rootfs PROFILE=cli`, +`make rootfs-test`, `make initramfs`, and `make system-card PROFILE=cli`, then +run the recovery sequence above. Run builds and VM tests sequentially because +they share the same project-local Void environment. + +`make recovery` builds the `recovery` rootfs profile independently, configures all +packages and caches at image construction time, then creates EROFS. Its outputs +are: + +| File | Meaning | +| --- | --- | +| `out/rootfs-recovery.tar` | Complete recovery rootfs, including package database and compiled s6 configuration | +| `out/fds-recovery.img` | Raw EROFS payload for the internal GPT partition named `FDS_RECOVERY` | +| `out/recovery-build.*/manifest.json` | Rootfs hash, filesystem hash, profile and size | +| `out/manifests/rootfs-latest/` | Exact selected package archives and build input records | + +The latest-rootfs pointers also select this recovery build. Profile-specific +rootfs pointers preserve earlier CLI/development builds. A recovery EROFS payload +is not a whole-disk image and is not a SYSTEM cartridge. The SYSTEM builder +rejects a recovery rootfs to prevent confusing those roles. + +## Enter recovery + +Stage0 can enter recovery in two ways: + +1. At its missing-SYSTEM or ambiguous-SYSTEM console, type `recovery` and Enter. +2. For a deliberate maintenance boot, use `fds.boot=recovery` in the boot + partition's single-line `cmdline.txt`, replacing `fds.boot=normal` if present. + Restore normal mode when maintenance is complete. + +Stage0 requires exactly one readable `FDS_RECOVERY` partition and mounts it +read-only. It does not silently choose between duplicate partitions. The ARM VM +suite supplies disposable virtual partitions; physical firmware/NVMe/display +behavior must still be checked on the Pi. + +The local prompt is: + +```text +FDS RECOVERY — LOCAL MAINTENANCE CONSOLE +RECOVERY# +``` + +This is an explicit **root maintenance console**. It reads startup files from the +immutable recovery image and uses a temporary home under `/run/fds`. The root +password remains locked; no SSH or network login service is started. Normal CLI +and development images continue to use the ordinary `fds` account and `FDS>`. + +Recovery inspects cartridges read-only. It does not automatically mount DATA +writable, activate an ENVIRONMENT desktop, or enable Ethernet. Those actions +require explicit commands. The Dasung controller remains in the base boot bundle. + +## Inspect a failed system + +```sh +fds info +fds bays +fds bay 2 +fds inspect BAY02 +fds topology +fds boot-profile +lsusb -t +lsblk -o NAME,MAJ:MIN,MODEL,SERIAL,SIZE,RO,TYPE,FSTYPE,PARTLABEL,MOUNTPOINTS +dmesg +``` + +Use the calibrated bay number and the displayed model/serial to identify a +cartridge. Do not infer a bay from `/dev/sda` or from discovery order. A valid +inactive SYSTEM mounts read-only under `/run/fds/media/NN`; `fds bay N` reports +the actual path. A bad filesystem or manifest produces an error instead of +running anything from the cartridge. + +An empty bay configuration produces `UNCONFIGURED`, not guessed bay numbers. +See [Cartridges](cartridges.md) for calibration. Permanent machine configuration +on `FDS_INTERNAL` is part of the remaining M12 work. + +## Check and repair DATA + +Start with a read-only check. For example, for DATA in bay 2: + +```sh +fds recovery check BAY02 +``` + +This command is available only to root in the recovery image. It identifies one +USB disk and one `FDS_DATA` partition, unmounts its read-only view, reserves the +disk exclusively, verifies GPT and the kernel partition identity, and invokes +`e2fsck -f -n`. It does not repair the filesystem. A clean result leaves DATA +unmounted and reports `SAFE TO REMOVE`. + +If DATA was explicitly activated writable, eject that session first. Close any +shell whose current directory is on DATA and any other reader before checking; +an ordinary busy-unmount failure is reported, never bypassed with lazy unmount. + +If the check reports problems, review its log and preserve a backup where +possible. Preview the repair with: + +```sh +fds recovery repair BAY02 +``` + +The preview displays the model, serial, capacity and a confirmation command. +Copy that exact command only after checking the intended cartridge. Its token +binds the bay, kernel insertion number and current boot. A token from another +insertion or boot is rejected. The preview makes no filesystem changes. + +Confirmed repair runs `e2fsck -f -p`, which performs conservative automatic +repairs and stops when manual judgement is required. It then flushes the device, +invalidates its block cache, and runs a second `e2fsck -f -n`. Only a successful +verification produces `DATA REPAIRED AND VERIFIED` and `SAFE TO REMOVE`. + +The checker uses a temporary kernel loop device backed by the already verified +partition descriptor. This lets `e2fsck` take its own exclusive device claim +while FDS retains the physical whole-disk reservation. The loop is removed +automatically when its last descriptor closes. This uses the existing kernel +loop driver, `libc` crate and base `e2fsprogs`; no new package or Rust dependency +is introduced. + +Failed or interrupted checks/repairs retain a quarantine record across cartridge +daemon restarts for the same insertion. They do not inherit an earlier SAFE +status. The checker holds the disk reservation and is killed if its supervising +daemon dies. The command runs synchronously: bay operations and orderly shutdown +wait for it to finish; the local shell remains usable. A client timeout is not +success and does not authorize removing media. Inspect the log and wait for the +service to finish before retrying. Logs are in `/run/fds/recovery/` and are +limited to 16 MiB per invocation. + +This narrow recovery command requires a readable GPT and primary ext filesystem +signature. It deliberately does not guess partition boundaries, recreate a +broken GPT, or force answers to destructive `e2fsck` questions. Such cases require +an offline backup and expert use of the included filesystem tools. It cannot +recover data that was never written to storage. + +For structured results, prefix the command with `fds --json`. After a successful +check or repair, remove/reinsert DATA to inspect it again. Recovery still keeps +it read-only until `fds data use N` is explicitly requested. + +## Prepare replacement SYSTEM media + +A known-good SYSTEM image must be supplied on separate source media. Do not use +a damaged image as an update source. For example, source DATA in bay 2 and an +unmounted replacement cartridge in bay 4: + +```sh +fds bay 2 +fds inspect image /run/fds/media/02/fds-system-cli.img +fds inspect BAY04 +fds burn system /run/fds/media/02/fds-system-cli.img BAY04 +``` + +Use the actual source mount shown by `fds bay 2`. If the destination already +contains a mounted cartridge, run `fds eject 4` first. The burn preview identifies +the source checksum, destination capacity/model/serial and confirmation command. +Follow [Media creation and writing](media-tools.md) for confirmation and status commands. +Completion requires device flush, readback and GPT verification. Recovery uses +the same protected writer as the main system: it refuses mounted destinations, +active root storage and disks containing internal FDS partition names. + +Keep the previous known-good SYSTEM cartridge. After a successful replacement +write, use `fds poweroff`, swap SYSTEM while powered off, and boot normally. +Rollback means restoring the previous SYSTEM cartridge; it does not undo DATA +file changes, application migrations, internal boot updates or EEPROM changes. + +## Capture diagnostics and shut down + +```sh +bash /usr/share/fds/capture-hardware /tmp/recovery-capture +cat /tmp/recovery-capture/status.tsv +fds power status +fds poweroff +``` + +The collector records identities, USB topology, mounts, kernel messages, packages +and boot events with checksums. Its output is temporary unless copied to a +healthy DATA cartridge after explicitly activating that DATA with `fds data use N`. +Do not activate the damaged cartridge merely to save a report. Eject writable +DATA after copying, or let `fds poweroff` perform the normal verified shutdown. +See [Shutdown](power.md) when shutdown reports a blocking DATA error. + +Recovery is independent of SYSTEM, but it still depends on working internal +boot storage, the kernel and firmware. An external rescue medium is required +when that layer fails. EEPROM preparation and rollback are described separately +in [EEPROM configuration](eeprom.md); no EEPROM is changed by these recovery tools. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..0e35ce8 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,167 @@ +# Release signatures and verification + +[Documentation index](README.md) · [M12 evidence](m12-validation.md) + +`fds-release` signs an artifact manifest and verifies its signature and every +listed file. The static ARM executable is included in the base CLI package, so +CLI, development and recovery images can all verify downloads. The workstation +build supplies an x86_64 signer with the same format. + +The signing tool has passed host, static ARM and independent OpenSSL acceptance. +The complete frozen-input, versioned local 0.1.0 release passed acceptance; +see [M12 validation](m12-validation.md#final-local-release-acceptance) for its identity +and [Offline rebuilds](reproducible-builds.md) for the workflow. +The examples below describe the working signing interface; they do not identify +a published or hardware-qualified FDS release. + +## Build and test the tools + +After [bootstrap](getting-started.md), run: + +```sh +make signing +make signing-test +``` + +The workstation executable is +`target/x86_64-unknown-linux-gnu/release/fds-release`. The ARM executable is +`target/aarch64-unknown-linux-musl/release/fds-release`; `make tooling` also +exports it to `out/fds-release`. An ARM file cannot execute directly on the +x86_64 workstation. `make signing-test` runs that file through the project-local +QEMU interpreter as well as testing the host executable. + +OpenSSL is a **host test dependency** used as an independent Ed25519 +implementation. It is not linked into the FDS verifier. The test creates private +disposable keys under its `out/m12-signing.*` directory, checks exact signatures, +then exercises wrong keys, altered manifests, damaged or missing artifacts, +symlinks, FIFOs, malformed fields and unsafe paths. Test keys are not release +keys. + +## Create and retain a signing key + +For a local signing identity, choose an existing private directory outside the +checkout and its build output. This example creates a new directory; choose a +different name if it already exists: + +```sh +mkdir -m 700 "$HOME/fds-signing" +target/x86_64-unknown-linux-gnu/release/fds-release keygen \ + "$HOME/fds-signing/release-01" +``` + +The command creates `release-01.key` (32 secret bytes, mode 0600) and +`release-01.pub` (the hexadecimal public key). Existing files are never +overwritten. It prints SHA-256 of the decoded 32-byte public key, never the +secret. This fingerprint differs from `sha256sum release-01.pub`, which hashes +the hexadecimal text and its newline. Retain the private key separately from release downloads and source +archives. Anyone who has that key can sign a release under that identity. + +If public-key export failed after private-key creation, retain the private file +and export it to a new path: + +```sh +target/x86_64-unknown-linux-gnu/release/fds-release public-key \ + "$HOME/fds-signing/release-01.key" "$HOME/fds-signing/recovered.pub" +``` + +Private keys must be regular files owned by the invoking user with no group or +other permissions. The tool rejects symlinks and invalid key lengths. Key +generation uses Linux `getrandom`; temporary seed storage and the signing key +are zeroized when dropped. + +## Sign an assembled directory + +An assembled release directory must already contain its artifacts and a +`manifest.json` matching the format below. Signing verifies every artifact's +length and hash before creating `manifest.sig`: + +```sh +target/x86_64-unknown-linux-gnu/release/fds-release sign \ + /path/to/release-directory --key "$HOME/fds-signing/release-01.key" +``` + +If a signature already exists, use a new release directory. The tool deliberately +does not replace signatures or silently rewrite the manifest. A failed operation +may leave its new output file for inspection; it never declares it verified. + +## Verify before using images + +Obtain the signer's public key through a channel you already trust, or compare +its fingerprint against a separately authenticated value. A public key included +beside an untrusted download does not establish its identity by itself. + +On FDS, including recovery: + +```sh +fds-release verify /data/downloads/fds-release --key /data/keys/fds-release.pub +``` + +On the workstation, use the host executable instead: + +```sh +target/x86_64-unknown-linux-gnu/release/fds-release verify \ + /path/to/release-directory --key /path/to/trusted-fds-release.pub +``` + +A successful command reports `VERIFIED FDS/OS 0.1.0`, the number of checked +artifacts, and `Hardware validation: deferred`. Failure returns exit status 2 +and an explanation. Keep downloaded images unchanged between verification and +use. Verification covers only files listed in the manifest; unrelated extra +files are not endorsed. The [media writer](media-tools.md) performs its own +image/layout validation and readback checks when writing a cartridge. + +These are distribution signatures. The Pi firmware and stage0 currently do not +enforce them at boot, so this is not a secure-boot implementation or a claim +that the hardware has been tested. + +## Manifest and signature format + +The JSON document has exactly these fields: + +```json +{ + "format": 1, + "version": "0.1.0", + "source_epoch": 1789909701, + "source_sha256": "<64 lowercase hexadecimal characters>", + "void_commit": "02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1", + "hardware_validation": "deferred", + "files": [ + { + "name": "fds-system-cli-0.1.0.img", + "bytes": 123456, + "sha256": "<64 lowercase hexadecimal characters>" + } + ] +} +``` + +This is a schema illustration, not a usable manifest: the size and hash +placeholders must be replaced with values from the actual artifact. Format 1 +accepts the current tool version, a positive source epoch, a 40-character Void +commit, and 1–64 nonempty artifacts. Filenames must be flat safe ASCII names; +paths, duplicates, hidden names, reserved manifest filenames and unknown fields +are rejected. The manifest is bounded to 1 MiB. Files must be regular files, +opened without following symlinks; hashing streams their bytes and checks for +changes during the read. + +The signature is Ed25519 over the exact bytes +`FDS/OS release manifest v1` followed by a NUL byte and the raw `manifest.json` +bytes. JSON whitespace is therefore authenticated too. `manifest.sig` contains +128 lowercase hexadecimal characters and a newline. The public-key file contains +64 lowercase hexadecimal characters and a newline. Verification rejects weak +public keys and uses strict signature verification. + +The implementation uses locked `ed25519-dalek` 3.0.0 for Ed25519, +`zeroize` 1.9.0 for secret buffers, and the existing SHA-256/JSON crates. +Its locked transitive dependencies provide curve arithmetic, digest and +signature types. Default dalek features are disabled; no asynchronous runtime, +OpenSSL binding or shared target library is added. The dependencies and their +license metadata are recorded in `Cargo.lock` and the cached crate sources. +The build collects their license texts, including build dependencies and +vendored libusb notices, into `/usr/share/licenses/fds-cli/RUST-NOTICES.txt`. +The usage guide is installed at `/usr/share/doc/fds/releases.md` in every profile. +Algorithm interoperability is checked against the +[RFC 8032 test vector](https://www.rfc-editor.org/rfc/rfc8032.html#section-7.1) +and OpenSSL; strict verification follows the +[dalek API](https://docs.rs/ed25519-dalek/3.0.0/ed25519_dalek/struct.VerifyingKey.html). diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md new file mode 100644 index 0000000..eb1f333 --- /dev/null +++ b/docs/reproducible-builds.md @@ -0,0 +1,180 @@ +# Frozen inputs and offline rebuilds + +[Documentation index](README.md) · [Build host](build-host.md) · [Release signatures](releases.md) + +M12 adds a local input snapshot, fresh restore, network-isolated build, and +complete artifact comparison. **Two fresh network-isolated builds passed with +all 27 artifacts byte-identical.** The [M12 evidence page](m12-validation.md#final-local-release-acceptance) +records the source/input identities, signed local release and actual archive +round-trip. This result applies to the recorded inputs and documented build +host; it does not claim equivalence across arbitrary host environments. + +Normal builds still use Void's signed rolling repositories. A frozen build uses +the selected package files and build environment from one explicit snapshot. +It does not silently consult a newer repository if an input is missing. + +## What is frozen + +`tools/frozen-inputs create NEW_DIRECTORY` records: + +- Project-owned source, documentation, configuration and package overlays, with + a content identity independent of an FDS Git commit. This works before the + repository's first commit too; it does not invent a commit identifier. +- The exact pinned Void checkout in a Git bundle, preserving upstream files and + the shallow-history boundary needed to read its commit timestamp after restore. +- Cargo's locked vendored sources, the selected Rust toolchain and its static + ARM target. Cargo credentials and user configuration are not copied. +- The project-local Void binary build environment, static XBPS host tools and + image-tool prefix. Temporary build trees, personal home directories and + mount points are excluded. +- Cached build dependencies, exact third-party packages selected by each of + the three rootfs profiles, and kernel/DHCP/hello source archives. Local FDS + packages are rebuilt from source instead of seeding the output repository. +- Pinned Pi firmware, the VM kernel fixture, and the official EEPROM preview + inputs. The preview is not a backup of a physical Pi. + +`lock.json` records paths, kinds, modes, lengths, hashes and symlink targets. +Added, missing or changed entries fail verification. The snapshot is several +gigabytes. The completed offline diagnostic build occupied about 24 GB and its +snapshot occupied 6.9 GB. Two restored builds need separate space; retained VM +test images add further usage. Check available space before starting and keep +headroom beyond these observations, which are not upper bounds. The workstation still supplies the documented +Arch/Linux prerequisites, including Python, bubblewrap, Git, Make and compression +tools. Their environment is recorded; this is not a replacement for a host OS. + +## Prepare the snapshot + +Complete the [first-build setup](getting-started.md), then run the complete +artifact build from the repository root: + +```sh +make all +``` + +`make all` runs bootstrap and package construction, builds both normal SYSTEM +profiles and independent recovery, then creates the four initramfs formats, +production Pi boot volume, complete internal disk, signing tools and production +EEPROM preview. It does not write physical media or change EEPROM settings. +Use `make packages` to build only the FDS base packages. Builders perform their +normal format, ABI and rootfs checks; the complete runtime acceptance suite +remains separate. + +Runtime test targets select their required profile explicitly: init, boot, +console, cartridge, DATA, media and stress checks use CLI; desktop and power +checks use development; recovery checks use recovery. They no longer depend on +which rootfs happened to be built last. `make rootfs-test` intentionally checks +the most recently exported archive, so use it immediately after a profile build +when validating that specific export. + +Fetch all locked crates, including platform-specific sources that Cargo's vendor +operation requires even when they are not built for ARM: + +```sh +cargo fetch --locked +./tools/frozen-inputs create "$PWD/out/inputs-0.1.0" +./tools/frozen-inputs verify "$PWD/out/inputs-0.1.0" +``` + +Use a new output directory. The command will not overwrite a previous snapshot. +The snapshot operation itself is offline; missing selected packages or locked +crate sources fail with an error. The subsequent fresh build establishes whether +the complete dependency set was captured. Keep failed outputs for diagnosis and +retry into a new directory. +Run every build, snapshot and VM workflow sequentially: the normal checkout +shares one Void masterdir. Snapshot creation locks rootfs/base-package assembly, +but a separate standalone kernel or VM command must not be started concurrently. + +## Restore into two independent trees + +If starting from the signed release's input archive, extract it as the ordinary +build user with `tar --extract --zstd --same-permissions --no-same-owner` and +the desired `--file` and new `--directory` paths. Mode preservation matters: +default extraction can mask writable bits or remove sticky bits and cause the +input lock to reject an otherwise intact archive. Verify the extracted snapshot +before restoring it. The release's README provides the complete commands. + +The destination parent directory must already exist, and the destination itself +must not exist. Use paths without whitespace, as required by xbps-src: + +```sh +./tools/frozen-inputs restore "$PWD/out/inputs-0.1.0" "$PWD/out/rebuild-a" +./tools/frozen-inputs restore "$PWD/out/inputs-0.1.0" "$PWD/out/rebuild-b" +``` + +Restoration verifies every locked entry first. Each new tree gets its own Void +masterdir, source/download caches, Cargo directory and Rustup directory. It starts +without an FDS target directory or generated FDS packages/images. + +Run the builds sequentially and retain their full logs: + +```sh +out/rebuild-a/tools/in-frozen-build make all >out/rebuild-a.log 2>&1 +out/rebuild-b/tools/in-frozen-build make all >out/rebuild-b.log 2>&1 +./tools/compare-builds out/rebuild-a out/rebuild-b \ + --report out/reproducibility-0.1.0.json +``` + +The wrapper changes to the restored project directory, uses its private Rust +caches, makes the rest of the workstation read-only, and removes network access +for the build and all child processes. Missing input therefore cannot be hidden +by a download. FDS's xbps-src wrapper keeps dependency resolution enabled and +excludes remote repositories; the custom repository filename includes `local` +so the pinned xbps-src also retains it in its cross sysroot. Rootfs assembly uses the frozen per-profile +repository. Setting `FDS_OFFLINE=1` alone selects offline inputs but does not +provide the wrapper's network isolation. + +The comparison checks both rootfs tars, recovery rootfs, all installable images, +all four initramfs formats, Pi kernel/DTB, every FDS package and the EEPROM +configuration/rollback outputs. It reports each mismatch and exits nonzero if +anything differs. Matching files alone do not attest how they were built; retain +the isolated build logs alongside the report. + +## Assemble a local signed release + +After both complete builds match, create or choose a signing key as described in +[Release signatures](releases.md). Use the assembler in the first restored tree: + +```sh +out/rebuild-a/tools/assemble-release \ + --inputs "$PWD/out/inputs-0.1.0" \ + --comparison-build "$PWD/out/rebuild-b" \ + --output-directory "$PWD/out/fds-os-0.1.0" \ + --key "$HOME/fds-signing/release-01.key" +``` + +The assembler verifies the snapshot again, checks that the first checkout still +matches its source identity, compares the complete pair, and verifies recorded +rootfs build inputs. It creates versioned image names, source and complete input +archives, package inventories, comparison evidence and usage guides. It signs +the manifest and immediately verifies every listed file. The private key is +never copied into the release, and existing output directories are refused. + +The result is a **local** signed release with physical validation marked +`deferred`; it is not published or installed automatically. The supplied key +defines its signing identity. Distribute its fingerprint through an independently +trusted channel before someone relies on the bundled public key. + +## Determinism and troubleshooting + +FDS uses the pinned Void commit's timestamp for package and image timestamps. +Owned Rust binaries are stripped. `make` uses `tools/cargo-build` to map checkout, +Rustup and Cargo registry source paths to stable `/usr/src/fds` paths; otherwise +panic messages can embed the builder's absolute directory even in stripped +binaries. The helper preserves the target's static linking flags, and its flags +participate in Cargo's build cache. Direct `cargo build` remains useful for local +development, but release construction uses the helper. This uses Rust's +[source-path remapping](https://doc.rust-lang.org/rustc/command-line-arguments.html#--remap-path-prefix-remap-source-names-in-output) +and Cargo's [merged configuration arrays](https://doc.rust-lang.org/cargo/reference/config.html). +The kernel has explicit build user/host/time, +and filesystem UUIDs derive from content/configuration identities. Rootfs export +restores target ownership and capabilities instead of preserving the builder's +UID. It removes only the build-specific auxiliary linker cache and fetched XBPS +repository indexes; the installed package database, keys and runtime linker +cache remain available. + +If verification reports changed snapshot entries, restore the original snapshot +or create a new one from the intended inputs. Do not edit `lock.json` to hide a +mismatch. If the two builds differ, use the comparison's named artifacts and +each build's rootfs/package manifests to identify the first differing stage. +Archive compression is not a substitute for comparing the uncompressed images. +Physical performance and durability still require the actual Pi and storage. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..2daf3e0 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,77 @@ +# Roadmap and current status + +[Documentation index](README.md) · [Master plan](master-plan.md) · [M0 evidence](m0-validation.md) + +**M0–M12 software acceptance is complete. Physical Pi testing is deferred.** +The user has additionally authorized [Dasung base integration](dasung.md): an +imported daemon, ARM package, mandatory base selection, and s6 service sources. +M2 starts it under native s6 in a booted ARM virtual machine. +This is a sequence of acceptance milestones, not a release-date schedule. +The user authorized continued implementation through M12, deferring physical Pi +testing. Work proceeds in order with software/VM evidence recorded in the +[implementation ledger](implementation-status.md). + +Current development additionally implements [workstation software cartridges +and QEMU hotplug tooling](workstation.md). Its [acceptance record](workstation-validation.md) +is separate from the frozen 0.1.0 artifacts below. + +## Milestones and acceptance boundaries + +| Milestone | Status | Deliverable and acceptance | +| --- | --- | --- | +| **M0 — Build host** | **Verified** | On x86_64 Arch, bootstrap the pinned Void build environment, cross-build an aarch64 glibc package, and build/inspect a static-musl Rust executable. See the recorded skips below. | +| **M1 — Base rootfs** | **Implemented** | `fds-base`, `fds-base-files`, and `out/rootfs-aarch64.tar` with glibc, GNU tools, XBPS and Dasung; no BusyBox, systemd, runit or musl runtime package. [Usage](rootfs.md) · [Evidence](m1-validation.md). | +| **M2 — Native s6** | **Verified in ARM VM** | Native s6 PID 1, read-only root, eudev/getty/Dasung supervision, test-service control, and orderly power-off. [Usage](init.md) · [Evidence](m2-validation.md). | +| M3 — FDS tooling foundations | Verified | Add shared Rust code and CLI/stage0 skeletons; verify static-musl AArch64 artifacts without a glibc runtime dependency. | +| M4 — Pi boot | Software verified; hardware deferred | Kernel/initramfs/SYSTEM/boot-volume builds and nine VM boot cases passed. The ordinary-user prompt follows in M5; physical NVMe/RP1/display acceptance is deferred. [Evidence](m4-validation.md). | +| M5 — Fast console | Software verified; physical timing deferred | Ordinary-user console, boot tracing and regression checks passed. [Evidence](m5-validation.md). Physical performance targets remain unmeasured. | +| M6 — Cartridge daemon | Software verified; physical calibration deferred | Handle events, configured 12-bay topology, metadata, mounting, and eject; virtual hotplug checks passed. Twelve-device stress follows in M11 and physical calibration remains deferred. | +| M7 — DATA integration | Software verified; physical media behavior deferred | Integrate data/home mounts and safe eject; leave the filesystem clean after sustained writes and ejection. | +| M8 — WindowMaker | Software verified; physical display deferred | Xorg/WindowMaker, grayscale theme, ENVIRONMENT activation, managed PROGRAMs and Ethernet passed virtual display/network tests. [Usage](desktop.md) · [Evidence](m8-validation.md). | +| M9 — Media tools | Software verified; physical media behavior deferred | ARM image creation, protected and confirmed writes, readback verification, and boot of a newly written SYSTEM passed. [Usage](media-tools.md) · [Evidence](m9-validation.md). | +| M10 — Shutdown | Software verified; physical timing/durability deferred | Ordered native halt/reboot, busy refusal, persistent DATA faults and independent filesystem checks passed. [Usage](power.md) · [Evidence](m10-validation.md). | +| M11 — Hardware stress | Software verified; physical tests deferred | Twelve-device cold/hotplug and I/O stress, native reboot/halt, readiness independence and six media failure cases passed. [Usage](stress-testing.md) · [Evidence](m11-validation.md). | +| M12 — Production hardening | Software verified; hardware deferred | Signed local FDS/OS 0.1.0, exact frozen inputs, two matching offline builds, recovery/internal settings, documentation and reversible EEPROM previews. [Final evidence](m12-validation.md#final-local-release-acceptance). | + +Detailed requirements remain in sections 53–65 of the [master plan](master-plan.md). +The ledger distinguishes implemented software, remaining work, and tests that +still require physical hardware. + +## What M0 completion means + +The initial validation successfully ran `make bootstrap`, `make smoke-test`, and +`make check`. It produced and inspected an actual ARM glibc GNU hello package and +an actual static-musl ARM Rust executable. It also checked failure cases for ELF +validation, source pin drift, modified upstream files, and overlay conflicts. + +It did **not** run the ARM program in QEMU, boot a Pi, test a physical bay map, or +measure boot/shutdown performance. QEMU was absent and was explicitly reported +as an optional skip. The later snapshot and offline-build work belongs to M12. +See [M0 validation](m0-validation.md) for the exact environment, outputs, and hashes. + +## From native init to cartridge boot + +M1 changes the output from isolated test artifacts to a base operating-system +filesystem. Its first result is a rootfs archive with an explicit package set +and exclusions. That archive alone will not establish native init or Pi boot; +those are M2 and M4 acceptance boundaries. + +M2 now boots that filesystem with a generic ARM kernel and exercises native +s6 supervision. `make init-test` automates the acceptance test; `make vm` opens +a temporary development shell. Neither supplies a Pi boot image or measures +physical boot performance. M3 adds verified FDS Rust foundations; +[tooling usage](tooling.md) explains its commands. M4's [boot guide](boot.md) +covers the Pi kernel, initramfs, SYSTEM image, boot volume, and full stage0 tests. + +## Find the current command interface + +The master plan includes a design directory sketch as well as runtime commands. +The checkout now implements the complete build interface, cartridge inspection +and media management, and native power-off/reboot. A few script locations and +configuration formats differ from the original sketch; the +[implementation ledger](implementation-status.md#interface-and-policy-decisions) +maps those entries to the actual interfaces. Use [Development](development.md) +for targeted rebuilds and the project overview for the complete build/VM path. + +Use `make help` to inspect the current build entry points. Follow +[Your first build](getting-started.md) for the complete usable workflow today. diff --git a/docs/rootfs.md b/docs/rootfs.md new file mode 100644 index 0000000..723ef5b --- /dev/null +++ b/docs/rootfs.md @@ -0,0 +1,237 @@ +# Building and using the ARM root filesystem + +[Documentation index](README.md) · [First build](getting-started.md) · [Validation](m1-validation.md) + +The assembler produces a configured **aarch64 glibc Linux userspace** at +`out/rootfs-aarch64.tar`. It contains GNU tools, XBPS, the s6 tools, the cartridge manager, optional desktop stack, and the Dasung +Paperlike 13K controller. You can inspect it and run its ARM programs on the build +workstation. M2 adds native s6 init, runtime mounts and getty. The tar itself has +no bootable partition table or Pi boot volume; do not flash the tar to a disk. +Follow [Native init and ARM VM](init.md) to boot it and open a development shell. +M12 also adds `make rootfs PROFILE=recovery` and `make recovery`; see +[Recovery](recovery.md) for its distinct local maintenance console and read-only +cartridge defaults. Each build preserves a profile-specific +`out/rootfs-PROFILE.tar` pointer in addition to the latest-rootfs pointer. + +## 1. Prepare the workstation + +Use the x86_64 Arch setup in [Your first build](getting-started.md). M0 should pass +before you build M1: + +```sh +make bootstrap +make smoke-test +make check +``` + +M1 also needs **Python 3.14 or newer** on Arch. It inspects XBPS metadata and zstd +package archives and produces the tar using only the standard library: + +```sh +sudo pacman -S --needed python +python3 --version +``` + +The rootfs builder installs `qemu-user-aarch64` and `util-linux` into the existing +project-local Void build container. Static QEMU executes ARM programs; util-linux +supplies namespace-local mount and capability setup. Linux 6.7+ provides a private +`binfmt_misc` registry inside the new user namespace, allowing ARM child programs +to execute without PRoot's syscall translation. Mount/setup capabilities are +dropped before package scripts run; only namespace-local file-capability support +remains. These are build tools, not additional target requirements. No host-wide +binfmt registration, sudo chroot, or host boot configuration is needed. +The Dasung musl compiler dependencies remain documented +in [Dasung integration](dasung.md#dependencies-and-source-provenance). + +Allow several additional GB for package downloads, staging trees, the archive, +and retained inputs. Build commands share one Void container; run them sequentially. + +## 2. Build the CLI base + +From the checkout root: + +```sh +make rootfs PROFILE=cli +``` + +The first run downloads signed Void ARM packages and may take several minutes. +Later runs reuse the download cache, rebuild the local FDS packages, and assemble +a fresh root. Success ends with: + +```text +PASS: FDS rootfs built and verified: out/rootfs-aarch64.tar +``` + +`cli` boots to the ordinary-user FDS console and includes optional desktop and +Ethernet services. They start only when requested by the user or eligible media. +`PROFILE=development` adds native C/C++/Rust compilers, build systems, Git, +debuggers and Vim, plus virtual display diagnostics; see +[Development](development.md) and the [desktop guide](desktop.md). +Each archive embeds its profile identity. Unknown +profiles and mismatched SYSTEM image labels fail explicitly. + +| Output | Purpose | +| --- | --- | +| `out/rootfs-aarch64.tar` | Exported root filesystem, with target ownership, permissions and file capabilities | +| `out/rootfs-aarch64/` | Staging tree for inspection and emulated commands; owned by your host user | +| `out/manifests/rootfs-latest/` | Exact package list, checksums, metadata, scripts and build inputs | +| `out/manifests/rootfs-latest/packages/` | Copies of every selected XBPS package and available upstream signatures | +| `out/packages/fds-base-0.1.0_1.aarch64.xbps` | Explicit base dependency metapackage | +| `out/packages/fds-base-files-0.1.0_1.aarch64.xbps` | FDS identity, accounts, defaults and layout specification | +| `out/logs/rootfs-build.log` | Package installation, configuration and acceptance output | + +The first three paths are links into one retained `out/rootfs-build.*` directory. +A failed build keeps its workspace for diagnosis and does not publish a partial +archive over the last successful one. Copy the tar itself when moving the result +to another machine; copying only the link does not copy the artifact. + +## 3. Inspect the result + +```sh +tar -tf out/rootfs-aarch64.tar | head -40 +cat out/rootfs-aarch64/etc/os-release +cat out/manifests/rootfs-latest/packages.json +(cd out/manifests/rootfs-latest && sha256sum -c rootfs.sha256 packages.sha256) +``` + +The default hostname is `fds`, the timezone is UTC, and the default language is +English (`en_US.UTF-8`). Account passwords are locked and no SSH server starts. +CLI and development boot to the local `fds` account (UID 1000) through autologin; +recovery has a local root maintenance console. The build shell below runs as +emulated root so it can inspect and configure the staging tree. It is separate +from the normal booted user session. + +## 4. Run ARM programs on the workstation + +```sh +./tools/in-rootfs out/rootfs-aarch64 /usr/bin/ls --version +./tools/in-rootfs out/rootfs-aarch64 /usr/bin/readelf --version +./tools/in-rootfs out/rootfs-aarch64 /usr/bin/xbps-query -l +./tools/in-rootfs out/rootfs-aarch64 /usr/bin/dasungd check +./tools/in-rootfs out/rootfs-aarch64 /bin/bash --login +``` + +Inside the shell, try `getconf GNU_LIBC_VERSION`, `locale charmap`, `ls /usr/bin`, +or `xbps-query fds-base`. Type `exit` to return to the workstation. + +This runs ARM userspace on the host kernel through QEMU. It does not boot an ARM +kernel or validate Pi drivers. Bubblewrap makes the target the actual process +root, supplies minimal devices, and mounts temporary `/run` and `/tmp`. Native +emulation tools are mounted read-only under `/tmp/fds-build-host`. Physical USB +and DRM are absent, so this helper cannot control your monitor. Use it for trusted +image-build commands. Do not use +this shell to test disk formatting, mounts, power-off, or real device access. + +Commands can modify the staging tree. Those changes do **not** change the exported +tar. Rebuild to get a clean staging tree and archive. Built SYSTEM images remain +read-only: change packages at image build time, not by upgrading a live SYSTEM. +For the booted target's on-demand Ethernet and DNS policy, see +[Desktop and Ethernet](desktop.md). + +## 5. Run the acceptance checks + +```sh +make rootfs-test +``` + +This verifies the archive and saved package checksums, restores a disposable +tree, and runs ARM glibc, GNU tools, a shell pipeline, locale, XBPS integrity, +Dasung configuration, and s6 database queries under QEMU. It also checks every +ELF file for the target architecture, validates archive permissions and ping +capabilities, and tests rejection of forbidden packages, unfinished configuration, +wrong ABIs, a host executable, missing boot entries, a shell init replacement, +and unsupported profiles. + +The image has no BusyBox, systemd, runit, or musl runtime package. Dasung carries +its own statically linked musl and libusb. Its service database is compiled at +build time and includes the base monitor bundle. M2 boots the current graph under native s6 PID 1 in a full ARM VM. Physical +monitor recovery still requires a Pi test. + +## What goes into the base + +`packages/fds-base/template` owns the explicit dependency set. The assembler also +consumes `image/base-packages.list`, so the required hardware package cannot be +lost when selecting a profile. + +| Package group | Reason | +| --- | --- | +| glibc, glibc-locales | GNU C runtime and compiled English UTF-8 locale | +| coreutils, binutils, findutils, diffutils, grep, sed, gawk, bash, tar, gzip, xz | GNU command-line tools, ELF inspection, scripting and archives | +| util-linux, procps-ng, shadow, kbd | Standard system, process, account and console utilities | +| e2fsprogs, dosfstools, erofs-utils | Tools for the planned ext4, FAT and EROFS media | +| eudev, kmod | Device discovery tools and module management | +| iproute2, iputils | Network inspection and configuration tools | +| pciutils, usbutils | Hardware inspection | +| ca-certificates, tzdata, xbps | TLS trust, timezones, and the package database/tools | +| skalibs, execline, s6, s6-linux-init, s6-rc | Native init, process supervision, and service dependency tools | +| fds-base-files, fds-init, fds-dasungd | FDS defaults, native init/service graph, and the mandatory monitor controller | + +XBPS resolves shared libraries and other transitive dependencies. The exact +resolved set is in `packages.json`; do not mistake the direct dependency list for +the entire installed set. Planned `fds-cli` and `fds-cartridged` packages are +deliberately absent until their implementation milestones. + +## Assembly details + +The FDS base-files package supplies `usr/share/fds/layout.json`. The assembler +applies that manifest before XBPS extraction to create merged `/usr` links and +empty mountpoints. Upstream xbps-src reserves these links for Void's own +`base-files`; this approach keeps its tracked files and lint rules unchanged. + +Remote packages are signature-checked using Void keys from the pinned checkout. +Local FDS packages are built here and are unsigned development artifacts. A +foreign-architecture XBPS transaction unpacks them; the target's own XBPS then +configures every package under QEMU. Locales, CA certificates, the udev hardware +database, the dynamic linker cache, native init environment, and the complete +s6 service database are ready before export. Certificate generation also runs as +an explicit checked step: the upstream CA package wrapper suppresses updater +errors. There is no first-boot package configuration step. + +Unused upstream runit service files are excluded by the shipped XBPS `noextract` +rules. This does not replace upstream package templates or add runit to the image. + +The staging directory cannot preserve arbitrary target UID/GID values as a normal +host user. Export restores ownership from the exact XBPS archive headers, applies +the util-linux `wall`/`write` tty-group and XBPS `xbps-uchroot` xbuilder-group adjustments, and gives generated +files root ownership. Namespaced Linux capabilities are converted to the portable +root-owned encoding. The archive, not the host-owned staging tree, is the authority +for installation metadata. When eventually restoring as root, use GNU tar with +`--numeric-owner --same-permissions --xattrs --xattrs-include=security.capability`. +For complete disk images and the deferred physical installation procedure, see +[Internal storage](internal-storage.md). Do not write this rootfs tar to a disk. + +The rootless configuration log therefore reports group-change failures for +`wall`, `write`, and `xbps-uchroot`. These three known adjustments are applied +and checked in the exported archive. Other installation errors require investigation. +The builder also reapplies and verifies ping's `CAP_NET_RAW` with direct QEMU +inside a user namespace and checks the actual stored xattr. + +Package versions, input hashes and selected archives are retained for inspection. +The Void binary repository is rolling; a fresh build later may resolve different +versions. Those records alone do not establish reproducibility. Use the +separate [frozen-input and offline rebuild workflow](reproducible-builds.md) for +release construction and consult its recorded acceptance status. + +## If a build fails + +Read the last error in `out/logs/rootfs-build.log`; keep the printed build-workspace +path. A package setup error is a build failure, not a reason to publish an +unconfigured filesystem. Correct the issue and run the build again; downloads +already in `out/cache/rootfs` are reused. + +If you changed an overlay and receive `Stale overlay`, compare your edited +`packages/fds-*` directory with the named generated copy under +`vendor/void-packages/srcpkgs/`. Move only that generated copy out of the way and +rerun. Never discard tracked upstream changes to silence the pin check. See +[Troubleshooting](troubleshooting.md) for the existing build-container checks. + +References: [Void chroot installation](https://docs.voidlinux.org/installation/guides/chroot.html), +[XBPS configuration](https://man.voidlinux.org/xbps.d.5), +[Linux binary-format handlers](https://docs.kernel.org/admin-guide/binfmt-misc.html), and +[QEMU userspace emulation](https://www.qemu.org/docs/master/user/main.html). + +The rootless exporter restores ownership changes that package install scripts +cannot represent in a single-UID user namespace. This includes tty ownership for +`wall`/`write`, the xbuilder group for `xbps-uchroot`, and the utmp group for the +terminal-accounting helper. Archive checks validate these identities explicitly; +the ARM boot check also inspects the installed helper's actual group. diff --git a/docs/services.md b/docs/services.md new file mode 100644 index 0000000..68362c4 --- /dev/null +++ b/docs/services.md @@ -0,0 +1,142 @@ +# Services, readiness, and shutdown + +[Documentation index](README.md) · [Native init and ARM VM](init.md) · [Cartridges](cartridges.md) + +**Implemented in M2:** native s6 PID 1, a compiled base service graph, console +login, eudev, the Dasung controller, and a controllable test service. The +[init walkthrough](init.md) explains how to boot the ARM VM and try the commands +below. M6 adds the base cartridge service; M8 adds optional desktop/network profiles as described in the [desktop guide](desktop.md). + +## Startup and service state + +```text +Linux → /sbin/init → s6-linux-init → s6-svscan (PID 1) + └── rc.init → s6-rc-init → s6-rc change boot +``` + +The generated execline launcher immediately executes native init. Stage 1 prepares +writable `/run`, starts the catch-all logger, and replaces itself with s6-svscan. +Bash stage-2 scripts run as ordinary children. They initialize the live database +and activate the `boot` bundle. No service compiler runs during boot. + +| Location in FDS | Purpose | +| --- | --- | +| `/etc/s6-rc/source` | Packaged, readable service definitions | +| `/etc/s6-rc/compiled` | Dependency database compiled under ARM emulation during the build | +| `/run/s6-rc` | Live service state on tmpfs | +| `/run/service` | Supervision directories created at runtime | +| `/run/uncaught-logs` | Native init catch-all log, also copied to the console | +| `/run/log/dasungd` | Rotating monitor-controller log | + +These are guest paths, not instructions to start services on the Arch host. +The Void build container never boots the FDS service graph. + +## The base boot graph + +| Service | Function | Dependencies | +| --- | --- | --- | +| `runtime-fs` | Mount proc, sysfs, devpts and volatile temporary/log paths | Kernel or stage 0 supplies `/dev`; native stage 1 supplies `/run` | +| `hostname` | Set the image's hostname | None | +| `console` | Create volatile login accounting files | `runtime-fs` | +| `getty` | Provide a console login | `console`, `hostname` | +| `eudevd` | Process kernel device events | `runtime-fs`; readiness checked through its control channel | +| `udev-trigger` | Request initial subsystem and device events | Ready `eudevd` | +| `machine-config` | Validate internal NVMe settings and publish a fixed boot snapshot, or image defaults | `runtime-fs`; no console dependency | +| `cartridged-runtime` | Prepare bounded cartridge log storage | `runtime-fs` | +| `cartridged-log`, `cartridged` | USB events, bay inventory, validated mounts and IPC | Runtime paths and `machine-config`; no getty or eudev readiness dependency | +| `dasungd-runtime` | Prepare private controller state and log paths | `runtime-fs` | +| `dasungd-log`, `dasungd` | Supervise the monitor controller and its logger | Runtime paths and s6's logging pipeline | + +The bundle selects these services through their dependencies; it does not impose +one serial list. Console startup does not wait for device enumeration, monitor +reconnection, networking, or cartridge discovery. Dasung remains part of the base, +independent of a GUI or an inserted ENVIRONMENT cartridge. See [Dasung](dasung.md). + +M6 includes the real static `cartridged` service and its logger. Network, DHCP, +NTP, sshd, D-Bus, Xorg, WindowMaker, +Bluetooth, and Wi-Fi are absent from the current boot bundle. + +## Inspect and control services + +Inside the shell opened by `make vm`: + +```sh +s6-rc -l /run/s6-rc -a list +s6-svstat /run/service/getty +s6-svstat /run/service/eudevd +s6-svstat /run/service/dasungd +cat /run/log/dasungd/current +s6-rc -l /run/s6-rc -u change test-echo +s6-svstat -o up,ready,pid /run/service/test-echo +printf 'hello\n' | s6-ipcclient /run/fds-test/echo.sock s6-ioconnect -t 3000 +s6-rc -l /run/s6-rc -d change test-echo +``` + +`test-echo` is a root-only Unix-socket echo server. Its database entry ships in +this development rootfs, but it is excluded from the normal boot bundle. Its +`test-runtime` dependency creates `/run/fds-test` with mode 0700. The ARM boot +test verifies replies, a supervised restart with a new PID, and stop/start state +transitions. It also verifies that getty and Dasung are running without hardware. + +## Readiness and failures + +A process existing does not always mean its service is usable. The echo server +notifies s6 after it starts listening. Eudev has no native s6 notification, so +`s6-notifyoncheck` checks its control channel within a bounded deadline. The +initial device trigger does not wait for every unrelated device to settle. + +There is no global `udevadm settle` or sleep added to hide a boot race. A missing +monitor leaves Dasung supervised and disconnected; it does not hold up getty. +Service errors go to the runtime logs and console. Investigate the failed +service and its dependencies instead of extending an arbitrary startup delay. + +## Shutdown + +`poweroff`, `reboot`, `halt`, and `shutdown` are the native s6-linux-init wrappers. +M10's `fds poweroff` and `fds reboot` first freeze new operations, stop optional +sessions and managed jobs, then verify DATA and cartridge unmounts. A native +shutdown hook also enforces this preparation for administrative use of the +wrappers. Only successful preparation proceeds to the remaining s6 service stop +and final kernel action. The two internal +s6-rc helper services are marked essential and remain until native final cleanup; +its diagnostic warnings about not stopping them are expected. + +The [shutdown guide](power.md) explains commands, failure recovery and the current +M10 acceptance status. No physical Pi shutdown measurement is claimed. See +[Performance](performance.md) for the hardware targets and measurement boundaries. + +M8 ENVIRONMENT profiles select OS-defined service bundles after console readiness. +Profile metadata is declarative; a cartridge cannot inject a root shell +script into startup. See [Cartridges](cartridges.md) for that trust boundary. + +## Cartridge service + +M6 adds `cartridged` and its bounded volatile logger to the base boot bundle. +It depends on runtime filesystem setup and M12 machine settings loading, +independently of getty and eudev. See [Internal storage](internal-storage.md) for +the read-only loading policy and explicit fallback diagnostics. Its +readiness notification means the control socket exists, not that all cartridges +have finished enumeration. Getty has no cartridge dependency. + +The service listens before scanning and reconciles kernel USB/block events with +current sysfs state. See [Cartridges](cartridges.md) for configuration, controls, +restart behavior and diagnostics. Dasung stays a separate mandatory base service. + +## Optional desktop and network graph + +The `desktop` bundle selects `xserver` and `desktop-session`. Xorg's display-fd +notification establishes server readiness; the static session helper subscribes +to X11 property events before launching WindowMaker, then observes its EWMH +supporting-window property. This avoids missing an event when the atom does not +yet exist. Session processes run as fds in a root-owned cgroup. Their descendants +are stopped before Xorg. Native service supervision handles unexpected exits. + +The `network` bundle selects `dhcp`. Its process readiness does not wait for a +lease; DHCP remains asynchronous. Both bundles have bounded volatile loggers, +and neither belongs to `boot`. Their runtime directories are prepared by +separate oneshots. See [Desktop and Ethernet](desktop.md) for user commands. + +M10 places the DHCP client and its privilege-separated children in a root-owned +cgroup. Network stop checks actual group exit as well as the supervised main +process, then brings the managed interfaces down and clears volatile resolver +state. It introduces no additional daemon or boot dependency. diff --git a/docs/software-format.md b/docs/software-format.md new file mode 100644 index 0000000..b2f5dfe --- /dev/null +++ b/docs/software-format.md @@ -0,0 +1,117 @@ +# Software cartridge format 1 + +[Build and use a cartridge](workstation.md) · [Original cartridge classes](cartridges.md) + +A software cartridge is a complete GPT disk image with **1 + m** partitions, +where `m` is 1–32. Partition 1 describes every software package in all payload +partitions. All partitions use read-only EROFS. This format is the current +PROGRAM creation format; existing single-partition PROGRAM media remain readable. +SYSTEM, DATA and ENVIRONMENT layouts retain their existing meanings. + +## Disk and filesystem layout + +```text +GPT protective MBR + primary GPT + 1 FDS_METADATA + FDS/CARTRIDGE.TOML + FDS/SOFTWARE.TOML + 2 FDS_PAYLOAD02 + bundles/demo.hello.tar.xz + bundles/demo.editor.tar.xz + 3 FDS_PAYLOAD03 + bundles/demo.report.tar.xz + ... +backup GPT +``` + +Partition entries are consecutive, use Linux filesystem type GUIDs, unique +nonzero partition UUIDs, no GPT attributes, and nonoverlapping MiB-aligned +extents. Partition 1 starts at LBA 2048. Both GPT copies, their headers and CRCs +must agree. A full-drive write to a larger USB device relocates the backup table +and header. The existing on-target image parser and the workstation writer +share these checks. + +`FDS/CARTRIDGE.TOML` uses the existing format-1 identity with `class = "program"` +and `media.writable = false`. It contains no startup command. A sample catalogue: + +```toml +format = 1 + +[[software]] +id = "demo.hello" +name = "AArch64 hello" +version = "1.0" +architecture = "aarch64" +partition = 2 +archive_bytes = 2308 +unpacked_bytes = 70504 +entries = 2 +sha256 = "REPLACE_WITH_THE_64_CHARACTER_LOWERCASE_SHA256" +[software.commands] +hello = "bin/hello" +``` + +Those lengths and the digest are illustrative. The builder generates actual +values. IDs and command names use the existing restricted FDS identifier syntax. +IDs are unique per cartridge. Every declared payload partition must contain +exactly the catalogue's `bundles/.tar.xz` archive names, and every payload +partition must be represented. Multiple software entries may share a partition. +The catalogue contains 1–128 software entries and at most 64 KiB of TOML. + +## Archive contract + +Bundles are deterministic USTAR archives compressed with single-threaded xz. +Entries are sorted, timestamps/UID/GID are zero, and permissions normalize to +0755 for directories/executables or 0644 for ordinary data. Host source symlinks +to regular files inside the root are flattened into regular files. Directory or +escaping symlinks are rejected; the archive itself never contains links. + +The reader rejects: + +- Absolute, parent-traversing, repeated-separator, non-UTF-8 or control-character paths. +- Duplicate entries, file-as-parent conflicts, links, devices, sockets, FIFOs, + privileged permissions, and GNU/PAX extension entries. +- Incorrect SHA-256, compressed length, unpacked length, entry count, or command + paths that do not name executable regular files. +- ELF files that are not little-endian 64-bit AArch64, or any ELF in a bundle + declared `architecture = "any"`. +- Invalid xz data, nonzero material after the tar end marker, and resource-limit + violations. Each archive is at most 512 MiB compressed, 1 GiB unpacked and + 65,536 entries. Xz decompression has a 256 MiB memory limit. + +A source path must fit USTAR's path fields. Packaging reports paths that cannot +be represented rather than emitting an unsupported extension header. + +## Guest lifecycle + +On insertion the daemon validates the complete GPT against kernel partition +geometry and the current USB disk identity. It reads metadata without following +symlinks, mounts every payload read-only/noexec/nosuid/nodev, and checks the +archive inventory and lengths. `fds bay BAY` reports the full catalogue; +`fds bays` stays compact. No archive command runs automatically on insertion. + +`fds run BAY -- SOFTWARE-ID:COMMAND [ARGUMENT...]` verifies and extracts that +package on first use. It creates a private temporary filesystem, applies the +archive contract, then makes the finished tree read-only and accessible for +execution. The runtime cache has a stricter **256 MiB per software tree** limit, +including reserved inode overhead; a host-valid larger bundle may therefore +need splitting before running. Caches live under `/run/fds/software//` and +vanish after eject, unplug, restart cleanup, or shutdown. + +Managed consumers run as UID/GID 1000 with no new privileges in the bay's cgroup. +`FDS_APP`, `PATH`, `LD_LIBRARY_PATH` and `XDG_DATA_DIRS` point to that software +root. DISPLAY/XAUTHORITY retain the established optional desktop integration. +The xz utility is already an explicit dependency of the mandatory `fds-base` +package; runtime extraction adds no target package. + +Build recipes, compilers, installation hooks and privileged archive scripts are +never part of this runtime path. + +Safe eject stops consumers, rejects unexpected mount aliases, unmounts caches, +payloads and metadata, then reports SAFE. Confirmed surprise removal permits +lazy detachment after stopping consumers. Service restart removes stale mounts +before rescanning. SYSTEM stays immutable throughout. + +SHA-256 detects corruption and binds an archive to its catalogue; it does not +establish a publisher's identity. Only run software you trust. Release signature +verification remains the separate [release workflow](releases.md). diff --git a/docs/stress-testing.md b/docs/stress-testing.md new file mode 100644 index 0000000..bace601 --- /dev/null +++ b/docs/stress-testing.md @@ -0,0 +1,151 @@ +# Twelve-bay stress tests + +[Documentation index](README.md) · [Cartridge configuration](cartridges.md) · [Shutdown](power.md) + +M11 exercises twelve devices together and failures during media writing. The +software tests run on the build workstation. The separate physical procedure +below is for the assembled Pi; no virtual result certifies its wiring, power +budget, USB hubs, storage controllers or display. + +## Run the virtual tests + +Complete the [boot-image prerequisites](boot.md), then run sequentially: + +```sh +make rootfs PROFILE=cli +make rootfs-test +make system-card PROFILE=cli +make media-image-test +make stress-test +``` + +`make stress-test` compiles two test-only static ARM workloads, boots the actual +packaged CLI image and Pi kernel, then runs the media failure suite. To repeat +only the latter after those prerequisites: + +```sh +make media-fault-test +``` + +No host disk or physical USB device is passed through. All media are disposable +regular files inside a new `out/m11-vm.*` or `out/m11-faults.*` directory. QEMU runs +inside the existing private network namespace. The SYSTEM root is a protected +read-only virtual disk; the twelve additional USB disks stress all twelve bay +slots. Earlier boot tests cover a removable USB SYSTEM itself. + +The virtual controller explicitly has twelve USB 2 and USB 3 ports. Its live +controller identity is discovered before generating the test map. This is not a +map to install on the Pi. Physical hub chains and USB 2/3 aliases must be calibrated +on the actual machine using [the cartridge guide](cartridges.md). + +The test records: + +- Three cold-start pairs with empty and fully populated bays, using the actual + ordinary-user console prompt and identical image/controller configuration. +- Twelve correct cartridge IDs and serials, followed by three simultaneous + removal/insertion cycles with reversed enumeration and rotated bay positions. +- Eleven direct-I/O readers checking known bytes while DATA writes and flushes + 64 MiB. Per-thread intervals must overlap, and QEMU's real block counters must + show reads/writes for every disk. +- Console readiness while the cartridge daemon is deliberately held at an + event gate with all twelve disks present. No sleep releases that gate. +- Native reboot during sustained DATA writes, independent `e2fsck`, exact payload + verification, another boot with a different device declaration order, and halt. +- Kernel logs checked for unexpected I/O failures, USB resets, filesystem errors + and controller failures. Intentional fault tests have separate logs. + +Boot samples, shutdown events, bay inventories, I/O counters and kernel logs stay +beside the images. A greater-than-100 ms device-load change must receive the +recorded review explanation; it must not be hidden by selecting a faster sample. +Three emulator samples do not establish a Pi performance claim. + +The fault suite injects actual EIO during writing, cache flush and readback. Other +cases interrupt a confirmed transfer after at least 64 MiB by cancellation, +worker termination or daemon termination. Failed operations must remain failed +across restart, the daemon must remain usable, and none may acquire SAFE status. +A test-only QEMU bandwidth limit keeps the large transfer observable; production +code has no corresponding delay. See [QEMU fault injection](https://www.qemu.org/docs/master/devel/testing/blkdebug.html). + +## Prepare the physical session + +Use twelve labelled test cartridges and a written bay inventory. Include the +intended SYSTEM, a backed-up or disposable DATA cartridge, and the PROGRAM, +ENVIRONMENT and hardware cartridges you plan to use. Keep a known-working SYSTEM +available for rollback. Record the Pi revision, EEPROM version, hub/controller +models, cable arrangement, power source, storage serials and image hashes. + +Label physical bays `01` through `12`. Confirm one device at a time with +`fds topology` and `fds bay N`, then create the permanent machine map following +the cartridge guide. Check both USB 2 and USB 3 modes where supported. A serial +identifies the cartridge, while the stable controller/port path identifies its +bay; the `/dev/sdX` name is not a bay label. Do not copy the VM map. + +No destructive operation is necessary merely to collect diagnostics. For write +stress, use test media whose contents can be replaced. Any `fds burn` or +`fds format` operation still requires its exact per-operation confirmation. + +## Capture evidence on the target + +Copy [capture-hardware](../tools/capture-hardware) to the DATA cartridge. At the +FDS console, run it explicitly through Bash; DATA intentionally does not permit +direct execution: + +```sh +bash /data/capture-hardware /data/capture-before +``` + +The destination must be a new absolute directory. It is created private to the +current user. The script collects FDS status, topology, boot events, kernel logs, +USB/block inventories, mounts, network links and package versions. It writes a +status table and checksums. It does not change services, mounts, firmware, disks +or networking. A command denied to the ordinary user is recorded with its exit +status; use an administrative maintenance session for the missing diagnostic if +needed. A successful capture is not a hardware PASS. + +Choose a new directory for every capture. If DATA itself is under investigation, +write to `/tmp/capture-before` instead and copy the directory to trusted storage +before shutdown; `/tmp` is volatile. Review captured device serials and network +identifiers before sharing the files. + +## Physical acceptance sequence + +Copy the [physical session template](../tests/hardware/session-template.json) +and fill it with observed values. Null means unknown, not zero. +Record every run, including failures. The following is an initial repeatable +matrix, not evidence that it has already passed: + +| Scenario | Procedure and required evidence | +| --- | --- | +| Cold boot baseline | Run five full cold boots with the intended SYSTEM and no optional media. Capture boot reports and external power-on/display timing. | +| Fully populated cold boot | Repeat five times with all twelve bays occupied. Compare prompt timing, every physical bay/serial pair and kernel errors against baseline. | +| Hot insertion | Insert each cartridge individually, then insert several together. Verify all twelve positions and expected activation. Capture before/after inventories. | +| Safe removal | Stop explicit jobs and use `fds eject N` for storage. Remove only after SAFE. Verify that reusing the same bay does not retain the old cartridge's identity or SAFE state. | +| Rotated placement | Move the test cartridges to different bays and repeat. Each bay must follow its physical position regardless of Linux disk names. | +| Simultaneous I/O | Read known files from every readable cartridge while a managed DATA job writes a known payload. Record hashes, throughput and logs; USB hardware without storage needs its own functional workload. | +| Reboot under load | Request `fds reboot`. Verify the ordered shutdown record, boot again, and compare DATA hashes. Run a filesystem check only after that filesystem is unmounted in maintenance/recovery. | +| Poweroff under load | Request `fds poweroff`; record software phases and external power/display behavior. A blocked command is not permission to unplug DATA. | +| Power and USB behavior | Observe the intended supply/battery configuration under simultaneous device load, within each component's ratings. Record undervoltage indications, resets, disconnects and any display recovery failure. | + +Use external instrumentation for power applied, visible E-Ink readiness and actual +power-off. Kernel timestamps cannot measure time before the kernel starts or prove +that a flash controller honored a flush. Do not convert a VM measurement into a +physical result. Intentional power-loss or surprise-removal durability tests need +a separate disposable-data session and an explicit test plan; ordinary SAFE +acceptance does not establish power-loss durability. + +## Pass, fail and follow-up + +A physical run fails if a cartridge appears in the wrong bay, a confirmed write +or DATA eject reports success after an actual I/O error, a busy DATA shutdown +forces poweroff, a kernel/controller error is unexplained, or verified payloads +change. Capture logs before restarting a service or rebooting so its evidence is +not lost. Preserve both the observed failure and any later successful rerun. + +Compare the same image, power source and device population when evaluating a code +change. Explain or fix a boot regression over 100 ms. Keep the idle <1 second and +normal DATA <1.5 second shutdown targets separate from measured outcomes. The +physical report should explicitly list untested devices, profiles and power modes. + +No new target dependency is introduced by M11. Test workloads use Rust's standard +library; the capture script uses Bash and tools already included in the base +image. QEMU, filesystem tools and Python were existing host test dependencies. diff --git a/docs/tooling.md b/docs/tooling.md new file mode 100644 index 0000000..fc81d46 --- /dev/null +++ b/docs/tooling.md @@ -0,0 +1,143 @@ +# FDS Rust tools + +[Documentation index](README.md) · [Implementation ledger](implementation-status.md) + +M3 introduces `fds-common`, the `fds` command, and the `fds-stage0` diagnostic +entry point. Both executables cross-build as static-musl AArch64 programs. The +normal OS continues to use glibc; no target musl runtime package is added. + +For native Linux software packaging and QEMU hotplug, use `make workstation` +and the [workstation guide](workstation.md). Those Clap tools are host binaries; +`make tooling` below builds the static ARM runtime tools. + +## Build and test + +After the [workstation setup](getting-started.md): + +```sh +make tooling +make tooling-test +``` + +The outputs are `out/fds`, `out/fds-stage0`, `out/fds-boottrace`, `out/fds-cartridged`, `out/fds-profile`, `out/fds-burn`, `out/fds-inspect`, `out/fds-eject`, `out/fds-power` and `out/fds-release`, with hashes and source-input +records under `out/manifests/fds-tools*.sha256`. The tests run shared Rust unit +tests on x86_64, inspect the ARM ELF files, then execute those actual ARM binaries +with the project-local QEMU userspace emulator. No physical monitor or disk is +opened by this test. + +The M3 workspace members reuse the already-locked `serde`, `serde_json`, +and `toml` dependencies: typed configuration, machine-readable output, and the +specified cartridge format. No asynchronous runtime, OpenSSL library, libudev +binding, C compiler dependency, or additional native library is introduced. + +All Rust command-line interfaces use **Clap**, with typed commands, generated +help, and argument validation before execution. The workspace shares the Clap +dependency already used by Dasung; `Cargo.lock` pins version 4.6.7 and its derive +support. It adds no shared runtime library. The dependency-free smoketest has no +command-line interface. Test helpers also use Clap and build through Cargo. +The [migration validation](clap-validation.md) records parser, full-image, VM +and startup comparison evidence. + +M9 adds media creation and confirmed writes; see [Media tools](media-tools.md) +for its current validation state and the new SHA-256 dependency. +M12 adds the base `fds-release` signature verifier and a workstation signer; +see [Release signatures](releases.md) for key handling, verification and the +Ed25519 dependency rationale. + +## Find commands and options + +Run these inside FDS (or prefix them with the emulator invocation below on the +workstation): + +```sh +fds --help +fds help format +fds format data --help +fds recovery repair --help +fds machine --help +fds-burn create --help +fds-release verify --help +``` + +Help is specific to the selected command. `-h` and `--help` print help without +performing an operation; `--version` reports the executable version. Missing, +unknown, duplicated, and conflicting arguments produce a usage error with exit +status 2. Operational failures still come from the existing FDS safety checks. + +`fds --json bays` and `fds bays --json` are equivalent. The installed aliases +`fds-inspect`, `fds-eject`, and `fds-power` have their own generated help and +accept the same relevant arguments as `fds inspect`, `fds eject`, and `fds power`. +Bay selectors accept `1` through `12`, including the `BAY1` through `BAY12` form. + +Use `--` to separate a managed program from FDS options: + +```sh +fds run 2 -- editor --help +``` + +Here `--help` belongs to `editor`. Every argument after the separator is passed +to the managed program unchanged, including `--json` and another `--`. +Media writes still require the exact confirmation associated with the current +cartridge insertion and image; accepting CLI syntax never authorizes a write. + +## Inspect identity and cartridge metadata + +Run the ARM programs through the existing container: + +```sh +./tools/in-void qemu-aarch64 "$PWD/out/fds" info +./tools/in-void qemu-aarch64 "$PWD/out/fds" --json info +./tools/in-void qemu-aarch64 "$PWD/out/fds" inspect \ + "$PWD/tests/fixtures/manifests/windowmaker.toml" +``` + +`info` reports tool version, build target, kernel and the visible PID 1. In +userspace emulation those kernel/process values describe the execution environment, +not a booted FDS machine. Full-system boot remains a separate test. + +Manifest inspection reads at most 64 KiB and rejects unknown keys, unsupported +format versions, unsafe identifiers, terminal control characters, writable +SYSTEM/PROGRAM/ENVIRONMENT media, and activation actions outside ENVIRONMENT. +A profile name is declarative; validation does not execute or activate it. +DATA requires writable media. `--json` gives parsed, validated metadata. + +Unknown commands and invalid data return nonzero. Bay state and eject now come from the base cartridge daemon; see +[Cartridge usage](cartridges.md) for available operations and remaining milestones. + +## Stage-0 diagnostics + +```sh +./tools/in-void qemu-aarch64 "$PWD/out/fds-stage0" --help +./tools/in-void qemu-aarch64 "$PWD/out/fds-stage0" --check-cmdline /path/to/cmdline +``` + +Supported FDS kernel options are `fds.boot=normal|recovery` and `fds.debug=0|1`. +Normal boot selects GPT `PARTNAME=FDS_SYSTEM`; recovery selects `FDS_RECOVERY`. +Duplicate/unknown FDS options are rejected, while unrelated kernel options are +left to the kernel. Read-only `--probe SYSFS_ROOT [FDS_SYSTEM|FDS_RECOVERY]` +reports missing, unique, or ambiguous partitions from kernel-reported identities. +It never chooses the first of multiple SYSTEMs and does not infer identity from +`sdX` order. + +With no arguments, stage0 now performs the [M4 boot path](boot.md) as root PID 1 +in the initramfs. It refuses that operation in an ordinary shell. The full-system +boot matrix exercises root handoff; diagnostics alone do not establish a boot. +M4 reuses the locked `libc` crate for direct Linux syscalls and adds no shared +runtime dependency. The [M2 VM](init.md) remains a separate direct-root fixture. + +## Boot-event reporting + +M5 adds `fds boot-profile` (or `fds --json boot-profile`) for the current boot. +`fds-boottrace` adopts stage0 events into volatile runtime storage and records +the first console readiness; a restarted shell does not replace that timestamp. +Missing events remain absent. See [Performance](performance.md) for event +boundaries, saved-report comparison, and the greater-than-100 ms regression rule. + +## Desktop, applications, and Ethernet + +M8 adds `fds profiles`, `fds profile activate windowmaker`, `fds profile deactivate`, +`fds network on|off`, and PROGRAM launches with `fds run N -- executable-name`. +The standalone `fds-profile` is also a static ARM executable. Its root-only s6 +helpers create the authenticated X server and supervise the unprivileged desktop +session; ordinary callers use the existing peer-checked control socket. +See [Desktop and programs](desktop.md) for a complete usage walkthrough. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..98f27a8 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,243 @@ +# Troubleshooting the build + +[Documentation index](README.md) · [First build](getting-started.md) · [Build reference](build-host.md) + +Find the first failing command or `ERROR:` in the log, fix that problem, then +repeat the same step. Do not continue past a failed bootstrap and interpret later +missing files as independent problems. Run all commands below from the repository +root, as your regular user, unless explicitly shown otherwise. + +## Find the right log + +| Failing action | Where to look | +| --- | --- | +| `make bootstrap` | `out/logs/bootstrap.log` | +| Void build-container creation | `out/logs/xbps-bootstrap.log` | +| `make smoke-test` | `out/logs/smoke-test.log` | +| GNU hello build | `out/logs/xbps-hello.log` | +| `make check` | Terminal output; this target does not save a log automatically | + +For example, after a failed smoke-test: + +```sh +tail -n 80 out/logs/smoke-test.log +rg -n 'ERROR:|FAILED|error:|No space left|Permission denied' out/logs/ +df -h . +``` + +A search with no matches exits 1. Read the surrounding log too: upstream tools +may report failures using other wording. Save logs before re-running if you need +the previous output; see [Development](development.md#preserve-logs-and-identify-inputs). + +## Unsupported host, root execution, or missing commands + +- `M0 requires an x86_64 Linux build host` or `supports Arch Linux`: use the + supported host described in [Your first build](getting-started.md). Changing + the check alone does not port the build system to another platform. +- `Run as a normal user, not root`: leave the root shell and run the build from + your normal account. Only host package installation uses sudo. +- `Missing host command`: install the prerequisites from the first-build guide, + then repeat bootstrap. `readelf` comes from binutils, `bwrap` from bubblewrap, + and `flock` from util-linux. +- `checkout path without whitespace`: move or obtain the checkout at a path + with no spaces, tabs, or other whitespace, then use that directory consistently. + +If a previous root-run left files that your account cannot write, inspect their +ownership before correcting it. Do not respond by running all later builds as root. + +## Bubblewrap or user namespace failure + +Reproduce the same capability check bootstrap uses: + +```sh +bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true +``` + +If it reports `Operation not permitted` or a namespace error, the host kernel, +container policy, or security configuration prevents the unprivileged container. +Use an Arch host that permits this operation, or have the host administrator +resolve the restriction. Repeated downloads, sudo builds, and a different Pi +will not resolve this preflight failure. Success is a zero exit status with no +output; run `make bootstrap` again once that works. + +## Void submodule missing, wrong, or modified + +Inspect before changing anything: + +```sh +cat VOID_PACKAGES_COMMIT +git submodule status vendor/void-packages +git -C vendor/void-packages status --short +git -C vendor/void-packages diff HEAD -- +``` + +If the submodule is missing, `make bootstrap` initializes it. The directory must +be part of a Git checkout; an unpacked source archive is insufficient. If a clone +was interrupted by a network error, restore connectivity and repeat bootstrap. + +`Void checkout mismatch` means the checked-out commit differs from the explicit +pin. A `+` in `git submodule status` means it differs from the parent repository's +gitlink. These are two separate checks. Do not update the pin merely to silence +the error; first determine why the commits differ. + +`Tracked Void files were changed` means upstream source has local edits. Preserve +and review the diff. Move intentional FDS changes into the overlay design or a +separate patch for review; restore upstream only after saving work you need. +Generated ignored files such as `etc/conf`, `hostdir`, and the masterdir are +normal. Never use a broad reset or clean command without checking what it removes. + +## Configuration or overlay conflict + +For `etc/conf differs from config/xbps-src.conf`, compare the two: + +```sh +diff -u vendor/void-packages/etc/conf config/xbps-src.conf +``` + +Put the desired settings in `config/xbps-src.conf`, then follow +[the explicit synchronization procedure](development.md#change-build-configuration-deliberately). +A `diff` exit status of 1 means differences were found, not that the comparison failed. + +For `Stale overlay`, compare the named `vendor/void-packages/srcpkgs/fds-*` +directory with its source under `packages/`. Preserve any unique edits in the +source first. Move that specific generated copy into a backup directory under +`out/`, outside `srcpkgs/`, then run `./tools/prepare-void` again. Do not move or +remove unrelated upstream packages. The Dasung package is now an active overlay. After changing it, reconcile that +generated copy by this same procedure before rebuilding. + +`Overlay would replace an upstream package` is an intentional protection. Choose +an FDS-owned package name and design an overlay; do not disable the check or edit +the tracked upstream template in place. + +## Download failure, checksum mismatch, or apparent stall + +Bootstrap and package builds need network access. The first run can spend much +of its time downloading compiler packages. Read the latest log and check disk +space before assuming that a quiet download is a deadlock: + +```sh +tail -n 40 out/logs/bootstrap.log +tail -n 40 out/logs/xbps-hello.log +du -sh out/downloads vendor/void-packages/hostdir +df -h . +``` + +A log or directory may not exist if its step has not started. Bootstrap's static +XBPS download has retries and a timeout; an incomplete transfer uses a `.part` +filename. A retry re-downloads that partial file. Completed cached archives are +checksum-checked on every bootstrap. + +For a checksum failure, retain the error and compare the named file against +`config/host-tools.conf`. If the cached completed archive is corrupt, move that +specific archive to a backup location and repeat bootstrap so it downloads again. +Do not change the expected hash to match an unexplained download. If a clean +re-download still disagrees, stop and investigate the source. + +A Void mirror timeout is separate from the source pin: the templates are pinned, +but bootstrap and missing build dependencies still use rolling binary repositories. +An unavailable historical dependency may need a reviewed input/mirror solution; +blindly changing the source commit does not establish an equivalent build. + +## XBPS command not found or package index seems empty + +The XBPS tools are intentionally not installed in the host's `/usr/bin`. +`make bootstrap` creates `.host/xbps/usr/bin/`. FDS build helpers set PATH for you. +For manual queries, use the explicit paths in the [package guide](packages.md). + +When querying or indexing ARM output, set `XBPS_ARCH=aarch64`. The host's static +musl executable can otherwise default to its own architecture and ignore the ARM +package. `make smoke-test` uses the correct override. `tools/build-package` exports +packages but does not update `out/packages/aarch64-repodata`; use the indexing +command in the package guide after a standalone package build. + +## Rust target, linker, or architecture error + +Check the selected toolchain and local configuration: + +```sh +rustup show active-toolchain +rustup target list --installed +cat rust-toolchain.toml +cat .cargo/config.toml +printenv RUSTFLAGS CARGO_BUILD_TARGET CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER +``` + +`printenv` exits nonzero when a listed variable is unset; that is normal here. +Bootstrap installs the required target and rustfmt for the pinned toolchain. +Repeat it if those components are missing. + +Build from the repository root with the exact Cargo command in +[Development](development.md#edit-and-rebuild-the-rust-program). Environment +variables, command-line `+toolchain` overrides, or outside Cargo configuration +can change the target, flags, or linker. Reconcile such overrides with the +checked-in `rust-lld` and static CRT configuration. Adding an arbitrary system +linker or glibc library is not a repair for a static-musl build. + +## Exec format error, ldd output, and QEMU skips + +`./out/fds-smoketest` normally gives `Exec format error` on x86_64: it is an ARM +program. Run it explicitly with optional `qemu-aarch64`, as described in +[the first-build guide](getting-started.md#7-optionally-execute-the-arm-rust-program). +Do not change the target to x86_64 merely to make direct execution work. + +`ldd: not a dynamic executable` with exit 1 is expected for the static program. +Use `./tools/verify-elf out/fds-smoketest aarch64 static` for the actual check. +Host `ldd` cannot establish that a foreign ARM program will run. + +`SKIP: ARM execution` means QEMU is absent. Compilation and ELF checks still run. +If QEMU is present but fails, the smoke-test must fail; inspect its diagnostic. +The dynamic GNU hello program also requires an ARM glibc loader and libraries, +so the simple static-program QEMU command is not a complete hello runtime setup. + +## Missing artifacts or check fails before any build + +`make check` expects the Cargo release artifact in `target/`. Run the Rust build +or `make smoke-test` first. A bare Cargo build updates `target/`, while +`make smoke-test` also refreshes `out/fds-smoketest` and the output manifests. +If checksums fail after editing or replacing outputs, inspect what changed and +rebuild through smoke-test; do not edit the manifest to conceal a mismatch. + +For a Rust formatting failure, run `cargo fmt --all`, inspect the source diff, +then repeat `make check`. + +## Busy /tmp message during Void bootstrap + +The initial verified bootstrap emitted +`rm: cannot remove '//tmp': Device or resource busy` during upstream cleanup. +The bubblewrap bind mount explained that instance; upstream exited successfully, +and container GCC, the cross build, and the public M0 commands all passed. + +Do not treat every later `/tmp` error as harmless. Confirm a zero command exit +status and the final bootstrap PASS, then run smoke-test. If the command exits +nonzero, report it as a failure with the surrounding log. + +## Rootfs, images, and runtime commands + +M1 implements `make rootfs PROFILE=cli` and `make rootfs-test`. If the target is +missing, check that you are in the current checkout. Read [M1 rootfs](rootfs.md) +for prerequisites, shell access, logs, and retained failed-build directories. +Python 3.14+ is required for XBPS archive inspection. Supported profiles are +`cli`, `development`, and `recovery`; unknown names fail explicitly. Package +configuration must pass before an archive is published. + +`tools/in-rootfs` supports ARM child execution through a private user-namespace +binary-format handler. It requires Linux 6.7+ and namespaced `binfmt_misc` support. +The workstation's global registry is unchanged. For a single ELF executable, +`--direct` invokes QEMU without installing even a private child-execution handler. +For example: `./tools/in-rootfs out/rootfs-aarch64 --direct /usr/bin/xbps-pkgdb -a`. + +The tar is configured userspace, not a flashable image. M2 adds `make init-test` +for a full ARM boot and `make vm` for a temporary development shell. Follow +[Native init and ARM VM](init.md) for its dependencies, expected output, and +failure logs. For the full kernel/stage0/SYSTEM path, use `make all`, +`make boot-test`, then `make console-vm`. Run `fds bays` and `fds poweroff` +inside the booted FDS console, not on the Arch host. An exported ARM binary will +not execute directly on x86_64. Physical Pi acceptance remains deferred. + +## What to include in a bug report + +Include the exact failing command, its exit status if available, the relevant log +excerpt, host distribution and architecture, current Void pin, and whether this +is the first build or a previously working checkout. Mention local configuration +changes and toolchain overrides. Preserve the initial error; a later missing-file +message often only describes its consequence. diff --git a/docs/workstation-tooling-plan.md b/docs/workstation-tooling-plan.md new file mode 100644 index 0000000..e982b26 --- /dev/null +++ b/docs/workstation-tooling-plan.md @@ -0,0 +1,60 @@ +# Workstation emulator and software cartridges + +Software implementation and acceptance are complete; see the +[acceptance record](workstation-validation.md) and [user guide](workstation.md). +This follow-up extends the accepted 0.1.0 checkpoint. That signed release remains +unchanged and does not contain the new format or emulator interface. Physical +hardware acceptance remains deferred. + +## Required result + +- A Linux workstation CLI boots the real FDS kernel, initramfs and SYSTEM image + with QEMU, exposes its console, and controls twelve virtual USB cartridge bays. +- Cartridge image insertion and removal use QMP USB hotplug. Safe eject asks the + guest to release the cartridge; forced removal explicitly simulates pulling it. +- Software cartridges use GPT with exactly `1 + m` partitions. Partition 1 is + `FDS_METADATA`; partitions 2 through `m + 1` are `FDS_PAYLOAD02`, and so on. + All are read-only EROFS containers. This permits multiple software archives + in one partition without inventing a raw-partition archive container format. +- Metadata includes the cartridge identity and a catalogue locating every + software bundle, its version, architecture, commands, lengths and SHA-256. + Each software payload is an xz-compressed tarball, stored as + `bundles/.tar.xz` in its declared payload partition. +- Workstation tools build software from explicit trusted recipes, package the + resulting trees, construct and verify a complete disk image, then separately + preview/confirm a whole-USB write and verify its readback. Building must not + depend on running on the Pi or on an Arch-specific container. +- The guest recognizes this layout, reports its software catalogue, and runs a + selected software command as the ordinary user. Archives never run build hooks + on the guest. Runtime extraction is bounded, verified and temporary; eject + and surprise removal stop consumers and release every associated resource. +- SYSTEM and writable DATA retain their boot/data layouts. Legacy PROGRAM + reading may remain for existing images, but newly built software cartridges + use the metadata-first multi-partition format. + +## Acceptance gates + +1. Typed Clap host CLIs, actionable help and Linux prerequisite diagnostics. +2. Real workstation builds of at least two software programs, including an + AArch64 executable; archive inspection and independent xz/tar checks. +3. `1 + m` images with multiple software packages sharing a partition as well + as packages in separate partitions; independent GPT/filesystem inspection. +4. Negative checks for overlapping/corrupt GPT, wrong catalogue mappings, + archive corruption, unsupported architecture, unsafe paths/types and size + limits. Existing image/write rejection tests remain passing. +5. Complete-image write/readback using disposable regular files, including a + larger target and confirmation/source/target-change rejection. Actual USB + writes are deferred until the user selects physical hardware. +6. Real FDS boot under the public emulator, insert, catalogue, run, safe eject, + reinsert, forced removal during execution, and cleanup/restart behavior. + Every software partition must be exercised, not just the first payload. +7. Required rootfs/native-init regression and relevant DATA, PROGRAM, media and + shutdown checks; English installation, usage, troubleshooting and format docs. + +The QMP implementation follows the [QEMU reference](https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html), +including waiting for completed device deletion before releasing block nodes. +The Rust `tar` library supplies archive entry decoding; FDS imposes stricter +path, entry-type and size rules instead of trusting an archive's ownership or +permissions. The existing `xz` utility supplies streaming compression and +decompression with a memory limit. QEMU is host-only; no emulator or software +build daemon is added to the Pi boot graph. diff --git a/docs/workstation-validation.md b/docs/workstation-validation.md new file mode 100644 index 0000000..d65c0ff --- /dev/null +++ b/docs/workstation-validation.md @@ -0,0 +1,98 @@ +# Workstation extension acceptance + +**Software acceptance passed on 2026-09-21.** This record covers the new native +Linux software/cartridge builder, whole-image writer, public QEMU emulator and +guest software runtime. It is separate from the immutable local 0.1.0 release. + +The host was Arch Linux x86_64, with QEMU 11.1.1 and erofs-utils 1.9.4. The public +workstation tools use ordinary Linux interfaces and distribution tools; other +Linux distributions and ARM workstations have not been exercised in this run. +The guest tests used the actual FDS Pi kernel, current initramfs and current CLI +SYSTEM on QEMU `virt`. The separate native-init test used its documented generic +ARM kernel fixture. Neither environment substitutes for physical Pi testing. + +## Workstation and emulator evidence + +| Check | Result and evidence | +| --- | --- | +| Typed Rust interfaces and archive contracts | **62 tests passed** across all host-testable workspace crates; `out/logs/workstation-all-rust-tests.log`. The architecture-restricted smoketest is validated separately by the ARM build. | +| Workstation builds and images | `out/workstation-images.5jczgwr7/acceptance.json` and `commands.log`; the recorded CLI SHA-256 matches the delivered `fds-cartridge` binary. | +| Public emulator lifecycle | `out/emu-test.36iqvx0j/acceptance.json`, `inputs.sha256`, `commands.log` and serial logs. The input hash matches the delivered `fds-emulator` binary. | +| Exact documentation examples | `out/logs/workstation-documented-example.log`; the compiled C program and report script were packaged into `out/demo-tools.img`, booted, run as UID 1000, safely ejected and shut down. | +| Incompatible initramfs | `out/emu-old-initramfs-final/console.log`; start promptly reports the complete unsupported boot-option diagnostic and stops the failed VM. | +| Current build inventory | `out/manifests/workstation-acceptance.json`, `workstation-artifacts.sha256` and `workstation-sources.sha256`. These are local evidence records, not a new signed release. | + +The host suite executed a real AArch64 build recipe and packaged a portable +script. It built both separate-payload three-partition images and shared-payload +two-partition images; unchanged recipes produced identical bytes. Independent +`sfdisk`, `xz` and `tar` checks passed. Whole-image writes to exact-size and larger +disposable files passed flush/readback and backup-GPT checks. + +Negative checks covered wrong confirmations, changed source images, stale target +previews, corrupt GPT, corrupt archives, invalid catalogue mappings, ELF files in +an architecture-independent bundle, escaping source symlinks, unsafe archive +paths/types, privileged modes, incorrect lengths/counts, trailing content and a +file replacing an implied parent directory. Existing writer failure tests remain +passing. The final host suite also uses the relative image-tool runner shown in +the guide, including extraction inside the inspection sandbox. + +The emulator suite exercised **all twelve virtual USB bays**, catalogue reporting, +execution from both payload partitions and from shared partitions, read-only +runtime caches, safe eject, forced removal during execution, and reinsertion. +It verified that managed consumers and mounts disappear and that QEMU retains +only the SYSTEM block nodes after removal. It also checked literal guest +arguments, guest exit statuses, interactive console detach and terminal-state +restoration, DATA overlays preserving their original image, and native shutdown +with active software. + +Normal-image workflows used the ordinary FDS user. A **separate test-only admin +console image** exercised service interruption/restart cleanup, unexpected +payload mount refusal and on-target PROGRAM creation rejection. That admin +console is not part of the delivered base SYSTEM. + +## Regression commands completed + +| Command | Evidence log under `out/logs/` | +| --- | --- | +| `make bootstrap` | `workstation-bootstrap.log` | +| `make smoke-test` | `workstation-smoke-test.log` | +| `make check` | `workstation-check.log`; final Rust formatting also passed | +| `make rootfs PROFILE=cli` | `workstation-rootfs-cli-accepted.log` | +| `make system-card PROFILE=cli` | `workstation-system-cli-accepted.log` | +| `make initramfs` | `workstation-initramfs.log` | +| `make rootfs-test` | `workstation-rootfs-test-accepted.log` | +| `make init-test` | `workstation-init-test-accepted.log` | +| `make tooling-test` | `workstation-tooling-test.log` | +| `make boot-test` | `workstation-boot-test.log` — all nine boot cases passed | +| `make cartridge-test` | `workstation-cartridge-test.log` | +| `make data-test` | `workstation-data-test.log` | +| `make media-image-test` | `workstation-media-image-test.log` | +| `make media-test` | `workstation-media-test-accepted.log`, including actual SYSTEM write/readback and boot | +| `make power-test` | `workstation-power-test.log` | +| `make desktop-test` | `workstation-desktop-test.log` | +| `make workstation-test` | `workstation-release-final.log` | +| `make emulator-test` | `workstation-emulator-final.log` | + +The legacy media tests now require PROGRAM creation to fail inside FDS while +retaining prepared-image writing and legacy application execution. New software +creation is exercised through the native workstation tool. The ARM CLI test also +checks the explicit emulator boot option alongside strict invalid-option rejection. + +The final CLI rootfs is `out/rootfs-build.KfWKvx/rootfs-aarch64.tar`; its SYSTEM +is `out/system-build.u1050ckh/system.img`. The matching emulator initramfs is +`out/initramfs-build.y6x9022y/initramfs.cpio`. Public convenience paths point to +these accepted artifacts. Upstream Void tracked files remain unchanged at +`02a3cbc132c3c4a3a9d59e9b98f517af5dd11cd1`. + +## Skips and hardware boundary + +M0's optional host `qemu-aarch64` execution was skipped because that command is +not installed in the host PATH; ARM static/glibc compilation and ELF validation +passed. The required guest tests ran separately through the project-local QEMU. + +These checks do not measure Pi boot/shutdown speed, RP1 USB behavior, USB power +cycling, real drive writeback, physical bay wiring, or Dasung display recovery. +Use the [new software-cartridge hardware procedure](workstation.md#physical-pi-acceptance-procedure-deferred), +[stress procedure](stress-testing.md), and [Dasung guide](dasung.md) for those +checks. No physical USB disk or EEPROM was written. The frozen 0.1.0 release and +its source/build-input archives remain unchanged; it does not include this extension. diff --git a/docs/workstation.md b/docs/workstation.md new file mode 100644 index 0000000..e88f3df --- /dev/null +++ b/docs/workstation.md @@ -0,0 +1,341 @@ +# Build software cartridges and run FDS in QEMU + +This guide uses a **Linux workstation**, not the Raspberry Pi. It takes you from +source software to a cartridge disk image, runs that image in FDS, and explains +how to write the same complete image to USB. All commands run from the checkout +root unless marked as guest commands. + +The two native host tools are `fds-cartridge` and `fds-emulator`. They use typed +Clap interfaces; append `--help` to any command or subcommand. The target's `fds` +command remains a static AArch64 executable. The new host tools are never part +of the Pi base image. Dasung support remains in the base boot bundle. + +The accepted local `out/fds-os-0.1.0/` release predates this extension. Leave that +release intact and build current images for the emulator. See +[workstation acceptance](workstation-validation.md) for current evidence. + +## Install and build the workstation tools + +Use a normal Linux account with Rust/Cargo, a C linker, Bash and Make. The +repository's `rust-toolchain.toml` selects the Rust version. Runtime prerequisites +are: + +| Tool | Purpose | +| --- | --- | +| `qemu-system-aarch64` | Runs the FDS ARM kernel and userspace using portable TCG | +| `qemu-img` | Creates a separate writable overlay for DATA; source images stay unchanged | +| `mkfs.erofs`, `fsck.erofs` from erofs-utils | Build and verify cartridge partitions; tested with 1.9.4 | +| `xz` | Compress/decompress software tarballs with a decompression memory limit | +| `bwrap` from bubblewrap | Restricts filesystem inspection to a private writable staging directory | +| A software-specific cross compiler | Builds AArch64 applications; unnecessary for portable scripts | +| `sfdisk`, `tar`, `mke2fs`, Python 3 | Independent integration tests; not needed to boot an existing image | + +Install these with your distribution's package manager. Unprivileged user +namespaces must work for bubblewrap. Building **these host tools** does not need +Void, XBPS, an Arch host, a Pi, root, or a running FDS guest: + +```sh +make workstation +export PATH="$PWD/out/workstation:$PATH" +fds-cartridge doctor +fds-emulator doctor +``` + +Current acceptance ran on Arch Linux x86_64. The tools use native Linux +interfaces and distribution-provided utilities; other distributions and ARM +workstations have not yet been exercised by this acceptance run. + +The outputs are native binaries in `out/workstation/`. The build explicitly +selects the workstation architecture instead of the workspace's default ARM +target. Rust dependencies are pinned in `Cargo.lock`. `tar` 0.4.46 and its +`filetime` dependency provide archive decoding/creation; FDS additionally checks +entry types, paths, counts, sizes, executable architecture and hashes. + +For this repository's **existing Arch/Void setup**, reuse its local tools: + +```sh +make workstation +./tools/prepare-image-tools +./tools/in-void xbps-install -y qemu-img +out/workstation/fds-cartridge --image-tool-runner tools/in-image-tools doctor +out/workstation/fds-emulator doctor --qemu-runner tools/in-void +``` + +The install command above changes only the project-local build container. The +optional runners accept a program name followed by its arguments; omitting them +uses programs in the workstation's `PATH`. Set the image runner on every +cartridge create, inspect or preview invocation that needs it. A QEMU runner is +saved in the session and reused for later DATA insertions. + +## Build two software bundles + +Each software recipe declares an id, display name, version, target architecture, +root directory, and named executable commands. A trusted `[build]` section may +run a workstation compiler. It is executed only by `software build`, never by +`software pack`, inspection, or a cartridge insertion. + +The [hello recipe](../examples/software/hello/software.toml) builds an AArch64 +C executable. Install an AArch64 glibc cross compiler as `aarch64-linux-gnu-gcc`, +then run: + +```sh +fds-cartridge software build examples/software/hello/software.toml out/demo-hello +fds-cartridge software pack examples/software/report/software.toml out/demo-report +fds-cartridge software inspect out/demo-hello +``` + +If your cross compiler has a different name, edit the recipe's `build.command`. +With this checkout's existing Void cross toolchain, the equivalent is: + +```sh +mkdir -p examples/software/hello/root/bin +./tools/in-void aarch64-linux-gnu-gcc -O2 examples/software/hello/hello.c \ + -o examples/software/hello/root/bin/hello +out/workstation/fds-cartridge software pack \ + examples/software/hello/software.toml out/demo-hello +out/workstation/fds-cartridge software pack \ + examples/software/report/software.toml out/demo-report +``` + +Use **one** of those hello workflows. Outputs must not already exist: choose +new output names when rebuilding, then update your cartridge recipe. The example +bundles each contain `software.toml` and `.tar.xz`. The archive contains the +contents of the software root, such as `bin/hello`; it has no leading `root/`. + +For your own application, copy a recipe and populate its root with `bin/`, +`lib/` and `share/` as needed. C/C++ glibc builds target AArch64; static Rust +programs should use `aarch64-unknown-linux-musl`. Set `architecture = "any"` only +for scripts/data without ELF files. An x86_64 executable is rejected. Shared +libraries must be compatible with the target SYSTEM's glibc, or bundled as +appropriate. There is no cross-cartridge dependency resolver. + +## Assemble the cartridge image + +The example cartridge recipe groups the two bundles into two payload partitions: + +```sh +fds-cartridge create examples/software/cartridge.toml out/demo-tools.img +fds-cartridge inspect out/demo-tools.img +``` + +For the project-local EROFS tools, use: + +```sh +out/workstation/fds-cartridge --image-tool-runner tools/in-image-tools \ + create examples/software/cartridge.toml out/demo-tools.img +``` + +The resulting complete disk image has **three GPT partitions**: + +| Partition | GPT name | Contents | +| --- | --- | --- | +| 1 | `FDS_METADATA` | Cartridge identity and the software catalogue | +| 2 | `FDS_PAYLOAD02` | `bundles/demo.hello.tar.xz` | +| 3 | `FDS_PAYLOAD03` | `bundles/demo.report.tar.xz` | + +To put both programs in one payload partition, use one `[[payload]]` entry with +`bundles = ["../../out/demo-hello", "../../out/demo-report"]`. This makes a +two-partition image. Each payload group creates exactly one partition. The first +partition identifies every software bundle and its payload partition. + +Creation finishes only after verifying both GPT tables, all EROFS partitions, +every archive and the catalogue. Inspection prints JSON, including offsets, +lengths, SHA-256 and available commands. Repeating an unchanged recipe produces +the same image bytes. See [the format](software-format.md) for limits and rules. + +## Boot current FDS in the emulator + +You need three matching current artifacts: the FDS Pi kernel, its initramfs, and +a SYSTEM image containing the guest software runtime. Build these on the OS +build workstation using [the build guide](getting-started.md). For an already +bootstrapped checkout: + +```sh +make rootfs PROFILE=cli +make system-card PROFILE=cli +make initramfs +make workstation +out/workstation/fds-emulator --session out/my-emulator start \ + --qemu-runner tools/in-void +``` + +If QEMU is installed directly on your Linux workstation, omit `--qemu-runner`. +To use images copied from another build machine, supply their paths: + +```sh +fds-emulator --session out/my-emulator start \ + --kernel /path/to/kernel_2712.img \ + --initramfs /path/to/fds-initramfs.img \ + --system /path/to/fds-system-cli.img +``` + +Create `out/` first if using a fresh directory. The session directory must be +**new**, private, and short enough for Unix sockets (under 90 bytes including its +absolute parent path). Start waits for the FDS prompt and verifies the emulator +bay configuration. It uses two emulated CPUs and 1024 MiB RAM by default; +`--memory-mib 2048` increases RAM. `--timeout` adjusts the boot deadline. + +The session records inputs, virtual devices, serial output and QEMU diagnostics. +QEMU stays running after the command exits. SYSTEM and software images are +read-only. No physical disks, host network interface, or Pi monitor are attached. + +## Insert, use and remove cartridges + +From the workstation: + +```sh +fds-emulator --session out/my-emulator insert 01 out/demo-tools.img +fds-emulator --session out/my-emulator guest -- fds bay 01 +fds-emulator --session out/my-emulator guest -- fds run 01 -- demo.hello:hello +fds-emulator --session out/my-emulator guest -- fds run 01 -- demo.report:report +fds-emulator --session out/my-emulator guest -- tail -20 /run/log/cartridged/current +fds-emulator --session out/my-emulator eject 01 +``` + +Insertion is asynchronous: `fds bay 01` may briefly show `EMPTY` before USB +storage discovery completes. Retry the status command until it reports +`MOUNTED READ ONLY`, or inspect its error. The catalogue lists exact commands. +`fds run` starts a managed process and prints its PID; its output goes to the +cartridge service log. Packages are verified and extracted into temporary, +read-only RAM filesystems on first run. They are not compiled on the guest. + +All twelve bays, numbered `01`–`12`, are available. Simultaneously mounted +PROGRAM cartridges need distinct cartridge IDs; inserting duplicate IDs is +reported as a guest error. `eject` asks FDS to stop +managed consumers, release mounts and declare `SAFE`, then removes the virtual +USB device. If FDS refuses, QEMU leaves the cartridge attached. To deliberately +simulate an accidental pull, use: + +```sh +fds-emulator --session out/my-emulator unplug 01 +``` + +A forced unplug can lose DATA writes. The guest must then detect the removal +and clean up its processes and mounts. `status` reports the actual QEMU devices +and block nodes as well as the recorded image paths. + +Use the interactive shell if preferred: + +```sh +fds-emulator --session out/my-emulator console +``` + +At the `FDS>` prompt, type `fds bays`, `fds bay 01`, or `fds run 01 -- +demo.hello:hello`. **Ctrl-] detaches**; it does not stop the VM. Use a second +workstation terminal for insertion/unplug. Detach the console before `guest`, +`eject`, or ordinary `stop`, because they also need exclusive serial access. + +Finish with: + +```sh +fds-emulator --session out/my-emulator stop +``` + +This invokes native FDS shutdown. `stop --force` cuts virtual power without that +sequence. Logs and DATA overlays remain in the session directory. Choose a new +session directory to boot again; stopped sessions are retained for inspection, +not resumed from RAM snapshots. + +## Writable DATA images + +Insert an existing FDS DATA disk image with the same `insert` command. The +emulator creates a unique `data--.qcow2` overlay in the session directory; +all guest writes go there. The original image remains unchanged. Safe eject +retains the overlay, while inserting the original image again starts a fresh +one. Software cartridges remain read-only. + +After eject or shutdown, export an overlay if you need the changed DATA contents: + +```sh +qemu-img convert -f qcow2 -O raw /path/to/session/data-02-IDENTIFIER.qcow2 out/saved-data.img +``` + +Keep the original backing image at its recorded path until conversion completes. +Never convert or edit an image while it is attached to a running VM. + +## Write the completed image to USB + +The burn flow always starts with the complete image created above. It does not +construct partitions directly on a drive. Identify the intended **whole USB +drive**, unmount it, then make a preview as your normal user: + +```sh +fds-cartridge preview out/demo-tools.img /dev/sdX out/usb-preview.json +``` + +Replace `/dev/sdX` with the actual whole USB drive. The JSON records its model, +size, insertion identity, exact image hash and an exact `confirmation` string. +Review these before copying the full phrase into the write command: + +```sh +sudo /absolute/path/to/fds-cartridge write out/usb-preview.json \ + --confirm 'COPY THE EXACT confirmation VALUE FROM THE PREVIEW' +``` + +The writer rechecks source and target identity, requires a USB whole disk, +rejects mounted/protected storage, writes the **full image**, flushes and verifies +readback. On larger drives it relocates the backup GPT to the end. A replaced +USB drive or changed image requires a fresh preview. This destroys the selected +drive's existing contents. No physical drive has been written by the automated +acceptance tests. + +To rehearse on a disposable file without root or USB hardware: + +```sh +truncate -s 64M out/disposable-usb.img +fds-cartridge preview out/demo-tools.img out/disposable-usb.img \ + out/file-preview.json --file-target +fds-cartridge write out/file-preview.json --confirm 'COPY THE EXACT confirmation VALUE' +fds-cartridge inspect out/disposable-usb.img +``` + +The file must be at least as large as the source image. Use `--image-tool-runner` +for preview/inspection if EROFS tools are provided by the local wrapper. + +## Troubleshooting + +| Symptom | What to do | +| --- | --- | +| Required executable not found | Run the corresponding `doctor`; install the named host utility or select an explicit runner | +| Bubblewrap namespace failure | Enable unprivileged user namespaces according to your workstation policy; do not run creation/inspection as root | +| Output already exists | Use a new bundle/image/preview/session name; creation does not overwrite outputs | +| Unknown `fds.emulator` option or missing emulator settings | Rebuild both the current initramfs and SYSTEM; the frozen 0.1.0 images predate this feature | +| Console in use | Detach with Ctrl-] before guest commands, safe eject, or shutdown | +| Guest command timed out | Inspect `console.log`; the command may still be running, so do not blindly repeat a write | +| Incomplete insertion | Run `unplug BAY` to reconcile the recorded intent with actual QEMU devices, then reinsert | +| Software digest/path/architecture error | Rebuild the bundle and cartridge on the workstation; the guest will not run an invalid archive | +| Cache limit exceeded | Reduce the software bundle; one runtime tree including inode overhead is limited to 256 MiB | +| Safe eject blocked | Close unmanaged processes or extra mounts using the media, then retry; `unplug` is only for deliberate failure simulation | +| VM will not boot | Read session `console.log` and `qemu.log`; verify all three supplied boot artifacts belong to the current build | + +QEMU exercises the actual Linux/FDS software path. It does not emulate Pi +firmware, RP1, USB power sequencing, the physical twelve-bay wiring, or Dasung +power recovery. Those acceptance checks remain hardware procedures. + +## Physical Pi acceptance procedure (deferred) + +After assembling and calibrating the physical bay map, use a disposable USB +cartridge and the current matching SYSTEM/initramfs. Perform these checks on the +Pi; the VM results do not replace them: + +1. Build the hello/report cartridge on the workstation, record its image hash, + and use preview/confirmation to write that entire image to the chosen USB drive. +2. Insert it in a calibrated bay. Record `fds --json bay BAY`; confirm both + software entries and their declared payload partitions appear. +3. Run `demo.hello:hello` and `demo.report:report`. Save their output from the + cartridge log and confirm UID 1000. Check `/proc/self/mountinfo` for read-only + payload and software-cache mounts. +4. Run `fds eject BAY`, verify SAFE, then remove and reinsert the cartridge in + another calibrated bay. Repeat both commands. Record any USB enumeration or + I/O errors. +5. With a disposable cartridge and a deliberately long-running test program, + test surprise removal separately. Verify its managed processes terminate and + all of that bay's software mounts disappear. Do not use valuable DATA for + this failure test. +6. Exercise native shutdown with active software, cold boot, and a real USB + power cycle. Save actual observations and kernel/service logs. Follow the + separate [Dasung hardware procedure](dasung.md) for monitor recovery. + +Record the Pi, hub, USB drive and kernel versions with the result. Hardware +latency, electrical behavior and data durability remain unverified until these +measurements are performed on the assembled machine. diff --git a/examples/software/cartridge.toml b/examples/software/cartridge.toml new file mode 100644 index 0000000..77324b9 --- /dev/null +++ b/examples/software/cartridge.toml @@ -0,0 +1,11 @@ +format = 1 +id = "demo.tools" +name = "Hello and system report" +version = "1.0" + +# Paths are relative to this recipe. Each payload entry creates one partition. +[[payload]] +bundles = ["../../out/demo-hello"] + +[[payload]] +bundles = ["../../out/demo-report"] diff --git a/examples/software/hello/hello.c b/examples/software/hello/hello.c new file mode 100644 index 0000000..ef9322f --- /dev/null +++ b/examples/software/hello/hello.c @@ -0,0 +1,6 @@ +#include +int main(int argc, char **argv) { + printf("Hello from an FDS AArch64 software cartridge%s%s\n", + argc > 1 ? ": " : "", argc > 1 ? argv[1] : ""); + return 0; +} diff --git a/examples/software/hello/software.toml b/examples/software/hello/software.toml new file mode 100644 index 0000000..4f96049 --- /dev/null +++ b/examples/software/hello/software.toml @@ -0,0 +1,14 @@ +format = 1 +id = "demo.hello" +name = "AArch64 hello" +version = "1.0" +architecture = "aarch64" +root = "root" + +[commands] +hello = "bin/hello" + +# This trusted command runs only on the workstation. It is never put on media. +[build] +directory = "." +command = ["sh", "-eu", "-c", "mkdir -p root/bin; aarch64-linux-gnu-gcc -O2 hello.c -o root/bin/hello"] diff --git a/examples/software/report/root/bin/report b/examples/software/report/root/bin/report new file mode 100755 index 0000000..020ee80 --- /dev/null +++ b/examples/software/report/root/bin/report @@ -0,0 +1,4 @@ +#!/bin/sh +printf 'FDS cartridge system report\n' +uname -m +id diff --git a/examples/software/report/software.toml b/examples/software/report/software.toml new file mode 100644 index 0000000..f3f811f --- /dev/null +++ b/examples/software/report/software.toml @@ -0,0 +1,9 @@ +format = 1 +id = "demo.report" +name = "System report" +version = "1.0" +architecture = "any" +root = "root" + +[commands] +report = "bin/report" diff --git a/image/.gitkeep b/image/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/image/base-packages.list b/image/base-packages.list new file mode 100644 index 0000000..a5d1925 --- /dev/null +++ b/image/base-packages.list @@ -0,0 +1,5 @@ +# Mandatory hardware additions to every FDS SYSTEM image, including CLI-only. +# The rootfs builder consumes this alongside the fds-base metapackage. +# fds-base also depends on this controller so it cannot be accidentally omitted. +fds-base +fds-dasungd diff --git a/image/build-boot-volume b/image/build-boot-volume new file mode 100755 index 0000000..e399d4c --- /dev/null +++ b/image/build-boot-volume @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Assemble and read back a Pi 5 FAT32 boot partition; never access a host disk.""" +import argparse +import datetime +import json +import mmap +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile + +project = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(project/'tools')) +from image_formats import digest + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--mode', choices=['production', 'development'], default='production') +args = parser.parse_args() +subprocess.run([str(project/'tools/prepare-pi-firmware')], check=True) +subprocess.run([str(project/'tools/prepare-image-tools')], check=True) +epoch = int(subprocess.check_output(['git', '-C', str(project/'vendor/void-packages'), 'show', '-s', '--format=%ct', 'HEAD'])) +firmware_config = dict(line.split('=', 1) for line in (project/'config/pi-firmware.conf').read_text().splitlines() if line and not line.startswith('#')) +package = project/'out/cache/boot'/f'rpi-firmware-{firmware_config["PI_FIRMWARE_VERSION"]}.aarch64.xbps' +work = Path(tempfile.mkdtemp(prefix='boot-build.', dir=project/'out')) +files = work/'files' +files.mkdir() +with tarfile.open(package) as archive: + for member in archive: + name = member.name.removeprefix('./') + if not member.isfile(): continue + relative = Path(name) + if relative.parent == Path('boot') and relative.suffix in ('.elf', '.dat', '.bin'): + (files/relative.name).write_bytes(archive.extractfile(member).read()) + elif relative.parent == Path('usr/share/licenses/rpi-firmware'): + (files/relative.name).write_bytes(archive.extractfile(member).read()) +kernel = project/'out/kernel/boot' +shutil.copy2(kernel/'kernel_2712.img', files) +for dtb in sorted(kernel.glob('bcm2712-*.dtb')): shutil.copy2(dtb, files) +shutil.copytree(kernel/'overlays', files/'overlays') +shutil.copy2(project/'out/fds-initramfs.img', files/'fds-initramfs.img') +shutil.copy2(project/'image/pi5/config.txt', files) +shutil.copy2(project/'image/pi5'/f'cmdline-{args.mode}.txt', files/'cmdline.txt') +assert (files/'bcm2712-rpi-5-b.dtb').is_file() +assert len((files/'cmdline.txt').read_text().splitlines()) == 1 +for path in sorted(files.rglob('*')): os.utime(path, (epoch, epoch)) +image = work/'boot.fat' +runner = str(project/'tools/in-image-tools') +subprocess.run([runner, 'mkfs.fat', '--invariant', '-C', '-F', '32', '-n', 'FDS_BOOT', str(image), '524288'], check=True) +for path in sorted(files.iterdir()): + subprocess.run([runner, 'mcopy', '-m', '-s', '-i', str(image), str(path), '::/'], check=True) + +# mtools preserves file times but creates directory records at wall-clock time. +# Normalize FAT32 directory timestamps without touching file data or long names. +stamp = datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc) +date = ((stamp.year-1980)<<9) | (stamp.month<<5) | stamp.day +clock = (stamp.hour<<11) | (stamp.minute<<5) | (stamp.second//2) +with image.open('r+b') as stream, mmap.mmap(stream.fileno(), 0) as disk: + sector = struct.unpack_from('= 2 and cluster not in visited + visited.add(cluster) + start = data_offset+(cluster-2)*cluster_bytes + assert start+cluster_bytes <= len(disk) + for offset in range(start, start+cluster_bytes, 32): + if disk[offset] == 0: break + if disk[offset] == 0xe5 or disk[offset+11] == 0x0f: continue + disk[offset+13] = (stamp.second%2)*100 + struct.pack_into(' 0 )) +for interface in "${interfaces[@]}"; do + [[ $interface =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,14}$ ]] + # This profile is IPv4 DHCP. Do not leave autonomous kernel IPv6 routing + # active outside the managed client's lifetime. + if [[ -e /proc/sys/net/ipv6/conf/$interface/disable_ipv6 ]]; then + printf '1\n' >"/proc/sys/net/ipv6/conf/$interface/disable_ipv6" + fi +done +exec /usr/bin/dhcpcd --nobackground --noipv6 --noipv4ll "${interfaces[@]}" diff --git a/packages/fds-cartridged/files/network-runtime b/packages/fds-cartridged/files/network-runtime new file mode 100644 index 0000000..5f1a549 --- /dev/null +++ b/packages/fds-cartridged/files/network-runtime @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail +install -d -m 0750 -o root -g fds /run/log/network +install -d -m 0755 /run/dhcpcd /run/dhcpcd/db +chown _dhcpcd:_dhcpcd /run/dhcpcd/db +: > /run/fds/resolv.conf diff --git a/packages/fds-cartridged/files/network-stop b/packages/fds-cartridged/files/network-stop new file mode 100644 index 0000000..2d62bdc --- /dev/null +++ b/packages/fds-cartridged/files/network-stop @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail +[[ -r /run/fds/network/interfaces ]] || exit 0 +mapfile -t interfaces < /run/fds/network/interfaces +for interface in "${interfaces[@]}"; do + [[ $interface =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,14}$ ]] + if [[ -e /sys/class/net/$interface ]]; then + ip link set dev "$interface" down || [[ ! -e /sys/class/net/$interface ]] + fi +done +: > /run/fds/resolv.conf diff --git a/packages/fds-cartridged/template b/packages/fds-cartridged/template new file mode 100644 index 0000000..2d4e761 --- /dev/null +++ b/packages/fds-cartridged/template @@ -0,0 +1,26 @@ +pkgname=fds-cartridged +version=0.1.0 +revision=1 +archs="aarch64" +nostrip=yes +noverifyrdeps=yes +depends="s6 s6-rc execline coreutils fds-cli" +short_desc="FDS static cartridge daemon and stable bay discovery" +maintainer="FDS/OS maintainers" +license="MIT" +homepage="https://github.com/void-linux/void-packages" +conf_files="/etc/fds/bays.toml /etc/fds/hardware-catalog.toml" +do_install() { + local input="${XBPS_SRCDISTDIR}/${pkgname}-${version}" + (cd "$input" && sha256sum -c SHA256SUMS) || return 1 + vbin "${input}/fds-cartridged" + vbin "${input}/fds-profile" + vinstall "${FILESDIR}/network-run" 755 usr/libexec/fds + vinstall "${FILESDIR}/network-stop" 755 usr/libexec/fds + vinstall "${FILESDIR}/network-runtime" 755 usr/libexec/fds + vlicense "${input}/LICENSE" + vmkdir etc/s6-rc/source + cp -a "${input}/s6-source/." "${DESTDIR}/etc/s6-rc/source/" + vinstall "${FILESDIR}/bays.toml" 644 etc/fds + vinstall "${FILESDIR}/hardware-catalog.toml" 644 etc/fds +} diff --git a/packages/fds-cli/.gitkeep b/packages/fds-cli/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/fds-cli/template b/packages/fds-cli/template new file mode 100644 index 0000000..6d39ab8 --- /dev/null +++ b/packages/fds-cli/template @@ -0,0 +1,25 @@ +# Static payloads are built and checked on the host before packaging. +pkgname=fds-cli +version=0.1.0 +revision=1 +archs="aarch64" +nostrip=yes +noverifyrdeps=yes +depends="coreutils e2fsprogs erofs-utils" +short_desc="FDS static system and cartridge command interface" +maintainer="FDS/OS maintainers" +license="MIT" +homepage="https://github.com/void-linux/void-packages" +do_install() { + local input="${XBPS_SRCDISTDIR}/${pkgname}-${version}" + (cd "$input" && sha256sum -c SHA256SUMS) || return 1 + vbin "${input}/fds" + vbin "${input}/fds-boottrace" + vbin "${input}/fds-burn" + vbin "${input}/fds-inspect" + vbin "${input}/fds-eject" + vbin "${input}/fds-power" + vbin "${input}/fds-release" + vlicense "${input}/LICENSE" + vlicense "${input}/RUST-NOTICES.txt" +} diff --git a/packages/fds-dasungd/files/99-dasung-spi.rules b/packages/fds-dasungd/files/99-dasung-spi.rules new file mode 100644 index 0000000..8805a31 --- /dev/null +++ b/packages/fds-dasungd/files/99-dasung-spi.rules @@ -0,0 +1,4 @@ +# Supplement the Pi kernel's CONFIG_SPI_CH341=n requirement with scoped autoload +# suppression. An already loaded/built-in driver can bind before userspace. +# Plain RUN assignment is compatible with eudev and replaces earlier queued runs. +ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_interface", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="5512", PROGRAM="/usr/bin/dasungd udev-match-spi %p", RESULT=="yes", RUN="/usr/bin/true" diff --git a/packages/fds-dasungd/files/dasungd.toml b/packages/fds-dasungd/files/dasungd.toml new file mode 100644 index 0000000..5cf7735 --- /dev/null +++ b/packages/fds-dasungd/files/dasungd.toml @@ -0,0 +1,22 @@ +# The user's grayscale Paperlike 13K. Do not match arbitrary CH340 adapters. +monitor_serial = "L56051794302" +socket = "/run/dasungd/control.sock" +# Session state survives daemon restart. Persistent machine storage is not built yet. +state_file = "/run/dasungd/settings.json" +transport = "usb" +require_companion = true +require_display = true +keepalive_ms = 2000 +reconnect_ms = 2000 +watchdog_ms = 30000 + +[display] +# Pi uses DRM/KMS firmware EDID or its display client, not AMD debugfs hotplug. +enabled = false +edid = "/usr/lib/firmware/edid/dasung-paperlike13k-37hz.bin" +hotplug = "none" + +[startup] +# Values read back on this unit in the source task; color-model names do not apply. +mode = 1 +contrast = 4 diff --git a/packages/fds-dasungd/template b/packages/fds-dasungd/template new file mode 100644 index 0000000..bb38f12 --- /dev/null +++ b/packages/fds-dasungd/template @@ -0,0 +1,29 @@ +# FDS-owned overlay. The build helper supplies the verified static ARM payload. +pkgname=fds-dasungd +version=0.1.0 +revision=1 +archs="aarch64" +nostrip=yes +noverifyrdeps=yes +depends="coreutils execline s6 s6-rc eudev" +short_desc="FDS base-system controller for the Dasung Paperlike 13K" +maintainer="FDS/OS maintainers" +license="MIT, LGPL-2.1-or-later" +# Hardware vendor reference; this local FDS controller is not an official driver. +homepage="https://www.dasung.com/" +conf_files="/etc/dasungd.toml" + +do_install() { + local input="${XBPS_SRCDISTDIR}/${pkgname}-${version}" + [ -f "${input}/SHA256SUMS" ] || msg_error "Run make dasung to prepare the verified payload\n" + (cd "$input" && sha256sum -c SHA256SUMS) || return 1 + vbin "${input}/dasungd" + vinstall "${FILESDIR}/dasungd.toml" 644 etc + vinstall "${FILESDIR}/99-dasung-spi.rules" 644 usr/lib/udev/rules.d + vinstall "${input}/paperlike13k-37hz.edid" 644 usr/lib/firmware/edid dasung-paperlike13k-37hz.bin + vmkdir etc/s6-rc/source + cp -a "${input}/s6-source/." "${DESTDIR}/etc/s6-rc/source/" + vlicense "${input}/LICENSE" + vlicense "${input}/libusb-COPYING" libusb-COPYING + vdoc "${input}/dasung.md" README.md +} diff --git a/packages/fds-dhcpcd/files/dhcpcd.conf b/packages/fds-dhcpcd/files/dhcpcd.conf new file mode 100644 index 0000000..6075214 --- /dev/null +++ b/packages/fds-dhcpcd/files/dhcpcd.conf @@ -0,0 +1,8 @@ +# DHCP starts only after an explicit request or a mapped Ethernet cartridge. +hostname fds +clientid +option rapid_commit +option domain_name_servers, domain_name, domain_search, host_name +option classless_static_routes +require dhcp_server_identifier +nohook wpa_supplicant diff --git a/packages/fds-dhcpcd/template b/packages/fds-dhcpcd/template new file mode 100644 index 0000000..46ee7c9 --- /dev/null +++ b/packages/fds-dhcpcd/template @@ -0,0 +1,27 @@ +# Derived from dhcpcd at VOID_PACKAGES_COMMIT; native s6 services live in FDS. +pkgname=fds-dhcpcd +version=10.3.2 +revision=1 +wrksrc="dhcpcd-${version}" +build_style=configure +make_check_target=test +configure_args="--prefix=/usr --sbindir=/usr/bin --sysconfdir=/etc + --rundir=/run/dhcpcd --dbdir=/run/dhcpcd/db --enable-privsep --privsepuser=_dhcpcd" +hostmakedepends="pkg-config" +makedepends="eudev-libudev-devel" +short_desc="FDS on-demand DHCP client with volatile leases and privilege separation" +maintainer="FDS/OS maintainers" +license="BSD-2-Clause" +homepage="https://github.com/NetworkConfiguration/dhcpcd" +distfiles="https://github.com/NetworkConfiguration/dhcpcd/archive/refs/tags/v${version}.tar.gz" +checksum=fa9aa4867e2cd5d1551bf93e34fb8ed6891702448df8a71afffab067ceb66a4f +lib32disabled=yes +conf_files=/etc/dhcpcd.conf +conflicts="dhcpcd>=0" +provides="dhcpcd-${version}_${revision}" +system_accounts="_dhcpcd" +_dhcpcd_homedir="/var/empty" +post_install() { + vlicense LICENSE + vinstall "${FILESDIR}/dhcpcd.conf" 644 etc +} diff --git a/packages/fds-eink/.gitkeep b/packages/fds-eink/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/fds-eink/files/20-fds.conf b/packages/fds-eink/files/20-fds.conf new file mode 100644 index 0000000..27f83ff --- /dev/null +++ b/packages/fds-eink/files/20-fds.conf @@ -0,0 +1,10 @@ +Section "ServerFlags" + Option "DontVTSwitch" "false" + Option "BlankTime" "0" + Option "StandbyTime" "0" + Option "SuspendTime" "0" + Option "OffTime" "0" +EndSection +Section "Extensions" + Option "Composite" "Disable" +EndSection diff --git a/packages/fds-eink/files/WMRootMenu b/packages/fds-eink/files/WMRootMenu new file mode 100644 index 0000000..366c4f0 --- /dev/null +++ b/packages/fds-eink/files/WMRootMenu @@ -0,0 +1,6 @@ +("FDS/OS", + ("Terminal", EXEC, "/usr/libexec/fds/terminal"), + ("Enable Ethernet", EXEC, "fds network on"), + ("Disable Ethernet", EXEC, "fds network off"), + ("Return to console", EXEC, "fds profile deactivate") +) diff --git a/packages/fds-eink/files/WMWindowAttributes b/packages/fds-eink/files/WMWindowAttributes new file mode 100644 index 0000000..66e3226 --- /dev/null +++ b/packages/fds-eink/files/WMWindowAttributes @@ -0,0 +1,4 @@ +{ + "*" = { NoAppIcon = YES; }; + "FDS" = { NoAppIcon = YES; }; +} diff --git a/packages/fds-eink/files/WindowMaker b/packages/fds-eink/files/WindowMaker new file mode 100644 index 0000000..8598a7a --- /dev/null +++ b/packages/fds-eink/files/WindowMaker @@ -0,0 +1,44 @@ +{ + DisableDock = YES; + DisableClip = YES; + DisableDrawers = YES; + EnableWorkspacePager = NO; + DisableAnimations = YES; + DisableBlinking = YES; + Superfluous = NO; + DoNotMakeAppIconsBounce = YES; + BounceAppIconsWhenUrgent = NO; + OpaqueMove = NO; + OpaqueResize = NO; + OpaqueMoveResizeKeyboard = NO; + IconificationStyle = None; + SaveSessionOnExit = NO; + SmoothWorkspaceBack = NO; + WorkspaceBack = (solid, "white"); + WidgetColor = (solid, "#cccccc"); + IconBack = (solid, "white"); + FTitleBack = (solid, "black"); + PTitleBack = (solid, "#cccccc"); + UTitleBack = (solid, "white"); + ResizebarBack = (solid, "#cccccc"); + MenuTitleBack = (solid, "black"); + MenuTextBack = (solid, "white"); + FTitleColor = "white"; + PTitleColor = "black"; + UTitleColor = "black"; + MenuTitleColor = "white"; + MenuTextColor = "black"; + MenuDisabledColor = "#666666"; + HighlightColor = "black"; + HighlightTextColor = "white"; + WindowTitleFont = "Terminus:pixelsize=20"; + MenuTitleFont = "Terminus:pixelsize=20"; + MenuTextFont = "Terminus:pixelsize=20"; + IconTitleFont = "Terminus:pixelsize=16"; + ClipTitleFont = "Terminus:pixelsize=16"; + LargeDisplayFont = "Terminus:pixelsize=32"; + FrameBorderWidth = 2; + FrameBorderColor = "black"; + FocusedFrameBorderColor = "black"; + SelectedFrameBorderColor = "black"; +} diff --git a/packages/fds-eink/files/desktop-runtime b/packages/fds-eink/files/desktop-runtime new file mode 100644 index 0000000..09da9c4 --- /dev/null +++ b/packages/fds-eink/files/desktop-runtime @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +install -d -m 0750 -o root -g fds /run/log/desktop /run/log/xserver /run/fds/x11 +# Active XKB keymaps are session state; all global font caches are built in the image. +if [[ -d /var/lib/xkb ]] && ! mountpoint -q /var/lib/xkb; then + mount -t tmpfs -o mode=0755,nosuid,nodev,noexec tmpfs /var/lib/xkb +fi diff --git a/packages/fds-eink/files/terminal b/packages/fds-eink/files/terminal new file mode 100644 index 0000000..9259011 --- /dev/null +++ b/packages/fds-eink/files/terminal @@ -0,0 +1,5 @@ +#!/bin/bash +export PS1='FDS> ' +exec /usr/bin/xterm -class FDS -name fds-terminal -title 'FDS Terminal' \ + -bg white -fg black -cr black -fn '-xos4-terminus-medium-r-normal--20-200-72-72-c-100-iso10646-1' \ + -ut -bc -uc -sb -geometry 100x35+40+40 -e /bin/bash --noprofile --norc -i diff --git a/packages/fds-eink/files/windowmaker-session b/packages/fds-eink/files/windowmaker-session new file mode 100644 index 0000000..e1e6c09 --- /dev/null +++ b/packages/fds-eink/files/windowmaker-session @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +# Runs as fds; never install files into a user-controlled home as root. +mkdir -p "$HOME/GNUstep/Defaults" +for name in WindowMaker WMRootMenu WMWindowAttributes; do + if [[ ! -e $HOME/GNUstep/Defaults/$name ]]; then + cp --no-clobber "/usr/share/fds/eink/$name" "$HOME/GNUstep/Defaults/$name" + fi +done +xset s off +xset -dpms +xsetroot -solid white +exec wmaker --no-dock --no-clip --no-autolaunch diff --git a/packages/fds-eink/files/xserver b/packages/fds-eink/files/xserver new file mode 100644 index 0000000..2122546 --- /dev/null +++ b/packages/fds-eink/files/xserver @@ -0,0 +1 @@ +xorg diff --git a/packages/fds-eink/template b/packages/fds-eink/template new file mode 100644 index 0000000..db008af --- /dev/null +++ b/packages/fds-eink/template @@ -0,0 +1,22 @@ +pkgname=fds-eink +version=0.1.0 +revision=1 +archs="aarch64" +depends="xorg-server xf86-input-libinput WindowMaker terminus-font xterm xset xsetroot fontconfig" +short_desc="FDS grayscale WindowMaker session and E-Ink defaults" +maintainer="FDS/OS maintainers" +license="MIT" +homepage="https://github.com/void-linux/void-packages" +conf_files="/etc/fds/xserver" +do_install() { + vmkdir etc/fonts/conf.d + ln -s ../conf.avail/75-yes-terminus.conf "${DESTDIR}/etc/fonts/conf.d/75-yes-terminus.conf" + for f in WindowMaker WMRootMenu WMWindowAttributes; do + vinstall "${FILESDIR}/${f}" 644 usr/share/fds/eink + done + for f in windowmaker-session terminal desktop-runtime; do + vinstall "${FILESDIR}/${f}" 755 usr/libexec/fds + done + vinstall "${FILESDIR}/xserver" 644 etc/fds + vinstall "${FILESDIR}/20-fds.conf" 644 usr/share/X11/xorg.conf.d +} diff --git a/packages/fds-init/files/console b/packages/fds-init/files/console new file mode 100755 index 0000000..03fdc9b --- /dev/null +++ b/packages/fds-init/files/console @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail +# Login accounting is volatile on a read-only SYSTEM root. +install -m 0664 -o root -g utmp /dev/null /run/utmp +install -m 0664 -o root -g utmp /dev/null /var/log/wtmp +install -m 0600 -o root -g utmp /dev/null /var/log/btmp diff --git a/packages/fds-init/files/console-session b/packages/fds-init/files/console-session new file mode 100755 index 0000000..8730ba0 --- /dev/null +++ b/packages/fds-init/files/console-session @@ -0,0 +1,17 @@ +#!/bin/bash +# Local single-user console. Drop root before reading any user shell startup files. +set -euo pipefail +if [[ $(cat /usr/share/fds/image-profile) == recovery ]]; then + # Explicit recovery image only; never read shell startup files from DATA. + install -d -m 0700 /run/fds/recovery-home + cd /run/fds/recovery-home + printf '\nFDS RECOVERY — LOCAL MAINTENANCE CONSOLE\nDATA stays read-only until explicitly selected.\nUse fds recovery help for inspection and repair commands.\n' + exec /usr/bin/env -i HOME=/run/fds/recovery-home USER=root LOGNAME=root \ + SHELL=/bin/bash PATH=/usr/bin:/bin LANG=en_US.UTF-8 TERM="${TERM:-linux}" \ + /bin/bash --login +fi +cd /home/fds +exec /usr/bin/s6-setuidgid fds /usr/bin/env -i \ + HOME=/home/fds USER=fds LOGNAME=fds SHELL=/bin/bash \ + PATH=/usr/bin:/bin LANG=en_US.UTF-8 TERM="${TERM:-linux}" \ + /bin/bash --login diff --git a/packages/fds-init/files/hostname b/packages/fds-init/files/hostname new file mode 100755 index 0000000..af30b65 --- /dev/null +++ b/packages/fds-init/files/hostname @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +read -r name < /etc/hostname +exec /usr/bin/hostname "$name" diff --git a/packages/fds-init/files/runtime-fs b/packages/fds-init/files/runtime-fs new file mode 100755 index 0000000..2ccf70f --- /dev/null +++ b/packages/fds-init/files/runtime-fs @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +# /dev is supplied by the kernel/early userspace; s6 stage 1 mounts /run. +# Never remount SYSTEM writable. +mountpoint -q /proc || mount -t proc -o nosuid,nodev,noexec proc /proc +mountpoint -q /sys || mount -t sysfs -o nosuid,nodev,noexec sysfs /sys +mountpoint -q /tmp || mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs /tmp +mountpoint -q /var/tmp || mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs /var/tmp +mountpoint -q /var/log || mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs /var/log +mountpoint -q /home/fds || mount -t tmpfs -o mode=0700,uid=1000,gid=1000,nosuid,nodev tmpfs /home/fds +mkdir -p /dev/pts /dev/shm /run/lock /run/log +mountpoint -q /dev/pts || mount -t devpts -o mode=0620,gid=5,nosuid,noexec devpts /dev/pts +mountpoint -q /dev/shm || mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs /dev/shm +ln -snf /proc/self/fd /dev/fd +ln -snf /proc/self/fd/0 /dev/stdin +ln -snf /proc/self/fd/1 /dev/stdout +ln -snf /proc/self/fd/2 /dev/stderr +# Local TCP/UDP is base IPC. External interfaces remain down until requested. +ip link set dev lo up diff --git a/packages/fds-init/files/skel/rc.init b/packages/fds-init/files/skel/rc.init new file mode 100755 index 0000000..dab8a29 --- /dev/null +++ b/packages/fds-init/files/skel/rc.init @@ -0,0 +1,6 @@ +#!/bin/bash +# Stage 2 is a child of native s6-svscan, never PID 1. +set -euo pipefail +/usr/bin/fds-boottrace adopt || printf 'Boot trace could not be initialized\n' >&2 +s6-rc-init -c /etc/s6-rc/compiled -l /run/s6-rc /run/service +exec /etc/s6-linux-init/current/scripts/runlevel "${1:-default}" diff --git a/packages/fds-init/files/skel/rc.shutdown b/packages/fds-init/files/skel/rc.shutdown new file mode 100755 index 0000000..2f8702e --- /dev/null +++ b/packages/fds-init/files/skel/rc.shutdown @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +# A native shutdown is irrevocable once started. Keep this hook alive on failure +# instead of letting a nonzero script exit trigger final process killing. +if ! /usr/bin/fds-power --shutdown-hook; then + printf 'FDS shutdown helper failed; remaining powered on for recovery.\n' >&2 + exec /usr/bin/fds-power --hold-shutdown +fi +failed=() +if [[ -d /run/s6-rc ]]; then + if ! s6-rc -b -l /run/s6-rc -t 15000 -d -a change; then + printf 'FDS service stop failed after verified DATA unmount; native cleanup will terminate remaining processes.\n' >&2 + failed=(failed) + fi +fi +/usr/bin/fds-power --record-final "${failed[@]}" +# s6-linux-init-shutdownd handles the final process cleanup, sync and unmount. diff --git a/packages/fds-init/files/skel/rc.shutdown.final b/packages/fds-init/files/skel/rc.shutdown.final new file mode 100755 index 0000000..7fab9e2 --- /dev/null +++ b/packages/fds-init/files/skel/rc.shutdown.final @@ -0,0 +1,2 @@ +#!/usr/bin/execlineb -P +/usr/bin/true diff --git a/packages/fds-init/files/skel/runlevel b/packages/fds-init/files/skel/runlevel new file mode 100755 index 0000000..5079949 --- /dev/null +++ b/packages/fds-init/files/skel/runlevel @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail +case ${1:-default} in + default|boot|2|3|4|5) exec s6-rc -l /run/s6-rc -t 15000 -u change boot ;; + *) printf 'Unsupported FDS runlevel: %s\n' "$1" >&2; exit 100 ;; +esac diff --git a/packages/fds-init/template b/packages/fds-init/template new file mode 100644 index 0000000..923d713 --- /dev/null +++ b/packages/fds-init/template @@ -0,0 +1,21 @@ +# Image-time s6 configuration. The native upstream binaries own PID 1. +pkgname=fds-init +version=0.1.0 +revision=1 +archs="aarch64" +depends="iproute2 s6-linux-init s6-rc s6 execline coreutils util-linux bash eudev fds-dasungd fds-cli fds-cartridged" +short_desc="FDS native s6 boot graph and shutdown configuration" +maintainer="FDS/OS maintainers" +license="Public Domain" +homepage="https://skarnet.org/software/s6-linux-init/" +do_install() { + local input="${XBPS_SRCDISTDIR}/${pkgname}-${version}" + (cd "$input" && sha256sum -c SHA256SUMS) || return 1 + vmkdir etc/s6-rc/source + cp -a "${input}/s6-source/." "${DESTDIR}/etc/s6-rc/source/" + vmkdir usr/share/fds/init-skel + cp -a "${FILESDIR}/skel/." "${DESTDIR}/usr/share/fds/init-skel/" + for f in runtime-fs console hostname console-session; do + vinstall "${FILESDIR}/${f}" 755 usr/libexec/fds + done +} diff --git a/packages/fds-kernel/.gitkeep b/packages/fds-kernel/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/fds-kernel/files/fds.config b/packages/fds-kernel/files/fds.config new file mode 100644 index 0000000..7f9d699 --- /dev/null +++ b/packages/fds-kernel/files/fds.config @@ -0,0 +1,68 @@ +# Native early userspace, immutable SYSTEM and DATA support. +CONFIG_LOCALVERSION="-fds1" +# CONFIG_LOCALVERSION_AUTO is not set +CONFIG_BLK_DEV_INITRD=y +CONFIG_RD_GZIP=y +CONFIG_RD_LZ4=y +CONFIG_RD_ZSTD=y +CONFIG_DEVTMPFS=y +CONFIG_DEVTMPFS_MOUNT=y +CONFIG_EFI_PARTITION=y +CONFIG_EROFS_FS=y +CONFIG_EROFS_FS_ZIP=y +CONFIG_EXT4_FS=y +CONFIG_FAT_FS=y +CONFIG_VFAT_FS=y +CONFIG_NLS_CODEPAGE_437=y +CONFIG_NLS_ISO8859_1=y +CONFIG_TMPFS=y +CONFIG_TMPFS_POSIX_ACL=y +CONFIG_SCSI=y +CONFIG_BLK_DEV_SD=y +CONFIG_USB=y +CONFIG_USB_XHCI_HCD=y +CONFIG_USB_XHCI_PCI=y +CONFIG_USB_XHCI_PLATFORM=y +CONFIG_USB_STORAGE=y +CONFIG_USB_UAS=y +CONFIG_PCI=y +CONFIG_PCIE_BRCMSTB=y +CONFIG_PCI_MSI=y +CONFIG_MFD_RP1=y +CONFIG_PINCTRL_RP1=y +CONFIG_COMMON_CLK_RP1=y +CONFIG_BCM2712_IOMMU=y +CONFIG_BLK_DEV_NVME=y +CONFIG_HID=y +CONFIG_HID_GENERIC=y +CONFIG_USB_HID=y +CONFIG_INPUT_EVDEV=y +CONFIG_VT=y +CONFIG_VT_CONSOLE=y +CONFIG_DRM=y +# VC4 requires these audio core interfaces even when sound devices are optional. +CONFIG_SND=y +CONFIG_SND_SOC=y +CONFIG_DRM_VC4=y +CONFIG_DRM_VC4_HDMI_CEC=y +CONFIG_DRM_FBDEV_EMULATION=y +CONFIG_FRAMEBUFFER_CONSOLE=y +CONFIG_FW_LOADER=y +CONFIG_DRM_LOAD_EDID_FIRMWARE=y +# The companion CH341 SPI interface must remain available to dasungd. +# CONFIG_SPI_CH341 is not set +# Generic ARM VM drivers let us test this build without pretending to emulate Pi firmware. +CONFIG_SERIAL_AMBA_PL011=y +CONFIG_SERIAL_AMBA_PL011_CONSOLE=y +CONFIG_PCI_HOST_GENERIC=y +CONFIG_ATA=y +CONFIG_SATA_AHCI=y +CONFIG_VIRTIO=y +CONFIG_VIRTIO_MMIO=y +CONFIG_VIRTIO_PCI=y +CONFIG_VIRTIO_BLK=y +# Avoid random module signing inputs and large development-only debug artifacts. +# Release authentication covers the complete immutable kernel/module image. +# CONFIG_MODULE_SIG is not set +CONFIG_DEBUG_INFO_NONE=y +# CONFIG_DEBUG_INFO_BTF is not set diff --git a/packages/fds-kernel/template b/packages/fds-kernel/template new file mode 100644 index 0000000..5e91c94 --- /dev/null +++ b/packages/fds-kernel/template @@ -0,0 +1,63 @@ +# Based on rpi5-kernel at the pinned Void commit; upstream stays unchanged. +pkgname=fds-kernel +version=6.12.87 +revision=1 +_githash=ae4d75fb36deba0fe1a986d9bfae65755e082dd0 +archs="aarch64" +wrksrc="linux-${_githash}" +hostmakedepends="perl kmod openssl-devel bc bison flex xz python3 cpio" +short_desc="FDS Raspberry Pi 5 kernel with built-in boot drivers" +maintainer="FDS/OS maintainers" +license="GPL-2.0-only" +homepage="https://github.com/raspberrypi/linux" +distfiles="https://github.com/raspberrypi/linux/archive/${_githash}.tar.gz" +checksum=432afe0cbb9370fc9329f67d44bdc892b8e2417a60dd33de8f0cd22e9c697c62 +python_version=3 +nodebug=yes +nostrip=yes +noverifyrdeps=yes +noshlibprovides=yes + +export KBUILD_BUILD_TIMESTAMP="Thu Jan 1 00:00:00 UTC 1970" +export KBUILD_BUILD_USER=fds +export KBUILD_BUILD_HOST=fds-builder +_cross="CROSS_COMPILE=aarch64-linux-gnu-" + +do_configure() { + make ARCH=arm64 ${_cross} bcm2712_defconfig + scripts/kconfig/merge_config.sh -m .config "${FILESDIR}/fds.config" + make ARCH=arm64 ${_cross} olddefconfig + # Kconfig can silently drop impossible requests. Verify every delta entry. + while IFS= read -r setting; do + case "$setting" in + CONFIG_*=*) grep -Fqx "$setting" .config || return 1 ;; + '# CONFIG_'*' is not set') + local symbol="${setting#\# }" + symbol="${symbol% is not set}" + ! grep -Eq "^${symbol}=[ym]$" .config || return 1 ;; + esac + done <"${FILESDIR}/fds.config" +} + +do_build() { + export LDFLAGS= + make ARCH=arm64 ${_cross} ${makejobs} Image modules dtbs +} + +do_install() { + local release + release=$(make -s ARCH=arm64 ${_cross} kernelrelease) + vinstall arch/arm64/boot/Image 644 boot kernel_2712.img + vinstall .config 644 boot config-fds + vinstall System.map 644 boot System.map-fds + vmkdir boot/overlays + vcopy "arch/arm64/boot/dts/broadcom/bcm2712-*.dtb" boot + vcopy "arch/arm64/boot/dts/overlays/*.dtbo" boot/overlays + vinstall arch/arm64/boot/dts/overlays/README 644 boot/overlays + make ARCH=arm64 ${_cross} ${makejobs} INSTALL_MOD_PATH="${DESTDIR}/usr" \ + INSTALL_MOD_STRIP=1 DEPMOD=true modules_install + rm -f "${DESTDIR}/usr/lib/modules/${release}/build" "${DESTDIR}/usr/lib/modules/${release}/source" + depmod -b "${DESTDIR}/usr" "$release" + vmkdir usr/share/fds + printf '%s\n' "$release" >"${DESTDIR}/usr/share/fds/kernel-release" +} diff --git a/profiles/.gitkeep b/profiles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/profiles/cli.list b/profiles/cli.list new file mode 100644 index 0000000..62c0118 --- /dev/null +++ b/profiles/cli.list @@ -0,0 +1 @@ +# Console boots first; desktop and network remain optional at runtime. diff --git a/profiles/development.list b/profiles/development.list new file mode 100644 index 0000000..8a0dbd8 --- /dev/null +++ b/profiles/development.list @@ -0,0 +1,23 @@ +# Native aarch64 glibc development tools. None adds a boot service. +gcc +glibc-devel +make +cmake +meson +ninja +pkg-config +rust +cargo +git +gdb +strace +vim + +# Virtual desktop diagnostics for software verification. +xorg-server-xvfb +xdotool +xwd +xwininfo +xdpyinfo +xauth +xprop diff --git a/profiles/recovery.list b/profiles/recovery.list new file mode 100644 index 0000000..4051f46 --- /dev/null +++ b/profiles/recovery.list @@ -0,0 +1,4 @@ +# Independent local maintenance environment. All required recovery tools are +# already in fds-base; keep the base Dasung controller and native s6 services. +# The immutable image-profile marker selects the recovery console and disables +# automatic DATA writes, ENVIRONMENT activation, and Ethernet activation. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..4b6027b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.98.0" +profile = "minimal" +components = ["rustfmt"] +targets = ["aarch64-unknown-linux-musl"] diff --git a/rust/dasungd/Cargo.toml b/rust/dasungd/Cargo.toml new file mode 100644 index 0000000..1457850 --- /dev/null +++ b/rust/dasungd/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dasungd" +version = "0.1.0" +edition = "2024" +description = "DASUNG Paperlike USB keepalive, control socket and Linux display profile daemon" +license = "MIT" + +[features] +vendored = ["rusb/vendored"] + +[dependencies] +anyhow = "1" +clap.workspace = true +ctrlc = { version = "3", features = ["termination"] } +libc = "0.2" +rusb = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + diff --git a/rust/dasungd/IMPORT.md b/rust/dasungd/IMPORT.md new file mode 100644 index 0000000..8abbac3 --- /dev/null +++ b/rust/dasungd/IMPORT.md @@ -0,0 +1,27 @@ +# Imported Dasung controller source + +Source task: Fix Dasung monitor black screen +Task ID: `01a0b7d0-7020-71e0-bfec-13c9568b5872` +Imported from the local `dasungd` project on 2026-09-20. + +The original task directory is not a build dependency. FDS owns the imported +source snapshot and subsequent adaptations. Original input hashes: + +```text +cc97a9bf34829b213bf6c882729e0e272438fb60253ba4e248d6714c8325a637 Cargo.toml +8771f6322c399ab10bb321f341ca8f9b2a31e055ef72d8c5acd169f626fdd27a Cargo.lock +1126322e2cc8d165adc4c792eeb195717de2bcc7b39be1ce77959d78e87ef685 LICENSE +4fda0939b3ae291fc6ff5c2b57933dc4a00fb7d3e44bf4d4b1f42f855c1ac63a src/config.rs +55498d77a942f73f4c737b4f2efd4cc80dfc6985c93d942d1a21f77809774afc src/display.rs +be1d2956bec44dc396b37ba66016d333defff368bc62777573f1198aeb447f7f src/main.rs +5c3a1d0a6fa97a9b020433cc7a66482eb75ae2330cfd2b094aa347b29313e02d src/protocol.rs +53e412f7fac3be01cd3e03144ba7a6cdb44059321e820ecd1ab45be32916e53f src/transport.rs +b6c1e0a8d315d9cf5c2fb83784a177530bcc085c2d0724dc7478a9c12449e4f4 profiles/paperlike13k-37hz.edid +``` + +FDS changes: workspace integration, target filesystem paths, native s6 packaging, +and a portable simulator binary path. The original AMD/systemd installer and +workstation configuration are not part of the target package. + +See [the FDS guide](../../docs/dasung.md) and the historical +[hardware validation record](VALIDATION.md). diff --git a/rust/dasungd/LICENSE b/rust/dasungd/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/rust/dasungd/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/dasungd/README.md b/rust/dasungd/README.md new file mode 100644 index 0000000..2ecd517 --- /dev/null +++ b/rust/dasungd/README.md @@ -0,0 +1,16 @@ +# dasungd in FDS/OS + +The controller for the user's grayscale Dasung Paperlike 13K is maintained as an +FDS Rust workspace member. It provides USB keepalive, reconnect/watchdog handling, +configuration, and a local control socket. + +Use the [FDS integration and usage guide](../../docs/dasung.md) for build commands, +base packaging, native s6 supervision, Pi display configuration, and validation +limits. From the repository root, run `make dasung` followed by `make dasung-test`. +These targets do not operate the workstation's live monitor. + +[IMPORT.md](IMPORT.md) records the source task and original input hashes. +[VALIDATION.md](VALIDATION.md) is the original workstation acceptance record; its +references to the original README and systemd installation are historical. The +FDS target uses the native s6 integration documented in the guide above. Pi boot +and corrected physical cold-start recovery are not yet verified. diff --git a/rust/dasungd/VALIDATION.md b/rust/dasungd/VALIDATION.md new file mode 100644 index 0000000..5f37452 --- /dev/null +++ b/rust/dasungd/VALIDATION.md @@ -0,0 +1,29 @@ +# Validation record — 2026-09-20 + +## Confirmed before installation + +The user confirmed 3200×2400 at 36.9998735 Hz, 304.21 MHz, 2× scaling was **very stable** using the Python control helper. The full timing is in README.md. The monitor's SPI interface and native UART driver were unbound during that confirmation. + +## Software and initial live handoff + +- Six Rust unit tests pass: exact outgoing framing, fragmented/concatenated real replies, malformed stream recovery, parameter validation, profile checksum/identity/clock, bounded IPC and saved-setting round trip (some checks share test cases). +- `cargo clippy --locked --all-targets -- -D warnings` passes; release build succeeds. +- The pseudo-terminal integration test passes late attachment, keepalive, real response fragmentation, invalid command rejection, saved parameter replay, unplug/node replacement/replug, duplicate daemon exclusion, reply watchdog, process restart and forgetting saved settings. +- Systemd unit and udev rule syntax verification pass. +- Live Rust handoff received all expected queries: mode 1, contrast 4, front-light 2, brightness 40, temperature 0, protocol version 0x31. Mode 1 and contrast 4 writes were read back correctly and saved. +- Service restart and Hyprland reload retained the exact 304210 kHz timing and 2× scaling. The ASUS remained 2560×1440 at 144 Hz, scale 1. The diagnostic mpv window is closed. +- Service is enabled at boot. Current initramfs contains neither CH341 UART nor CH341 SPI modules; no bootloader or initramfs changes were required. + +## Physical reconnect exposed a remaining cold-start issue + +The first full power/reconnect test returned a dark picture. Video timing/scaling restored, and the UART was rediscovered at a new USB address. However, the kernel had bound `spi_ch341` to the second USB chip and replies stopped after the first two queries. Removing the driver and resetting that USB chip afterward did not recover replies. + +Corrections now installed: + +- Scoped udev autoload suppression for the monitor's hub/UART/SPI combination. A real synthetic add event left `spi_ch341` unloaded. +- The USB transport reserves the companion SPI interface without configuring it or sending SPI data, preventing a later-loaded driver from binding while the daemon owns it. +- Minimum 150 ms packet spacing and one-second startup query spacing, matching the proven Python helper; the initial Rust version could send adjacent keepalive/query packets. +- Reply timeouts reopen only control; they do not continually retrain an otherwise unchanged video signal. +- Status distinguishes an open transport (`connected`) from receipt of valid monitor frames (`responsive`). + +**A clean physical power cycle with these corrections is still pending user confirmation. Persistence is installed, but cold-start picture recovery must not yet be described as verified.** No computer reboot has been performed. diff --git a/rust/dasungd/profiles/paperlike13k-37hz.edid b/rust/dasungd/profiles/paperlike13k-37hz.edid new file mode 100644 index 0000000000000000000000000000000000000000..cc0c360f03ada0b9d78d1a661dedc89235ed8f00 GIT binary patch literal 256 zcmZSh4+acg-TN6C83K}dL>QTu$O=@vGHqQJ<}ph}C?JG^f#IV6N{6WqjSj93n_U, + pub usb_path: Option, + pub require_companion: bool, + pub require_display: bool, + pub keepalive_ms: u64, + pub reconnect_ms: u64, + pub watchdog_ms: u64, + pub display: Display, + pub startup: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Display { + pub enabled: bool, + pub edid: PathBuf, + // AMD's virtual hotplug API was verified on the actual machine. Other GPUs + // should use the portable firmware EDID mechanism or their KMS client. + pub hotplug: String, +} +impl Default for Display { + fn default() -> Self { + Self { + enabled: false, + edid: "/usr/lib/firmware/edid/dasung-paperlike13k-37hz.bin".into(), + hotplug: "none".into(), + } + } +} +impl Default for Config { + fn default() -> Self { + Self { + monitor_serial: "L56051794302".into(), + socket: "/run/dasungd/control.sock".into(), + state_file: "/run/dasungd/settings.json".into(), + transport: "usb".into(), + serial_device: None, + usb_path: None, + require_companion: true, + require_display: true, + keepalive_ms: 2000, + reconnect_ms: 2000, + watchdog_ms: 30000, + display: Display::default(), + startup: BTreeMap::new(), + } + } +} +impl Config { + pub fn load(path: &Path) -> Result { + let config: Self = toml::from_str( + &fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?, + )?; + ensure!( + !config.monitor_serial.is_empty(), + "monitor_serial must identify the intended monitor" + ); + ensure!( + (500..=5000).contains(&config.keepalive_ms), + "keepalive_ms must be 500..5000" + ); + ensure!( + (500..=30000).contains(&config.reconnect_ms), + "reconnect_ms must be 500..30000" + ); + ensure!( + config.watchdog_ms >= 20000, + "watchdog_ms must be at least 20000" + ); + ensure!( + matches!(config.transport.as_str(), "usb" | "serial"), + "transport must be usb or serial" + ); + ensure!( + config.transport != "serial" || config.serial_device.is_some(), + "serial transport needs serial_device" + ); + ensure!( + matches!(config.display.hotplug.as_str(), "none" | "amdgpu"), + "display.hotplug must be none or amdgpu" + ); + for (¶meter, &value) in &config.startup { + parameter.validate(value)?; + } + Ok(config) + } +} diff --git a/rust/dasungd/src/display.rs b/rust/dasungd/src/display.rs new file mode 100644 index 0000000..b5958ba --- /dev/null +++ b/rust/dasungd/src/display.rs @@ -0,0 +1,231 @@ +use crate::config::Config; +use anyhow::{Context, Result, ensure}; +use serde::Serialize; +use std::{collections::BTreeMap, fs, path::PathBuf, thread, time::Duration}; + +pub fn serial(edid: &[u8]) -> Option { + if edid.len() < 128 { + return None; + } + for d in edid[54..126].as_chunks::<18>().0 { + if d[..5] == [0, 0, 0, 0xff, 0] { + return Some(String::from_utf8_lossy(&d[5..18]).trim().to_owned()); + } + } + None +} + +pub fn validate(edid: &[u8], identity: &str) -> Result<()> { + ensure!( + edid.len() >= 128 && edid.len().is_multiple_of(128), + "EDID must contain complete 128-byte blocks" + ); + ensure!( + &edid[..8] == b"\x00\xff\xff\xff\xff\xff\xff\x00", + "invalid EDID header" + ); + ensure!( + usize::from(edid[126]) + 1 == edid.len() / 128, + "EDID extension count mismatch" + ); + ensure!( + edid.as_chunks::<128>() + .0 + .iter() + .all(|b| b.iter().fold(0u8, |sum, v| sum.wrapping_add(*v)) == 0), + "EDID checksum failed" + ); + ensure!( + serial(edid).as_deref() == Some(identity), + "EDID profile serial does not match configured monitor_serial" + ); + Ok(()) +} + +#[derive(Clone, Debug, Serialize)] +pub struct Connector { + pub name: String, + pub path: PathBuf, + pub debug: PathBuf, +} + +pub fn find(identity: &str) -> Result> { + let mut found = Vec::new(); + for entry in fs::read_dir("/sys/class/drm")? { + let path = entry?.path(); + if fs::read_to_string(path.join("status")) + .unwrap_or_default() + .trim() + != "connected" + { + continue; + } + if serial(&fs::read(path.join("edid")).unwrap_or_default()).as_deref() != Some(identity) { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let Some((card, connector)) = name.split_once('-') else { + continue; + }; + let Some(index) = card.strip_prefix("card") else { + continue; + }; + found.push(Connector { + debug: PathBuf::from(format!("/sys/kernel/debug/dri/{index}/{connector}")), + name, + path, + }); + } + Ok(found) +} + +fn redetect(c: &Connector, config: &Config) -> Result<()> { + if config.display.hotplug == "amdgpu" { + let trigger = c.debug.join("trigger_hotplug"); + ensure!( + trigger.exists(), + "amdgpu trigger_hotplug absent on {}", + c.name + ); + // The current kernel's parser requires newline-terminated numeric writes. + fs::write(&trigger, b"0\n")?; + thread::sleep(Duration::from_millis(500)); + fs::write(&trigger, b"1\n")?; + } + Ok(()) +} + +pub struct Manager { + profile: Vec, + owned: BTreeMap, +} +impl Manager { + pub fn recover_stale(&self, config: &Config) -> Result<()> { + if !config.display.enabled { + return Ok(()); + } + // A previous process may have been killed without cleanup. Remove only + // this exact profile and reread the physical identity before trusting it. + // This prevents an old port override from impersonating a replacement + // monitor when the service restarts after an unattended cable swap. + for entry in fs::read_dir("/sys/class/drm")? { + let path = entry?.path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let Some((card, connector)) = name.split_once('-') else { + continue; + }; + let Some(index) = card.strip_prefix("card") else { + continue; + }; + let debug = PathBuf::from(format!("/sys/kernel/debug/dri/{index}/{connector}")); + let p = debug.join("edid_override"); + if fs::read(&p).unwrap_or_default() != self.profile { + continue; + } + fs::write(&p, b"reset")?; + let c = Connector { name, path, debug }; + if fs::read_to_string(c.path.join("status")) + .unwrap_or_default() + .trim() + == "connected" + { + redetect(&c, config)?; + } + } + Ok(()) + } + pub fn new(config: &Config) -> Result { + let profile = if config.display.enabled { + let b = fs::read(&config.display.edid).context("read display.edid")?; + validate(&b, &config.monitor_serial)?; + b + } else { + Vec::new() + }; + Ok(Self { + profile, + owned: BTreeMap::new(), + }) + } + pub fn tick(&mut self, config: &Config) -> Result> { + if !config.display.enabled { + return Ok(find(&config.monitor_serial)? + .iter() + .map(|c| c.name.clone()) + .collect()); + } + // A connector override must not remain attached to a port after unplug. + let removed: Vec<_> = self + .owned + .iter() + .filter(|(_, c)| { + fs::read_to_string(c.path.join("status")) + .unwrap_or_default() + .trim() + != "connected" + }) + .map(|(n, _)| n.clone()) + .collect(); + for n in removed { + let c = self.owned.get(&n).unwrap(); + self.clear(c)?; + self.owned.remove(&n); + } + let found = find(&config.monitor_serial)?; + ensure!( + found.len() <= 1, + "multiple connected outputs match monitor serial; connect video by one cable" + ); + for c in &found { + let override_path = c.debug.join("edid_override"); + let existing = fs::read(&override_path) + .context("read EDID override; mount debugfs and check permissions")?; + ensure!( + existing.is_empty() || existing == self.profile, + "another EDID override exists on {}; refusing to overwrite it", + c.name + ); + self.owned.insert(c.name.clone(), c.clone()); + if existing != self.profile { + fs::write(&override_path, &self.profile)?; + redetect(c, config)?; + eprintln!("{}: applied confirmed 304.21 MHz EDID", c.name); + } + } + Ok(found.iter().map(|c| c.name.clone()).collect()) + } + fn clear(&self, c: &Connector) -> Result<()> { + let p = c.debug.join("edid_override"); + if p.exists() && fs::read(&p)? == self.profile { + fs::write(p, b"reset")?; + } + Ok(()) + } + pub fn cleanup(&mut self, config: &Config, reprobe: bool) { + for c in self.owned.values() { + if let Err(e) = self.clear(c) { + eprintln!("clear {}: {e:#}", c.name); + continue; + } + if reprobe && let Err(e) = redetect(c, config) { + eprintln!("redetect {}: {e:#}", c.name); + } + } + self.owned.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn profile_has_valid_identity_checksum_and_exact_clock() { + let b = include_bytes!("../profiles/paperlike13k-37hz.edid"); + validate(b, "L56051794302").unwrap(); + assert_eq!(u16::from_le_bytes([b[54], b[55]]), 30421); + let mut broken = b.to_vec(); + broken[60] ^= 1; + assert!(validate(&broken, "L56051794302").is_err()); + assert!(validate(b, "another monitor").is_err()); + } +} diff --git a/rust/dasungd/src/main.rs b/rust/dasungd/src/main.rs new file mode 100644 index 0000000..e217e92 --- /dev/null +++ b/rust/dasungd/src/main.rs @@ -0,0 +1,573 @@ +mod config; +mod display; +mod protocol; +mod transport; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use config::Config; +use protocol::{Decoder, Parameter, packet}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, VecDeque}, + fs, + io::{BufRead, BufReader, Read, Write}, + os::{ + fd::AsRawFd, + unix::{ + fs::PermissionsExt, + net::{UnixListener, UnixStream}, + }, + }, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +#[derive(Parser)] +#[command(version, about)] +struct Cli { + #[arg(long, global = true, default_value = "/etc/dasungd.toml")] + config: PathBuf, + #[arg(long, global = true)] + socket: Option, + #[command(subcommand)] + command: Command, +} +#[derive(Subcommand)] +enum Command { + /// Internal udev matcher: recognize the monitor's SPI/UART hub combination. + #[command(hide = true)] + UdevMatchSpi { syspath: PathBuf }, + /// Foreground daemon; a service manager may supervise it. + Daemon, + /// Validate configuration and the optional EDID without changing hardware. + Check, + /// Discover matching video/USB devices without claiming them. + Discover, + /// JSON status including cached hardware settings and connection health. + Status, + /// Ask the monitor to refresh all cached parameter values. + Query, + /// Send one full-refresh command. + Refresh, + /// Set a raw monitor parameter through the daemon's single USB owner. + Set { + #[arg(value_enum)] + parameter: Parameter, + value: u8, + /// Reapply this value after reconnection/restart. + #[arg(long)] + save: bool, + }, + /// Forget a saved parameter without changing its current hardware value. + Forget { + #[arg(value_enum)] + parameter: Parameter, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Status, + Query, + Refresh, + Set { + parameter: Parameter, + value: u8, + #[serde(default)] + save: bool, + }, + Forget { + parameter: Parameter, + }, +} +type Work = (Request, mpsc::Sender); + +#[derive(Default, Serialize)] +struct Status { + connected: bool, + responsive: bool, + transport: Option, + reconnects: u64, + keepalives_sent: u64, + rx_frames: u64, + parameters: BTreeMap, + saved: BTreeMap, + last_rx_ms_ago: Option, + usb_error: Option, + displays: Vec, + display_error: Option, +} + +fn reply_ok(message: &str) -> Value { + json!({"ok":true,"message":message}) +} +fn reply_error(error: impl std::fmt::Display) -> Value { + json!({"ok":false,"error":error.to_string()}) +} + +fn read_request(stream: &UnixStream) -> Result { + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + let mut reader = BufReader::new(stream); + let mut line = Vec::new(); + // Bound local requests, including requests without a terminating newline. + std::io::Read::by_ref(&mut reader) + .take(4097) + .read_until(b'\n', &mut line)?; + if line.len() > 4096 || !line.ends_with(b"\n") { + bail!("request must be one JSON line of at most 4096 bytes"); + } + Ok(serde_json::from_slice(&line)?) +} + +fn control_server( + listener: UnixListener, + sender: mpsc::SyncSender, + running: Arc, +) { + while running.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => { + let response = match read_request(&stream) { + Ok(request) => { + let (reply, recv) = mpsc::channel(); + if sender.try_send((request, reply)).is_err() { + reply_error("daemon busy") + } else { + recv.recv_timeout(Duration::from_secs(4)) + .unwrap_or_else(|_| reply_error("request timed out")) + } + } + Err(e) => reply_error(e), + }; + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + let _ = writeln!(stream, "{response}"); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(50)) + } + Err(e) => { + eprintln!("control socket: {e}"); + thread::sleep(Duration::from_millis(250)); + } + } + } +} + +fn save_settings(path: &Path, settings: &BTreeMap) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + let mut file = fs::File::create(&temporary)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(serde_json::to_string_pretty(settings)?.as_bytes())?; + file.sync_all()?; + fs::rename(temporary, path)?; + Ok(()) +} + +fn query_all(queue: &mut VecDeque<(u8, u8)>) { + for parameter in [0x10, 0x13, 1, 2, 7, 9, 8] { + if !queue.contains(&(0x0a, parameter)) { + queue.push_back((0x0a, parameter)); + } + } +} + +fn daemon(config: Config) -> Result<()> { + // Lock a separate inode before replacing any stale socket. A second instance + // must never steal the active daemon's pathname or USB interface. + fs::create_dir_all( + config + .socket + .parent() + .context("socket needs a parent directory")?, + )?; + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(config.socket.with_extension("lock"))?; + // SAFETY: flock receives a live fd and defined operation flags. + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + bail!("another daemon owns this socket"); + } + if config.socket.exists() { + fs::remove_file(&config.socket)?; + } + let listener = UnixListener::bind(&config.socket)?; + fs::set_permissions(&config.socket, fs::Permissions::from_mode(0o660))?; + listener.set_nonblocking(true)?; + let running = Arc::new(AtomicBool::new(true)); + let signal_flag = running.clone(); + ctrlc::set_handler(move || signal_flag.store(false, Ordering::Relaxed))?; + let mut saved = if config.state_file.exists() { + serde_json::from_slice::>(&fs::read(&config.state_file)?)? + } else { + BTreeMap::new() + }; + for (&p, &v) in &saved { + p.validate(v)?; + } + let status = Arc::new(Mutex::new(Status { + saved: saved.clone(), + ..Default::default() + })); + let mut manager = display::Manager::new(&config)?; + let ds = status.clone(); + let dc = config.clone(); + let dr = running.clone(); + let invalidate = Arc::new(AtomicBool::new(false)); + let di = invalidate.clone(); + let display_thread = thread::spawn(move || { + let mut last_error = String::new(); + // Do not apply any profile if stale-override recovery failed. Retrying + // keeps the daemon useful when debugfs appears later during boot. + let mut recovered = false; + while dr.load(Ordering::Relaxed) { + if !recovered { + match manager.recover_stale(&dc) { + Ok(()) => recovered = true, + Err(e) => { + let message = format!("stale EDID recovery: {e:#}"); + if message != last_error { + eprintln!("{message}"); + last_error = message.clone(); + } + ds.lock().unwrap().display_error = Some(message); + thread::sleep(Duration::from_secs(1)); + continue; + } + } + } + if di.swap(false, Ordering::Relaxed) { + manager.cleanup(&dc, true); + } + match manager.tick(&dc) { + Ok(displays) => { + let mut s = ds.lock().unwrap(); + s.displays = displays; + s.display_error = None; + last_error.clear(); + } + Err(e) => { + let message = format!("{e:#}"); + if message != last_error { + eprintln!("display: {message}"); + last_error = message.clone(); + } + ds.lock().unwrap().display_error = Some(message); + } + } + thread::sleep(Duration::from_millis(250)); + } + manager.cleanup(&dc, false); + }); + let (sender, receiver) = mpsc::sync_channel::(16); + let sr = running.clone(); + let server = thread::spawn(move || control_server(listener, sender, sr)); + let mut port: Option> = None; + let mut decoder = Decoder::default(); + let mut queue = VecDeque::new(); + let mut reconnect = Instant::now(); + let mut keepalive = Instant::now(); + let mut query = Instant::now(); + let mut control = Instant::now(); + let mut last_rx = Instant::now(); + let mut last_error = String::new(); + eprintln!( + "dasungd {} ready; waiting for {}", + env!("CARGO_PKG_VERSION"), + config.monitor_serial + ); + while running.load(Ordering::Relaxed) { + let now = Instant::now(); + if port.is_none() && now >= reconnect { + let attempt = (|| -> Result> { + if config.require_display && display::find(&config.monitor_serial)?.is_empty() { + bail!("waiting for matching display EDID"); + } + transport::open(&config) + })(); + match attempt { + Ok(p) => { + eprintln!("connected {}", p.label()); + let mut s = status.lock().unwrap(); + s.connected = true; + s.responsive = false; + s.last_rx_ms_ago = None; + s.transport = Some(p.label().into()); + s.reconnects += 1; + s.usb_error = None; + s.parameters.clear(); + last_error.clear(); + port = Some(p); + decoder = Decoder::default(); + queue.clear(); + query_all(&mut queue); + let mut startup = config.startup.clone(); + startup.extend(saved.clone()); + for (parameter, value) in startup { + queue.push_back((parameter.command(), value)); + } + queue.push_back((3, 0)); + last_rx = now; + keepalive = now + Duration::from_secs(1); + control = now; + query = now + Duration::from_secs(10); + } + Err(e) => { + let message = format!("{e:#}"); + if message != last_error { + eprintln!("USB: {message}"); + last_error = message.clone(); + } + status.lock().unwrap().usb_error = Some(message); + reconnect = now + Duration::from_millis(config.reconnect_ms); + } + } + } + while let Ok((request, reply)) = receiver.try_recv() { + let result = (|| -> Result { + if matches!(request, Request::Status) { + return Ok(json!({"ok":true,"status":*status.lock().unwrap()})); + } + if let Request::Forget { parameter } = request { + let mut updated = saved.clone(); + updated.remove(¶meter); + save_settings(&config.state_file, &updated)?; + saved = updated; + status.lock().unwrap().saved = saved.clone(); + return Ok(reply_ok( + "saved setting removed; configured startup defaults still apply", + )); + } + let p = port.as_mut().context("monitor is not connected")?; + match request { + Request::Query => { + query_all(&mut queue); + Ok(reply_ok( + "parameter queries queued; read status for replies", + )) + } + Request::Refresh => { + p.write(&packet(3, 0))?; + Ok(reply_ok("refresh packet written")) + } + Request::Set { + parameter, + value, + save, + } => { + parameter.validate(value)?; + p.write(&packet(parameter.command(), value))?; + queue.push_front((0x0a, parameter.command())); + if save { + let mut updated = saved.clone(); + updated.insert(parameter, value); + save_settings(&config.state_file, &updated)?; + saved = updated; + status.lock().unwrap().saved = saved.clone(); + } + Ok(reply_ok( + "setting packet written; status reports the monitor's observed value", + )) + } + _ => unreachable!(), + } + })(); + let _ = reply.send(result.unwrap_or_else(reply_error)); + } + if let Some(p) = port.as_mut() { + let mut reply_timeout = false; + let result = (|| -> Result<()> { + if now >= keepalive { + p.write(&packet(0x20, 1))?; + keepalive = Instant::now() + Duration::from_millis(config.keepalive_ms); + status.lock().unwrap().keepalives_sent += 1; + } + if now >= query { + if !queue.contains(&(0x0a, 2)) { + queue.push_back((0x0a, 2)); + } + query = now + Duration::from_secs(10); + } + if now >= control + && let Some((cmd, opt)) = queue.pop_front() + { + p.write(&packet(cmd, opt))?; + control = Instant::now() + Duration::from_secs(1); + } + let mut bytes = [0; 256]; + let n = p.read(&mut bytes)?; + for frame in decoder.push(&bytes[..n]) { + let mut s = status.lock().unwrap(); + s.rx_frames += 1; + s.responsive = true; + last_rx = Instant::now(); + if let Some((parameter, value)) = frame.parameter() { + s.parameters.insert(format!("0x{parameter:02X}"), value); + } + } + let age = last_rx.elapsed(); + { + let mut s = status.lock().unwrap(); + s.last_rx_ms_ago = s.responsive.then_some(age.as_millis() as u64); + } + if age > Duration::from_millis(config.watchdog_ms) { + reply_timeout = true; + bail!( + "no valid monitor replies for {} seconds; reopening control channel", + age.as_secs() + ); + } + Ok(()) + })(); + if let Err(e) = result { + eprintln!("control connection lost: {e:#}"); + port = None; + queue.clear(); + let mut s = status.lock().unwrap(); + s.connected = false; + s.responsive = false; + s.last_rx_ms_ago = None; + s.transport = None; + s.usb_error = Some(format!("{e:#}")); + reconnect = Instant::now() + Duration::from_millis(config.reconnect_ms); + // A silent control MCU needs a UART reopen, not a video mode + // reset. Actual transport loss invalidates the port association. + if !reply_timeout { + invalidate.store(true, Ordering::Relaxed); + } + } + } else { + thread::sleep(Duration::from_millis(50)); + } + } + if let Some(mut p) = port { + let _ = p.write(&packet(0x20, 0)); + } + let _ = server.join(); + let _ = display_thread.join(); + let _ = fs::remove_file(&config.socket); + eprintln!("stopped; released control interface and owned runtime EDID overrides"); + Ok(()) +} + +fn client(socket: &Path, request: Request) -> Result<()> { + let mut stream = UnixStream::connect(socket) + .with_context(|| format!("connect to {}; is dasungd running?", socket.display()))?; + stream.set_read_timeout(Some(Duration::from_secs(6)))?; + writeln!(stream, "{}", serde_json::to_string(&request)?)?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + let reply: Value = serde_json::from_str(&line)?; + println!("{}", serde_json::to_string_pretty(&reply)?); + if reply["ok"] != true { + bail!("daemon rejected the request"); + } + Ok(()) +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + if let Command::UdevMatchSpi { syspath } = &cli.command { + if transport::is_monitor_spi(syspath) { + println!("yes"); + return Ok(()); + } + bail!("not a matching monitor SPI interface"); + } + if matches!( + cli.command, + Command::Daemon | Command::Check | Command::Discover + ) { + let mut config = Config::load(&cli.config)?; + if let Some(socket) = cli.socket { + config.socket = socket; + } + return match cli.command { + Command::Daemon => daemon(config), + Command::Check => { + display::Manager::new(&config)?; + println!("Configuration and EDID valid"); + Ok(()) + } + Command::Discover => { + println!( + "{}", + serde_json::to_string_pretty( + &json!({"displays":display::find(&config.monitor_serial)?,"usb":transport::usb_candidates(&config)?}) + )? + ); + Ok(()) + } + _ => unreachable!(), + }; + } + let socket = match cli.socket { + Some(socket) => socket, + None if cli.config.exists() => Config::load(&cli.config)?.socket, + None => "/run/dasungd/control.sock".into(), + }; + let request = match cli.command { + Command::Status => Request::Status, + Command::Query => Request::Query, + Command::Refresh => Request::Refresh, + Command::Set { + parameter, + value, + save, + } => { + parameter.validate(value)?; + Request::Set { + parameter, + value, + save, + } + } + Command::Forget { parameter } => Request::Forget { parameter }, + _ => unreachable!(), + }; + client(&socket, request) +} +fn main() { + if let Err(e) = run() { + eprintln!("dasungd: {e:#}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn invalid_or_oversized_ipc_is_rejected() { + let (a, mut b) = UnixStream::pair().unwrap(); + b.write_all(b"{\"op\":\"set\",\"parameter\":\"firmware\",\"value\":3}\n") + .unwrap(); + assert!(read_request(&a).is_err()); + let (a, mut b) = UnixStream::pair().unwrap(); + b.write_all(&vec![b'x'; 4097]).unwrap(); + assert!(read_request(&a).is_err()); + } + #[test] + fn saved_settings_survive_reload() { + let dir = std::env::temp_dir().join(format!("dasungd-test-{}", std::process::id())); + let p = dir.join("settings.json"); + let settings = BTreeMap::from([(Parameter::Mode, 7)]); + save_settings(&p, &settings).unwrap(); + let read: BTreeMap = serde_json::from_slice(&fs::read(&p).unwrap()).unwrap(); + assert_eq!(read, settings); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/dasungd/src/protocol.rs b/rust/dasungd/src/protocol.rs new file mode 100644 index 0000000..3584fc3 --- /dev/null +++ b/rust/dasungd/src/protocol.rs @@ -0,0 +1,131 @@ +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +pub fn packet(command: u8, option: u8) -> Vec { + format!("5FF5{command:02X}{option:02X}000000000000A0FA").into_bytes() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame(pub Vec); + +impl Frame { + pub fn parameter(&self) -> Option<(u8, u8)> { + match self.0.as_slice() { + [0xf0, 0x0a, parameter, value, ..] => Some((*parameter, *value)), + [command @ (1 | 2 | 7 | 8 | 9), value, ..] => Some((*command, *value)), + _ => None, + } + } +} + +// USB reads can split frames, concatenate them, or contain unsolicited button events. +// The observed monitor replies have both 22- and 24-character framing. +#[derive(Default)] +pub struct Decoder(Vec); + +impl Decoder { + pub fn push(&mut self, input: &[u8]) -> Vec { + self.0.extend(input.iter().map(u8::to_ascii_uppercase)); + let mut frames = Vec::new(); + loop { + let Some(start) = self.0.windows(4).position(|v| v == b"5FF5") else { + if self.0.len() > 3 { + self.0.drain(..self.0.len() - 3); + } + break; + }; + self.0.drain(..start); + let end = [22, 24] + .into_iter() + .find(|&n| self.0.len() >= n && &self.0[n - 4..n] == b"A0FA"); + if let Some(n) = end { + let body = &self.0[4..n - 4]; + if body.iter().all(u8::is_ascii_hexdigit) { + let bytes = body + .as_chunks::<2>() + .0 + .iter() + .map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()) + .collect(); + frames.push(Frame(bytes)); + } + self.0.drain(..n); + } else if self.0.len() >= 24 { + self.0.remove(0); + } else { + break; + } + } + frames + } +} + +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, clap::ValueEnum, PartialEq, Eq, PartialOrd, Ord, +)] +#[serde(rename_all = "snake_case")] +pub enum Parameter { + Mode, + Contrast, + FrontLight, + Brightness, + Temperature, +} + +impl Parameter { + pub fn command(self) -> u8 { + match self { + Self::Contrast => 1, + Self::Mode => 2, + Self::FrontLight => 7, + Self::Temperature => 8, + Self::Brightness => 9, + } + } + pub fn validate(self, value: u8) -> Result<()> { + let valid = match self { + Self::Mode => (1..=8).contains(&value), + Self::Contrast => (1..=9).contains(&value), + Self::FrontLight => value <= 2, + Self::Brightness | Self::Temperature => value <= 100, + }; + if !valid { + bail!("value {value} outside the allowed raw range for {self:?}"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn framing_and_fragmented_real_responses() { + assert_eq!(packet(0x20, 1), b"5FF52001000000000000A0FA"); + let mut d = Decoder::default(); + assert!(d.push(b"junk5FF5F00").is_empty()); + let frames = d.push(b"A020100000000A0FA5FF50207000000000000A0FA"); + assert_eq!( + frames + .iter() + .filter_map(Frame::parameter) + .collect::>(), + [(2, 1), (2, 7)] + ); + } + #[test] + fn resynchronizes_after_bad_frame_and_bounds_noise() { + let mut d = Decoder::default(); + assert!(d.push(&vec![b'X'; 50000]).is_empty()); + assert!(d.0.len() <= 3); + let f = d.push(b"5FF5ZZ02000000000000A0FA5FF5F00A020300000000A0FA"); + assert_eq!(f.len(), 1); + assert_eq!(f[0].parameter(), Some((2, 3))); + } + #[test] + fn reject_invalid_settings() { + assert!(Parameter::Mode.validate(0).is_err()); + assert!(Parameter::Mode.validate(7).is_ok()); + assert!(Parameter::FrontLight.validate(3).is_err()); + } +} diff --git a/rust/dasungd/src/transport.rs b/rust/dasungd/src/transport.rs new file mode 100644 index 0000000..99a8b87 --- /dev/null +++ b/rust/dasungd/src/transport.rs @@ -0,0 +1,306 @@ +use crate::config::Config; +use anyhow::{Context, Result, bail, ensure}; +use rusb::{DeviceHandle, GlobalContext}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + path::Path, + time::{Duration, Instant}, +}; + +pub trait Transport: Send { + fn write(&mut self, bytes: &[u8]) -> Result<()>; + fn read(&mut self, bytes: &mut [u8]) -> Result; + fn label(&self) -> &str; +} + +struct Usb { + handle: DeviceHandle, + // Claiming this interface sends no SPI data and prevents a driver loaded + // later for another adapter from probing the monitor's internal bridge. + _spi_guard: Option>, + last_write: Option, + name: String, +} +impl Transport for Usb { + fn write(&mut self, bytes: &[u8]) -> Result<()> { + pace(self.last_write); + let n = self + .handle + .write_bulk(0x02, bytes, Duration::from_millis(500))?; + ensure!(n == bytes.len(), "partial USB write: {n}/{}", bytes.len()); + self.last_write = Some(Instant::now()); + Ok(()) + } + fn read(&mut self, bytes: &mut [u8]) -> Result { + match self + .handle + .read_bulk(0x82, bytes, Duration::from_millis(50)) + { + Ok(n) => Ok(n), + Err(rusb::Error::Timeout) => Ok(0), + Err(e) => Err(e.into()), + } + } + fn label(&self) -> &str { + &self.name + } +} + +fn pace(last_write: Option) { + // Match the proven helper's inter-command gap. In particular, a keepalive + // and query must not reach the MCU back-to-back at cold startup. + if let Some(last) = last_write { + let gap = Duration::from_millis(150); + if last.elapsed() < gap { + std::thread::sleep(gap.saturating_sub(last.elapsed())); + } + } +} + +fn matches_id(path: &Path, vendor: &str, product: &str) -> bool { + fs::read_to_string(path.join("idVendor")) + .unwrap_or_default() + .trim() + == vendor + && fs::read_to_string(path.join("idProduct")) + .unwrap_or_default() + .trim() + == product +} + +pub fn is_monitor_spi(path: &Path) -> bool { + let path = if path.starts_with("/sys") { + path.to_path_buf() + } else { + Path::new("/sys").join(path.strip_prefix("/").unwrap_or(path)) + }; + let Ok(interface) = path.canonicalize() else { + return false; + }; + let Some(device) = interface.parent() else { + return false; + }; + let Some(hub) = device.parent() else { + return false; + }; + matches_id(device, "1a86", "5512") + && matches_id(hub, "1a40", "0101") + && fs::read_dir(hub).is_ok_and(|entries| { + entries + .filter_map(Result::ok) + .any(|e| matches_id(&e.path(), "1a86", "7523")) + }) +} + +pub fn usb_candidates(config: &Config) -> Result> { + let mut candidates = Vec::new(); + for entry in fs::read_dir("/sys/bus/usb/devices")? { + let path = entry?.path(); + if !matches_id(&path, "1a86", "7523") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + if config + .usb_path + .as_ref() + .is_some_and(|wanted| wanted != &name) + { + continue; + } + if config.require_companion { + let actual = path.canonicalize()?; + let Some(parent) = actual.parent() else { + continue; + }; + // The monitor contains an SPI bridge and UART under the same hub. + // This avoids claiming an unrelated generic CH340 serial adapter. + let sibling = fs::read_dir(parent)? + .filter_map(Result::ok) + .any(|p| matches_id(&p.path(), "1a86", "5512")); + if !sibling { + continue; + } + } + let bus = fs::read_to_string(path.join("busnum"))?.trim().parse()?; + let address = fs::read_to_string(path.join("devnum"))?.trim().parse()?; + candidates.push((bus, address, name)); + } + Ok(candidates) +} + +fn usb_open(config: &Config) -> Result> { + let candidates = usb_candidates(config)?; + ensure!( + candidates.len() == 1, + "expected one matching monitor UART; found {} (set usb_path to disambiguate)", + candidates.len() + ); + let (bus, address, name) = &candidates[0]; + let devices = rusb::devices()?; + let spi_guard = if config.require_companion { + let uart_path = Path::new("/sys/bus/usb/devices") + .join(name) + .canonicalize()?; + let hub = uart_path.parent().context("UART hub absent")?; + let path = fs::read_dir(hub)? + .filter_map(Result::ok) + .map(|e| e.path()) + .find(|p| matches_id(p, "1a86", "5512")) + .context("SPI companion disappeared")?; + let spi_bus: u8 = fs::read_to_string(path.join("busnum"))?.trim().parse()?; + let spi_address: u8 = fs::read_to_string(path.join("devnum"))?.trim().parse()?; + let dev = devices + .iter() + .find(|d| d.bus_number() == spi_bus && d.address() == spi_address) + .context("SPI companion disappeared from USB enumeration")?; + let descriptor = dev.device_descriptor()?; + ensure!( + descriptor.vendor_id() == 0x1a86 && descriptor.product_id() == 0x5512, + "SPI companion identity changed" + ); + let guard = dev.open()?; + ensure!( + !guard.kernel_driver_active(0)?, + "monitor SPI interface has a kernel driver; install the scoped udev rule, unbind/unload spi_ch341 when unused, and power-cycle the monitor" + ); + guard.claim_interface(0)?; + Some(guard) + } else { + None + }; + let device = devices + .iter() + .find(|d| d.bus_number() == *bus && d.address() == *address) + .context("UART disappeared during enumeration")?; + let descriptor = device.device_descriptor()?; + ensure!( + descriptor.vendor_id() == 0x1a86 && descriptor.product_id() == 0x7523, + "USB device changed during enumeration" + ); + let handle = device.open()?; + handle.set_auto_detach_kernel_driver(true)?; + if handle.active_configuration()? != 1 { + handle.set_active_configuration(1)?; + } + handle.claim_interface(0)?; + // Rebinding the kernel ch341 driver is unreliable on the affected host. + // Physical reconnection restores normal kernel probing after daemon exit. + handle.set_auto_detach_kernel_driver(false)?; + let cfg = device.active_config_descriptor()?; + let iface = cfg + .interfaces() + .find(|i| i.number() == 0) + .context("missing CH340 interface 0")?; + let endpoints: Vec<_> = iface + .descriptors() + .flat_map(|i| { + i.endpoint_descriptors() + .map(|e| e.address()) + .collect::>() + }) + .collect(); + ensure!( + endpoints.contains(&0x02) && endpoints.contains(&0x82), + "unexpected UART endpoints" + ); + // Same 115200 8N1 sequence used by the confirmed Python helper. No SPI I/O. + for (request, value, index) in [ + (0xa1, 0, 0), + (0x9a, 0x1312, 0xcc03), + (0x9a, 0x2518, 0x00c3), + (0xa4, 0x00ff, 0), + ] { + handle.write_control(0x40, request, value, index, &[], Duration::from_secs(1))?; + } + Ok(Box::new(Usb { + handle, + _spi_guard: spi_guard, + last_write: None, + name: format!("usb:{name} ({bus:03}/{address:03})"), + })) +} + +struct Serial { + file: File, + name: String, + last_write: Option, +} +impl Drop for Serial { + fn drop(&mut self) { + // SAFETY: release only the exclusive flag on our still-open tty. + unsafe { + libc::ioctl(self.file.as_raw_fd(), libc::TIOCNXCL); + } + } +} +impl Transport for Serial { + fn write(&mut self, bytes: &[u8]) -> Result<()> { + pace(self.last_write); + self.file.write_all(bytes)?; + self.last_write = Some(Instant::now()); + Ok(()) + } + fn read(&mut self, bytes: &mut [u8]) -> Result { + let mut poll = libc::pollfd { + fd: self.file.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll points to one initialized pollfd for a live descriptor. + let n = unsafe { libc::poll(&mut poll, 1, 50) }; + if n < 0 { + return Err(std::io::Error::last_os_error().into()); + } + if poll.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { + bail!("serial device disconnected"); + } + if n == 0 { + return Ok(0); + } + Ok(self.file.read(bytes)?) + } + fn label(&self) -> &str { + &self.name + } +} + +pub fn open(config: &Config) -> Result> { + if config.transport == "usb" { + return usb_open(config); + } + let path = config + .serial_device + .as_ref() + .context("missing serial_device")?; + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK) + .open(path)?; + let fd = file.as_raw_fd(); + // SAFETY: termios is initialized by tcgetattr; all operations use a live fd. + unsafe { + let mut attrs = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut attrs) != 0 { + return Err(std::io::Error::last_os_error().into()); + } + libc::cfmakeraw(&mut attrs); + libc::cfsetispeed(&mut attrs, libc::B115200); + libc::cfsetospeed(&mut attrs, libc::B115200); + attrs.c_cflag = (attrs.c_cflag + & !(libc::CRTSCTS | libc::CSTOPB | libc::PARENB | libc::CSIZE)) + | libc::CS8 + | libc::CLOCAL + | libc::CREAD; + if libc::tcsetattr(fd, libc::TCSANOW, &attrs) != 0 || libc::ioctl(fd, libc::TIOCEXCL) != 0 { + return Err(std::io::Error::last_os_error().into()); + } + } + Ok(Box::new(Serial { + file, + last_write: None, + name: format!("serial:{}", path.display()), + })) +} diff --git a/rust/dasungd/tests/pty_smoke.py b/rust/dasungd/tests/pty_smoke.py new file mode 100644 index 0000000..503c323 --- /dev/null +++ b/rust/dasungd/tests/pty_smoke.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Exercise the real daemon/IPC/reconnect/persistence using a fake serial monitor. + +No root access, physical display changes, USB claims or Python runtime dependency +in the shipped daemon. Run after cargo build --release. +""" +import errno +import json +import os +from pathlib import Path +import pty +import select +import signal +import socket +import subprocess +import tempfile +import threading +import time + +ROOT = Path(__file__).resolve().parents[1] +BINARY = Path(os.environ.get("DASUNGD_BIN", ROOT.parents[1] / "target/x86_64-unknown-linux-gnu/release/dasungd")) + + +class Monitor: + def __init__(self, link): + self.master, slave = pty.openpty() + self.values = {1: 4, 2: 1, 7: 2, 8: 0, 9: 40, 0x10: 0x31, 0x13: 2} + self.keepalives = 0 + self.running = True + self.respond = True + link.unlink(missing_ok=True) + link.symlink_to(os.ttyname(slave)) + os.close(slave) + self.thread = threading.Thread(target=self.loop) + self.thread.start() + + def loop(self): + buffer = b"" + while self.running: + try: + if not select.select([self.master], [], [], 0.1)[0]: + continue + data = os.read(self.master, 4096) + buffer += data + while b"5FF5" in buffer and len(buffer) >= 24: + buffer = buffer[buffer.index(b"5FF5"):] + if len(buffer) < 24: + break + frame, buffer = buffer[:24], buffer[24:] + if frame[-4:] != b"A0FA": + continue + cmd, opt = int(frame[4:6], 16), int(frame[6:8], 16) + if cmd == 0x20: + self.keepalives += opt == 1 + elif cmd == 0x0A and self.respond: + reply = f"5FF5F00A{opt:02X}{self.values.get(opt,0):02X}000000A0FA".encode() + # Fragment actual 22-byte query responses across reads. + os.write(self.master, reply[:9]) + time.sleep(0.005) + os.write(self.master, reply[9:]) + elif cmd in self.values: + self.values[cmd] = opt + if self.respond: + os.write(self.master, frame) + except OSError as exc: + if exc.errno in (errno.EIO, errno.EBADF): + time.sleep(0.05) + else: + raise + + def close(self): + self.running = False + self.thread.join(timeout=2) + os.close(self.master) + + +def wait_for(predicate, timeout=12): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + value = predicate() + if value: + return value + except (FileNotFoundError, ConnectionRefusedError, json.JSONDecodeError): + pass + time.sleep(0.1) + raise AssertionError("condition did not become true") + + +with tempfile.TemporaryDirectory(prefix="dasungd-smoke-") as directory: + d = Path(directory) + sock = d / "control.sock" + link = d / "uart" + cfg = d / "config.toml" + cfg.write_text(f'''monitor_serial = "TEST-NOT-A-PHYSICAL-MONITOR" +socket = "{sock}" +state_file = "{d / 'state.json'}" +transport = "serial" +serial_device = "{link}" +require_display = false +keepalive_ms = 500 +reconnect_ms = 500 +watchdog_ms = 20000 +[display] +enabled = false +''') + + def request(value): + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(5) + connection.connect(str(sock)) + connection.sendall(json.dumps(value).encode() + b"\n") + with connection.makefile("r") as reader: + return json.loads(reader.readline()) + + def status(): + return request({"op": "status"})["status"] + + log = (d / "daemon.log").open("w+") + process = None + monitor = None + try: + # Start with the monitor absent, then plug it in. + process = subprocess.Popen([str(BINARY), "--config", str(cfg), "daemon"], stderr=log) + wait_for(lambda: sock.exists()) + assert not status()["connected"] + monitor = Monitor(link) + wait_for(lambda: status()["parameters"].get("0x02") == 1) + wait_for(lambda: monitor.keepalives >= 2) + assert not request({"op": "set", "parameter": "mode", "value": 255})["ok"] + assert request({"op": "set", "parameter": "contrast", "value": 5, "save": True})["ok"] + wait_for(lambda: monitor.values[1] == 5) + # USB/serial unplug, node replacement, automatic reinitialization. + monitor.close() + monitor = None + wait_for(lambda: not status()["connected"]) + monitor = Monitor(link) + wait_for(lambda: monitor.values[1] == 5) + assert status()["reconnects"] >= 2 + wait_for(lambda: monitor.keepalives >= 2) + # Only one daemon can own a given socket; the first remains reachable. + duplicate = subprocess.run([str(BINARY), "--config", str(cfg), "daemon"], capture_output=True) + assert duplicate.returncode != 0 + assert status()["connected"] + # A control board that stops replying gets reopened by the watchdog. + old_reconnects = status()["reconnects"] + monitor.respond = False + wait_for(lambda: status()["reconnects"] > old_reconnects, timeout=25) + monitor.respond = True + wait_for(lambda: status()["parameters"].get("0x02") == 1) + # Restart the process and verify saved settings survive. + process.send_signal(signal.SIGTERM) + assert process.wait(timeout=5) == 0 + monitor.values[1] = 4 + process = subprocess.Popen([str(BINARY), "--config", str(cfg), "daemon"], stderr=log) + wait_for(lambda: monitor.values[1] == 5) + assert request({"op": "forget", "parameter": "contrast"})["ok"] + assert json.loads((d / "state.json").read_text()) == {} + print("PASS: late attach, keepalive, fragmented replies, IPC validation, saved settings, unplug/replug, duplicate exclusion, reply watchdog, restart and forget") + finally: + if process is not None and process.poll() is None: + process.terminate() + process.wait(timeout=5) + if monitor is not None: + monitor.close() + log.flush() + log.seek(0) + print(log.read()) diff --git a/rust/fds-boottrace/.gitkeep b/rust/fds-boottrace/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rust/fds-boottrace/Cargo.toml b/rust/fds-boottrace/Cargo.toml new file mode 100644 index 0000000..827d7d1 --- /dev/null +++ b/rust/fds-boottrace/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fds-boottrace" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Measured FDS boot events and regression reports" + +[dependencies] +clap.workspace = true +fds-common = { path = "../fds-common" } +serde_json = "1" +libc = "0.2" diff --git a/rust/fds-boottrace/src/main.rs b/rust/fds-boottrace/src/main.rs new file mode 100644 index 0000000..83d641b --- /dev/null +++ b/rust/fds-boottrace/src/main.rs @@ -0,0 +1,232 @@ +use clap::{CommandFactory, Parser, Subcommand}; +use fds_common::{ + Error, Result, read_text, + trace::{self, Point, Report}, +}; +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::ExitCode, +}; + +fn root() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + return Err(Error("This boot event requires root".into())); + } + Ok(()) +} +fn clock_floor() -> Result<()> { + root()?; + let epoch: i64 = read_text(Path::new("/usr/share/fds/build-epoch"), 32)? + .trim() + .parse() + .map_err(|_| Error("Invalid image clock floor".into()))?; + if !(1..=4_102_444_800).contains(&epoch) { + return Err(Error( + "Image clock floor is outside the supported range".into(), + )); + } + let mut before: libc::timespec = unsafe { std::mem::zeroed() }; + if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut before) } < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let advance = before.tv_sec < epoch; + if advance { + let floor = libc::timespec { + tv_sec: epoch, + tv_nsec: 0, + }; + if unsafe { libc::clock_settime(libc::CLOCK_REALTIME, &floor) } < 0 { + return Err(std::io::Error::last_os_error().into()); + } + } + fs::create_dir_all("/run/fds")?; + let status = serde_json::json!({"format":1, "source":if advance {"image_floor"} else {"retained_kernel_clock"}, + "image_epoch":epoch, "previous_unix_seconds":before.tv_sec, "minimum_unix_seconds":before.tv_sec.max(epoch)}); + fs::write( + "/run/fds/clock.json", + serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?, + )?; + Ok(()) +} +fn adopt() -> Result<()> { + root()?; + let instant = trace::now()?; + let directory = Path::new(trace::RUNTIME); + fs::create_dir_all(directory.join("console"))?; + fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?; + fs::set_permissions(directory.join("console"), fs::Permissions::from_mode(0o755))?; + let path = std::ffi::CString::new(directory.join("console").to_str().unwrap()).unwrap(); + if unsafe { libc::chown(path.as_ptr(), 1000, 1000) } < 0 { + return Err(std::io::Error::last_os_error().into()); + } + for point in Point::ALL { + let source = Path::new(trace::EARLY).join(format!("{}.json", point.name())); + if source.exists() { + fs::rename(&source, directory.join(source.file_name().unwrap())).or_else(|error| { + if error.raw_os_error() != Some(libc::EXDEV) { + return Err(error); + } + fs::copy(&source, directory.join(source.file_name().unwrap()))?; + fs::remove_file(source) + })?; + } + } + trace::save(directory, Point::S6Start, instant)?; + // Preserve a valid kernel/RTC clock. A machine without an RTC must still + // create files newer than immutable image inputs; no time server is awaited. + clock_floor() +} +#[derive(Parser)] +#[command( + version, + about = "Record and compare measured boot events", + after_help = "Measurements use Linux CLOCK_BOOTTIME, excluding firmware and power-on." +)] +struct Cli { + #[command(subcommand)] + command: Option, +} +#[derive(Subcommand)] +enum Action { + /// Import stage0 events and record native s6 startup (root only). + Adopt, + /// Advance an older clock to the image timestamp (root only). + ClockFloor, + /// Record a named boot event. + Mark { + #[arg(value_parser = Point::parse)] + event: Point, + }, + /// Show this boot's events and durations. + Report { + #[arg(long)] + json: bool, + }, + /// Compare reports; a regression over 100 ms requires an explanation. + Compare { + baseline: PathBuf, + current: PathBuf, + #[arg(long, value_parser = explanation)] + explain: Option, + }, +} +fn explanation(value: &str) -> std::result::Result { + if value.trim().is_empty() { + Err("An explanation must not be blank".into()) + } else { + Ok(value.into()) + } +} +fn run() -> Result<()> { + match Cli::parse().command { + None => { + Cli::command().print_help()?; + println!(); + } + Some(Action::Adopt) => adopt()?, + Some(Action::ClockFloor) => clock_floor()?, + Some(Action::Mark { event: point }) => { + let directory = if point == Point::ConsoleReady { + let uid = unsafe { libc::geteuid() }; + if uid != 0 && uid != 1000 { + return Err(Error("Console readiness requires the FDS user".into())); + } + Path::new(trace::RUNTIME).join("console") + } else { + root()?; + Path::new(trace::RUNTIME).to_owned() + }; + trace::save(&directory, point, trace::now()?)?; + } + Some(Action::Report { json }) => trace::load(Path::new(trace::RUNTIME))?.print(json)?, + Some(Action::Compare { + baseline, + current, + explain, + }) => compare(&baseline, ¤t, explain.as_deref())?, + } + Ok(()) +} +fn compare(baseline: &Path, current: &Path, explanation: Option<&str>) -> Result<()> { + let load = |path| -> Result { + serde_json::from_str(&read_text(path, 65536)?).map_err(|e| Error(e.to_string())) + }; + let (before, after) = (load(baseline)?, load(current)?); + if before.format != 1 + || after.format != 1 + || before.clock != after.clock + || before.platform != after.platform + { + return Err(Error( + "Compare reports from the same platform and clock".into(), + )); + } + let elapsed = |report: &Report| { + report + .durations_ns + .get("kernel-to-console") + .copied() + .ok_or_else(|| Error("Console readiness was not recorded".into())) + }; + let delta = i128::from(elapsed(&after)?) - i128::from(elapsed(&before)?); + println!( + "KERNEL TO CONSOLE CHANGE: {:+.3} ms", + delta as f64 / 1_000_000.0 + ); + if let Some(reason) = explanation { + println!("EXPLANATION: {reason}"); + } + if delta > 100_000_000 && explanation.is_none() { + return Err(Error( + "Regression exceeds 100 ms; fix it or record --explain REASON".into(), + )); + } + Ok(()) +} +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("fds-boottrace: {error}"); + ExitCode::from(2) + } + } +} + +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::{CommandFactory, Parser}; + #[test] + fn typed_command_contract() { + Cli::command().debug_assert(); + assert!(Cli::try_parse_from(["fds-boottrace", "mark", "console-ready"]).is_ok()); + assert!(Cli::try_parse_from(["fds-boottrace", "mark", "unknown"]).is_err()); + assert!(Cli::try_parse_from(["fds-boottrace", "report", "--json"]).is_ok()); + assert!( + Cli::try_parse_from([ + "fds-boottrace", + "compare", + "before", + "after", + "--explain", + "measured change" + ]) + .is_ok() + ); + assert!( + Cli::try_parse_from([ + "fds-boottrace", + "compare", + "before", + "after", + "--explain", + " " + ]) + .is_err() + ); + assert!(Cli::try_parse_from(["fds-boottrace", "clock-floor", "--json"]).is_err()); + } +} diff --git a/rust/fds-burn/.gitkeep b/rust/fds-burn/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rust/fds-burn/Cargo.toml b/rust/fds-burn/Cargo.toml new file mode 100644 index 0000000..a045146 --- /dev/null +++ b/rust/fds-burn/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fds-burn" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Verified cartridge images and protected FDS media writes" + +[dependencies] +clap.workspace = true +fds-common = { path = "../fds-common" } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "=0.10.9" + diff --git a/rust/fds-burn/src/cli.rs b/rust/fds-burn/src/cli.rs new file mode 100644 index 0000000..9b90b03 --- /dev/null +++ b/rust/fds-burn/src/cli.rs @@ -0,0 +1,215 @@ +//! Typed command grammar shared by `fds burn`, `fds format`, and `fds-burn`. +use clap::{Args, Subcommand}; +use fds_common::Bay; +use std::path::PathBuf; + +#[derive(Debug, Subcommand)] +pub enum BurnCommand { + /// Write a prepared SYSTEM image after confirmation. + System(ImageArgs), + /// Write a prepared PROGRAM image after confirmation. + Program(ImageArgs), + /// Write IMAGE BAY, or create DATA with BAY [--label NAME] [--size-mib N]. + Data(DataWrite), + /// Write IMAGE BAY, or create ENVIRONMENT with BAY [--profile NAME]. + Environment(EnvironmentWrite), + /// Show a media operation's current state. + Status { id: String }, + /// Wait for a media operation to finish. + Wait { id: String }, + /// Confirm the exact phrase returned by a write preview. + Confirm { id: String, confirmation: String }, + /// Cancel a media operation. + Cancel { id: String }, +} + +#[derive(Debug, Args)] +pub struct ImageArgs { + pub image: PathBuf, + #[arg(value_parser = crate::client::bay)] + pub bay: Bay, +} + +#[derive(Debug, Args)] +pub struct DataWrite { + /// Prepared image path, or a bay number to create a new DATA filesystem. + #[arg(value_name = "IMAGE_OR_BAY")] + pub source: PathBuf, + #[arg(value_parser = crate::client::bay, conflicts_with_all = ["label", "id", "size_mib"])] + pub bay: Option, + #[command(flatten)] + pub options: DataOptions, +} + +#[derive(Debug, Args)] +pub struct EnvironmentWrite { + /// Prepared image path, or a bay number to create a new ENVIRONMENT. + #[arg(value_name = "IMAGE_OR_BAY")] + pub source: PathBuf, + #[arg(value_parser = crate::client::bay, conflicts_with_all = ["label", "id", "profile"])] + pub bay: Option, + #[command(flatten)] + pub options: EnvironmentOptions, +} + +#[derive(Debug, Default, Args)] +pub struct MetadataOptions { + /// Human-readable cartridge name. + #[arg(long, value_name = "NAME")] + pub label: Option, + /// Cartridge identifier (generated when omitted). + #[arg(long)] + pub id: Option, +} + +#[derive(Debug, Args)] +pub struct DataOptions { + #[command(flatten)] + pub metadata: MetadataOptions, + /// Filesystem size in MiB (otherwise fill the target, leaving GPT space). + #[arg(long, value_name = "N")] + pub size_mib: Option, +} + +#[derive(Debug, Args)] +pub struct EnvironmentOptions { + #[command(flatten)] + pub metadata: MetadataOptions, + /// Activation profile (defaults to windowmaker). + #[arg(long, value_name = "NAME")] + pub profile: Option, +} + +#[derive(Debug, Args)] +pub struct DataFormat { + #[arg(value_name = "BAY", value_parser = crate::client::bay)] + pub target: Bay, + #[command(flatten)] + pub options: DataOptions, +} + +#[derive(Debug, Args)] +pub struct EnvironmentFormat { + #[arg(value_name = "BAY", value_parser = crate::client::bay)] + pub target: Bay, + #[command(flatten)] + pub options: EnvironmentOptions, +} + +#[derive(Debug, Subcommand)] +pub enum FormatCommand { + /// Create DATA and preview a confirmed cartridge write. + Data(DataFormat), + /// Create an ENVIRONMENT descriptor and preview its write. + Environment(EnvironmentFormat), + /// Package an application directory containing bin/ and preview its write. + Program { + #[arg(value_name = "APP_DIRECTORY")] + source: PathBuf, + #[arg(value_name = "BAY", value_parser = crate::client::bay)] + target: Bay, + #[command(flatten)] + metadata: MetadataOptions, + }, + /// Package a prepared SYSTEM root and preview its write. + System { + #[arg(value_name = "ROOT_DIRECTORY")] + source: PathBuf, + #[arg(value_name = "BAY", value_parser = crate::client::bay)] + target: Bay, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{CommandFactory, Parser}; + #[derive(Debug, Parser)] + struct Burn { + #[command(subcommand)] + command: BurnCommand, + } + #[derive(Debug, Parser)] + struct Format { + #[command(subcommand)] + command: FormatCommand, + } + + #[test] + fn prepared_images_and_format_shorthand_have_distinct_options() { + Burn::command().debug_assert(); + Format::command().debug_assert(); + let parsed = Burn::try_parse_from([ + "burn", + "data", + "BAY2", + "--label", + "My data", + "--id", + "user.data", + "--size-mib", + "64", + ]) + .unwrap(); + let BurnCommand::Data(args) = parsed.command else { + panic!("DATA") + }; + assert!(args.bay.is_none()); + assert_eq!(args.options.size_mib, Some(64)); + assert_eq!(args.options.metadata.label.as_deref(), Some("My data")); + for class in ["data", "environment", "system", "program"] { + assert!(Burn::try_parse_from(["burn", class, "card.img", "12"]).is_ok()); + assert!(Burn::try_parse_from(["burn", class, "card.img", "13"]).is_err()); + assert!( + Burn::try_parse_from(["burn", class, "card.img", "1", "--label", "name"]).is_err() + ); + } + assert!(Burn::try_parse_from(["burn", "confirm", "id", "phrase with spaces"]).is_ok()); + assert!(Burn::try_parse_from(["burn", "confirm", "id"]).is_err()); + assert!(Burn::try_parse_from(["burn", "data", "1", "--size-mib", "bad"]).is_err()); + assert!( + Burn::try_parse_from(["burn", "data", "1", "--label", "one", "--label", "two"]) + .is_err() + ); + } + + #[test] + fn format_rejects_unknown_duplicate_and_inapplicable_options() { + for arguments in [ + vec![ + "format", + "data", + "BAY12", + "--label", + "DATA", + "--id", + "test.data", + ], + vec![ + "format", + "environment", + "2", + "--profile", + "windowmaker", + "--label", + "GUI", + ], + vec!["format", "program", "app", "3", "--label", "Editor"], + vec!["format", "system", "root", "4"], + ] { + assert!(Format::try_parse_from(&arguments).is_ok(), "{arguments:?}"); + } + for arguments in [ + vec!["format", "data", "1", "--profile", "windowmaker"], + vec!["format", "data", "1", "--size-mib", "-1"], + vec!["format", "data", "1", "--label", "a", "--label", "b"], + vec!["format", "data", "1", "--force"], + vec!["format", "environment", "1", "--size-mib", "32"], + vec!["format", "program", "app", "1", "--profile", "cli"], + vec!["format", "system", "root", "1", "--label", "SYS"], + vec!["format", "system", "root"], + ] { + assert!(Format::try_parse_from(&arguments).is_err(), "{arguments:?}"); + } + } +} diff --git a/rust/fds-burn/src/client.rs b/rust/fds-burn/src/client.rs new file mode 100644 index 0000000..dda84bf --- /dev/null +++ b/rust/fds-burn/src/client.rs @@ -0,0 +1,326 @@ +use crate::{ + cli::{BurnCommand, DataFormat, EnvironmentFormat, FormatCommand, MetadataOptions}, + create, image, +}; +use fds_common::{ + Bay, Error, Result, + control::{self, MediaJob, Request}, + manifest::{Activation, Cartridge, Class, Manifest, Media}, +}; +use std::{ + fs, + io::{self, IsTerminal, Write}, + path::Path, + process::{Command, Stdio}, +}; +fn job(request: Request) -> Result { + control::request(&request)? + .media_job + .ok_or_else(|| Error("Missing media operation response".into())) +} +pub fn bay(value: &str) -> Result { + value.strip_prefix("BAY").unwrap_or(value).parse() +} +fn print(job: &MediaJob, json: bool) -> Result<()> { + if json { + println!( + "{}", + serde_json::to_string_pretty(job).map_err(|e| Error(e.to_string()))? + ); + return Ok(()); + } + println!( + "BAY {} {} {} bytes\nOPERATION {} {}", + job.bay, + job.model, + job.target_bytes, + job.id, + job.phase.to_uppercase().replace('_', " ") + ); + if let Some(bytes) = job.image_bytes { + println!("IMAGE {} {bytes} bytes", job.image_class.label()); + } + if let Some(hash) = &job.image_sha256 { + println!("SHA256 {hash}"); + } + if let Some(serial) = &job.serial { + println!("SERIAL {serial}"); + } + println!("INSERTION {}", job.diskseq); + if let Some(error) = &job.error { + println!("ERROR {error}"); + } + if job.phase == "complete" { + println!("VERIFIED — SAFE TO REMOVE"); + } + Ok(()) +} +fn wait(mut current: MediaJob, until_ready: bool) -> Result { + while !current.finished() && !(until_ready && current.phase == "awaiting_confirmation") { + current = job(Request::MediaStatus { + id: current.id.clone(), + after_sequence: Some(current.sequence), + })?; + } + Ok(current) +} +fn result(current: MediaJob, json: bool) -> Result<()> { + print(¤t, json)?; + if let Some(error) = current.error { + return Err(Error(error)); + } + Ok(()) +} +pub fn burn(command: BurnCommand, json: bool) -> Result<()> { + match command { + BurnCommand::Status { id } => result( + job(Request::MediaStatus { + id, + after_sequence: None, + })?, + json, + ), + BurnCommand::Wait { id } => result( + wait( + job(Request::MediaStatus { + id, + after_sequence: None, + })?, + false, + )?, + json, + ), + BurnCommand::Confirm { id, confirmation } => result( + wait(job(Request::MediaConfirm { id, confirmation })?, false)?, + json, + ), + BurnCommand::Cancel { id } => result(wait(job(Request::MediaCancel { id })?, false)?, json), + BurnCommand::System(args) => prepare(Class::System, &args.image, args.bay, json), + BurnCommand::Program(args) => prepare(Class::Program, &args.image, args.bay, json), + BurnCommand::Data(args) => match args.bay { + Some(target) => prepare(Class::Data, &args.source, target, json), + None => format( + FormatCommand::Data(DataFormat { + target: format_target(&args.source)?, + options: args.options, + }), + json, + ), + }, + BurnCommand::Environment(args) => match args.bay { + Some(target) => prepare(Class::Environment, &args.source, target, json), + None => format( + FormatCommand::Environment(EnvironmentFormat { + target: format_target(&args.source)?, + options: args.options, + }), + json, + ), + }, + } +} +fn format_target(source: &Path) -> Result { + source.to_str().and_then(|value| bay(value).ok()).ok_or_else(|| + Error("Supply IMAGE BAY to write an image, or BAY with format options to create a cartridge".into())) +} +pub fn prepare(class: Class, path: &Path, target: Bay, json: bool) -> Result<()> { + let image = fs::canonicalize(path)? + .to_str() + .ok_or_else(|| Error("Image path must be UTF-8".into()))? + .to_owned(); + let current = wait( + job(Request::MediaPrepare { + bay: target, + image, + class, + })?, + true, + )?; + print(¤t, json)?; + if let Some(error) = ¤t.error { + return Err(Error(error.clone())); + } + if current.phase != "awaiting_confirmation" { + return Err(Error("Media operation did not reach confirmation".into())); + } + let phrase = current + .confirmation + .as_deref() + .ok_or_else(|| Error("Missing confirmation phrase".into()))?; + if json { + return Ok(()); + } + println!("This erases the entire selected cartridge.\nType exactly: {phrase}"); + if !io::stdin().is_terminal() { + println!( + "To proceed: fds burn confirm {} '{phrase}'\nTo cancel: fds burn cancel {}", + current.id, current.id + ); + return Ok(()); + } + print!("> "); + io::stdout().flush()?; + let mut reply = String::new(); + io::stdin().read_line(&mut reply)?; + if reply.trim_end() != phrase { + let _ = job(Request::MediaCancel { id: current.id }); + return Err(Error("Cancelled before writing".into())); + } + result( + wait( + job(Request::MediaConfirm { + id: current.id, + confirmation: phrase.into(), + })?, + false, + )?, + false, + ) +} +pub fn inspect_bay(target: Bay, json: bool) -> Result<()> { + let disk = control::request(&Request::Disk { bay: target })? + .disk + .ok_or_else(|| Error("Missing disk inspection".into()))?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&disk).map_err(|e| Error(e.to_string()))? + ); + } else { + println!( + "BAY {} {}\nCAPACITY {} bytes\nSECTOR {} bytes\nINSERTION {}", + disk.bay, disk.model, disk.bytes, disk.sector_bytes, disk.diskseq + ); + if let Some(serial) = disk.serial { + println!("SERIAL {serial}"); + } + println!( + "{}", + disk.protected.map_or_else( + || "AVAILABLE FOR CONFIRMED WRITE".into(), + |reason| format!("PROTECTED: {reason}") + ) + ); + } + Ok(()) +} +/// Prepare a user-owned filesystem tree; never run source scripts or use shell +/// interpolation. Explicit confirmation follows creation and privileged preview. +pub fn format(command: FormatCommand, json: bool) -> Result<()> { + let (class, source, target, metadata, profile, size) = match command { + FormatCommand::Data(args) => ( + Class::Data, + None, + args.target, + args.options.metadata, + None, + args.options.size_mib, + ), + FormatCommand::Environment(args) => ( + Class::Environment, + None, + args.target, + args.options.metadata, + args.options.profile, + None, + ), + FormatCommand::Program { + source, + target, + metadata, + } => (Class::Program, Some(source), target, metadata, None, None), + FormatCommand::System { source, target } => ( + Class::System, + Some(source), + target, + MetadataOptions::default(), + None, + None, + ), + }; + let disk = control::request(&Request::Disk { bay: target })? + .disk + .ok_or_else(|| Error("Missing target geometry".into()))?; + if let Some(reason) = disk.protected { + return Err(Error(reason)); + } + let label = metadata + .label + .unwrap_or_else(|| format!("FDS {}", class.label())); + let profile = profile.unwrap_or_else(|| "windowmaker".into()); + let id = match metadata.id { + Some(id) => id, + None => format!( + "fds.{}.{}", + class.label().to_ascii_lowercase(), + image::hex(&image::random_id()?) + ), + }; + let manifest = Manifest { + format: 1, + cartridge: Cartridge { + id, + name: label, + class, + version: fds_common::VERSION.into(), + }, + media: Media { + writable: class == Class::Data, + }, + activation: if class == Class::Environment { + Some(Activation { profile }) + } else { + None + }, + }; + let text = manifest.to_toml()?; + let parent = std::env::current_dir()?; + let work = create::Work::new(&parent)?; + let tree = work.0.join("source"); + fs::create_dir(&tree)?; + if class != Class::System { + fs::create_dir(tree.join("FDS"))?; + fs::write(tree.join("FDS/CARTRIDGE.TOML"), text)?; + } + if class == Class::Program { + let source = fs::canonicalize(source.as_ref().unwrap())?; + if !source.is_dir() || !source.join("bin").is_dir() { + return Err(Error( + "PROGRAM input must contain bin, with optional lib and share".into(), + )); + } + let status = Command::new("/usr/bin/cp") + .args(["-a", "--no-preserve=ownership", "--"]) + .arg(source) + .arg(tree.join("app")) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + if !status.success() { + return Err(Error("Copying PROGRAM input failed".into())); + } + } + let source = if class == Class::System { + source.as_ref().unwrap().as_path() + } else { + tree.as_path() + }; + let size = if class == Class::Data { + Some( + size.unwrap_or( + (disk.bytes / (1024 * 1024)) + .checked_sub(2) + .filter(|mib| *mib >= 32) + .ok_or_else(|| { + Error("DATA target must hold at least a 32 MiB filesystem plus GPT".into()) + })?, + ), + ) + } else { + None + }; + let output = work.0.join("cartridge.img"); + create::create(class, source, &output, size)?; + prepare(class, &output, target, json) +} diff --git a/rust/fds-burn/src/create.rs b/rust/fds-burn/src/create.rs new file mode 100644 index 0000000..0b1da03 --- /dev/null +++ b/rust/fds-burn/src/create.rs @@ -0,0 +1,231 @@ +//! Filesystem utilities run only against private regular staging files. Creating +//! an image does not open a disk; writing a cartridge has a separate confirmation. +use crate::image::{self, Image, Layout}; +use fds_common::{ + Error, Result, + manifest::{Class, Manifest}, +}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + os::{ + fd::{AsRawFd, BorrowedFd, FromRawFd}, + unix::fs::{OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; +pub(crate) struct Work(pub PathBuf); +impl Work { + pub(crate) fn new(parent: &Path) -> Result { + let path = parent.join(format!(".fds-image-{}", image::hex(&image::random_id()?))); + let mut builder = fs::DirBuilder::new(); + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700).create(&path)?; + Ok(Self(path)) + } +} +impl Drop for Work { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn command(program: &str, args: &[&str]) -> Result<()> { + let result = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::from( + unsafe { BorrowedFd::borrow_raw(2) }.try_clone_to_owned()?, + )) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| Error(format!("Run {program}: {e}")))?; + if !result.success() { + return Err(Error(format!("{program} failed: {result}"))); + } + Ok(()) +} +fn text(path: &Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| Error("Image paths must be UTF-8".into())) +} +pub fn class(value: &str) -> Result { + match value { + "system" => Ok(Class::System), + "data" => Ok(Class::Data), + "program" => Ok(Class::Program), + "environment" => Ok(Class::Environment), + _ => Err(Error( + "Expected system, data, program or environment".into(), + )), + } +} +pub fn tree_manifest(tree: &Path) -> Result { + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(tree)?; + let dir = unsafe { + libc::openat( + root.as_raw_fd(), + c"FDS".as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if dir < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let dir = unsafe { File::from_raw_fd(dir) }; + let fd = unsafe { + libc::openat( + dir.as_raw_fd(), + c"CARTRIDGE.TOML".as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let file = unsafe { File::from_raw_fd(fd) }; + if !file.metadata()?.is_file() { + return Err(Error("CARTRIDGE.TOML must be a regular file".into())); + } + let mut contents = String::new(); + file.take(fds_common::MAX_CONFIG_BYTES + 1) + .read_to_string(&mut contents)?; + Manifest::parse(&contents) +} +pub fn create(class: Class, tree: &Path, output: &Path, size_mib: Option) -> Result { + if class == Class::Program && Path::new("/usr/share/fds/image-profile").exists() { + return Err(Error("Build software cartridges on a Linux workstation with fds-cartridge software build and fds-cartridge create; no software build runs on the Pi".into())); + } + let tree = fs::canonicalize(tree)?; + if !tree.is_dir() { + return Err(Error("Image source must be a directory".into())); + } + let manifest = tree_manifest(&tree)?; + if manifest.cartridge.class != class { + return Err(Error( + "Requested class does not match CARTRIDGE.TOML".into(), + )); + } + match class { + Class::System => { + for name in [ + "sbin/init", + "usr/bin/fds", + "usr/bin/fds-cartridged", + "usr/bin/dasungd", + ] { + if !tree.join(name).is_file() { + return Err(Error(format!("SYSTEM source lacks {name}"))); + } + } + } + Class::Program if !tree.join("app/bin").is_dir() => { + return Err(Error("PROGRAM source needs app/bin".into())); + } + Class::Environment if size_mib.is_some() => { + return Err(Error( + "ENVIRONMENT size is determined by its contents".into(), + )); + } + _ => (), + } + let parent = fs::canonicalize( + output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")), + )?; + // A staging file inside SOURCE could be consumed by its own image builder. + if parent.starts_with(&tree) { + return Err(Error( + "Image output must be outside its source directory".into(), + )); + } + let work = Work::new(&parent)?; + let payload = work.0.join("filesystem.img"); + let payload_arg = text(&payload)?; + let source_arg = text(&tree)?; + let label = image::label(class)?; + if class == Class::Data { + let bytes = size_mib + .unwrap_or(128) + .checked_mul(1024 * 1024) + .filter(|v| *v >= 32 * 1024 * 1024 && *v <= 1024 * 1024 * 1024 * 1024) + .ok_or_else(|| Error("DATA filesystem size must be 32..1048576 MiB".into()))?; + OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&payload)? + .set_len(bytes)?; + // Explicit root ownership supports both ordinary-user and recovery builds. + // Lazy initialization is disabled: all construction happens before boot. + command( + "/usr/bin/mkfs.ext4", + &[ + "-q", + "-F", + "-m", + "0", + "-L", + label, + "-E", + "root_owner=1000:1000,lazy_itable_init=0,lazy_journal_init=0", + "-d", + source_arg, + payload_arg, + ], + )?; + command("/usr/bin/e2fsck", &["-f", "-n", payload_arg])?; + } else { + if size_mib.is_some() { + return Err(Error("--size-mib applies only to DATA filesystems".into())); + } + command( + "/usr/bin/mkfs.erofs", + &[ + "--quiet", + "-b4096", + "-T0", + "--mkfs-time", + "-L", + label, + payload_arg, + source_arg, + ], + )?; + command("/usr/bin/fsck.erofs", &["--extract", payload_arg])?; + } + let mut payload = File::open(&payload)?; + let layout = Layout::new( + class, + payload.metadata()?.len(), + None, + image::random_id()?, + image::random_id()?, + )?; + let staged = work.0.join("cartridge.img"); + let mut disk = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&staged)?; + layout.write(&disk)?; + use std::io::{Seek, SeekFrom}; + disk.seek(SeekFrom::Start(image::FIRST_LBA * image::SECTOR))?; + std::io::copy(&mut payload, &mut disk)?; + disk.flush()?; + disk.sync_all()?; + let mut info = image::inspect(&disk, layout.bytes)?; + info.sha256 = Some(image::digest(&disk, layout.bytes, |_| Ok(()))?); + // Atomic no-replace publication: never truncate an existing path or follow + // a symlink. Staging is on the same filesystem as the final image. + fs::set_permissions(&staged, fs::Permissions::from_mode(0o644))?; + fs::hard_link(&staged, output)?; + File::open(parent)?.sync_all()?; + Ok(info) +} diff --git a/rust/fds-burn/src/device.rs b/rust/fds-burn/src/device.rs new file mode 100644 index 0000000..da44163 --- /dev/null +++ b/rust/fds-burn/src/device.rs @@ -0,0 +1,526 @@ +//! Whole-disk selection and conservative protection checks. A bay maps to a +//! kernel USB path. Names are used only to open the verified kernel identity. +use fds_common::{Error, Result, read_text}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + os::{ + fd::AsRawFd, + unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt}, + }, + path::{Path, PathBuf}, +}; +const BLKGETSIZE64: libc::c_ulong = 0x80081272; +const BLKSSZGET: libc::c_ulong = 0x1268; +const BLKROGET: libc::c_ulong = 0x125e; +const BLKGETDISKSEQ: libc::c_ulong = 0x80081280; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Disk { + pub path: PathBuf, + pub sysfs_path: PathBuf, + pub major: u32, + pub minor: u32, + pub diskseq: u64, + pub bytes: u64, + pub sector_bytes: u32, + pub model: String, + pub serial: Option, +} +fn bad(message: impl Into) -> Error { + Error(message.into()) +} +fn number(path: &Path) -> Result { + read_text(path, 128)? + .trim() + .parse() + .map_err(|_| bad(format!("Invalid kernel block number: {}", path.display()))) +} +fn fields(path: &Path) -> Result> { + let mut fields = BTreeMap::new(); + for line in read_text(path, 16384)?.lines() { + if let Some((k, v)) = line.split_once('=') { + if fields.insert(k.into(), v.into()).is_some() { + return Err(bad("Duplicate block uevent field")); + } + } + } + Ok(fields) +} +fn dev(path: &Path) -> Result<(u32, u32)> { + let value = read_text(&path.join("dev"), 128)?; + let (major, minor) = value + .trim() + .split_once(':') + .ok_or_else(|| bad("Invalid kernel device number"))?; + Ok(( + major.parse().map_err(|_| bad("Invalid device major"))?, + minor.parse().map_err(|_| bad("Invalid device minor"))?, + )) +} +fn text_field(path: &Path) -> Result { + match read_text(path, 4096) { + Ok(s) => Ok(s + .trim() + .chars() + .filter(|c| !c.is_control()) + .take(128) + .collect()), + Err(_) if !path.exists() => Ok(String::new()), + Err(e) => Err(e), + } +} +pub fn select(sysfs: &Path, usb: &Path) -> Result { + let usb = fs::canonicalize(usb)?; + if !usb.starts_with(fs::canonicalize(sysfs.join("devices"))?) { + return Err(bad("USB identity is outside kernel devices")); + } + let mut matches = Vec::new(); + for entry in fs::read_dir(sysfs.join("class/block"))? { + let entry = entry?.path(); + let path = match fs::canonicalize(&entry) { + Ok(p) => p, + Err(_) if !entry.exists() => continue, + Err(e) => return Err(e.into()), + }; + if !path.starts_with(&usb) || path.join("partition").exists() { + continue; + } + let values = fields(&path.join("uevent"))?; + if values.get("DEVTYPE").map(String::as_str) != Some("disk") { + continue; + } + let name = values + .get("DEVNAME") + .ok_or_else(|| bad("Missing disk name"))?; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) + { + return Err(bad("Unsafe disk name")); + } + let (major, minor) = dev(&path)?; + let bytes = number(&path.join("size"))? + .checked_mul(512) + .ok_or_else(|| bad("Disk size overflow"))?; + let sector_bytes = number(&path.join("queue/logical_block_size"))? + .try_into() + .map_err(|_| bad("Invalid sector size"))?; + let serial = text_field(&usb.join("serial"))?; + matches.push(Disk { + path: Path::new("/dev").join(name), + sysfs_path: path.clone(), + major, + minor, + diskseq: number(&path.join("diskseq"))?, + bytes, + sector_bytes, + model: text_field(&path.join("device/model"))?, + serial: if serial.is_empty() { + None + } else { + Some(serial) + }, + }); + } + if matches.len() != 1 { + return Err(bad("Bay must contain exactly one whole USB disk")); + } + Ok(matches.remove(0)) +} +/// Inspect both existing GPTs directly as well as using kernel partitions. A +/// freshly enumerated disk can appear before all partition uevents arrive. +fn protect_existing_gpt(file: &File, bytes: u64) -> Result<()> { + use crate::image::{u32le, u64le}; + if bytes < 1024 || bytes % 512 != 0 { + return Err(bad("Invalid target capacity")); + } + for offset in [512, bytes - 512] { + let mut header = [0u8; 512]; + file.read_exact_at(&mut header, offset)?; + if &header[..8] != b"EFI PART" { + continue; + } + let count = u32le(&header, 80) as usize; + let stride = u32le(&header, 84) as usize; + if count == 0 || count > 4096 || !(128..=1024).contains(&stride) || stride % 128 != 0 { + return Err(bad("Cannot safely inspect existing GPT entry geometry")); + } + let start = u64le(&header, 72) + .checked_mul(512) + .ok_or_else(|| bad("Existing GPT table offset overflow"))?; + let length = count * stride; + if start < 1024 + || start + .checked_add(length as u64) + .is_none_or(|end| end > bytes - 512) + { + return Err(bad("Existing GPT table extends outside the target")); + } + let mut entries = vec![0; length]; + file.read_exact_at(&mut entries, start)?; + for entry in entries.chunks_exact(stride) { + let name: Vec = entry[56..128] + .chunks_exact(2) + .map(|c| u16::from_le_bytes(c.try_into().unwrap())) + .take_while(|c| *c != 0) + .collect(); + let name = String::from_utf16_lossy(&name); + if ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"].contains(&name.as_str()) { + return Err(bad(format!( + "Protected internal partition in existing GPT: {name}" + ))); + } + } + } + Ok(()) +} +impl Disk { + pub fn select_current(usb: &Path) -> Result { + select(Path::new("/sys"), usb) + } + pub fn key(&self) -> String { + format!( + "{}:{}:{}:{}:{}", + self.sysfs_path.display(), + self.major, + self.minor, + self.diskseq, + self.bytes + ) + } + pub fn present(&self) -> bool { + self.present_at(Path::new("/sys")) + } + fn present_at(&self, sysfs: &Path) -> bool { + fs::canonicalize( + sysfs + .join("dev/block") + .join(format!("{}:{}", self.major, self.minor)), + ) + .is_ok_and(|p| { + p == self.sysfs_path + && number(&p.join("diskseq")).is_ok_and(|s| s == self.diskseq) + && number(&p.join("size")).is_ok_and(|s| s.checked_mul(512) == Some(self.bytes)) + }) + } + /// Refuse all mounted children, swap, holders, or reserved internal labels. + /// This is re-run after exclusive open and immediately before a confirmed write. + pub fn protect(&self, sysfs: &Path, proc: &Path) -> Result<()> { + if !self.present_at(sysfs) { + return Err(bad( + "Cartridge identity changed; inspect and confirm it again", + )); + } + if self.sector_bytes != 512 { + return Err(bad( + "Only 512-byte logical sectors are supported for cartridge writes", + )); + } + if number(&self.sysfs_path.join("ro"))? != 0 { + return Err(bad("Disk is write protected")); + } + let mut children = BTreeSet::new(); + let mut paths = BTreeSet::new(); + for entry in fs::read_dir(sysfs.join("class/block"))? { + let entry = entry?.path(); + let path = match fs::canonicalize(&entry) { + Ok(p) => p, + Err(_) if !entry.exists() => continue, + Err(e) => return Err(e.into()), + }; + if path != self.sysfs_path && !path.starts_with(&self.sysfs_path) { + continue; + } + children.insert(dev(&path)?); + let values = fields(&path.join("uevent"))?; + if let Some(label) = values.get("PARTNAME") { + if ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"].contains(&label.as_str()) { + return Err(bad(format!("Protected internal partition: {label}"))); + } + } + if let Some(name) = values.get("DEVNAME") { + paths.insert(format!("/dev/{name}")); + } + if fs::read_dir(path.join("holders"))? + .next() + .transpose()? + .is_some() + { + return Err(bad( + "Disk or partition has a kernel holder (RAID, encryption or device mapper)", + )); + } + } + if !children.contains(&(self.major, self.minor)) { + return Err(bad("Target vanished during protection check")); + } + for line in read_text(&proc.join("self/mountinfo"), 4 * 1024 * 1024)?.lines() { + let parts: Vec<_> = line.split_whitespace().collect(); + if parts.len() < 6 { + return Err(bad("Malformed mount table; refusing to write")); + } + let (major, minor) = parts[2] + .split_once(':') + .ok_or_else(|| bad("Invalid mount device"))?; + let id = ( + major.parse().map_err(|_| bad("Invalid mount major"))?, + minor.parse().map_err(|_| bad("Invalid mount minor"))?, + ); + if children.contains(&id) { + return Err(bad(format!( + "Mounted disk or partition at {}; eject it before writing", + parts[4] + ))); + } + } + // Swap on a block path is excluded directly. Swap files necessarily live + // on a mounted filesystem, already excluded above. + for line in read_text(&proc.join("swaps"), 1024 * 1024)?.lines().skip(1) { + let path = line + .split_whitespace() + .next() + .ok_or_else(|| bad("Invalid swap table"))?; + if paths.contains(path) + || fs::metadata(path).is_ok_and(|m| { + m.file_type().is_block_device() + && children.contains(&(libc::major(m.rdev()), libc::minor(m.rdev()))) + }) + { + return Err(bad("Disk or partition is active swap")); + } + } + Ok(()) + } + pub fn open_exclusive(&self) -> Result { + self.protect(Path::new("/sys"), Path::new("/proc"))?; + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(&self.path)?; + let metadata = file.metadata()?; + if !metadata.file_type().is_block_device() + || libc::major(metadata.rdev()) != self.major + || libc::minor(metadata.rdev()) != self.minor + { + return Err(bad("Opened target does not match the selected disk")); + } + let mut bytes = 0u64; + let mut sector = 0u32; + let mut ro = 0u32; + let mut diskseq = 0u64; + for (request, pointer) in [ + ( + BLKGETSIZE64, + (&mut bytes as *mut u64).cast::(), + ), + (BLKSSZGET, (&mut sector as *mut u32).cast()), + (BLKROGET, (&mut ro as *mut u32).cast()), + (BLKGETDISKSEQ, (&mut diskseq as *mut u64).cast()), + ] { + if unsafe { libc::ioctl(file.as_raw_fd(), request as _, pointer) } < 0 { + return Err(std::io::Error::last_os_error().into()); + } + } + if bytes != self.bytes || sector != self.sector_bytes || ro != 0 || diskseq != self.diskseq + { + return Err(bad( + "Opened disk changed size, sector format, write protection or insertion identity", + )); + } + protect_existing_gpt(&file, bytes)?; + self.protect(Path::new("/sys"), Path::new("/proc"))?; + Ok(file) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + struct Fixture { + root: PathBuf, + sys: PathBuf, + proc: PathBuf, + usb: PathBuf, + disk: PathBuf, + } + impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "fds-disk-test-{}", + crate::image::hex(&crate::image::random_id().unwrap()) + )); + let sys = root.join("sys"); + let proc = root.join("proc"); + let usb = sys.join("devices/platform/usb2/2-1"); + let disk = usb.join("host/target/block/sdz"); + for path in [ + disk.join("queue"), + disk.join("device"), + disk.join("holders"), + sys.join("class/block"), + sys.join("dev/block"), + proc.join("self"), + ] { + fs::create_dir_all(path).unwrap(); + } + for (path, text) in [ + (disk.join("uevent"), "DEVTYPE=disk\nDEVNAME=sdz\n"), + (disk.join("dev"), "8:240\n"), + (disk.join("diskseq"), "12\n"), + (disk.join("size"), "262144\n"), + (disk.join("ro"), "0\n"), + (disk.join("queue/logical_block_size"), "512\n"), + (disk.join("device/model"), "Test disk\n"), + ( + proc.join("self/mountinfo"), + "1 0 0:1 / / rw - tmpfs none rw\n", + ), + (proc.join("swaps"), "Filename Type Size Used Priority\n"), + ] { + fs::write(path, text).unwrap(); + } + symlink(&disk, sys.join("class/block/sdz")).unwrap(); + symlink(&disk, sys.join("dev/block/8:240")).unwrap(); + Self { + root, + sys, + proc, + usb, + disk, + } + } + fn partition(&self, label: &str) { + let p = self.disk.join("sdz1"); + fs::create_dir_all(p.join("holders")).unwrap(); + fs::write(p.join("partition"), "1\n").unwrap(); + fs::write(p.join("dev"), "8:241\n").unwrap(); + fs::write( + p.join("uevent"), + format!("DEVTYPE=partition\nDEVNAME=sdz1\nPARTNAME={label}\n"), + ) + .unwrap(); + symlink(&p, self.sys.join("class/block/sdz1")).unwrap(); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } + } + #[test] + fn internal_labels_are_protected_without_partition_uevents() { + use std::os::fd::FromRawFd; + let fd = unsafe { libc::memfd_create(c"fds-existing-gpt".as_ptr(), libc::MFD_CLOEXEC) }; + assert!(fd >= 0); + let file = unsafe { File::from_raw_fd(fd) }; + let layout = crate::image::Layout::new( + fds_common::manifest::Class::Data, + 1024 * 1024, + None, + [1; 16], + [2; 16], + ) + .unwrap(); + layout.write(&file).unwrap(); + protect_existing_gpt(&file, layout.bytes).unwrap(); + let mut name = [0u8; 72]; + for (i, c) in "FDS_INTERNAL".encode_utf16().enumerate() { + name[2 * i..2 * i + 2].copy_from_slice(&c.to_le_bytes()); + } + // Protection is conservative even when the old table CRC is damaged. + file.write_all_at(&name, layout.bytes - layout.tail.len() as u64 + 56) + .unwrap(); + file.write_all_at(&[0; 512], 512).unwrap(); + assert!( + protect_existing_gpt(&file, layout.bytes) + .unwrap_err() + .to_string() + .contains("FDS_INTERNAL") + ); + } + #[test] + fn blank_media_selects_by_usb_and_replacement_invalidates_identity() { + let f = Fixture::new(); + let disk = select(&f.sys, &f.usb).unwrap(); + assert_eq!(disk.bytes, 128 * 1024 * 1024); + disk.protect(&f.sys, &f.proc).unwrap(); + fs::write(f.disk.join("diskseq"), "13\n").unwrap(); + assert!( + disk.protect(&f.sys, &f.proc) + .unwrap_err() + .to_string() + .contains("identity changed") + ); + } + #[test] + fn internal_partitions_remain_protected_while_unmounted() { + for label in ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"] { + let f = Fixture::new(); + f.partition(label); + let disk = select(&f.sys, &f.usb).unwrap(); + assert!( + disk.protect(&f.sys, &f.proc) + .unwrap_err() + .to_string() + .contains("Protected internal") + ); + } + } + #[test] + fn mounts_swap_and_holders_block_writes() { + let f = Fixture::new(); + f.partition("FDS_SYSTEM"); + let disk = select(&f.sys, &f.usb).unwrap(); + disk.protect(&f.sys, &f.proc).unwrap(); + fs::write( + f.proc.join("self/mountinfo"), + "1 0 8:241 / / ro - erofs /dev/sdz1 ro\n", + ) + .unwrap(); + assert!( + disk.protect(&f.sys, &f.proc) + .unwrap_err() + .to_string() + .contains("Mounted") + ); + fs::write(f.proc.join("self/mountinfo"), "").unwrap(); + fs::write( + f.proc.join("swaps"), + "Filename Type Size Used Priority\n/dev/sdz1 partition 1 0 -1\n", + ) + .unwrap(); + assert!( + disk.protect(&f.sys, &f.proc) + .unwrap_err() + .to_string() + .contains("swap") + ); + fs::write(f.proc.join("swaps"), "Filename Type Size Used Priority\n").unwrap(); + fs::write(f.disk.join("sdz1/holders/dm-0"), "").unwrap(); + assert!( + disk.protect(&f.sys, &f.proc) + .unwrap_err() + .to_string() + .contains("holder") + ); + } + #[test] + fn sector_size_and_readonly_are_checked() { + let f = Fixture::new(); + let mut disk = select(&f.sys, &f.usb).unwrap(); + disk.sector_bytes = 4096; + assert!(disk.protect(&f.sys, &f.proc).is_err()); + disk.sector_bytes = 512; + fs::write(f.disk.join("ro"), "1\n").unwrap(); + assert!( + disk.protect(&f.sys, &f.proc) + .unwrap_err() + .to_string() + .contains("write protected") + ); + } +} diff --git a/rust/fds-burn/src/image.rs b/rust/fds-burn/src/image.rs new file mode 100644 index 0000000..4ad30ec --- /dev/null +++ b/rust/fds-burn/src/image.rs @@ -0,0 +1,538 @@ +//! Bounded FDS GPT images: legacy single-filesystem cartridges and metadata-first +//! software cartridges, with both table/header CRCs and exact backup agreement. +use fds_common::{Error, Result, manifest::Class}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fs::File, io::Read, os::unix::fs::FileExt}; + +pub const SECTOR: u64 = 512; +pub const TABLE_BYTES: usize = 128 * 128; +pub const FIRST_LBA: u64 = 2048; +pub const LINUX_TYPE: [u8; 16] = [ + 0xaf, 0x3d, 0xc6, 0x0f, 0x83, 0x84, 0x72, 0x47, 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4, +]; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Partition { + pub number: u8, + pub name: String, + pub start: u64, + pub bytes: u64, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Image { + pub bytes: u64, + pub partition_start: u64, + pub partition_bytes: u64, + pub class: Class, + pub filesystem: String, + pub disk_uuid: String, + pub sha256: Option, + pub partitions: Vec, +} +fn bad(message: &str) -> Error { + Error(format!("Invalid cartridge image: {message}")) +} +pub fn u32le(data: &[u8], at: usize) -> u32 { + u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) +} +pub fn u64le(data: &[u8], at: usize) -> u64 { + u64::from_le_bytes(data[at..at + 8].try_into().unwrap()) +} +pub fn put32(data: &mut [u8], at: usize, value: u32) { + data[at..at + 4].copy_from_slice(&value.to_le_bytes()); +} +pub fn put64(data: &mut [u8], at: usize, value: u64) { + data[at..at + 8].copy_from_slice(&value.to_le_bytes()); +} +/// IEEE CRC-32 for GPT's small fixed tables; no cryptographic role. +pub fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for byte in data { + crc ^= *byte as u32; + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1)); + } + } + !crc +} +pub fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() +} +pub fn digest( + file: &File, + bytes: u64, + mut progress: impl FnMut(u64) -> Result<()>, +) -> Result { + let mut hash = Sha256::new(); + let mut buffer = vec![0; 1024 * 1024]; + let mut offset = 0; + while offset < bytes { + let n = buffer.len().min((bytes - offset) as usize); + file.read_exact_at(&mut buffer[..n], offset)?; + hash.update(&buffer[..n]); + offset += n as u64; + if offset % (64 * 1024 * 1024) == 0 || offset == bytes { + progress(offset)?; + } + } + Ok(hex(&hash.finalize())) +} +pub fn random_id() -> Result<[u8; 16]> { + let mut id = [0; 16]; + File::open("/dev/urandom")?.read_exact(&mut id)?; + // GPT stores the first UUID fields little endian. + id[7] = (id[7] & 15) | 0x40; + id[8] = (id[8] & 63) | 0x80; + Ok(id) +} +fn uuid(id: &[u8]) -> String { + format!( + "{:08x}-{:04x}-{:04x}-{}-{}", + u32le(id, 0), + u16::from_le_bytes(id[4..6].try_into().unwrap()), + u16::from_le_bytes(id[6..8].try_into().unwrap()), + hex(&id[8..10]), + hex(&id[10..]) + ) +} +pub fn label(class: Class) -> Result<&'static str> { + match class { + Class::System => Ok("FDS_SYSTEM"), + Class::Data => Ok("FDS_DATA"), + Class::Program => Ok("FDS_PROGRAM"), + Class::Environment => Ok("FDS_ENVIRONMENT"), + _ => Err(bad( + "only SYSTEM, DATA, PROGRAM and ENVIRONMENT are writable cartridge classes", + )), + } +} +fn header(file: &File, lba: u64, sectors: u64) -> Result<[u8; 512]> { + let mut data = [0; 512]; + file.read_exact_at(&mut data, lba * SECTOR)?; + if &data[..8] != b"EFI PART" + || u32le(&data, 8) != 0x10000 + || u32le(&data, 12) != 92 + || u32le(&data, 20) != 0 + || data[92..].iter().any(|b| *b != 0) + { + return Err(bad("unsupported or malformed GPT header")); + } + let recorded = u32le(&data, 16); + let mut checked = data; + put32(&mut checked, 16, 0); + if crc32(&checked[..92]) != recorded { + return Err(bad("GPT header CRC mismatch")); + } + if u64le(&data, 24) != lba + || u64le(&data, 32) != if lba == 1 { sectors - 1 } else { 1 } + || u64le(&data, 40) != 34 + || u64le(&data, 48) != sectors - 34 + || data[56..72].iter().all(|b| *b == 0) + || u64le(&data, 72) != if lba == 1 { 2 } else { sectors - 33 } + || u32le(&data, 80) != 128 + || u32le(&data, 84) != 128 + { + return Err(bad("GPT geometry, UUID or entry format is invalid")); + } + Ok(data) +} +pub fn inspect(file: &File, bytes: u64) -> Result { + if bytes % SECTOR != 0 || bytes < (FIRST_LBA + 2048 + 33) * SECTOR { + return Err(bad("image is too small or is not sector aligned")); + } + let sectors = bytes / SECTOR; + let mut mbr = [0; 512]; + file.read_exact_at(&mut mbr, 0)?; + if mbr[510..] != [0x55, 0xaa] + || mbr[446] != 0 + || mbr[450] != 0xee + || u32le(&mbr, 454) != 1 + || u32le(&mbr, 458) != (sectors - 1).min(u32::MAX as u64) as u32 + || mbr[462..510].iter().any(|b| *b != 0) + { + return Err(bad( + "missing protective MBR or unsupported hybrid partitions", + )); + } + let main = header(file, 1, sectors)?; + let backup = header(file, sectors - 1, sectors)?; + if main[40..72] != backup[40..72] || main[80..92] != backup[80..92] { + return Err(bad("primary and backup GPT disagree")); + } + let mut table = vec![0; TABLE_BYTES]; + let mut mirror = vec![0; TABLE_BYTES]; + file.read_exact_at(&mut table, 2 * SECTOR)?; + file.read_exact_at(&mut mirror, (sectors - 33) * SECTOR)?; + if table != mirror || crc32(&table) != u32le(&main, 88) { + return Err(bad("GPT table CRC or backup mismatch")); + } + let mut partitions = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + let mut next = FIRST_LBA; + for (index, entry) in table.chunks_exact(128).enumerate() { + if entry[..16].iter().all(|b| *b == 0) { + if entry.iter().any(|b| *b != 0) { + return Err(bad("nonempty unused GPT entry")); + } + continue; + } + if index != partitions.len() || partitions.len() == 33 { + return Err(bad("partition entries must be consecutive and at most 33")); + } + if entry[..16] != LINUX_TYPE + || entry[16..32].iter().all(|b| *b == 0) + || !ids.insert(entry[16..32].to_vec()) + || u64le(entry, 48) != 0 + { + return Err(bad( + "unsupported partition type, duplicate UUID or attributes", + )); + } + let first = u64le(entry, 32); + let last = u64le(entry, 40); + if first < next + || first > sectors - 34 + || first % 2048 != 0 + || last < first + || last > sectors - 34 + || (last - first + 1) < 2048 + || (last - first + 1) % 2048 != 0 + || (index == 0 && first != FIRST_LBA) + { + return Err(bad( + "partition overlaps another partition or GPT, or is not MiB aligned", + )); + } + next = last + 1; + let units: Vec = entry[56..128] + .chunks_exact(2) + .map(|c| u16::from_le_bytes(c.try_into().unwrap())) + .collect(); + let end = units + .iter() + .position(|u| *u == 0) + .ok_or_else(|| bad("partition name has no terminator"))?; + if units[end..].iter().any(|u| *u != 0) { + return Err(bad("partition name contains trailing data")); + } + let name = + String::from_utf16(&units[..end]).map_err(|_| bad("invalid UTF-16 partition name"))?; + partitions.push(Partition { + number: (index + 1) as u8, + name, + start: first * SECTOR, + bytes: (last - first + 1) * SECTOR, + }); + } + let first = partitions + .first() + .ok_or_else(|| bad("no cartridge partition"))?; + let software = first.name == "FDS_METADATA"; + let class = if software { + if partitions.len() < 2 + || partitions + .iter() + .skip(1) + .any(|p| p.name != format!("FDS_PAYLOAD{:02}", p.number)) + { + return Err(bad( + "software requires metadata followed by consecutive payload partitions", + )); + } + Class::Program + } else { + if partitions.len() != 1 { + return Err(bad("legacy cartridges require exactly one partition")); + } + match first.name.as_str() { + "FDS_SYSTEM" => Class::System, + "FDS_DATA" => Class::Data, + "FDS_PROGRAM" => Class::Program, + "FDS_ENVIRONMENT" => Class::Environment, + _ => return Err(bad("unrecognized cartridge partition name")), + } + }; + let filesystem = if class == Class::Data { + "ext4" + } else { + "erofs" + }; + for part in &partitions { + let mut superblock = [0; 1024]; + file.read_exact_at(&mut superblock, part.start + 1024)?; + if filesystem == "ext4" { + if superblock[56..58] != [0x53, 0xef] { + return Err(bad("DATA needs ext4")); + } + } else if superblock[..4] != [0xe2, 0xe1, 0xf5, 0xe0] { + return Err(bad("every read-only partition needs EROFS")); + } + } + Ok(Image { + bytes, + partition_start: first.start, + partition_bytes: first.bytes, + class, + filesystem: filesystem.into(), + disk_uuid: uuid(&main[56..72]), + sha256: None, + partitions, + }) +} +/// Fixed GPT metadata for bounded filesystem payloads. Callers write only new +/// regular image files; privileged block writes are a separate confirmed step. +pub struct Layout { + pub head: Vec, + pub tail: Vec, + pub bytes: u64, + pub partition_bytes: u64, + pub partitions: Vec, +} +impl Layout { + pub fn new( + class: Class, + payload_bytes: u64, + total: Option, + disk_id: [u8; 16], + part_id: [u8; 16], + ) -> Result { + Self::from_parts( + &[(label(class)?.into(), payload_bytes, part_id)], + total, + disk_id, + ) + } + /// Metadata is first; the remaining entries contain xz software bundles. + pub fn software(payloads: &[u64], disk_id: [u8; 16], ids: &[[u8; 16]]) -> Result { + if !(2..=33).contains(&payloads.len()) || ids.len() != payloads.len() { + return Err(bad( + "software images require one metadata and 1..32 payload partitions", + )); + } + let parts: Vec<_> = payloads + .iter() + .enumerate() + .map(|(i, bytes)| { + ( + if i == 0 { + "FDS_METADATA".into() + } else { + format!("FDS_PAYLOAD{:02}", i + 1) + }, + *bytes, + ids[i], + ) + }) + .collect(); + Self::from_parts(&parts, None, disk_id) + } + fn from_parts( + parts: &[(String, u64, [u8; 16])], + total: Option, + disk_id: [u8; 16], + ) -> Result { + let mut table = vec![0; TABLE_BYTES]; + let mut partitions = Vec::new(); + let mut offset = FIRST_LBA * SECTOR; + let mut ids = std::collections::BTreeSet::new(); + if disk_id == [0; 16] { + return Err(bad("zero disk UUID")); + } + for (i, (name, payload_bytes, part_id)) in parts.iter().enumerate() { + let allocated = payload_bytes + .checked_add(1024 * 1024 - 1) + .ok_or_else(|| bad("payload too large"))? + / (1024 * 1024) + * (1024 * 1024); + if allocated == 0 || *part_id == [0; 16] || !ids.insert(*part_id) { + return Err(bad("empty payload or invalid partition UUID")); + } + let end = offset + .checked_add(allocated) + .ok_or_else(|| bad("image size overflow"))?; + let entry = &mut table[i * 128..(i + 1) * 128]; + entry[..16].copy_from_slice(&LINUX_TYPE); + entry[16..32].copy_from_slice(part_id); + put64(entry, 32, offset / SECTOR); + put64(entry, 40, end / SECTOR - 1); + for (j, unit) in name.encode_utf16().enumerate() { + entry[56 + 2 * j..58 + 2 * j].copy_from_slice(&unit.to_le_bytes()); + } + partitions.push(Partition { + number: (i + 1) as u8, + name: name.clone(), + start: offset, + bytes: allocated, + }); + offset = end; + } + let minimum = offset + .checked_add(33 * SECTOR) + .ok_or_else(|| bad("image size overflow"))?; + let rounded = minimum + .checked_add(1024 * 1024 - 1) + .ok_or_else(|| bad("image size overflow"))? + / (1024 * 1024) + * (1024 * 1024); + let bytes = total.unwrap_or(rounded); + if bytes < minimum || bytes % SECTOR != 0 { + return Err(bad("invalid output size")); + } + let sectors = bytes / SECTOR; + let table_crc = crc32(&table); + let make_header = |lba, alternate, entries| { + let mut h = [0u8; 512]; + h[..8].copy_from_slice(b"EFI PART"); + put32(&mut h, 8, 0x10000); + put32(&mut h, 12, 92); + put64(&mut h, 24, lba); + put64(&mut h, 32, alternate); + put64(&mut h, 40, 34); + put64(&mut h, 48, sectors - 34); + h[56..72].copy_from_slice(&disk_id); + put64(&mut h, 72, entries); + put32(&mut h, 80, 128); + put32(&mut h, 84, 128); + put32(&mut h, 88, table_crc); + let crc = crc32(&h[..92]); + put32(&mut h, 16, crc); + h + }; + let mut head = vec![0; 1024 + TABLE_BYTES]; + head[447..450].copy_from_slice(&[0, 2, 0]); + head[450] = 0xee; + head[451..454].fill(255); + put32(&mut head, 454, 1); + put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32); + head[510..512].copy_from_slice(&[0x55, 0xaa]); + head[512..1024].copy_from_slice(&make_header(1, sectors - 1, 2)); + head[1024..].copy_from_slice(&table); + let mut tail = table; + tail.extend_from_slice(&make_header(sectors - 1, 1, sectors - 33)); + Ok(Self { + head, + tail, + bytes, + partition_bytes: partitions[0].bytes, + partitions, + }) + } + pub fn write(&self, output: &File) -> Result<()> { + if !output.metadata()?.is_file() { + return Err(bad("image output must be a regular file")); + } + output.set_len(self.bytes)?; + output.write_all_at(&self.head, 0)?; + output.write_all_at(&self.tail, self.bytes - self.tail.len() as u64)?; + Ok(()) + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::os::fd::FromRawFd; + fn example(class: Class) -> File { + let name = c"fds-gpt-test"; + let fd = unsafe { libc::memfd_create(name.as_ptr(), libc::MFD_CLOEXEC) }; + assert!(fd >= 0); + let f = unsafe { File::from_raw_fd(fd) }; + Layout::new(class, 1024 * 1024, None, [1; 16], [2; 16]) + .unwrap() + .write(&f) + .unwrap(); + if class == Class::Data { + f.write_all_at(&[0x53, 0xef], FIRST_LBA * SECTOR + 1080) + .unwrap(); + } else { + f.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], FIRST_LBA * SECTOR + 1024) + .unwrap(); + } + f + } + #[test] + fn known_hash_and_crc() { + assert_eq!(crc32(b"123456789"), 0xcbf43926); + assert_eq!( + hex(&Sha256::digest(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + #[test] + fn four_classes_roundtrip_and_corruption_is_rejected() { + for class in [ + Class::System, + Class::Data, + Class::Program, + Class::Environment, + ] { + let f = example(class); + let bytes = f.metadata().unwrap().len(); + assert_eq!(inspect(&f, bytes).unwrap().class, class); + for offset in [ + 510, + 512, + 1024, + bytes - 512, + bytes - 1024, + FIRST_LBA * SECTOR + if class == Class::Data { 1080 } else { 1024 }, + ] { + let mut old = [0]; + f.read_exact_at(&mut old, offset).unwrap(); + f.write_all_at(&[old[0] ^ 0x80], offset).unwrap(); + assert!( + inspect(&f, bytes).is_err(), + "accepted corrupt offset {offset}" + ); + f.write_all_at(&old, offset).unwrap(); + } + } + } + #[test] + fn untrusted_lengths_are_bounded() { + let f = example(Class::Data); + for bytes in [0, 511, 512, 1024, 1024 * 1024, u64::MAX] { + assert!(inspect(&f, bytes).is_err()); + } + assert!(Layout::new(Class::Data, u64::MAX, None, [1; 16], [2; 16]).is_err()); + assert!(Layout::new(Class::Data, 1024 * 1024, Some(1024), [1; 16], [2; 16]).is_err()); + } + #[test] + fn metadata_first_software_gpt_roundtrips_and_rejects_overlap() { + let file = example(Class::Program); + let layout = + Layout::software(&[4096, 8192, 4096], [9; 16], &[[1; 16], [2; 16], [3; 16]]).unwrap(); + layout.write(&file).unwrap(); + for part in &layout.partitions { + file.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], part.start + 1024) + .unwrap(); + } + let parsed = inspect(&file, layout.bytes).unwrap(); + assert_eq!(parsed.partitions, layout.partitions); + assert_eq!(parsed.class, Class::Program); + // Keep both CRCs and both copies valid: geometry must reject the overlap. + let mut head = layout.head.clone(); + put64(&mut head[1024..], 128 + 32, FIRST_LBA); + let table_crc = crc32(&head[1024..]); + put32(&mut head[512..1024], 88, table_crc); + put32(&mut head[512..1024], 16, 0); + let header_crc = crc32(&head[512..604]); + put32(&mut head[512..1024], 16, header_crc); + let mut tail = layout.tail.clone(); + tail[..TABLE_BYTES].copy_from_slice(&head[1024..]); + put32(&mut tail[TABLE_BYTES..], 88, table_crc); + put32(&mut tail[TABLE_BYTES..], 16, 0); + let crc = crc32(&tail[TABLE_BYTES..TABLE_BYTES + 92]); + put32(&mut tail[TABLE_BYTES..], 16, crc); + file.write_all_at(&head, 0).unwrap(); + file.write_all_at(&tail, layout.bytes - tail.len() as u64) + .unwrap(); + assert!( + inspect(&file, layout.bytes) + .unwrap_err() + .to_string() + .contains("overlaps") + ); + assert!(Layout::software(&[4096], [9; 16], &[[1; 16]]).is_err()); + assert!(Layout::software(&[4096, 4096], [9; 16], &[[1; 16], [1; 16]]).is_err()); + } +} diff --git a/rust/fds-burn/src/lib.rs b/rust/fds-burn/src/lib.rs new file mode 100644 index 0000000..7eebf77 --- /dev/null +++ b/rust/fds-burn/src/lib.rs @@ -0,0 +1,9 @@ +//! Shared image validation and media identity checks. Device paths come from +//! kernel topology; public commands select a physical bay, never /dev/sdX. +pub mod cli; +pub mod client; +pub mod create; +pub mod device; +pub mod image; +pub mod worker; +pub mod write; diff --git a/rust/fds-burn/src/main.rs b/rust/fds-burn/src/main.rs new file mode 100644 index 0000000..b4c42b8 --- /dev/null +++ b/rust/fds-burn/src/main.rs @@ -0,0 +1,132 @@ +use clap::{CommandFactory, Parser, Subcommand}; +use fds_burn::{cli::BurnCommand, create, image}; +use fds_common::{Error, Result, manifest::Class}; +use std::{fs::OpenOptions, os::unix::fs::OpenOptionsExt, path::PathBuf, process::ExitCode}; + +#[derive(Parser)] +#[command( + version, + about = "Create cartridge images and preview confirmed media writes", + after_help = "Creation writes a new regular file. Writes require confirmation tied to the selected insertion and image hash." +)] +struct Cli { + #[command(subcommand)] + command: Option, +} +#[derive(Subcommand)] +enum Action { + /// Validate a regular cartridge image and report its SHA-256 digest. + Inspect { image: PathBuf }, + /// Create a new image from a tree containing FDS/CARTRIDGE.TOML. + Create { + /// Cartridge class: system, data, program, or environment. + #[arg(value_parser = create::class)] + class: Class, + source_directory: PathBuf, + output: PathBuf, + /// DATA filesystem size in MiB. + #[arg(long, value_name = "N")] + size_mib: Option, + }, + #[command(flatten)] + Burn(BurnCommand), + #[command(long_flag = "worker", hide = true)] + Worker, +} +fn run() -> Result<()> { + match Cli::parse().command { + None => { + Cli::command().print_help()?; + println!(); + Ok(()) + } + Some(Action::Worker) => fds_burn::worker::run(), + Some(Action::Inspect { image: path }) => { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(Error( + "Image inspection requires a regular file; use bay inspection for devices" + .into(), + )); + } + let mut info = image::inspect(&file, metadata.len())?; + info.sha256 = Some(image::digest(&file, info.bytes, |_| Ok(()))?); + println!( + "{}", + serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))? + ); + Ok(()) + } + Some(Action::Create { + class, + source_directory, + output, + size_mib, + }) => { + let info = create::create(class, &source_directory, &output, size_mib)?; + println!( + "{}", + serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))? + ); + Ok(()) + } + Some(Action::Burn(command)) => fds_burn::client::burn(command, false), + } +} +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("fds-burn: {e}"); + ExitCode::from(2) + } + } +} + +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::{CommandFactory, Parser}; + #[test] + fn typed_command_contract() { + Cli::command().debug_assert(); + assert!(matches!( + Cli::try_parse_from(["fds-burn", "--worker"]) + .unwrap() + .command, + Some(Action::Worker) + )); + assert!(Cli::try_parse_from(["fds-burn", "--worker", "data", "1"]).is_err()); + assert!( + Cli::try_parse_from([ + "fds-burn", + "create", + "data", + "source", + "output", + "--size-mib", + "32" + ]) + .is_ok() + ); + assert!( + Cli::try_parse_from(["fds-burn", "create", "unknown", "source", "output"]).is_err() + ); + assert!( + Cli::try_parse_from([ + "fds-burn", + "create", + "data", + "source", + "output", + "--size-mib", + "bad" + ]) + .is_err() + ); + } +} diff --git a/rust/fds-burn/src/worker.rs b/rust/fds-burn/src/worker.rs new file mode 100644 index 0000000..d41331b --- /dev/null +++ b/rust/fds-burn/src/worker.rs @@ -0,0 +1,435 @@ +//! Root worker for one prepared media operation. All source-file access is +//! checked using the IPC caller's effective identity before privileges return. +use crate::{create, device::Disk, image, write}; +use fds_common::{ + Error, Result, + control::{LIMIT, MediaJob}, + manifest::Class, +}; +use serde::{Deserialize, Serialize}; +use std::{ + ffi::CString, + fs::{self, File, OpenOptions}, + io::{self, BufRead, Read, Write}, + os::{ + fd::{AsRawFd, BorrowedFd}, + unix::{ + fs::{MetadataExt, OpenOptionsExt}, + process::CommandExt, + }, + }, + path::Path, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; +#[derive(Serialize, Deserialize)] +pub struct Preparation { + pub disk: Disk, + pub image_path: String, + pub uid: u32, + pub job: MediaJob, +} +fn check(value: i32, action: &str) -> Result<()> { + if value < 0 { + Err(Error(format!("{action}: {}", io::Error::last_os_error()))) + } else { + Ok(()) + } +} +fn c(s: &str) -> Result { + CString::new(s).map_err(|_| Error("NUL in path".into())) +} +fn report_error(error: &str) -> String { + // Parser diagnostics may quote an entire untrusted manifest. Keep the IPC + // record bounded and prevent terminal controls from reaching CLI output. + let mut result = String::new(); + for ch in error.chars() { + let ch = if ch.is_control() { ' ' } else { ch }; + if result.len() + ch.len_utf8() > 2048 { + result.push_str(" [truncated]"); + break; + } + result.push(ch); + } + result +} +fn emit(job: &mut MediaJob, phase: &str, progress: u64) -> Result<()> { + job.sequence += 1; + job.phase = phase.into(); + job.progress_bytes = progress; + let mut stdout = io::stdout().lock(); + serde_json::to_writer(&mut stdout, job).map_err(|e| Error(e.to_string()))?; + stdout.write_all(b"\n")?; + stdout.flush()?; + Ok(()) +} +fn source(path: &str, uid: u32) -> Result { + if ![0, 1000].contains(&uid) || !path.starts_with('/') { + return Err(Error("Invalid image owner or path".into())); + } + check( + unsafe { libc::setgroups(0, std::ptr::null()) }, + "clear worker groups", + )?; + check(unsafe { libc::setegid(uid) }, "select image group")?; + check(unsafe { libc::seteuid(uid) }, "select image owner")?; + let opened = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path); + check(unsafe { libc::seteuid(0) }, "restore worker identity")?; + check(unsafe { libc::setegid(0) }, "restore worker group")?; + let file = opened?; + if !file.metadata()?.is_file() { + return Err(Error("Source image must be a regular file".into())); + } + Ok(file) +} +#[repr(C)] +struct LoopInfo { + device: u64, + inode: u64, + rdevice: u64, + offset: u64, + sizelimit: u64, + number: u32, + encrypt_type: u32, + key_size: u32, + flags: u32, + file_name: [u8; 64], + crypt_name: [u8; 64], + key: [u8; 32], + init: [u64; 2], +} +#[repr(C)] +struct LoopConfig { + fd: u32, + block_size: u32, + info: LoopInfo, + reserved: [u64; 8], +} +fn loop_image(source: &File, info: &image::Image, id: &str) -> Result { + let control = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/loop-control") + .map_err(|e| Error(format!("open loop-control: {e}")))?; + // LOOP_CONFIGURE is atomic. A competing loop user causes EBUSY, never an + // accidental configuration change to another user's loop device. + for _ in 0..8 { + let number = unsafe { libc::ioctl(control.as_raw_fd(), 0x4c82 as libc::Ioctl) }; + check(number, "allocate image loop device")?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC) + .open(format!("/dev/loop{number}")) + .map_err(|e| Error(format!("open loop{number}: {e}")))?; + let mut config: LoopConfig = unsafe { std::mem::zeroed() }; + config.fd = source.as_raw_fd() as u32; + config.block_size = 512; + config.info.offset = info.partition_start; + config.info.sizelimit = info.partition_bytes; + config.info.flags = 1 | 4; // READ_ONLY | AUTOCLEAR + let result = unsafe { libc::ioctl(file.as_raw_fd(), 0x4c0a as libc::Ioctl, &config) }; + if result >= 0 { + // Give the unprivileged checker an already-open private inode for + // this loop device. Reopening /proc/self/fd/3 still checks inode + // permissions; changing /dev/loopN permissions would expose it. + // /run intentionally has nodev. Use a root-private directory + // on devtmpfs, then unlink its device inode after opening it. + let private = create::Work::new(Path::new("/dev"))?; + let path = private + .0 + .join(format!("burn-{id}.device")) + .display() + .to_string(); + check( + unsafe { + libc::mknod( + c(&path)?.as_ptr(), + libc::S_IFBLK | 0o400, + file.metadata()?.rdev(), + ) + }, + "create private inspection handle", + )?; + let opened = (|| -> Result { + check( + unsafe { libc::chown(c(&path)?.as_ptr(), 1000, 1000) }, + "set inspection handle owner", + )?; + Ok(OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&path) + .map_err(|e| Error(format!("open private loop handle: {e}")))?) + })(); + fs::remove_file(path)?; + return opened; + } + if io::Error::last_os_error().raw_os_error() != Some(libc::EBUSY) { + check(result, "configure read-only image loop")?; + } + } + Err(Error("Image loop devices remained busy".into())) +} +fn check_filesystem(device: &File, info: &image::Image) -> Result<()> { + let (program, args): (&str, &[&str]) = if info.class == Class::Data { + ("/usr/bin/e2fsck", &["-f", "-n"]) + } else { + ("/usr/bin/fsck.erofs", &["--extract"]) + }; + let fd = device.as_raw_fd(); + let parent = unsafe { libc::getpid() }; + let mut command = Command::new(program); + command + .args(args) + .arg("/proc/self/fd/3") + .stdin(Stdio::null()) + .stdout(Stdio::from( + unsafe { BorrowedFd::borrow_raw(2) }.try_clone_to_owned()?, + )) + .stderr(Stdio::inherit()) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("LC_ALL", "C"); + unsafe { + command.pre_exec(move || { + if libc::dup2(fd, 3) < 0 || libc::fcntl(3, libc::F_SETFD, 0) < 0 { + return Err(io::Error::last_os_error()); + } + if libc::setgroups(0, std::ptr::null()) < 0 + || libc::setgid(1000) < 0 + || libc::setuid(1000) < 0 + { + return Err(io::Error::last_os_error()); + } + if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0 + || libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0 + { + return Err(io::Error::last_os_error()); + } + if libc::getppid() != parent { + return Err(io::Error::other("Media worker exited")); + } + Ok(()) + }); + } + let status = command + .status() + .map_err(|e| Error(format!("start filesystem checker: {e}")))?; + if !status.success() { + return Err(Error(format!( + "Image filesystem verification failed: {status}" + ))); + } + Ok(()) +} +fn verify_manifest(device: &File, info: &image::Image, id: &str) -> Result<()> { + check_filesystem(device, info)?; + let path = format!("/run/fds/probe/burn-{id}"); + fs::create_dir(&path).map_err(|e| Error(format!("create image probe directory: {e}")))?; + let source = format!("/proc/self/fd/{}", device.as_raw_fd()); + let options = if info.class == Class::Data { + Some(c("noload")?) + } else { + None + }; + let mounted = check( + unsafe { + libc::mount( + c(&source)?.as_ptr(), + c(&path)?.as_ptr(), + c(&info.filesystem)?.as_ptr(), + libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + options + .as_ref() + .map_or(std::ptr::null(), |s| s.as_ptr().cast()), + ) + }, + "mount image for metadata validation", + ); + if let Err(e) = mounted { + let _ = fs::remove_dir(&path); + return Err(e); + } + let parsed = create::tree_manifest(Path::new(&path)); + let unmounted = check( + unsafe { libc::umount2(c(&path)?.as_ptr(), 0) }, + "unmount inspected image", + ); + let _ = fs::remove_dir(&path); + unmounted?; + let manifest = parsed?; + if manifest.cartridge.class != info.class { + return Err(Error( + "Image metadata disagrees with its partition class".into(), + )); + } + Ok(()) +} +fn cancelled(input: &mut impl BufRead) -> Result<()> { + let mut fd = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + check( + unsafe { libc::poll(&mut fd, 1, 0) }, + "check media cancellation", + )?; + if fd.revents != 0 { + let mut line = String::new(); + input.read_line(&mut line)?; + return Err(Error( + "Media operation cancelled; partial media is not SAFE".into(), + )); + } + Ok(()) +} +fn perform(preparation: &mut Preparation, input: &mut impl BufRead) -> Result<()> { + let Preparation { + disk, + image_path, + uid, + job, + } = preparation; + check( + unsafe { libc::unshare(libc::CLONE_NEWNS) }, + "isolate image inspection mounts", + )?; + check( + unsafe { + libc::mount( + std::ptr::null(), + c("/")?.as_ptr(), + std::ptr::null(), + libc::MS_PRIVATE | libc::MS_REC, + std::ptr::null(), + ) + }, + "isolate mount propagation", + )?; + let target = disk.open_exclusive()?; + let source = source(image_path, *uid)?; + let mut info = image::inspect(&source, source.metadata()?.len())?; + if info.class != job.image_class { + return Err(Error( + "Image class does not match the requested operation".into(), + )); + } + if info.bytes > disk.bytes { + return Err(Error("Image is larger than the selected cartridge".into())); + } + job.image_bytes = Some(info.bytes); + emit(job, "inspecting", 0)?; + info.sha256 = Some(image::digest(&source, info.bytes, |n| { + cancelled(input)?; + emit(job, "inspecting", n) + })?); + let image_device = loop_image(&source, &info, &job.id)?; + verify_manifest(&image_device, &info, &job.id)?; + drop(image_device); + job.image_sha256 = info.sha256.clone(); + job.confirmation = Some(format!("ERASE BAY{} {}", job.bay, job.id)); + emit(job, "awaiting_confirmation", 0)?; + // Readiness and confirmation are event-driven; expiration is a deadline. + let deadline = Instant::now() + Duration::from_secs(300); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(Error("Confirmation expired; target untouched".into())); + } + let mut fd = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + let result = unsafe { + libc::poll( + &mut fd, + 1, + remaining.as_millis().min(i32::MAX as u128) as i32, + ) + }; + if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + check(result, "wait for media confirmation")?; + if result == 0 { + continue; + } + let mut line = String::new(); + input.take(LIMIT as u64).read_line(&mut line)?; + if line.trim_end() != job.confirmation.as_deref().unwrap_or("") { + return Err(Error("Media operation cancelled before writing".into())); + } + break; + } + job.confirmation = None; + disk.protect(Path::new("/sys"), Path::new("/proc"))?; + write::transfer(&source, &target, &info, disk.bytes, |phase, n| { + if !disk.present() { + return Err(Error( + "Cartridge removed during write; no SAFE status issued".into(), + )); + } + cancelled(input)?; + emit(job, phase, n) + })?; + check( + unsafe { libc::ioctl(target.as_raw_fd(), 0x125f as libc::Ioctl) }, + "reread verified partition table", + )?; + drop(target); + emit(job, "complete", info.bytes)?; + Ok(()) +} +pub fn run() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + return Err(Error("Internal media worker requires root".into())); + } + let mut input = io::BufReader::with_capacity(1, io::stdin()); + let mut line = String::new(); + (&mut input).take((LIMIT + 1) as u64).read_line(&mut line)?; + if line.len() > LIMIT { + return Err(Error("Oversized media preparation".into())); + } + let mut preparation: Preparation = + serde_json::from_str(&line).map_err(|e| Error(e.to_string()))?; + if preparation.job.id.len() != 32 || !preparation.job.id.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(Error("Invalid media operation identifier".into())); + } + if let Err(e) = perform(&mut preparation, &mut input) { + preparation.job.error = Some(report_error(&e.to_string())); + preparation.job.confirmation = None; + emit(&mut preparation.job, "failed", 0)?; + } + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn untrusted_diagnostics_remain_bounded_and_printable() { + let malformed = format!( + "format = 1\n[cartridge]\nname = \"{}", + "x".repeat(60 * 1024) + ); + let error = fds_common::manifest::Manifest::parse(&malformed).unwrap_err(); + let reported = report_error(&error.to_string()); + assert!(reported.starts_with("Invalid cartridge manifest:")); + assert!(reported.len() <= 2060); + assert!(!reported.chars().any(char::is_control)); + assert_eq!(report_error("a\x1b[2J\r\nb\0"), "a [2J b "); + let multibyte = report_error(&"\u{1f642}".repeat(1000)); + assert!(multibyte.len() <= 2060); + assert!(multibyte.ends_with(" [truncated]")); + } + #[test] + fn loop_ioctl_layout_matches_linux_uapi() { + assert_eq!(std::mem::size_of::(), 232); + assert_eq!(std::mem::size_of::(), 304); + } +} diff --git a/rust/fds-burn/src/write.rs b/rust/fds-burn/src/write.rs new file mode 100644 index 0000000..d3d7381 --- /dev/null +++ b/rust/fds-burn/src/write.rs @@ -0,0 +1,277 @@ +//! Byte-for-byte verified image transfer. GPT backup metadata is relocated to +//! the end of a larger target; filesystem contents are never silently resized. +//! The caller must own an exclusively opened, identity-checked and confirmed disk. +use crate::image::{self, Image}; +use fds_common::{Error, Result}; +use sha2::{Digest, Sha256}; +use std::{ + fs::File, + os::{ + fd::AsRawFd, + unix::fs::{FileExt, FileTypeExt}, + }, +}; + +fn error(s: &str) -> Error { + Error(s.into()) +} +fn apply(buffer: &mut [u8], offset: u64, patch: &[u8], at: u64) { + let start = offset.max(at); + let end = (offset + buffer.len() as u64).min(at + patch.len() as u64); + if start < end { + buffer[(start - offset) as usize..(end - offset) as usize] + .copy_from_slice(&patch[(start - at) as usize..(end - at) as usize]); + } +} +struct Patches { + head: Vec, + tail: Vec, + old_tail: Vec, + new_bytes: u64, + old_bytes: u64, +} +impl Patches { + fn new(source: &File, image: &Image, target_bytes: u64) -> Result { + if target_bytes < image.bytes || target_bytes % 512 != 0 { + return Err(error( + "Target is smaller than the image or is not sector aligned", + )); + } + let mut head = vec![0; 1024 + image::TABLE_BYTES]; + source.read_exact_at(&mut head, 0)?; + let mut tail = vec![0; 512 + image::TABLE_BYTES]; + let tail_offset = image.bytes - tail.len() as u64; + source.read_exact_at(&mut tail, tail_offset)?; + let sectors = target_bytes / 512; + image::put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32); + for (h, lba, backup, entries) in [ + (&mut head[512..1024], 1, sectors - 1, 2), + ( + &mut tail[image::TABLE_BYTES..], + sectors - 1, + 1, + sectors - 33, + ), + ] { + image::put64(h, 24, lba); + image::put64(h, 32, backup); + image::put64(h, 48, sectors - 34); + image::put64(h, 72, entries); + image::put32(h, 16, 0); + let crc = image::crc32(&h[..92]); + image::put32(h, 16, crc); + } + Ok(Self { + head, + tail, + old_tail: vec![0; 512 + image::TABLE_BYTES], + new_bytes: target_bytes, + old_bytes: image.bytes, + }) + } + fn overlay(&self, buffer: &mut [u8], offset: u64) { + apply(buffer, offset, &self.head, 0); + if self.new_bytes != self.old_bytes { + apply( + buffer, + offset, + &self.old_tail, + self.old_bytes - self.old_tail.len() as u64, + ); + } + apply( + buffer, + offset, + &self.tail, + self.new_bytes - self.tail.len() as u64, + ); + } +} +pub fn transfer( + source: &File, + target: &File, + approved: &Image, + target_bytes: u64, + mut progress: impl FnMut(&str, u64) -> Result<()>, +) -> Result<()> { + let expected = approved + .sha256 + .as_ref() + .filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit())) + .ok_or_else(|| error("Write requires a previously approved SHA-256"))?; + if !source.metadata()?.is_file() || source.metadata()?.len() != approved.bytes { + return Err(error("Source image changed type or size")); + } + let mut observed = image::inspect(source, approved.bytes)?; + observed.sha256 = approved.sha256.clone(); + if &observed != approved { + return Err(error("Source image geometry changed after inspection")); + } + progress("checking", 0)?; + if image::digest(source, approved.bytes, |n| progress("checking", n))? != *expected { + return Err(error( + "Source image changed after confirmation; target untouched", + )); + } + let patches = Patches::new(source, approved, target_bytes)?; + let mut original = vec![0; 1024 * 1024]; + let mut output = vec![0; original.len()]; + let mut hash = Sha256::new(); + let mut offset = 0; + progress("writing", 0)?; + while offset < approved.bytes { + let n = original.len().min((approved.bytes - offset) as usize); + source.read_exact_at(&mut original[..n], offset)?; + hash.update(&original[..n]); + output[..n].copy_from_slice(&original[..n]); + patches.overlay(&mut output[..n], offset); + target.write_all_at(&output[..n], offset)?; + offset += n as u64; + if offset % (64 * 1024 * 1024) == 0 || offset == approved.bytes { + progress("writing", offset)?; + } + } + // This also handles a target only one sector larger than the source, where + // the old and new backup tables overlap. + target.write_all_at(&patches.tail, target_bytes - patches.tail.len() as u64)?; + target.sync_all()?; + if image::hex(&hash.finalize()) != *expected { + return Err(error( + "Source changed during transfer; cartridge is incomplete, not SAFE", + )); + } + let metadata = target.metadata()?; + if metadata.file_type().is_block_device() { + // Flush and invalidate the block cache so readback reaches the device. + if unsafe { libc::ioctl(target.as_raw_fd(), 0x1261 as libc::Ioctl) } < 0 { + return Err(std::io::Error::last_os_error().into()); + } + } else if metadata.is_file() { + let result = + unsafe { libc::posix_fadvise(target.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED) }; + if result != 0 { + return Err(std::io::Error::from_raw_os_error(result).into()); + } + } else { + return Err(error("Unexpected target file type")); + } + progress("verifying", 0)?; + let mut hash = Sha256::new(); + let mut offset = 0; + while offset < approved.bytes { + let n = original.len().min((approved.bytes - offset) as usize); + source.read_exact_at(&mut original[..n], offset)?; + hash.update(&original[..n]); + patches.overlay(&mut original[..n], offset); + target.read_exact_at(&mut output[..n], offset)?; + if original[..n] != output[..n] { + return Err(error("Cartridge readback mismatch; no SAFE status issued")); + } + offset += n as u64; + if offset % (64 * 1024 * 1024) == 0 || offset == approved.bytes { + progress("verifying", offset)?; + } + } + let mut backup = vec![0; patches.tail.len()]; + let backup_offset = target_bytes - backup.len() as u64; + target.read_exact_at(&mut backup, backup_offset)?; + if backup != patches.tail || image::hex(&hash.finalize()) != *expected { + return Err(error( + "Image or backup GPT changed during verification; no SAFE status issued", + )); + } + let target_info = image::inspect(target, target_bytes)?; + if target_info.class != approved.class + || target_info.partition_start != approved.partition_start + || target_info.partition_bytes != approved.partition_bytes + || target_info.partitions != approved.partitions + || target_info.disk_uuid != approved.disk_uuid + { + return Err(error("Written cartridge geometry failed verification")); + } + target.sync_all()?; + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + use fds_common::manifest::Class; + use std::os::fd::FromRawFd; + fn memory() -> File { + let fd = unsafe { libc::memfd_create(c"fds-write-test".as_ptr(), libc::MFD_CLOEXEC) }; + assert!(fd >= 0); + unsafe { File::from_raw_fd(fd) } + } + fn source() -> (File, Image) { + let f = memory(); + let l = image::Layout::new(Class::Program, 1024 * 1024, None, [1; 16], [2; 16]).unwrap(); + l.write(&f).unwrap(); + f.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], image::FIRST_LBA * 512 + 1024) + .unwrap(); + f.write_all_at( + b"Approved application contents", + image::FIRST_LBA * 512 + 8192, + ) + .unwrap(); + let mut i = image::inspect(&f, l.bytes).unwrap(); + i.sha256 = Some(image::digest(&f, l.bytes, |_| Ok(())).unwrap()); + (f, i) + } + #[test] + fn exact_and_larger_targets_keep_verified_payload_and_valid_backup() { + for extra in [0, 512, 16 * 1024, 1024 * 1024] { + let (source, i) = source(); + let target = memory(); + target.set_len(i.bytes + extra).unwrap(); + transfer(&source, &target, &i, i.bytes + extra, |_, _| Ok(())).unwrap(); + let parsed = image::inspect(&target, i.bytes + extra).unwrap(); + assert_eq!(parsed.partition_bytes, i.partition_bytes); + if extra == 0 { + assert_eq!( + image::digest(&target, i.bytes, |_| Ok(())).unwrap(), + i.sha256.unwrap() + ); + } + } + } + #[test] + fn changed_source_is_rejected_before_writing() { + let (source, i) = source(); + let target = memory(); + target.set_len(i.bytes).unwrap(); + target.write_all_at(b"UNCHANGED", 0).unwrap(); + source + .write_all_at(b"x", image::FIRST_LBA * 512 + 8192) + .unwrap(); + assert!(transfer(&source, &target, &i, i.bytes, |_, _| Ok(())).is_err()); + let mut marker = [0; 9]; + target.read_exact_at(&mut marker, 0).unwrap(); + assert_eq!(&marker, b"UNCHANGED"); + } + #[test] + fn faults_during_write_and_readback_never_succeed() { + let (source, i) = source(); + let target = memory(); + target.set_len(i.bytes).unwrap(); + let result = transfer(&source, &target, &i, i.bytes, |phase, n| { + if phase == "verifying" && n == 0 { + target.write_all_at(b"wrong", image::FIRST_LBA * 512 + 8192)?; + } + Ok(()) + }); + assert!( + result + .unwrap_err() + .to_string() + .contains("readback mismatch") + ); + let result = transfer(&source, &target, &i, i.bytes, |phase, _| { + if phase == "writing" { + Err(error("Cancelled")) + } else { + Ok(()) + } + }); + assert!(result.is_err()); + } +} diff --git a/rust/fds-cartridged/.gitkeep b/rust/fds-cartridged/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rust/fds-cartridged/Cargo.toml b/rust/fds-cartridged/Cargo.toml new file mode 100644 index 0000000..87b62b6 --- /dev/null +++ b/rust/fds-cartridged/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "fds-cartridged" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "FDS event-driven cartridge and physical bay manager" + +[dependencies] +clap.workspace = true +fds-common = { path = "../fds-common" } +fds-software = { path = "../fds-software" } +fds-burn = { path = "../fds-burn" } +serde = { version = "1", features = ["derive"] } +libc = "0.2" +serde_json = "1" + +[[bin]] +name = "fds-cartridged" +path = "src/main.rs" + +[[bin]] +name = "fds-profile" +path = "src/profile-main.rs" diff --git a/rust/fds-cartridged/src/burning.rs b/rust/fds-cartridged/src/burning.rs new file mode 100644 index 0000000..1b024c1 --- /dev/null +++ b/rust/fds-cartridged/src/burning.rs @@ -0,0 +1,358 @@ +//! One background writer, with root-owned operation records retained until the +//! physical insertion changes. Restart never turns an interrupted write SAFE. +use crate::media::checked; +use fds_burn::{device::Disk, image, worker::Preparation}; +use fds_common::{ + Bay, Error, Result, + control::{LIMIT, MediaJob}, + manifest::Class, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + io::{self, Read, Write}, + os::{ + fd::AsRawFd, + unix::{fs::PermissionsExt, process::CommandExt}, + }, + path::Path, + process::{Child, ChildStdout, Command, Stdio}, +}; +const DIRECTORY: &str = "/run/fds/burn"; +#[derive(Serialize, Deserialize)] +struct Record { + disk: Disk, + job: MediaJob, + owner: u32, +} +struct Worker { + child: Child, + output: ChildStdout, + input: Vec, + bay: Bay, + confirmed: bool, + cancelled: bool, +} +#[derive(Default)] +pub struct Manager { + records: BTreeMap, + active: Option, +} +impl Manager { + pub fn active(&self) -> bool { + self.active.is_some() + } + pub fn load() -> Result { + fs::create_dir_all(DIRECTORY)?; + fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?; + let mut this = Self::default(); + for n in 1..=12 { + let bay = Bay::try_from(n)?; + let path = format!("{DIRECTORY}/{bay}.json"); + if Path::new(&path).exists() { + let mut record: Record = + serde_json::from_str(&fds_common::read_text(Path::new(&path), LIMIT as u64)?) + .map_err(|e| Error(format!("Invalid burn recovery record: {e}")))?; + if record.job.bay != bay { + return Err(Error("Burn recovery bay mismatch".into())); + } + if !record.job.finished() { + record.job.phase = "failed".into(); + record.job.sequence += 1; + record.job.confirmation = None; + record.job.error = Some( + "Media service restarted during an operation; cartridge is not SAFE".into(), + ); + } + this.records.insert(bay, record); + this.save(bay)?; + } + } + Ok(this) + } + fn save(&self, bay: Bay) -> Result<()> { + let path = format!("{DIRECTORY}/{bay}.json"); + fs::write( + format!("{path}.next"), + serde_json::to_vec(&self.records[&bay]).map_err(|e| Error(e.to_string()))?, + )?; + fs::rename(format!("{path}.next"), path)?; + Ok(()) + } + pub fn reserved(&self, bay: Bay) -> Option<&MediaJob> { + self.records + .get(&bay) + .filter(|r| r.disk.present()) + .map(|r| &r.job) + } + pub fn status(&self, id: &str) -> Result { + self.records + .values() + .find(|r| r.job.id == id) + .map(|r| r.job.clone()) + .ok_or_else(|| Error("Unknown media operation".into())) + } + pub fn begin( + &mut self, + bay: Bay, + usb: &Path, + path: &str, + class: Class, + uid: u32, + ) -> Result { + if self.active.is_some() { + return Err(Error( + "Another media operation is active; finish or cancel it first".into(), + )); + } + image::label(class)?; + if !path.starts_with('/') || path.len() > 4096 || path.contains('\0') { + return Err(Error( + "Image path must be an absolute path of at most 4096 bytes".into(), + )); + } + let disk = Disk::select_current(usb)?; + disk.protect(Path::new("/sys"), Path::new("/proc"))?; + let job = MediaJob { + id: image::hex(&image::random_id()?), + bay, + sequence: 0, + phase: "inspecting".into(), + diskseq: disk.diskseq, + target_bytes: disk.bytes, + model: disk.model.clone(), + serial: disk.serial.clone(), + image_class: class, + image_bytes: None, + image_sha256: None, + progress_bytes: 0, + confirmation: None, + error: None, + }; + let preparation = Preparation { + disk: disk.clone(), + image_path: path.into(), + uid, + job: job.clone(), + }; + self.records.insert( + bay, + Record { + disk, + job: job.clone(), + owner: uid, + }, + ); + self.save(bay)?; + let parent = unsafe { libc::getpid() }; + let mut command = Command::new("/usr/bin/fds-burn"); + command + .arg("--worker") + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + unsafe { + command.pre_exec(move || { + let mut mask: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 + || libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0 + { + return Err(io::Error::last_os_error()); + } + if libc::getppid() != parent { + return Err(io::Error::other("Cartridge service exited")); + } + Ok(()) + }); + } + let started = (|| -> Result { + let mut child = command.spawn()?; + let output = child.stdout.take().unwrap(); + let setup = (|| -> Result<()> { + checked( + unsafe { libc::fcntl(output.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK) }, + "make burn status asynchronous", + )?; + let mut data = + serde_json::to_vec(&preparation).map_err(|e| Error(e.to_string()))?; + data.push(b'\n'); + child.stdin.as_mut().unwrap().write_all(&data)?; + Ok(()) + })(); + if let Err(e) = setup { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + Ok(Worker { + child, + output, + input: Vec::new(), + bay, + confirmed: false, + cancelled: false, + }) + })(); + match started { + Ok(worker) => self.active = Some(worker), + Err(e) => { + let record = self.records.get_mut(&bay).unwrap(); + record.job.phase = "failed".into(); + record.job.error = Some(e.to_string()); + record.job.sequence += 1; + self.save(bay)?; + return Err(e); + } + } + Ok(job) + } + pub fn descriptor(&self) -> i32 { + self.active.as_ref().map_or(-1, |w| w.output.as_raw_fd()) + } + pub fn poll(&mut self) -> Result<()> { + let Some(worker) = self.active.as_mut() else { + return Ok(()); + }; + let bay = worker.bay; + let mut changed = false; + loop { + let mut chunk = [0; 8192]; + match worker.output.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + worker.input.extend_from_slice(&chunk[..n]); + if worker.input.len() > LIMIT { + return Err(Error("Media worker status exceeded protocol limit".into())); + } + while let Some(end) = worker.input.iter().position(|b| *b == b'\n') { + let report: MediaJob = serde_json::from_slice(&worker.input[..end]) + .map_err(|e| Error(format!("Invalid media worker status: {e}")))?; + worker.input.drain(..=end); + let record = self.records.get_mut(&bay).unwrap(); + if report.id != record.job.id + || report.bay != bay + || report.diskseq != record.disk.diskseq + || report.sequence <= record.job.sequence + { + return Err(Error("Media worker identity or sequence changed".into())); + } + record.job = report; + changed = true; + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e.into()), + } + } + let exited = worker.child.try_wait()?; + if let Some(status) = exited { + // The child can write its last report between EAGAIN and waitpid. + // Once it has exited, drain that final pipe data before deciding + // whether completion was reported. + worker.output.read_to_end(&mut worker.input)?; + while let Some(end) = worker.input.iter().position(|b| *b == b'\n') { + let report: MediaJob = serde_json::from_slice(&worker.input[..end]) + .map_err(|e| Error(e.to_string()))?; + worker.input.drain(..=end); + let record = self.records.get_mut(&bay).unwrap(); + if report.id != record.job.id + || report.bay != bay + || report.diskseq != record.disk.diskseq + || report.sequence <= record.job.sequence + { + return Err(Error( + "Final media worker identity or sequence changed".into(), + )); + } + record.job = report; + changed = true; + } + let record = self.records.get_mut(&bay).unwrap(); + if !status.success() || !record.job.finished() { + record.job.phase = "failed".into(); + record.job.sequence += 1; + record.job.confirmation = None; + record.job.error = Some(format!( + "Media worker exited without verified completion ({status}); no SAFE status issued" + )); + changed = true; + } + self.active = None; + } + if changed { + self.save(bay)?; + } + Ok(()) + } + fn control(&mut self, id: &str, uid: u32, line: &str, confirm: bool) -> Result { + let job = self.status(id)?; + let record = &self.records[&job.bay]; + if uid != 0 && uid != record.owner { + return Err(Error( + "Only the operation's owner may confirm or cancel it".into(), + )); + } + if confirm + && (job.phase != "awaiting_confirmation" || job.confirmation.as_deref() != Some(line)) + { + return Err(Error( + "Confirmation must exactly match this cartridge and image".into(), + )); + } + let worker = self + .active + .as_mut() + .filter(|w| w.bay == job.bay) + .ok_or_else(|| Error("Media operation is no longer active".into()))?; + if job.finished() { + return Err(Error("Media operation already finished".into())); + } + if (confirm && worker.confirmed) || worker.cancelled { + return Err(Error("Media control command was already sent".into())); + } + if confirm { + worker.confirmed = true; + } else { + worker.cancelled = true; + } + let input = worker + .child + .stdin + .as_mut() + .ok_or_else(|| Error("Media worker input closed".into()))?; + input.write_all(line.as_bytes())?; + input.write_all(b"\n")?; + Ok(job) + } + pub fn confirm(&mut self, id: &str, uid: u32, phrase: &str) -> Result { + self.control(id, uid, phrase, true) + } + pub fn cancel(&mut self, id: &str, uid: u32) -> Result { + self.control(id, uid, "CANCEL", false) + } + pub fn stop(&mut self) -> Result<()> { + if let Some(worker) = self.active.as_mut() { + worker.child.stdin.take(); + } + while self.active.is_some() { + self.poll()?; + if self.active.is_some() { + let mut fd = libc::pollfd { + fd: self.descriptor(), + events: libc::POLLIN, + revents: 0, + }; + let result = unsafe { libc::poll(&mut fd, 1, 1000) }; + if result < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted { + return Err(io::Error::last_os_error().into()); + } + } + } + Ok(()) + } +} diff --git a/rust/fds-cartridged/src/consumers.rs b/rust/fds-cartridged/src/consumers.rs new file mode 100644 index 0000000..c51ed66 --- /dev/null +++ b/rust/fds-cartridged/src/consumers.rs @@ -0,0 +1,257 @@ +//! Managed jobs enter a root-owned cgroup before losing privileges or executing. +//! Descendants inherit membership; they cannot escape by double-forking. +use crate::media::{c, checked}; +use fds_common::{Bay, Error, Result, read_text}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Seek, SeekFrom}, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd}, + unix::{ + fs::{OpenOptionsExt, PermissionsExt}, + process::CommandExt, + }, + }, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +const ROOT: &str = "/sys/fs/cgroup/fds"; + +pub fn prepare() -> Result<()> { + let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; + checked( + unsafe { libc::statfs(c("/sys/fs/cgroup")?.as_ptr(), &mut stat) }, + "inspect cgroup filesystem", + )?; + if stat.f_type as u64 == 0x6265_6572 { + checked( + unsafe { + libc::mount( + c("none")?.as_ptr(), + c("/sys/fs/cgroup")?.as_ptr(), + c("cgroup2")?.as_ptr(), + libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + std::ptr::null(), + ) + }, + "mount managed process hierarchy", + )?; + } else if stat.f_type as u64 != 0x6367_7270 { + return Err(Error( + "Managed programs require the cgroup v2 filesystem".into(), + )); + } + fs::create_dir_all(ROOT)?; + fs::set_permissions(ROOT, fs::Permissions::from_mode(0o755))?; + fs::write("/sys/fs/cgroup/cgroup.subtree_control", b"+pids")?; + fs::write(Path::new(ROOT).join("cgroup.subtree_control"), b"+pids")?; + Ok(()) +} +fn directory(name: &str) -> PathBuf { + Path::new(ROOT).join(name) +} +pub fn enter_service(name: &str) -> Result<()> { + if unsafe { libc::geteuid() } != 0 || !fds_common::manifest::identifier(name) { + return Err(Error("Invalid privileged service group".into())); + } + let path = directory(name); + fs::create_dir_all(&path)?; + fs::write(path.join("pids.max"), b"256")?; + fs::write(path.join("cgroup.procs"), b"0")?; + Ok(()) +} +pub fn start( + bay: Bay, + arguments: &[String], + working: &str, + environment: &[(String, String)], +) -> Result { + start_group(&format!("bay{bay}"), arguments, working, environment) +} +pub fn start_group( + name: &str, + arguments: &[String], + working: &str, + environment: &[(String, String)], +) -> Result { + if !fds_common::manifest::identifier(name) { + return Err(Error("Invalid process group".into())); + } + if arguments.is_empty() + || !arguments[0].starts_with('/') + || arguments.len() > 128 + || arguments.iter().any(|a| a.contains('\0')) + { + return Err(Error( + "Managed run requires an absolute executable path and at most 128 arguments".into(), + )); + } + let path = directory(name); + fs::create_dir_all(&path)?; + fs::write(path.join("pids.max"), b"256")?; + let group = OpenOptions::new() + .write(true) + .custom_flags(libc::O_CLOEXEC) + .open(path.join("cgroup.procs"))?; + let mut command = Command::new(&arguments[0]); + command + .args(&arguments[1..]) + .current_dir(working) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", "/home/fds") + .env("USER", "fds") + .env("LOGNAME", "fds") + .env("LANG", "en_US.UTF-8") + .env("SHELL", "/bin/bash") + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + command.envs(environment.iter().cloned()); + // Only async-signal-safe syscalls are used in the forked child. Writing 0 + // moves the child itself, avoiding PID reuse and parent/child migration races. + unsafe { + command.pre_exec(move || { + if libc::write(group.as_raw_fd(), b"0".as_ptr().cast(), 1) != 1 { + return Err(io::Error::last_os_error()); + } + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + if libc::setgroups(0, std::ptr::null()) < 0 + || libc::setgid(1000) < 0 + || libc::setuid(1000) < 0 + { + return Err(io::Error::last_os_error()); + } + if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0 { + return Err(io::Error::last_os_error()); + } + let mut mask: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + command.spawn().map_err(Into::into) +} +pub fn count(bay: Bay) -> Result { + let path = directory(&format!("bay{bay}")).join("cgroup.procs"); + if !path.exists() { + return Ok(0); + } + Ok(read_text(&path, 1024 * 1024)?.lines().count()) +} +fn populated(events: &mut File) -> Result { + events.seek(SeekFrom::Start(0))?; + let mut text = String::new(); + events.take(4096).read_to_string(&mut text)?; + if text.lines().any(|s| s == "populated 0") { + Ok(false) + } else if text.lines().any(|s| s == "populated 1") { + Ok(true) + } else { + Err(Error("Invalid cgroup event state".into())) + } +} +fn wait_empty(events: &mut File, limit: Duration) -> Result { + let deadline = Instant::now() + limit; + loop { + if !populated(events)? { + return Ok(true); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(false); + } + let mut descriptor = libc::pollfd { + fd: events.as_raw_fd(), + events: libc::POLLPRI | libc::POLLERR, + revents: 0, + }; + let result = unsafe { + libc::poll( + &mut descriptor, + 1, + remaining.as_millis().max(1).min(i32::MAX as u128) as i32, + ) + }; + if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + checked(result, "wait for cartridge consumers")?; + } +} +pub fn stop(bay: Bay) -> Result<()> { + stop_group(&format!("bay{bay}")) +} +pub fn stop_group(name: &str) -> Result<()> { + if !fds_common::manifest::identifier(name) { + return Err(Error("Invalid process group".into())); + } + let path = directory(name); + if !path.exists() { + return Ok(()); + } + let mut events = File::open(path.join("cgroup.events"))?; + if !populated(&mut events)? { + return Ok(()); + } + let membership = format!("0::/fds/{name}"); + for value in read_text(&path.join("cgroup.procs"), 1024 * 1024)?.lines() { + let pid: i32 = value + .parse() + .map_err(|_| Error("Invalid consumer process ID".into()))?; + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as libc::c_int; + if fd < 0 { + if io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + continue; + } + checked(fd, "open consumer process handle")?; + } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + // Verify membership after opening the stable handle. A recycled PID in + // another cgroup must never receive a signal intended for a consumer. + let current = match read_text( + &Path::new("/proc").join(pid.to_string()).join("cgroup"), + 16384, + ) { + Ok(s) => s, + Err(_) => continue, + }; + if current.lines().any(|s| s == membership) { + let sent = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + fd.as_raw_fd(), + libc::SIGTERM, + std::ptr::null::(), + 0, + ) + } as libc::c_int; + if sent < 0 && io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { + checked(sent, "stop cartridge consumer")?; + } + } + } + if !wait_empty(&mut events, Duration::from_secs(1))? { + // The kernel kills the entire cgroup atomically, including forks made + // after the TERM snapshot. This is an exit deadline, not a fixed wait. + fs::write(path.join("cgroup.kill"), b"1")?; + if !wait_empty(&mut events, Duration::from_secs(2))? { + return Err(Error( + "Cartridge consumers did not exit; media remains mounted".into(), + )); + } + } + Ok(()) +} +pub fn stop_all() -> Result<()> { + for n in 1..=12 { + stop(Bay::try_from(n)?)?; + } + Ok(()) +} diff --git a/rust/fds-cartridged/src/data_sessions.rs b/rust/fds-cartridged/src/data_sessions.rs new file mode 100644 index 0000000..edc7890 --- /dev/null +++ b/rust/fds-cartridged/src/data_sessions.rs @@ -0,0 +1,87 @@ +//! A DATA session outlives the daemon process. Losing its writeback descriptor +//! must never turn an earlier I/O error into a fresh, apparently healthy session. +use fds_common::{Bay, Error, Result, read_text}; +use serde::{Deserialize, Serialize}; +use std::{fs, io, os::unix::fs::PermissionsExt, path::Path}; + +const DIRECTORY: &str = "/run/fds/data-sessions"; +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + key: String, + fault: Option, +} +impl Record { + fn fault_for(&self, key: &str) -> Option { + (self.key == key).then(|| { + self.fault.clone().unwrap_or_else(|| { + "DATA service was interrupted before verified unmount; recover this cartridge before writable use".into() + }) + }) + } +} +pub fn prepare() -> Result<()> { + fs::create_dir_all(DIRECTORY)?; + fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?; + Ok(()) +} +fn path(bay: Bay) -> String { + format!("{DIRECTORY}/{bay}.json") +} +pub fn begin(bay: Bay, key: &str) -> Result<()> { + save(bay, key, None) +} +pub fn save(bay: Bay, key: &str, fault: Option<&str>) -> Result<()> { + let path = path(bay); + let record = Record { + key: key.into(), + fault: fault.map(Into::into), + }; + fs::write( + format!("{path}.next"), + serde_json::to_vec(&record).map_err(|e| Error(e.to_string()))?, + )?; + fs::rename(format!("{path}.next"), path)?; + Ok(()) +} +pub fn recovered_fault(bay: Bay, key: &str) -> Result> { + let path = path(bay); + if !Path::new(&path).try_exists()? { + return Ok(None); + } + let record: Record = serde_json::from_str(&read_text(Path::new(&path), 16 * 1024)?) + .map_err(|e| Error(format!("Invalid DATA session record: {e}")))?; + Ok(record.fault_for(key)) +} +pub fn clear(bay: Bay) -> Result<()> { + match fs::remove_file(path(bay)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn interrupted_sessions_and_faults_follow_only_the_same_insertion() { + let mut record = Record { + key: "usb/2:8:1:91".into(), + fault: None, + }; + assert!( + record + .fault_for(&record.key) + .unwrap() + .contains("interrupted") + ); + assert!(record.fault_for("usb/2:8:1:92").is_none()); + record.fault = Some("flush failed: I/O error".into()); + let decoded: Record = + serde_json::from_slice(&serde_json::to_vec(&record).unwrap()).unwrap(); + assert_eq!( + decoded.fault_for(&record.key).as_deref(), + Some("flush failed: I/O error") + ); + } +} diff --git a/rust/fds-cartridged/src/main.rs b/rust/fds-cartridged/src/main.rs new file mode 100644 index 0000000..345d274 --- /dev/null +++ b/rust/fds-cartridged/src/main.rs @@ -0,0 +1,66 @@ +mod burning; +mod consumers; +mod data_sessions; +mod media; +mod power; +mod profiles; +mod recovery; +mod server; +mod software; +use clap::Parser; +use fds_common::{Error, Result, topology}; +use std::{path::PathBuf, process::ExitCode}; + +#[derive(Parser)] +#[command( + version, + about = "Run the root cartridge service or inspect USB topology" +)] +struct Cli { + /// Notify s6 when the service is ready. + #[arg(long, conflicts_with = "topology")] + notify: bool, + /// Inspect topology without starting the service or mounting devices. + #[arg(long, value_name = "SYSFS_ROOT")] + topology: Option, +} +fn run() -> Result<()> { + let cli = Cli::parse(); + if let Some(sys) = cli.topology { + println!( + "{}", + serde_json::to_string_pretty(&topology::devices(&sys)?) + .map_err(|e| Error(e.to_string()))? + ); + Ok(()) + } else { + server::run(cli.notify) + } +} +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("fds-cartridged: {e}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::{CommandFactory, Parser}; + #[test] + fn typed_command_contract() { + Cli::command().debug_assert(); + assert!(!Cli::try_parse_from(["fds-cartridged"]).unwrap().notify); + assert!( + Cli::try_parse_from(["fds-cartridged", "--notify"]) + .unwrap() + .notify + ); + assert!(Cli::try_parse_from(["fds-cartridged", "--topology", "/sys"]).is_ok()); + assert!(Cli::try_parse_from(["fds-cartridged", "--notify", "--topology", "/sys"]).is_err()); + } +} diff --git a/rust/fds-cartridged/src/media.rs b/rust/fds-cartridged/src/media.rs new file mode 100644 index 0000000..ac0cbe6 --- /dev/null +++ b/rust/fds-cartridged/src/media.rs @@ -0,0 +1,548 @@ +use crate::data_sessions; +use fds_common::{ + Bay, Error, Result, + manifest::{Class, Manifest}, + read_text, + sysfs::{self, BlockPartition}, +}; +use std::{ + ffi::CString, + fs::{self, File, OpenOptions}, + io::{self, Read}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, + }, + path::Path, +}; + +pub fn checked(value: libc::c_int, action: &str) -> Result<()> { + if value < 0 { + Err(Error(format!("{action}: {}", io::Error::last_os_error()))) + } else { + Ok(()) + } +} +pub fn c(value: &str) -> Result { + CString::new(value).map_err(|_| Error("NUL in syscall argument".into())) +} +pub fn unmount(path: &str, removed: bool) -> Result<()> { + // Detach is restricted to confirmed surprise-removal cleanup, never safe eject. + checked( + unsafe { + libc::umount2( + c(path)?.as_ptr(), + if removed { libc::MNT_DETACH } else { 0 }, + ) + }, + "unmount cartridge", + ) +} +pub fn manifest(root: &str) -> Result { + Manifest::parse(&metadata(root, "CARTRIDGE.TOML")?) +} +pub fn metadata(root: &str, name: &str) -> Result { + // Each component is opened relative to an existing directory descriptor; + // a cartridge cannot redirect privileged metadata reads through symlinks. + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(root)?; + let dir = unsafe { + libc::openat( + root.as_raw_fd(), + c("FDS")?.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + checked(dir, "open FDS metadata directory")?; + let dir = unsafe { File::from_raw_fd(dir) }; + let fd = unsafe { + libc::openat( + dir.as_raw_fd(), + c(name)?.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK, + ) + }; + checked(fd, "open cartridge manifest")?; + let file = unsafe { File::from_raw_fd(fd) }; + if !file.metadata()?.is_file() { + return Err(Error("Cartridge manifest is not a regular file".into())); + } + let mut text = String::new(); + file.take(fds_common::MAX_CONFIG_BYTES + 1) + .read_to_string(&mut text)?; + if text.len() as u64 > fds_common::MAX_CONFIG_BYTES { + return Err(Error("Cartridge metadata exceeds 64 KiB".into())); + } + Ok(text) +} +pub struct Mounted { + pub bay: Bay, + pub key: String, + pub path: String, + pub manifest: Manifest, + pub protected: bool, + pub source: File, + pub writable: bool, + pub sync_handle: Option, + pub fault: Option, + pub partition: BlockPartition, + pub software: Option, +} +impl Mounted { + pub fn present(&self) -> bool { + key(&self.partition).is_ok_and(|key| key == self.key) + } + pub fn activate_data(&mut self) -> Result<()> { + if self.manifest.cartridge.class != Class::Data || self.protected { + return Err(Error("This is not a DATA cartridge".into())); + } + if let Some(fault) = &self.fault { + return Err(Error(format!("DATA is quarantined: {fault}"))); + } + if self.writable { + return Ok(()); + } + self.ensure_exclusive_mount()?; + data_sessions::begin(self.bay, &self.key)?; + if let Err(error) = unmount(&self.path, false) { + data_sessions::clear(self.bay)?; + return Err(error); + } + let result = checked( + unsafe { + libc::mount( + c(&format!("/proc/self/fd/{}", self.source.as_raw_fd()))?.as_ptr(), + c("/data")?.as_ptr(), + c("ext4")?.as_ptr(), + libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + std::ptr::null(), + ) + }, + "mount writable DATA", + ); + if let Err(error) = result { + // Restore read-only visibility when activation fails. If restoration + // also fails, retain a fault and never report this insertion SAFE. + let restored = checked( + unsafe { + libc::mount( + c(&format!("/proc/self/fd/{}", self.source.as_raw_fd()))?.as_ptr(), + c(&self.path)?.as_ptr(), + c("ext4")?.as_ptr(), + libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + c("noload")?.as_ptr().cast(), + ) + }, + "restore read-only DATA", + ); + if let Err(restore_error) = restored { + self.record_fault(&restore_error)?; + } else { + data_sessions::clear(self.bay)?; + } + return Err(error); + } + self.path = "/data".into(); + self.writable = true; + // Retain a filesystem descriptor from activation to observe writeback + // errors across the session. A block-device descriptor cannot do this. + match File::open(&self.path) { + Ok(handle) => self.sync_handle = Some(handle), + Err(error) => { + let error = Error(format!("Open DATA writeback handle: {error}")); + self.record_fault(&error)?; + return Err(error); + } + } + Ok(()) + } + fn record_fault(&mut self, error: &Error) -> Result<()> { + self.fault = Some(error.to_string()); + data_sessions::save(self.bay, &self.key, self.fault.as_deref()) + } + fn data_readonly(&self, readonly: bool) -> io::Result<()> { + let result = unsafe { + libc::mount( + std::ptr::null(), + c(&self.path).map_err(io::Error::other)?.as_ptr(), + std::ptr::null(), + libc::MS_REMOUNT + | libc::MS_NOSUID + | libc::MS_NODEV + | libc::MS_NOEXEC + | if readonly { libc::MS_RDONLY } else { 0 }, + std::ptr::null(), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + pub fn program( + &mut self, + arguments: &[String], + ) -> Result<(Vec, Vec<(String, String)>)> { + if self.manifest.cartridge.class != Class::Program + || self.fault.is_some() + || !self.present() + { + return Err(Error("No healthy PROGRAM cartridge in this bay".into())); + } + self.ensure_exclusive_mount()?; + if let Some(software) = &mut self.software { + return software.program(arguments); + } + let name = arguments + .first() + .ok_or_else(|| Error("Specify an executable name from app/bin".into()))?; + if !fds_common::manifest::identifier(name) { + return Err(Error( + "PROGRAM executable must be a simple name from app/bin".into(), + )); + } + let app = fs::canonicalize(Path::new(&self.path).join("app"))?; + let executable = fs::canonicalize(app.join("bin").join(name))?; + if !app.starts_with(&self.path) || !executable.starts_with(&app) || !executable.is_file() { + return Err(Error("PROGRAM executable escapes its app directory".into())); + } + self.ensure_exclusive_mount()?; + checked( + unsafe { + libc::mount( + std::ptr::null(), + c(&self.path)?.as_ptr(), + std::ptr::null(), + libc::MS_REMOUNT | libc::MS_RDONLY | libc::MS_NODEV | libc::MS_NOSUID, + std::ptr::null(), + ) + }, + "enable read-only PROGRAM execution", + )?; + let mut args = arguments.to_vec(); + args[0] = executable.to_string_lossy().into_owned(); + let env = vec![ + ("FDS_APP".into(), app.display().to_string()), + ( + "PATH".into(), + format!("{}/bin:/usr/bin:/bin", app.display()), + ), + ( + "LD_LIBRARY_PATH".into(), + app.join("lib").display().to_string(), + ), + ( + "XDG_DATA_DIRS".into(), + format!("{}/share:/usr/share", app.display()), + ), + ("DISPLAY".into(), ":0".into()), + ("XAUTHORITY".into(), "/run/fds/x11/authority".into()), + ]; + Ok((args, env)) + } + pub fn eject(&mut self) -> Result<()> { + if let Some(fault) = &self.fault { + return Err(Error(format!( + "DATA I/O fault: {fault}; no SAFE status issued" + ))); + } + self.ensure_exclusive_mount()?; + if self.writable { + let handle = self + .sync_handle + .as_ref() + .ok_or_else(|| Error("Missing DATA writeback handle".into()))?; + if let Err(error) = checked( + unsafe { libc::syncfs(handle.as_raw_fd()) }, + "flush DATA filesystem", + ) { + self.record_fault(&error)?; + return Err(error); + } + // Stop new writers before releasing the descriptor that tracks + // writeback errors. A busy writer leaves this descriptor open. + // MS_REMOUNT without MS_BIND makes the filesystem itself read-only. + if let Err(error) = self.data_readonly(true) { + let busy = error.raw_os_error() == Some(libc::EBUSY); + let error = Error(format!("Make DATA read-only before unmount: {error}")); + if !busy { + self.record_fault(&error)?; + } + return Err(error); + } + if let Err(error) = checked( + unsafe { libc::syncfs(handle.as_raw_fd()) }, + "verify DATA writeback after read-only transition", + ) { + self.record_fault(&error)?; + return Err(error); + } + // Our descriptor itself makes the mount busy. Close it only after + // syncfs succeeds, immediately before the ordinary unmount. + self.sync_handle.take(); + } + if let Some(software) = &mut self.software { + software.release(false)?; + } + match unmount(&self.path, false) { + Ok(()) => { + if self.writable { + data_sessions::clear(self.bay)?; + } + Ok(()) + } + Err(error) => { + if self.writable { + // Still read-only: establish the new error cursor before + // restoring writes, so no writeback failure is skipped. + let restored = File::open(&self.path).and_then(|handle| { + self.sync_handle = Some(handle); + self.data_readonly(false) + }); + if let Err(restore_error) = restored { + let restore_error = + Error(format!("Restore DATA after busy unmount: {restore_error}")); + self.record_fault(&restore_error)?; + return Err(restore_error); + } + } + Err(error) + } + } + } + pub fn removed(&mut self) -> Result<()> { + if let Some(software) = &mut self.software { + software.release(true)?; + } + self.sync_handle.take(); + unmount(&self.path, true) + } + pub fn ensure_exclusive_mount(&self) -> Result<()> { + if let Some(software) = &self.software { + software.exclusive()?; + } + let dev = self.source.metadata()?.rdev(); + let identity = format!("{}:{}", libc::major(dev), libc::minor(dev)); + let table = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?; + let matching: Vec<_> = table + .lines() + .filter(|line| line.split_whitespace().nth(2) == Some(identity.as_str())) + .collect(); + if matching.len() != 1 || matching[0].split_whitespace().nth(4) != Some(self.path.as_str()) + { + return Err(Error( + "Cartridge has additional or unexpected mounts; close them before eject".into(), + )); + } + Ok(()) + } +} +pub fn partitions_for(usb: &Path) -> Result> { + Ok(sysfs::partitions(Path::new("/sys"))? + .into_iter() + .filter(|part| { + let name = Path::new(&part.device).file_name().unwrap(); + fs::canonicalize(Path::new("/sys/class/block").join(name)) + .is_ok_and(|path| path.starts_with(usb)) + }) + .collect()) +} +pub fn key(part: &BlockPartition) -> Result { + let path = fs::canonicalize( + Path::new("/sys/dev/block").join(format!("{}:{}", part.major, part.minor)), + )?; + let disk = path + .parent() + .ok_or_else(|| Error("Missing parent disk".into()))?; + let sequence = read_text(&disk.join("diskseq"), 64)?; + Ok(format!( + "{}:{}:{}:{}", + path.display(), + part.major, + part.minor, + sequence.trim() + )) +} +/// Partition-table rereads can briefly remove every partition without removing +/// its physical disk. Preserve an eject record until that insertion is gone. +pub fn key_disk_present(key: &str) -> Result { + let fields: Vec<_> = key.rsplitn(4, ':').collect(); + if fields.len() != 4 || fields[..3].iter().any(|s| s.parse::().is_err()) { + return Err(Error("Invalid saved cartridge insertion identity".into())); + } + let partition = Path::new(fields[3]); + let parent = partition + .parent() + .ok_or_else(|| Error("Missing saved disk path".into()))?; + if !parent.starts_with("/sys/devices") { + return Err(Error( + "Saved disk identity is outside kernel devices".into(), + )); + } + let sequence = match read_text(&parent.join("diskseq"), 64) { + Ok(sequence) => sequence, + Err(_) if !parent.exists() => return Ok(false), + Err(error) => return Err(error), + }; + Ok(sequence.trim() == fields[0]) +} +pub fn mount(bay: Bay, part: &BlockPartition, key: String) -> Result { + let fault = if part.partition_name == "FDS_DATA" { + data_sessions::recovered_fault(bay, &key)? + } else { + None + }; + let source = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&part.device)?; + let st = source.metadata()?; + if !st.file_type().is_block_device() + || libc::major(st.rdev()) != part.major + || libc::minor(st.rdev()) != part.minor + || self::key(part)? != key + { + return Err(Error("Device changed during inspection".into())); + } + let root = fs::metadata("/")?; + let protected = root.dev() == st.rdev(); + let expected = match part.partition_name.as_str() { + "FDS_SYSTEM" => Class::System, + "FDS_DATA" => Class::Data, + "FDS_PROGRAM" | "FDS_METADATA" => Class::Program, + "FDS_ENVIRONMENT" => Class::Environment, + "FDS_UTILITY" => Class::Utility, + _ => return Err(Error("Unrecognized cartridge partition name".into())), + }; + let published = format!("/run/fds/media/{bay}"); + let path = if protected { + "/".into() + } else { + format!("/run/fds/probe/{bay}") + }; + if !protected { + fs::create_dir_all(&path)?; + let kind = if expected == Class::Data { + "ext4" + } else { + "erofs" + }; + let data = c(if expected == Class::Data { + "noload" + } else { + "" + })?; + checked( + unsafe { + libc::mount( + c(&format!("/proc/self/fd/{}", source.as_raw_fd()))?.as_ptr(), + c(&path)?.as_ptr(), + c(kind)?.as_ptr(), + libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + data.as_ptr().cast(), + ) + }, + "mount cartridge read-only", + )?; + } + let result = manifest(&path).and_then(|value| { + if value.cartridge.class != expected { + return Err(Error( + "Manifest class disagrees with GPT partition name".into(), + )); + } + Ok(value) + }); + match result { + Ok(manifest) => { + let published = if expected == Class::Program { + let destination = format!("/run/fds/apps/{}", manifest.cartridge.id); + let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?; + if mounts + .lines() + .any(|l| l.split_whitespace().nth(4) == Some(destination.as_str())) + { + unmount(&path, false)?; + return Err(Error("A PROGRAM with this id is already mounted".into())); + } + destination + } else { + published + }; + let path = if protected { + path + } else { + fs::create_dir_all(&published)?; + let moved = checked( + unsafe { + libc::mount( + c(&path)?.as_ptr(), + c(&published)?.as_ptr(), + std::ptr::null(), + libc::MS_MOVE, + std::ptr::null(), + ) + }, + "publish validated cartridge", + ); + if let Err(error) = moved { + unmount(&path, false)?; + return Err(error); + } + published + }; + Ok(Mounted { + bay, + key, + path, + manifest, + protected, + source, + writable: false, + sync_handle: None, + fault, + partition: part.clone(), + software: None, + }) + } + Err(error) => { + if !protected { + unmount(&path, false)?; + } + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + #[test] + fn metadata_symlinks_and_special_files_are_rejected() { + let root = std::env::temp_dir().join(format!("fds-manifest-{}", std::process::id())); + fs::create_dir_all(root.join("FDS")).unwrap(); + let metadata = root.join("FDS/CARTRIDGE.TOML"); + fs::write( + &metadata, + include_str!("../../../tests/fixtures/manifests/windowmaker.toml"), + ) + .unwrap(); + assert!(manifest(root.to_str().unwrap()).is_ok()); + fs::remove_file(&metadata).unwrap(); + symlink("/etc/passwd", &metadata).unwrap(); + assert!(manifest(root.to_str().unwrap()).is_err()); + fs::remove_file(&metadata).unwrap(); + let name = c(metadata.to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + assert!(manifest(root.to_str().unwrap()).is_err()); + fs::remove_file(&metadata).unwrap(); + fs::remove_dir(root.join("FDS")).unwrap(); + symlink("/etc", root.join("FDS")).unwrap(); + assert!(manifest(root.to_str().unwrap()).is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/rust/fds-cartridged/src/power.rs b/rust/fds-cartridged/src/power.rs new file mode 100644 index 0000000..80b87e4 --- /dev/null +++ b/rust/fds-cartridged/src/power.rs @@ -0,0 +1,139 @@ +//! Shutdown preparation is persistent for this boot. A failed step leaves new +//! operations frozen; only an explicit retry or resume changes that decision. +use fds_common::{ + Error, Result, + control::{PowerEvent, PowerState}, + trace, +}; +use std::{ + fs, io, + os::unix::{fs::PermissionsExt, process::CommandExt}, + path::Path, + process::Command, +}; + +pub const DIRECTORY: &str = "/run/fds/power"; +pub const RECORD: &str = "/run/fds/power/state.json"; +pub const NATIVE: &str = "/run/fds/power/native-pending"; +pub struct Manager { + pub state: PowerState, +} +impl Manager { + pub fn load() -> Result { + fs::create_dir_all(DIRECTORY)?; + fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?; + let state = if Path::new(RECORD).try_exists()? { + serde_json::from_str(&fds_common::read_text(Path::new(RECORD), 32 * 1024)?) + .map_err(|e| Error(format!("Invalid shutdown recovery record: {e}")))? + } else { + PowerState::default() + }; + let mut this = Self { state }; + this.state.native_pending = Path::new(NATIVE).try_exists()?; + if this.state.phase != "idle" && this.state.phase != "prepared" { + this.failed(&Error( + "Cartridge service restarted during shutdown preparation; retry required".into(), + ))?; + } else if this.state.native_pending && this.state.phase == "idle" { + this.failed(&Error( + "Native shutdown is waiting for DATA preparation".into(), + ))?; + } + Ok(this) + } + pub fn frozen(&self) -> bool { + self.state.phase != "idle" + } + fn save(&self) -> Result<()> { + fs::write( + format!("{RECORD}.next"), + serde_json::to_vec(&self.state).map_err(|e| Error(e.to_string()))?, + )?; + fs::rename(format!("{RECORD}.next"), RECORD)?; + Ok(()) + } + pub fn phase(&mut self, phase: &str) -> Result<()> { + self.state.phase = phase.into(); + self.state.events.push(PowerEvent { + phase: phase.into(), + at_ns: trace::now()?, + }); + if self.state.events.len() > 64 { + self.state.events.remove(0); + } + self.save()?; + eprintln!("FDS shutdown: {phase}"); + Ok(()) + } + pub fn begin(&mut self, action: Option<&str>, native: bool) -> Result<()> { + if native { + self.native_started()?; + } + if !self.state.native_pending { + self.state.action = action.map(Into::into); + } + self.state.error = None; + self.phase("frozen") + } + pub fn native_started(&mut self) -> Result<()> { + fs::write(NATIVE, b"native shutdown requested\n")?; + self.state.native_pending = true; + self.save() + } + pub fn failed(&mut self, error: &Error) -> Result<()> { + self.state.error = Some( + error + .to_string() + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(2048) + .collect(), + ); + self.phase("blocked") + } + pub fn resume(&mut self) -> Result<()> { + if self.state.native_pending || Path::new(NATIVE).try_exists()? { + return Err(Error("Native shutdown has already started and cannot be cancelled; resolve the reported problem and retry fds poweroff".into())); + } + self.state = PowerState::default(); + self.save() + } + pub fn commit(&mut self, reboot: bool) -> Result<()> { + if self.state.phase != "prepared" { + return Err(Error("Shutdown preparation is incomplete".into())); + } + if self.state.native_pending { + return Ok(()); + } + self.state.native_pending = true; + self.save()?; + let mut command = Command::new("/usr/bin/s6-linux-init-shutdown"); + command + .args([if reboot { "-r" } else { "-p" }, "-t", "0", "now"]) + .env_clear() + .env("PATH", "/usr/bin:/bin"); + unsafe { + command.pre_exec(|| { + let mut mask = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let result = command.status().map_err(Error::from).and_then(|status| { + if status.success() { + Ok(()) + } else { + Err(Error(format!("Native shutdown request failed: {status}"))) + } + }); + if let Err(error) = result { + self.state.native_pending = false; + self.failed(&error)?; + return Err(error); + } + Ok(()) + } +} diff --git a/rust/fds-cartridged/src/profile-main.rs b/rust/fds-cartridged/src/profile-main.rs new file mode 100644 index 0000000..f655f5a --- /dev/null +++ b/rust/fds-cartridged/src/profile-main.rs @@ -0,0 +1,328 @@ +//! Profile client and privileged s6 helpers. No cartridge supplies root commands. +#[allow(dead_code)] +mod consumers; +#[allow(dead_code)] +mod data_sessions; +#[allow(dead_code)] +mod media; +#[allow(dead_code)] +mod software; +mod x11; +use fds_common::{ + Error, Result, + control::{self, Request}, + trace, +}; +use media::{c, checked}; +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd}, + unix::{ + fs::{OpenOptionsExt, PermissionsExt}, + process::CommandExt, + }, + }, + path::Path, + process::{Command, ExitCode}, + time::{Duration, Instant}, +}; + +fn root() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + return Err(Error("This s6 service helper requires root".into())); + } + Ok(()) +} +fn xserver() -> Result<()> { + root()?; + fs::create_dir_all("/run/fds/x11")?; + checked( + unsafe { libc::chown(c("/run/fds/x11")?.as_ptr(), 0, 1000) }, + "set X authority directory group", + )?; + fs::set_permissions("/run/fds/x11", fs::Permissions::from_mode(0o750))?; + let mut cookie = [0u8; 16]; + fs::File::open("/dev/urandom")?.read_exact(&mut cookie)?; + let mut bytes = vec![0xff, 0xff]; // FamilyWild: Unix socket access still requires the secret cookie. + for field in [&b""[..], &b"0"[..], &b"MIT-MAGIC-COOKIE-1"[..], &cookie[..]] { + bytes.extend_from_slice(&(field.len() as u16).to_be_bytes()); + bytes.extend_from_slice(field); + } + let temporary = "/run/fds/x11/authority.next"; + match fs::remove_file(temporary) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + let mut authority = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o640) + .custom_flags(libc::O_NOFOLLOW) + .open(temporary)?; + authority.write_all(&bytes)?; + checked( + unsafe { libc::fchown(authority.as_raw_fd(), 0, 1000) }, + "set X authority group", + )?; + drop(authority); + fs::rename(temporary, "/run/fds/x11/authority")?; + let backend = fs::read_to_string("/etc/fds/xserver").unwrap_or_else(|_| "xorg".into()); + let mut command = match backend.trim() { + "xorg" => { + let mut c = Command::new("/usr/libexec/Xorg"); + c.args([":0", "vt2", "-logfile", "/run/log/xserver/Xorg.0.log"]); + c + } + "xvfb" => { + let mut c = Command::new("/usr/bin/Xvfb"); + c.args([":0", "-screen", "0", "1600x1200x24"]); + c + } + _ => return Err(Error("Unknown X server backend".into())), + }; + command.args([ + "-displayfd", + "3", + "-nolisten", + "tcp", + "-noreset", + "-auth", + "/run/fds/x11/authority", + "-dpi", + "120", + "-extension", + "Composite", + ]); + Err(command.exec().into()) +} +fn session() -> Result<()> { + root()?; + checked( + unsafe { libc::fcntl(3, libc::F_GETFD) }, + "require s6 readiness descriptor", + )?; + checked( + unsafe { libc::fcntl(3, libc::F_SETFD, libc::FD_CLOEXEC) }, + "protect readiness descriptor", + )?; + consumers::prepare()?; + consumers::stop_group("desktop")?; + let _ = fs::remove_file("/run/fds/desktop-ready-ns"); + let env = vec![ + ("DISPLAY".into(), ":0".into()), + ("XAUTHORITY".into(), "/run/fds/x11/authority".into()), + ]; + let mut mask: libc::sigset_t = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigemptyset(&mut mask); + for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGCHLD] { + libc::sigaddset(&mut mask, sig); + } + } + checked( + unsafe { libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) }, + "block session signals", + )?; + let fd = unsafe { libc::signalfd(-1, &mask, libc::SFD_CLOEXEC | libc::SFD_NONBLOCK) }; + checked(fd, "observe session processes")?; + let signals = unsafe { OwnedFd::from_raw_fd(fd) }; + let mut observer = Some(x11::Observer::connect()?); + let mut wm = consumers::start_group( + "desktop", + &["/usr/libexec/fds/windowmaker-session".into()], + "/home/fds", + &env, + )?; + let deadline = Instant::now() + Duration::from_secs(15); + let mut ready = false; + let mut terminal = None; + let result = (|| -> Result<()> { + loop { + if wm.try_wait()?.is_some() { + return Err(Error("WindowMaker exited".into())); + } + if !ready && Instant::now() >= deadline { + return Err(Error("WindowMaker readiness deadline expired".into())); + } + let mut fds = [ + libc::pollfd { + fd: signals.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: observer.as_ref().map_or(-1, AsRawFd::as_raw_fd), + events: libc::POLLIN, + revents: 0, + }, + ]; + let timeout = if ready { + -1 + } else { + deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(i32::MAX as u128) as i32 + }; + let count = unsafe { libc::poll(fds.as_mut_ptr(), 2, timeout) }; + if count < 0 + && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted + { + continue; + } + checked(count, "wait for desktop events")?; + if fds[0].revents != 0 { + let mut info: libc::signalfd_siginfo = unsafe { std::mem::zeroed() }; + while unsafe { + libc::read( + signals.as_raw_fd(), + (&mut info as *mut libc::signalfd_siginfo).cast(), + std::mem::size_of_val(&info), + ) + } > 0 + { + if info.ssi_signo == libc::SIGTERM as u32 + || info.ssi_signo == libc::SIGINT as u32 + { + return Ok(()); + } + } + if let Some(child) = &mut terminal { + let _: Option = std::process::Child::try_wait(child)?; + } + } + if !ready && fds[1].revents != 0 && observer.as_mut().unwrap().event()? { + let now = trace::now()?; + trace::save(Path::new(trace::RUNTIME), trace::Point::DesktopReady, now)?; + fs::write("/run/fds/desktop-ready-ns", format!("{now}\n"))?; + terminal = Some(consumers::start_group( + "desktop", + &["/usr/libexec/fds/terminal".into()], + "/home/fds", + &env, + )?); + checked( + unsafe { libc::write(3, b"\n".as_ptr().cast(), 1) } as i32, + "notify desktop readiness", + )?; + unsafe { + libc::close(3); + } + observer.take(); + ready = true; + } + } + })(); + consumers::stop_group("desktop")?; + let _ = wm.wait(); + if let Some(mut child) = terminal { + let _ = child.wait(); + } + let _ = fs::remove_file("/run/fds/desktop-ready-ns"); + result +} +#[derive(clap::Parser)] +#[command( + version, + about = "Control the desktop and network profile", + after_help = "Examples: fds-profile activate windowmaker; fds-profile deactivate" +)] +struct Cli { + #[command(subcommand)] + command: Option, +} +#[derive(clap::Subcommand)] +enum Action { + /// Show desktop and network state (the default). + Status, + /// Activate a profile. + Activate { + #[arg(value_parser = ["windowmaker", "cli"])] + name: String, + }, + /// Return to the console. + Deactivate, + #[command(long_flag = "xserver", hide = true)] + Xserver, + #[command(long_flag = "network", hide = true)] + Network, + #[command(long_flag = "cleanup-network", hide = true)] + CleanupNetwork, + #[command(long_flag = "session", hide = true)] + Session, + #[command(long_flag = "cleanup-session", hide = true)] + CleanupSession, +} +fn run() -> Result<()> { + use clap::Parser; + let request = match Cli::parse().command.unwrap_or(Action::Status) { + Action::Xserver => return xserver(), + Action::Network => { + root()?; + consumers::enter_service("network")?; + return Err(Command::new("/usr/libexec/fds/network-run").exec().into()); + } + Action::CleanupNetwork => { + root()?; + return consumers::stop_group("network"); + } + Action::Session => return session(), + Action::CleanupSession => { + root()?; + consumers::stop_group("desktop")?; + let _ = fs::remove_file("/run/fds/desktop-ready-ns"); + return Ok(()); + } + Action::Activate { name } => Request::Profile { profile: name }, + Action::Deactivate => Request::Profile { + profile: "cli".into(), + }, + Action::Status => Request::Profiles, + }; + let response = control::request(&request)?; + println!( + "{}", + serde_json::to_string_pretty(&response.profiles).map_err(|e| Error(e.to_string()))? + ); + Ok(()) +} +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("fds-profile: {e}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::{CommandFactory, Parser}; + #[test] + fn typed_command_contract() { + Cli::command().debug_assert(); + assert!( + Cli::try_parse_from(["fds-profile"]) + .unwrap() + .command + .is_none() + ); + for flag in [ + "--xserver", + "--network", + "--cleanup-network", + "--session", + "--cleanup-session", + ] { + assert!(Cli::try_parse_from(["fds-profile", flag]).is_ok()); + assert!(Cli::try_parse_from(["fds-profile", flag, "activate", "cli"]).is_err()); + } + assert!(Cli::try_parse_from(["fds-profile", "activate", "windowmaker"]).is_ok()); + assert!(Cli::try_parse_from(["fds-profile", "activate"]).is_err()); + } +} diff --git a/rust/fds-cartridged/src/profiles.rs b/rust/fds-cartridged/src/profiles.rs new file mode 100644 index 0000000..a70a669 --- /dev/null +++ b/rust/fds-cartridged/src/profiles.rs @@ -0,0 +1,288 @@ +//! Only SYSTEM-owned, named service bundles may be activated by media. +use crate::{consumers, media::Mounted}; +use fds_common::{ + Bay, Error, Result, control::ProfileState, manifest::Class, topology::UsbDevice, trace, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + os::unix::process::CommandExt, + path::Path, + process::{Child, Command}, +}; + +fn begin_change(service: &str, up: bool) -> Result { + let mut command = Command::new("/usr/bin/s6-rc"); + command.args([ + "-b", + "-l", + "/run/s6-rc", + "-t", + "20000", + if up { "-u" } else { "-d" }, + "change", + service, + ]); + unsafe { + command.pre_exec(|| { + let mut mask = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + command.spawn().map_err(Into::into) +} +pub fn change(service: &str, up: bool) -> Result<()> { + if begin_change(service, up)?.wait()?.success() { + Ok(()) + } else { + Err(Error(format!( + "Service transition failed: {service}; inspect /run/log/{service}" + ))) + } +} +#[derive(Default)] +pub struct Manager { + automatic: bool, + desktop: bool, + pending: Option, + owner: Option<(Bay, String)>, + attempted: BTreeSet, + pub manual_network: bool, + network_disabled: bool, + network: Vec, + network_attempted: Vec, + activation: Option, + error: Option, +} +impl Manager { + pub fn new(automatic: bool) -> Self { + Self { + automatic, + ..Self::default() + } + } + pub fn desktop(&mut self, name: &str, mounts: &BTreeMap) -> Result<()> { + match name { + "windowmaker" => { + self.owner = None; + self.start_desktop() + } + "cli" => { + self.attempted + .extend(mounts.values().map(|m| m.key.clone())); + self.stop_desktop() + } + _ => Err(Error( + "Unknown profile; available profiles: cli, windowmaker".into(), + )), + } + } + fn start_desktop(&mut self) -> Result<()> { + if self.desktop { + return Ok(()); + } + if !console_ready() { + return Err(Error("The console is not ready yet".into())); + } + let instant = trace::now()?; + self.activation = Some(instant); + self.error = None; + match fs::remove_file("/run/fds/desktop-ready-ns") { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + self.pending = Some(begin_change("desktop", true)?); + self.desktop = true; + Ok(()) + } + pub fn poll(&mut self) -> Result<()> { + let status = if let Some(child) = &mut self.pending { + child.try_wait()? + } else { + None + }; + if let Some(status) = status { + self.pending.take(); + if !status.success() { + self.desktop = false; + self.owner = None; + self.error = Some( + "Desktop startup failed; inspect /run/log/xserver and /run/log/desktop".into(), + ); + change("desktop", false)?; + } + } + Ok(()) + } + pub fn stop_desktop(&mut self) -> Result<()> { + if let Some(mut child) = self.pending.take() { + let _ = child.wait()?; + } + // s6-rc also tears down a partly started bundle after an activation error. + change("desktop", false)?; + self.desktop = false; + self.owner = None; + Ok(()) + } + pub fn before_eject( + &mut self, + bay: Bay, + data: bool, + mounts: &BTreeMap, + ) -> Result<()> { + if data && self.desktop || self.owner.as_ref().is_some_and(|(b, _)| *b == bay) { + self.desktop("cli", mounts)?; + } + Ok(()) + } + pub fn network(&mut self, enabled: bool) -> Result<()> { + let interfaces = if enabled { + interfaces(None)? + } else { + Vec::new() + }; + if enabled && interfaces.is_empty() { + return Err(Error("No Ethernet interface is available".into())); + } + self.set_network(interfaces)?; + self.manual_network = enabled; + self.network_disabled = !enabled; + Ok(()) + } + fn set_network(&mut self, interfaces: Vec) -> Result<()> { + if self.network == interfaces && !interfaces.is_empty() { + return Ok(()); + } + change("network", false)?; + consumers::stop_group("network")?; + self.network.clear(); + if interfaces.is_empty() { + return Ok(()); + } + fs::create_dir_all("/run/fds/network")?; + fs::write("/run/fds/network/interfaces", interfaces.join("\n") + "\n")?; + change("network", true)?; + self.network = interfaces; + Ok(()) + } + pub fn reconcile( + &mut self, + mounts: &BTreeMap, + devices: &[(Bay, UsbDevice)], + ) -> Result<()> { + self.poll()?; + let keys: BTreeSet<_> = mounts.values().map(|m| m.key.clone()).collect(); + self.attempted.retain(|key| keys.contains(key)); + if let Some((bay, key)) = &self.owner { + if !mounts.get(bay).is_some_and(|m| &m.key == key) { + self.stop_desktop()?; + } + } + if self.automatic && !self.desktop && console_ready() { + let candidates: Vec<_> = mounts + .iter() + .filter(|(_, m)| { + m.manifest.cartridge.class == Class::Environment + && m.fault.is_none() + && m.manifest + .activation + .as_ref() + .is_some_and(|a| a.profile == "windowmaker") + }) + .collect(); + if candidates.len() == 1 { + let (bay, mount) = candidates[0]; + if self.attempted.insert(mount.key.clone()) { + match self.start_desktop() { + Ok(()) => self.owner = Some((*bay, mount.key.clone())), + Err(error) => { + eprintln!("ENVIRONMENT activation: {error}"); + self.error = Some(error.to_string()); + } + } + } + } + } + let usb_paths: Vec<_> = devices.iter().map(|(_, d)| d.path.as_path()).collect(); + let automatic = interfaces(Some(&usb_paths))?; + if automatic.is_empty() { + self.network_disabled = false; + } + let wanted = if self.manual_network { + interfaces(None)? + } else if self.network_disabled || !self.automatic { + Vec::new() + } else { + automatic + }; + if wanted != self.network_attempted { + self.network_attempted = wanted.clone(); + if let Err(error) = self.set_network(wanted) { + eprintln!("Network activation: {error}"); + self.error = Some(error.to_string()); + } + } + Ok(()) + } + pub fn status(&self) -> ProfileState { + let ready = fs::read_to_string("/run/fds/desktop-ready-ns") + .ok() + .and_then(|s| s.trim().parse().ok()); + ProfileState { + desktop: if self.desktop { "windowmaker" } else { "cli" }.into(), + environment_bay: self.owner.as_ref().map(|(b, _)| *b), + network: self.network.clone(), + manual_network: self.manual_network, + activation_ns: self.activation, + ready_ns: if self.desktop { ready } else { None }, + error: self.error.clone(), + } + } + pub fn shutdown(&mut self) -> Result<()> { + self.stop_desktop()?; + change("network", false)?; + consumers::stop_group("network")?; + self.network.clear(); + Ok(()) + } +} +pub fn console_ready() -> bool { + Path::new(trace::RUNTIME) + .join("console/console-ready.json") + .is_file() +} +fn interfaces(usb_paths: Option<&[&Path]>) -> Result> { + let mut names = Vec::new(); + for entry in fs::read_dir("/sys/class/net")? { + let entry = entry?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "lo" + || name.len() > 15 + || !name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)) + { + continue; + } + if fs::read_to_string(path.join("type")).is_ok_and(|s| s.trim() == "1") + && !path.join("wireless").exists() + { + if let Some(parents) = usb_paths { + if !fs::canonicalize(&path) + .is_ok_and(|p| parents.iter().any(|parent| p.starts_with(parent))) + { + continue; + } + } + names.push(name); + } + } + names.sort(); + Ok(names) +} diff --git a/rust/fds-cartridged/src/recovery.rs b/rust/fds-cartridged/src/recovery.rs new file mode 100644 index 0000000..cbc518e --- /dev/null +++ b/rust/fds-cartridged/src/recovery.rs @@ -0,0 +1,291 @@ +//! Explicit, local recovery maintenance. Normal boot never runs a checker. +use crate::media::{self, checked}; +use fds_burn::device::Disk; +use fds_common::{ + Bay, Error, Result, + control::{BayDisk, RecoveryReport}, + manifest::Class, + read_text, + sysfs::BlockPartition, +}; +use std::{ + fs::{self, File, OpenOptions}, + os::{ + fd::AsRawFd, + unix::{ + fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + process::CommandExt, + }, + }, + path::Path, + process::{Command, Stdio}, +}; + +pub struct Selection { + bay: Bay, + disk: Disk, + partition: BlockPartition, + pub key: String, + confirmation: String, + log: String, +} + +// Linux UAPI include/uapi/linux/loop.h. LOOP_CONFIGURE atomically binds the +// already verified partition descriptor and enables automatic cleanup. +#[repr(C)] +struct LoopInfo { + device: u64, + inode: u64, + rdevice: u64, + offset: u64, + size_limit: u64, + number: u32, + encrypt_type: u32, + encrypt_key_size: u32, + flags: u32, + file_name: [u8; 64], + crypt_name: [u8; 64], + encrypt_key: [u8; 32], + init: [u64; 2], +} +#[repr(C)] +struct LoopConfig { + fd: u32, + block_size: u32, + info: LoopInfo, + reserved: [u64; 8], +} +const _: () = assert!(std::mem::size_of::() == 232); +const _: () = assert!(std::mem::size_of::() == 304); + +fn checker_device(partition: &File, repair: bool) -> Result { + let control = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open("/dev/loop-control")?; + for _ in 0..8 { + let number = unsafe { libc::ioctl(control.as_raw_fd(), 0x4c82u32 as _) }; + checked(number, "allocate recovery loop device")?; + let device = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(format!("/dev/loop{number}"))?; + let metadata = device.metadata()?; + if !metadata.file_type().is_block_device() + || libc::major(metadata.rdev()) != 7 + || libc::minor(metadata.rdev()) != number as u32 + { + return Err(Error("Unexpected recovery loop device identity".into())); + } + let mut config: LoopConfig = unsafe { std::mem::zeroed() }; + config.fd = partition.as_raw_fd() as u32; + config.info.flags = 4 | if repair { 0 } else { 1 }; // AUTOCLEAR, READ_ONLY + let result = unsafe { libc::ioctl(device.as_raw_fd(), 0x4c0au32 as _, &config) }; + if result == 0 { + return Ok(device); + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EBUSY) { + checked(result, "bind verified DATA partition for recovery")?; + } + // Retry an actual allocation race, without a time-based delay. + } + Err(Error( + "Recovery loop allocation repeatedly raced with another user".into(), + )) +} +pub fn select(bay: Bay, usb: &Path) -> Result { + let disk = Disk::select_current(usb)?; + let mut parts = media::partitions_for(usb)?; + if parts.len() != 1 || parts[0].partition_name != "FDS_DATA" { + return Err(Error( + "Recovery requires one FDS_DATA partition; SYSTEM and internal storage are excluded" + .into(), + )); + } + let partition = parts.remove(0); + let key = media::key(&partition)?; + let confirmation = format!( + "REPAIR BAY{bay} DISK{} BOOT{}", + disk.diskseq, + fds_common::trace::boot_id()? + ); + Ok(Selection { + bay, + disk, + partition, + key, + confirmation, + log: format!( + "/run/fds/recovery/data-{bay}-{}.log", + fds_common::trace::now()? + ), + }) +} +impl Selection { + pub fn confirm(&self, repair: bool, confirmation: Option<&str>) -> Result<()> { + if repair && confirmation != Some(self.confirmation.as_str()) + || !repair && confirmation.is_some() + { + return Err(Error( + "Recovery confirmation does not match this bay, insertion and boot".into(), + )); + } + Ok(()) + } + pub fn report(&self, checked: bool, repaired: bool) -> RecoveryReport { + RecoveryReport { + disk: BayDisk { + bay: self.bay, + diskseq: self.disk.diskseq, + bytes: self.disk.bytes, + sector_bytes: self.disk.sector_bytes, + model: self.disk.model.clone(), + serial: self.disk.serial.clone(), + protected: None, + }, + confirmation: if checked { + None + } else { + Some(self.confirmation.clone()) + }, + checked, + repaired, + log: checked.then(|| self.log.clone()), + } + } + pub fn run(&self, repair: bool) -> Result<()> { + let result = self.check_filesystem(repair); + result.map_err(|error| { + Error(format!( + "DATA recovery failed: {error}; no SAFE status issued. Log: {}", + self.log + )) + }) + } + fn check_filesystem(&self, repair: bool) -> Result<()> { + // The whole-disk reservation excludes mounts and other FDS writers. + // A child inherits it so daemon death cannot release it before fsck exits. + let disk = self.disk.open_exclusive()?; + let image = fds_burn::image::inspect(&disk, self.disk.bytes)?; + if image.class != Class::Data || image.filesystem != "ext4" { + return Err(Error( + "Recovery accepts only a valid single-partition DATA GPT with ext4".into(), + )); + } + let part = OpenOptions::new() + .read(true) + .write(repair) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&self.partition.device)?; + let metadata = part.metadata()?; + let mut diskseq = 0u64; + checked( + unsafe { libc::ioctl(part.as_raw_fd(), 0x80081280u32 as _, &mut diskseq) }, + "verify DATA insertion", + )?; + let sysfs = fs::canonicalize( + Path::new("/sys/dev/block") + .join(format!("{}:{}", self.partition.major, self.partition.minor)), + )?; + let number = |name: &str| -> Result { + read_text(&sysfs.join(name), 128)? + .trim() + .parse() + .map_err(|_| Error("Invalid partition geometry".into())) + }; + if !metadata.file_type().is_block_device() + || libc::major(metadata.rdev()) != self.partition.major + || libc::minor(metadata.rdev()) != self.partition.minor + || diskseq != self.disk.diskseq + || media::key(&self.partition)? != self.key + || number("start")?.checked_mul(512) != Some(image.partition_start) + || number("size")?.checked_mul(512) != Some(image.partition_bytes) + { + return Err(Error("DATA identity or partition geometry changed".into())); + } + // e2fsck claims its device exclusively too. Give it an automatically + // removed loop view while retaining the physical whole-disk claim; + // releasing that claim would allow an unrelated mount during repairs. + let checker = checker_device(&part, repair)?; + fs::create_dir_all("/run/fds/recovery")?; + fs::set_permissions("/run/fds/recovery", fs::Permissions::from_mode(0o700))?; + let log = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&self.log)?; + let run = |fix: bool| -> Result { + let parent = unsafe { libc::getpid() }; + let disk_fd = disk.as_raw_fd(); + let part_fd = checker.as_raw_fd(); + let mut command = Command::new("/usr/bin/e2fsck"); + command + .args(["-f", if fix { "-p" } else { "-n" }]) + .arg(format!("/proc/self/fd/{part_fd}")) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .stderr(log.try_clone()?); + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0 + || libc::getppid() != parent + { + return Err(std::io::Error::other("Recovery parent disappeared")); + } + let mut mask = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + let limit = libc::rlimit { + rlim_cur: 16 * 1024 * 1024, + rlim_max: 16 * 1024 * 1024, + }; + if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 + || libc::setrlimit(libc::RLIMIT_FSIZE, &limit) < 0 + || libc::fcntl(disk_fd, libc::F_SETFD, 0) < 0 + || libc::fcntl(part_fd, libc::F_SETFD, 0) < 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + command + .status()? + .code() + .ok_or_else(|| Error("Filesystem checker was terminated".into())) + }; + let status = run(repair)?; + if status != 0 && !(repair && status == 1) { + return Err(Error(format!( + "e2fsck returned {status}; unresolved errors require review" + ))); + } + if repair { + checker.sync_all()?; + part.sync_all()?; + checked( + unsafe { libc::ioctl(checker.as_raw_fd(), 0x1261u32 as _) }, + "invalidate recovery loop cache", + )?; + checked( + unsafe { libc::ioctl(part.as_raw_fd(), 0x1261u32 as _) }, + "flush and invalidate DATA block cache", + )?; + let verified = run(false)?; + if verified != 0 { + return Err(Error(format!( + "Post-repair read-only check returned {verified}" + ))); + } + } + if !self.disk.present() || media::key(&self.partition)? != self.key { + return Err(Error("DATA disappeared during recovery".into())); + } + Ok(()) + } +} diff --git a/rust/fds-cartridged/src/server.rs b/rust/fds-cartridged/src/server.rs new file mode 100644 index 0000000..b2a7c0f --- /dev/null +++ b/rust/fds-cartridged/src/server.rs @@ -0,0 +1,1216 @@ +use crate::media::{self, Mounted, c, checked}; +use crate::{burning, consumers, data_sessions, power, profiles, recovery}; +use fds_common::{ + Bay, Error, Result, + control::{BayState, LIMIT, Request, Response, SOCKET}, + topology::{self, BayMap, Catalog}, +}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd}, + unix::{ + fs::{OpenOptionsExt, PermissionsExt}, + net::{UnixListener, UnixStream}, + }, + }, + path::Path, + time::{Duration, Instant}, +}; + +struct State { + recovery: bool, + map: BayMap, + catalog: Catalog, + bays: Vec, + unmapped: Vec, + mounts: BTreeMap, + ejected: BTreeMap, + children: Vec, + profiles: profiles::Manager, + burning: burning::Manager, + power: power::Manager, +} +impl State { + fn scan(&mut self) -> Result<()> { + let devices = topology::devices(Path::new("/sys"))?; + self.unmapped = devices + .iter() + .filter(|d| self.map.bay(&d.topology).is_none()) + .cloned() + .collect(); + self.bays.clear(); + for n in 1..=12 { + let bay = Bay::try_from(n)?; + let mut entry = BayState { + bay, + state: if self.map.configured(bay) { + "empty" + } else { + "unconfigured" + } + .into(), + name: None, + detail: None, + devices: devices + .iter() + .filter(|d| self.map.bay(&d.topology) == Some(bay)) + .cloned() + .collect(), + manifest: None, + software: None, + mount: None, + consumers: consumers::count(bay)?, + }; + // A hub in a bay may contain several functions, but multiple actual + // devices are ambiguous until an explicit composite policy exists. + let result = self.inspect(&mut entry); + if let Err(error) = result { + entry.state = "error".into(); + entry.detail = Some( + error + .to_string() + .chars() + .filter(|c| !c.is_control()) + .take(512) + .collect(), + ); + } + self.bays.push(entry); + } + // Never select the first of simultaneously present DATA cartridges. + // Keep an already-active DATA session stable when another is inserted. + if !self.recovery && !self.power.frozen() && !self.mounts.values().any(|m| m.writable) { + let candidates: Vec<_> = self + .mounts + .iter() + .filter(|(_, m)| { + m.manifest.cartridge.class == fds_common::manifest::Class::Data + && m.fault.is_none() + }) + .map(|(b, _)| *b) + .collect(); + if candidates.len() == 1 { + let bay = candidates[0]; + match self.activate_data(bay) { + Ok(()) => self.refresh_mount_state(bay), + Err(error) => { + if let Some(entry) = self.bays.iter_mut().find(|b| b.bay == bay) { + entry.state = "error".into(); + entry.detail = Some(error.to_string()); + } + } + } + } + } + let devices = self.mapped_devices(); + if !self.power.frozen() { + self.profiles.reconcile(&self.mounts, &devices)?; + } + Ok(()) + } + fn mapped_devices(&self) -> Vec<(Bay, topology::UsbDevice)> { + self.bays + .iter() + .flat_map(|b| b.devices.iter().cloned().map(move |d| (b.bay, d))) + .collect() + } + fn activate_data(&mut self, bay: Bay) -> Result<()> { + if self.mounts.iter().any(|(b, m)| *b != bay && m.writable) { + return Err(Error( + "Another DATA cartridge is active; eject it first".into(), + )); + } + let mounted = self + .mounts + .get_mut(&bay) + .ok_or_else(|| Error("No validated cartridge in that bay".into()))?; + mounted.activate_data() + } + fn refresh_mount_state(&mut self, bay: Bay) { + if let (Some(entry), Some(mounted)) = ( + self.bays.iter_mut().find(|b| b.bay == bay), + self.mounts.get(&bay), + ) { + if mounted.writable { + entry.state = "mounted_read_write".into(); + entry.mount = Some(mounted.path.clone()); + } + if let Some(fault) = &mounted.fault { + entry.state = "error".into(); + entry.detail = Some(fault.clone()); + } + } + } + fn inspect(&mut self, entry: &mut BayState) -> Result<()> { + let bay = entry.bay; + if let Some(job) = self.burning.reserved(bay) { + entry.state = if job.phase == "complete" { + "safe".into() + } else { + job.phase.clone() + }; + entry.name = Some(job.model.clone()); + entry.detail = Some(job.error.clone().unwrap_or_else(|| { + format!("{} image operation {}", job.image_class.label(), job.id) + })); + return Ok(()); + } + let parts = if entry.devices.len() == 1 { + media::partitions_for(&entry.devices[0].path)? + } else { + Vec::new() + }; + let selected = if parts.len() == 1 { + parts.first() + } else { + let metadata: Vec<_> = parts + .iter() + .filter(|p| p.partition_name == "FDS_METADATA") + .collect(); + (metadata.len() == 1).then(|| metadata[0]) + }; + let current_key = if let Some(part) = selected { + Some(media::key(part)?) + } else { + None + }; + if self + .mounts + .get(&bay) + .is_some_and(|m| Some(&m.key) != current_key.as_ref()) + { + let live = self.mounts.get(&bay).unwrap().present(); + let data = self.mounts.get(&bay).unwrap().writable; + if !live && data { + self.profiles.before_eject(bay, true, &self.mounts)?; + consumers::stop_all()?; + } + let previous = self.mounts.get_mut(&bay).unwrap(); + if live { + // USB interfaces and block devices disappear in separate + // events. Retain a live filesystem until its kernel disk + // identity actually vanishes; never kill jobs on a partial + // sysfs snapshot or a conflicting topology alias. + entry.state = "error".into(); + entry.detail = + Some("USB inventory changed; existing mounted cartridge retained".into()); + entry.manifest = Some(previous.manifest.clone()); + entry.mount = Some(previous.path.clone()); + return Ok(()); + } + if !previous.protected { + consumers::stop(bay)?; + if !previous.present() { + if previous.writable { + eprintln!( + "BAY {bay}: DATA removed without eject; writes may have been lost" + ); + } + previous.removed()?; + } else { + previous.eject()?; + } + } + self.mounts.remove(&bay); + } + let forget_eject = if let Some(key) = self.ejected.get(&bay) { + Some(key) != current_key.as_ref() && !media::key_disk_present(key)? + } else { + false + }; + if forget_eject { + self.ejected.remove(&bay); + match fs::remove_file(format!("/run/fds/ejected/{bay}")) { + Ok(()) => (), + Err(e) if e.kind() == io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + } + if entry.devices.is_empty() { + return Ok(()); + } + if entry.devices.len() > 1 { + entry.state = "ambiguous".into(); + entry.detail = Some("Multiple USB devices map to this bay".into()); + return Ok(()); + } + if parts.len() > 1 && selected.is_none() { + entry.state = "ambiguous".into(); + entry.detail = Some("Multiple named partitions; no cartridge selected".into()); + return Ok(()); + } + if let Some(part) = selected { + let key = current_key.unwrap(); + if self.power.frozen() && part.partition_name == "FDS_DATA" { + if let Some(fault) = data_sessions::recovered_fault(bay, &key)? { + // An existing live session still owns its error descriptor; + // a recovered session does not, even if startup unmounted it. + if !self.mounts.contains_key(&bay) { + return Err(Error(format!("DATA is quarantined: {fault}"))); + } + } + } + if self.ejected.get(&bay) == Some(&key) { + if let Some(fault) = data_sessions::recovered_fault(bay, &key)? { + return Err(Error(format!("DATA is quarantined: {fault}"))); + } + entry.state = "safe".into(); + return Ok(()); + } + if let std::collections::btree_map::Entry::Vacant(slot) = self.mounts.entry(bay) { + if self.power.frozen() { + entry.state = "frozen".into(); + entry.detail = Some("New mounts are frozen for shutdown".into()); + return Ok(()); + } + let mut mounted = media::mount(bay, part, key)?; + if part.partition_name == "FDS_METADATA" { + match crate::software::Mounted::open( + bay, + &mounted.path, + &entry.devices[0].path, + &parts, + ) { + Ok(software) => mounted.software = Some(software), + Err(error) => { + mounted.eject()?; + return Err(error); + } + } + } + slot.insert(mounted); + } + let mounted = self.mounts.get(&bay).unwrap(); + entry.state = if mounted.protected { + "protected" + } else if mounted.writable { + "mounted_read_write" + } else { + "mounted_read_only" + } + .into(); + entry.name = Some(mounted.manifest.cartridge.name.clone()); + entry.manifest = Some(mounted.manifest.clone()); + entry.mount = Some(mounted.path.clone()); + if let Some(activation) = &mounted.manifest.activation { + entry.detail = Some(if activation.profile == "windowmaker" { + "Requests WindowMaker; use fds profiles for activation state".into() + } else { + format!( + "Profile '{}' is not installed; no activation performed", + activation.profile + ) + }); + } + if let Some(fault) = &mounted.fault { + entry.state = "error".into(); + entry.detail = Some(fault.clone()); + } + } else { + let usb = &entry.devices[0]; + entry.name = self.catalog.identify(usb)?; + let storage = usb.class == "08" || usb.interfaces.iter().any(|i| i == "08"); + entry.state = if storage { + "unrecognized_storage" + } else { + "hardware" + } + .into(); + entry.detail = Some(if storage { + "No single named FDS partition is available".into() + } else { + format!("USB {}:{}", usb.vendor, usb.product) + }); + } + Ok(()) + } + fn eject(&mut self, bay: Bay) -> Result<()> { + // The writer's private namespace retains its source filesystem mounts. + // Unmounting only the parent namespace would falsely declare that DATA + // safe while the worker still reads it. Wait for the worker to exit. + if self.burning.active() { + return Err(Error( + "A media operation is active; finish or cancel it before ejecting cartridges" + .into(), + )); + } + self.scan()?; + if self + .burning + .reserved(bay) + .is_some_and(|job| job.phase == "complete") + { + return Ok(()); + } + let data = self.mounts.get(&bay).is_some_and(|m| m.writable); + // Check unexpected mounts before interrupting the user's session. + if let Some(m) = self.mounts.get(&bay) { + m.ensure_exclusive_mount()?; + } + self.profiles.before_eject(bay, data, &self.mounts)?; + let mount = self + .mounts + .get_mut(&bay) + .ok_or_else(|| Error("No mounted cartridge in this bay".into()))?; + if mount.protected { + return Err(Error("Active SYSTEM cannot be ejected".into())); + } + mount.ensure_exclusive_mount()?; + if mount.writable { + consumers::stop_all()?; + } else { + consumers::stop(bay)?; + } + mount.eject()?; + self.ejected.insert(bay, mount.key.clone()); + self.mounts.remove(&bay); + let marker = format!("/run/fds/ejected/{bay}"); + fs::write(format!("{marker}.next"), &self.ejected[&bay])?; + fs::rename(format!("{marker}.next"), marker)?; + self.scan() + } + fn reply(&mut self, request: Request, uid: u32) -> Result { + if matches!(request, Request::PowerPrepare) && uid != 0 { + return Err(Error( + "Only native init may prepare an already-started shutdown".into(), + )); + } + if matches!( + request, + Request::Poweroff + | Request::Reboot + | Request::PowerPrepare + | Request::PowerStatus + | Request::PowerResume + ) { + let result = match request { + Request::PowerStatus => Ok(()), + Request::PowerResume => self.power.resume().and_then(|()| self.scan()), + Request::PowerPrepare => self + .power + .native_started() + .and_then(|()| self.prepare_power(None)), + Request::Poweroff | Request::Reboot => { + let reboot = matches!(request, Request::Reboot); + self.prepare_power(Some(if reboot { "reboot" } else { "poweroff" })) + .and_then(|()| self.power.commit(reboot)) + } + _ => unreachable!(), + }; + if let Err(error) = result { + if self.power.frozen() + && !matches!(request, Request::PowerStatus | Request::PowerResume) + { + self.power.failed(&error)?; + } + return Err(error); + } + let mut reply = Response::failure(""); + reply.error = None; + reply.power = Some(self.power.state.clone()); + return Ok(reply); + } + if self.power.frozen() + && !matches!( + request, + Request::Bays + | Request::Bay { .. } + | Request::Profiles + | Request::Topology + | Request::Disk { .. } + | Request::MediaStatus { .. } + | Request::MediaCancel { .. } + ) + { + return Err(Error("New cartridge operations are frozen for shutdown; use fds power status, retry poweroff/reboot, or fds power resume".into())); + } + if let Request::Disk { bay } = &request { + self.scan()?; + let entry = self + .bays + .iter() + .find(|b| b.bay == *bay) + .ok_or_else(|| Error("Unknown bay".into()))?; + if entry.devices.len() != 1 { + return Err(Error("Bay must contain exactly one USB device".into())); + } + let disk = fds_burn::device::Disk::select_current(&entry.devices[0].path)?; + let protected = disk + .protect(Path::new("/sys"), Path::new("/proc")) + .err() + .map(|e| e.to_string()); + let mut response = Response::failure(""); + response.error = None; + response.disk = Some(fds_common::control::BayDisk { + bay: *bay, + diskseq: disk.diskseq, + bytes: disk.bytes, + sector_bytes: disk.sector_bytes, + model: disk.model, + serial: disk.serial, + protected, + }); + return Ok(response); + } + if let Request::RecoveryData { + bay, + repair, + confirmation, + } = &request + { + if !self.recovery || uid != 0 { + return Err(Error( + "DATA recovery requires the local root console in the recovery image".into(), + )); + } + if self.burning.active() || self.burning.reserved(*bay).is_some() { + return Err(Error( + "Finish or cancel the media operation before DATA recovery".into(), + )); + } + self.scan()?; + let entry = self.bays.iter().find(|b| b.bay == *bay).unwrap(); + if entry.devices.len() != 1 { + return Err(Error("Bay must contain exactly one USB device".into())); + } + let selected = recovery::select(*bay, &entry.devices[0].path)?; + let mut response = Response::failure(""); + response.error = None; + if *repair && confirmation.is_none() { + response.recovery = Some(selected.report(false, false)); + return Ok(response); + } + selected.confirm(*repair, confirmation.as_deref())?; + if let Some(mounted) = self.mounts.get(bay) { + if mounted.writable || mounted.protected { + return Err(Error("Recovery requires unmounted or read-only DATA; eject an active writable session first".into())); + } + mounted.ensure_exclusive_mount()?; + media::unmount(&mounted.path, false)?; + self.mounts.remove(bay); + } + // A failed or interrupted checker must never inherit an old SAFE + // marker. Keep the fault across daemon restarts for this insertion. + data_sessions::save( + *bay, + &selected.key, + Some("DATA recovery is incomplete; check or repair it in recovery"), + )?; + let result = selected.run(*repair); + match result { + Ok(()) => { + data_sessions::clear(*bay)?; + self.ejected.insert(*bay, selected.key.clone()); + let marker = format!("/run/fds/ejected/{bay}"); + fs::write(format!("{marker}.next"), &selected.key)?; + fs::rename(format!("{marker}.next"), marker)?; + response.recovery = Some(selected.report(true, *repair)); + } + Err(error) => { + data_sessions::save(*bay, &selected.key, Some(&error.to_string()))?; + self.scan()?; + return Err(error); + } + } + self.scan()?; + return Ok(response); + } + let media_job = match &request { + Request::MediaPrepare { bay, image, class } => { + self.scan()?; + if self.mounts.contains_key(bay) { + return Err(Error( + "Cartridge is mounted; eject it before preparing a write".into(), + )); + } + let entry = self + .bays + .iter() + .find(|b| b.bay == *bay) + .ok_or_else(|| Error("Unknown bay".into()))?; + if entry.devices.len() != 1 { + return Err(Error("Bay must contain exactly one USB device".into())); + } + let usb = entry.devices[0].path.clone(); + Some(self.burning.begin(*bay, &usb, image, *class, uid)?) + } + Request::MediaStatus { id, .. } => Some(self.burning.status(id)?), + Request::MediaConfirm { id, confirmation } => { + Some(self.burning.confirm(id, uid, confirmation)?) + } + Request::MediaCancel { id } => Some(self.burning.cancel(id, uid)?), + _ => None, + }; + if let Some(media_job) = media_job { + let mut response = Response::failure(""); + response.error = None; + response.media_job = Some(media_job); + return Ok(response); + } + let mut started_pid = None; + match &request { + Request::Profile { profile } => self.profiles.desktop(profile, &self.mounts)?, + Request::Network { enabled } => { + self.profiles.network(*enabled)?; + } + Request::Rescan => self.scan()?, + Request::Eject { bay } => self.eject(*bay)?, + Request::DataUse { bay } => { + self.activate_data(*bay)?; + self.scan()?; + } + Request::Run { bay, arguments } => { + let mount = self + .mounts + .get_mut(bay) + .ok_or_else(|| Error("No mounted DATA or PROGRAM in that bay".into()))?; + let (arguments, environment, working) = if mount.writable && mount.fault.is_none() { + (arguments.clone(), Vec::new(), mount.path.as_str()) + } else if mount.manifest.cartridge.class == fds_common::manifest::Class::Program { + let (args, env) = mount.program(arguments)?; + (args, env, "/home/fds") + } else { + return Err(Error("Run requires healthy DATA or PROGRAM media".into())); + }; + let child = consumers::start(*bay, &arguments, working, &environment)?; + started_pid = Some(child.id()); + self.children.push(child); + } + _ => (), + } + for entry in &mut self.bays { + entry.consumers = consumers::count(entry.bay)?; + } + for n in 1..=12 { + self.refresh_mount_state(Bay::try_from(n)?); + } + let mut bays: Vec = match &request { + Request::Profiles | Request::Profile { .. } | Request::Network { .. } => Vec::new(), + Request::Bay { bay } + | Request::Eject { bay } + | Request::DataUse { bay } + | Request::Run { bay, .. } => self + .bays + .iter() + .filter(|b| b.bay == *bay) + .cloned() + .collect(), + _ => self.bays.clone(), + }; + if let Request::Bay { bay } = &request { + if let (Some(entry), Some(software)) = ( + bays.first_mut(), + self.mounts.get(bay).and_then(|m| m.software.as_ref()), + ) { + entry.software = Some(software.catalogue.clone()); + } + } + Ok(Response { + format: 1, + error: None, + bays, + unmapped: if matches!(request, Request::Topology) { + self.unmapped.clone() + } else { + Vec::new() + }, + started_pid, + profiles: if matches!( + request, + Request::Profiles | Request::Profile { .. } | Request::Network { .. } + ) { + Some(self.profiles.status()) + } else { + None + }, + media_job: None, + recovery: None, + disk: None, + power: None, + }) + } + fn prepare_power(&mut self, action: Option<&str>) -> Result<()> { + if self.power.state.phase == "prepared" { + return Ok(()); + } + self.power.begin(action, false)?; + if self.burning.active() { + return Err(Error("Shutdown blocked by an active media operation; wait for writing to finish or cancel it with fds burn cancel, then retry".into())); + } + self.scan()?; + if let Some(bay) = self.bays.iter().find(|b| b.state == "error") { + return Err(Error(format!( + "Shutdown blocked at BAY {}: {}", + bay.bay, + bay.detail + .as_deref() + .unwrap_or("unresolved cartridge error") + ))); + } + self.power.phase("stopping_desktop")?; + self.profiles.stop_desktop()?; + self.power.phase("stopping_programs")?; + consumers::stop_all()?; + self.power.phase("stopping_network")?; + self.profiles.network(false)?; + let mut bays: Vec<_> = self + .mounts + .iter() + .filter(|(_, m)| !m.protected) + .map(|(b, m)| (*b, m.writable)) + .collect(); + bays.sort_by_key(|(b, writable)| (!*writable, *b)); + self.power.phase("syncing_and_unmounting_data")?; + for (bay, _) in bays.iter().filter(|(_, writable)| *writable) { + self.power_eject(*bay)?; + } + self.power.phase("unmounting_cartridges")?; + for (bay, _) in bays.iter().filter(|(_, writable)| !*writable) { + self.power_eject(*bay)?; + } + // Also reject mounts not represented by the daemon's inventory. Native + // final cleanup must not become a fallback for unverified DATA writes. + let table = fds_common::read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?; + if table.lines().any(|line| { + line.split_whitespace().nth(4).is_some_and(|p| { + p == "/data" + || p.starts_with("/run/fds/media/") + || p.starts_with("/run/fds/apps/") + || p.starts_with("/run/fds/probe/") + || p.starts_with("/run/fds/software/") + }) + }) { + return Err(Error( + "Shutdown blocked: unverified cartridge mounts remain".into(), + )); + } + self.power.phase("prepared") + } + fn power_eject(&mut self, bay: Bay) -> Result<()> { + let mounted = self.mounts.get_mut(&bay).unwrap(); + mounted + .eject() + .map_err(|e| Error(format!("Shutdown blocked at BAY {bay}: {e}")))?; + let key = mounted.key.clone(); + self.mounts.remove(&bay); + let marker = format!("/run/fds/ejected/{bay}"); + fs::write(format!("{marker}.next"), &key)?; + fs::rename(format!("{marker}.next"), marker)?; + self.ejected.insert(bay, key); + if let Some(entry) = self.bays.iter_mut().find(|entry| entry.bay == bay) { + entry.state = "safe".into(); + entry.mount = None; + entry.consumers = 0; + } + Ok(()) + } +} +fn events() -> Result { + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + checked(fd, "open uevent socket")?; + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let mut address: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + address.nl_family = libc::AF_NETLINK as _; + address.nl_groups = 1; + checked( + unsafe { + libc::bind( + fd.as_raw_fd(), + (&address as *const libc::sockaddr_nl).cast(), + std::mem::size_of_val(&address) as _, + ) + }, + "bind uevent socket", + )?; + Ok(fd) +} +fn consume_events(fd: &OwnedFd) -> Result { + let mut changed = false; + for _ in 0..256 { + let mut data = [0u8; 16384]; + let mut sender: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of_val(&sender) as libc::socklen_t; + let n = unsafe { + libc::recvfrom( + fd.as_raw_fd(), + data.as_mut_ptr().cast(), + data.len(), + libc::MSG_TRUNC, + (&mut sender as *mut libc::sockaddr_nl).cast(), + &mut length, + ) + }; + if n < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::WouldBlock { + break; + } + if error.raw_os_error() == Some(libc::ENOBUFS) { + changed = true; + continue; + } + return Err(error.into()); + } + if sender.nl_pid != 0 { + continue; + } + if n as usize > data.len() + || data[..n as usize] + .split(|b| *b == 0) + .any(|s| s == b"SUBSYSTEM=block" || s == b"SUBSYSTEM=usb" || s == b"SUBSYSTEM=net") + { + changed = true; + } + } + Ok(changed) +} +fn signals() -> Result { + let mut mask: libc::sigset_t = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigemptyset(&mut mask); + libc::sigaddset(&mut mask, libc::SIGTERM); + libc::sigaddset(&mut mask, libc::SIGINT); + libc::sigaddset(&mut mask, libc::SIGCHLD); + } + checked( + unsafe { libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) }, + "block exit signals", + )?; + let fd = unsafe { libc::signalfd(-1, &mask, libc::SFD_NONBLOCK | libc::SFD_CLOEXEC) }; + checked(fd, "open exit notification")?; + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} +struct Client { + socket: UnixStream, + input: Vec, + output: Option>, + offset: usize, + deadline: Instant, + uid: u32, + waiting: Option<(String, u64)>, +} +fn peer_uid(socket: &UnixStream) -> Result> { + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of_val(&cred) as libc::socklen_t; + checked( + unsafe { + libc::getsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut length, + ) + }, + "read client credentials", + )?; + Ok([0, 1000].contains(&cred.uid).then_some(cred.uid)) +} +fn cleanup_stale_mounts() -> Result<()> { + // Restart adoption: detach nothing here. A busy leftover mount prevents + // startup instead of stacking a new mount or falsely declaring SAFE. + let mounts = fds_common::read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?; + if mounts + .lines() + .any(|line| line.split_whitespace().nth(4) == Some("/data")) + { + let handle = File::open("/data")?; + if let Err(error) = checked( + unsafe { libc::syncfs(handle.as_raw_fd()) }, + "flush leftover DATA mount", + ) { + // The activation record quarantines this insertion even if a new + // descriptor cannot recover its earlier writeback error history. + eprintln!("{error}; interrupted DATA remains quarantined, never SAFE"); + } + drop(handle); + media::unmount("/data", false)?; + } + let mut software_paths: Vec<_> = mounts + .lines() + .filter_map(|l| l.split_whitespace().nth(4)) + .filter(|p| p.starts_with("/run/fds/software/")) + .collect(); + software_paths.sort_by_key(|p| std::cmp::Reverse(p.len())); + for path in software_paths { + media::unmount(path, false)?; + } + if Path::new("/run/fds/software").exists() { + fs::remove_dir_all("/run/fds/software")?; + } + for line in mounts.lines() { + if let Some(path) = line.split_whitespace().nth(4) { + if path.starts_with("/run/fds/apps/") { + media::unmount(path, false)?; + } + } + } + for n in 1..=12 { + for kind in ["media", "probe"] { + let path = format!("/run/fds/{kind}/{n:02}"); + if mounts + .lines() + .any(|line| line.split_whitespace().nth(4) == Some(path.as_str())) + { + media::unmount(&path, false)?; + } + } + } + Ok(()) +} +pub fn run(notify: bool) -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + return Err(Error("The cartridge service requires root".into())); + } + if notify { + checked( + unsafe { libc::fcntl(3, libc::F_GETFD) }, + "require s6 notification descriptor", + )?; + } + fs::create_dir_all("/run/fds/media")?; + data_sessions::prepare()?; + fs::create_dir_all("/run/fds/probe")?; + fs::set_permissions("/run/fds/probe", fs::Permissions::from_mode(0o700))?; + fs::create_dir_all("/run/fds/ejected")?; + fs::set_permissions("/run/fds/ejected", fs::Permissions::from_mode(0o700))?; + fs::set_permissions("/run/fds/media", fs::Permissions::from_mode(0o755))?; + let lock: File = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open("/run/fds/cartridged.lock")?; + checked( + unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + "lock cartridge service", + )?; + let recovery = fds_common::recovery_mode()?; + let machine = fds_common::machine::Config::active()?; + let mut state = State { + recovery, + map: BayMap::parse(&machine.bays)?, + catalog: Catalog::parse(&machine.hardware_catalog)?, + bays: Vec::new(), + unmapped: Vec::new(), + mounts: BTreeMap::new(), + ejected: BTreeMap::new(), + children: Vec::new(), + profiles: profiles::Manager::new(!recovery), + burning: burning::Manager::load()?, + power: power::Manager::load()?, + }; + for n in 1..=12 { + let bay = Bay::try_from(n)?; + let path = format!("/run/fds/ejected/{bay}"); + if Path::new(&path).exists() { + state + .ejected + .insert(bay, fds_common::read_text(Path::new(&path), 16384)?); + } + } + let console_events = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + checked(console_events, "watch console readiness")?; + let console_events = unsafe { OwnedFd::from_raw_fd(console_events) }; + checked( + unsafe { + libc::inotify_add_watch( + console_events.as_raw_fd(), + c(&format!("{}/console", fds_common::trace::RUNTIME))?.as_ptr(), + libc::IN_CREATE | libc::IN_MOVED_TO, + ) + }, + "subscribe to console readiness", + )?; + let events = events()?; // Subscribe before the first snapshot. + let signals = signals()?; + match fs::remove_file(SOCKET) { + Ok(()) => (), + Err(e) if e.kind() == io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + let listener = UnixListener::bind(SOCKET)?; + checked( + unsafe { libc::chown(c(SOCKET)?.as_ptr(), 0, 1000) }, + "set control socket group", + )?; + fs::set_permissions(SOCKET, fs::Permissions::from_mode(0o660))?; + listener.set_nonblocking(true)?; + // s6 readiness means control endpoint exists, not all media enumerated. + if notify { + checked( + unsafe { libc::write(3, b"\n".as_ptr().cast(), 1) } as _, + "notify service ready", + )?; + unsafe { + libc::close(3); + } + } + consumers::prepare()?; + consumers::stop_all()?; + // s6-rc activation is only attempted after console readiness. On restart, + // retire the previous optional sessions before re-evaluating current media. + if profiles::console_ready() { + state.profiles.shutdown()?; + } + cleanup_stale_mounts()?; + state.scan()?; + let mut clients: Vec = Vec::new(); + loop { + let mut fds = vec![ + libc::pollfd { + fd: events.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: listener.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: signals.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: console_events.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: state.burning.descriptor(), + events: libc::POLLIN, + revents: 0, + }, + ]; + for client in &clients { + fds.push(libc::pollfd { + fd: client.socket.as_raw_fd(), + events: if client.output.is_some() { + libc::POLLOUT + } else if client.waiting.is_some() { + 0 + } else { + libc::POLLIN + }, + revents: 0, + }); + } + let timeout = clients + .iter() + .map(|c| { + c.deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(i32::MAX as u128) as i32 + }) + .min() + .unwrap_or(-1); + let result = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, timeout) }; + if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + checked(result, "wait for cartridge events")?; + if fds[2].revents != 0 { + let mut stop = false; + loop { + let mut info: libc::signalfd_siginfo = unsafe { std::mem::zeroed() }; + let n = unsafe { + libc::read( + signals.as_raw_fd(), + (&mut info as *mut libc::signalfd_siginfo).cast(), + std::mem::size_of_val(&info), + ) + }; + if n < 0 && io::Error::last_os_error().kind() == io::ErrorKind::WouldBlock { + break; + } + if n as usize != std::mem::size_of_val(&info) { + return Err(Error("Invalid service signal notification".into())); + } + if [libc::SIGTERM as u32, libc::SIGINT as u32].contains(&info.ssi_signo) { + stop = true; + } + } + state + .children + .retain_mut(|child| !matches!(child.try_wait(), Ok(Some(_)))); + if stop { + break; + } + state.profiles.poll()?; + } + if fds[4].revents != 0 || fds[2].revents != 0 { + state.burning.poll()?; + state.scan()?; + } + let console_changed = fds[3].revents != 0; + if console_changed { + let mut buffer = [0u8; 4096]; + while unsafe { + libc::read( + console_events.as_raw_fd(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + } > 0 + {} + } + if (fds[0].revents != 0 && consume_events(&events)?) || console_changed { + state.scan()?; + } + // Process existing clients before accepting more; vectors stay aligned. + for index in (0..clients.len()).rev() { + let client = &mut clients[index]; + let ready = fds[index + 5].revents; + if let Some((id, sequence)) = &client.waiting { + let job = state.burning.status(id); + if job + .as_ref() + .map_or(true, |job| job.finished() || job.sequence != *sequence) + || Instant::now() >= client.deadline + { + let request = Request::MediaStatus { + id: id.clone(), + after_sequence: None, + }; + let reply = state + .reply(request, client.uid) + .unwrap_or_else(Response::failure); + let mut output = + serde_json::to_vec(&reply).map_err(|e| Error(e.to_string()))?; + output.push(b'\n'); + client.output = Some(output); + client.waiting = None; + client.deadline = Instant::now() + Duration::from_secs(5); + } + } + let mut remove = + Instant::now() >= client.deadline || ready & (libc::POLLERR | libc::POLLNVAL) != 0; + if !remove && ready & libc::POLLIN != 0 && client.output.is_none() { + let mut chunk = [0u8; 4096]; + match client.socket.read(&mut chunk) { + Ok(0) => remove = true, + Ok(n) => { + client.input.extend_from_slice(&chunk[..n]); + if client.input.len() > LIMIT { + remove = true; + } else if let Some(end) = client.input.iter().position(|b| *b == b'\n') { + let reply = if end + 1 != client.input.len() { + Response::failure("Only one request is allowed per connection") + } else { + match serde_json::from_slice::(&client.input[..end]) { + Ok(request) => { + if let Request::MediaStatus { + id, + after_sequence: Some(sequence), + } = &request + { + if state.burning.status(id).is_ok_and(|job| { + !job.finished() && job.sequence == *sequence + }) { + client.waiting = Some((id.clone(), *sequence)); + } + } + state + .reply(request, client.uid) + .unwrap_or_else(Response::failure) + } + Err(_) => Response::failure("Invalid control request"), + } + }; + let mut output = + serde_json::to_vec(&reply).map_err(|e| Error(e.to_string()))?; + if output.len() + 1 > LIMIT { + output = serde_json::to_vec(&Response::failure( + "Inventory exceeds protocol limit", + )) + .unwrap(); + } + output.push(b'\n'); + if client.waiting.is_some() { + client.deadline = Instant::now() + Duration::from_secs(90); + } else { + client.output = Some(output); + } + // Input/output deadlines bound slow peers, not the + // time needed to flush a healthy DATA filesystem. + if client.waiting.is_none() { + client.deadline = Instant::now() + Duration::from_secs(5); + } + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => (), + Err(_) => remove = true, + } + } + if !remove && ready & libc::POLLOUT != 0 { + if let Some(output) = &client.output { + match client.socket.write(&output[client.offset..]) { + Ok(0) => remove = true, + Ok(n) => { + client.offset += n; + remove = client.offset == output.len(); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => (), + Err(_) => remove = true, + } + } + } + if !remove && ready & libc::POLLHUP != 0 && client.output.is_none() { + remove = true; + } + if remove { + clients.swap_remove(index); + } + } + if fds[1].revents & libc::POLLIN != 0 { + for _ in 0..32 { + match listener.accept() { + Ok((socket, _)) => { + let uid = peer_uid(&socket)?; + if clients.len() >= 16 || uid.is_none() { + continue; + } + socket.set_nonblocking(true)?; + clients.push(Client { + socket, + input: Vec::new(), + output: None, + offset: 0, + deadline: Instant::now() + Duration::from_secs(5), + uid: uid.unwrap(), + waiting: None, + }); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e.into()), + } + } + } + } + state.burning.stop()?; + consumers::stop_group("desktop")?; + consumers::stop_all()?; + for mount in state.mounts.values_mut() { + if !mount.protected { + if let Err(e) = mount.eject() { + eprintln!("{e}; no SAFE status issued"); + } + } + } + fs::remove_file(SOCKET)?; + Ok(()) +} diff --git a/rust/fds-cartridged/src/software.rs b/rust/fds-cartridged/src/software.rs new file mode 100644 index 0000000..717cc46 --- /dev/null +++ b/rust/fds-cartridged/src/software.rs @@ -0,0 +1,318 @@ +//! Metadata-first software media. Building is exclusively a workstation operation. +use crate::media::{self, c, checked}; +use fds_burn::{device::Disk, image}; +use fds_common::{Bay, Error, Result, read_text, sysfs::BlockPartition}; +use fds_software::{Catalogue, archive}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + os::{ + fd::AsRawFd, + unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, +}; +struct Payload { + path: String, + source: File, + partition: BlockPartition, + key: String, +} +pub struct Mounted { + pub catalogue: Catalogue, + root: String, + payloads: BTreeMap, + caches: BTreeMap, +} +fn mount(source: &str, path: &str, kind: &str, flags: libc::c_ulong, options: &str) -> Result<()> { + checked( + unsafe { + libc::mount( + c(source)?.as_ptr(), + c(path)?.as_ptr(), + c(kind)?.as_ptr(), + flags, + c(options)?.as_ptr().cast(), + ) + }, + "mount software storage", + ) +} +fn exclusive(source: &File, paths: &[&str]) -> Result<()> { + let meta = source.metadata()?; + let dev = if meta.file_type().is_block_device() { + meta.rdev() + } else { + meta.dev() + }; + let identity = format!("{}:{}", libc::major(dev), libc::minor(dev)); + let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?; + let found: BTreeSet<_> = mounts + .lines() + .filter(|line| line.split_whitespace().nth(2) == Some(identity.as_str())) + .filter_map(|line| line.split_whitespace().nth(4)) + .collect(); + if found != paths.iter().copied().collect() { + return Err(Error( + "Software storage has additional or unexpected mounts; close them before eject".into(), + )); + } + Ok(()) +} +impl Mounted { + pub fn open(bay: Bay, metadata: &str, usb: &Path, parts: &[BlockPartition]) -> Result { + let catalogue = Catalogue::parse(&media::metadata(metadata, "SOFTWARE.TOML")?)?; + let disk = Disk::select_current(usb)?; + let source = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&disk.path)?; + let stat = source.metadata()?; + if !stat.file_type().is_block_device() + || libc::major(stat.rdev()) != disk.major + || libc::minor(stat.rdev()) != disk.minor + { + return Err(Error("Software disk identity changed".into())); + } + let layout = image::inspect(&source, disk.bytes)?; + if layout.partitions.first().map(|p| p.name.as_str()) != Some("FDS_METADATA") + || layout.partitions.len() != catalogue.partition_count() + || parts.len() != layout.partitions.len() + { + return Err(Error( + "Software catalogue does not match the complete GPT layout".into(), + )); + } + let disk_path = fs::canonicalize(format!("/sys/dev/block/{}:{}", disk.major, disk.minor))?; + let root = format!("/run/fds/software/{bay}"); + fs::create_dir_all(&root)?; + fs::set_permissions(&root, fs::Permissions::from_mode(0o755))?; + let mut result = Self { + catalogue, + root, + payloads: BTreeMap::new(), + caches: BTreeMap::new(), + }; + for spec in &layout.partitions { + let part = parts + .iter() + .find(|p| p.partition_name == spec.name) + .ok_or_else(|| Error("Missing software partition".into()))?; + let sys = fs::canonicalize(format!("/sys/dev/block/{}:{}", part.major, part.minor))?; + let number = |name: &str| -> Result { + read_text(&sys.join(name), 64)? + .trim() + .parse() + .map_err(|_| Error("Invalid kernel partition geometry".into())) + }; + if sys.parent() != Some(disk_path.as_path()) + || number("partition")? != u64::from(spec.number) + || number("start")? != spec.start / 512 + || number("size")? != spec.bytes / 512 + { + return Err(Error( + "Kernel partition geometry disagrees with verified GPT".into(), + )); + } + if spec.number == 1 { + continue; + } + let key = media::key(part)?; + let source = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&part.device)?; + let stat = source.metadata()?; + if !stat.file_type().is_block_device() + || libc::major(stat.rdev()) != part.major + || libc::minor(stat.rdev()) != part.minor + || media::key(part)? != key + { + return Err(Error("Software payload changed during inspection".into())); + } + let path = format!("{}/payload{:02}", result.root, spec.number); + fs::create_dir_all(&path)?; + mount( + &format!("/proc/self/fd/{}", source.as_raw_fd()), + &path, + "erofs", + libc::MS_RDONLY | libc::MS_NOEXEC | libc::MS_NOSUID | libc::MS_NODEV, + "", + )?; + result.payloads.insert( + spec.number, + Payload { + path: path.clone(), + source, + partition: part.clone(), + key, + }, + ); + let bundles = Path::new(&path).join("bundles"); + if !fs::symlink_metadata(&bundles)?.is_dir() { + return Err(Error("Payload bundles must be a real directory".into())); + } + let expected: BTreeSet<_> = result + .catalogue + .software + .iter() + .filter(|s| s.partition == spec.number) + .map(|s| format!("{}.tar.xz", s.id)) + .collect(); + let actual: BTreeSet<_> = fs::read_dir(&bundles)? + .map(|e| Ok(e?.file_name().to_string_lossy().into_owned())) + .collect::>()?; + if actual != expected { + return Err(Error( + "Payload archive inventory disagrees with catalogue".into(), + )); + } + for software in result + .catalogue + .software + .iter() + .filter(|s| s.partition == spec.number) + { + if archive::open(&Path::new(&path).join(software.archive_path()))? + .metadata()? + .len() + != software.archive_bytes + { + return Err(Error( + "Software archive size disagrees with catalogue".into(), + )); + } + } + } + if Disk::select_current(usb)? != disk { + return Err(Error("Software disk changed during validation".into())); + } + result.exclusive()?; + Ok(result) + } + pub fn exclusive(&self) -> Result<()> { + for payload in self.payloads.values() { + exclusive(&payload.source, &[&payload.path])?; + } + for path in self.caches.values() { + exclusive(&File::open(path)?, &[path])?; + } + Ok(()) + } + pub fn program( + &mut self, + arguments: &[String], + ) -> Result<(Vec, Vec<(String, String)>)> { + let (id, command) = arguments + .first() + .and_then(|s| s.split_once(':')) + .ok_or_else(|| { + Error("Use SOFTWARE-ID:COMMAND; fds bay BAY lists available commands".into()) + })?; + let software = self + .catalogue + .software + .iter() + .find(|s| s.id == id) + .ok_or_else(|| Error("Unknown software id".into()))?; + let executable = software + .commands + .get(command) + .ok_or_else(|| Error("Unknown software command".into()))?; + let payload = self + .payloads + .get(&software.partition) + .ok_or_else(|| Error("Software is being ejected; remove and reinsert it".into()))?; + if media::key(&payload.partition)? != payload.key { + return Err(Error("Software payload was removed".into())); + } + if !self.caches.contains_key(id) { + // Each executable tree receives its own bounded, read-only tmpfs. + // Root owns every path; the consumer only receives ordinary UID 1000. + let capacity = software + .unpacked_bytes + .checked_add(u64::from(software.entries) * 4096 + 1024 * 1024) + .ok_or_else(|| Error("Runtime cache size overflow".into()))?; + if capacity > 256 * 1024 * 1024 { + return Err(Error("Software exceeds the 256 MiB runtime cache limit; use a smaller workstation bundle".into())); + } + let path = format!("{}/cache-{}", self.root, id); + fs::create_dir_all(&path)?; + mount( + "tmpfs", + &path, + "tmpfs", + libc::MS_NOSUID | libc::MS_NODEV, + &format!( + "mode=0700,size={capacity},nr_inodes={}", + software.entries + 1024 + ), + )?; + self.caches.insert(id.to_owned(), path.clone()); + let result = (|| { + archive::verify( + &archive::open(&PathBuf::from(&payload.path).join(software.archive_path()))?, + software, + Some(Path::new(&path)), + )?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?; + mount( + "tmpfs", + &path, + "tmpfs", + libc::MS_REMOUNT | libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV, + "", + )?; + Ok(()) + })(); + if let Err(error) = result { + media::unmount(&path, false)?; + self.caches.remove(id); + fs::remove_dir(&path)?; + return Err(error); + } + } + let root = &self.caches[id]; + let mut args = arguments.to_vec(); + args[0] = format!("{root}/{executable}"); + Ok(( + args, + vec![ + ("FDS_APP".into(), root.clone()), + ("PATH".into(), format!("{root}/bin:/usr/bin:/bin")), + ("LD_LIBRARY_PATH".into(), format!("{root}/lib")), + ("XDG_DATA_DIRS".into(), format!("{root}/share:/usr/share")), + ("DISPLAY".into(), ":0".into()), + ("XAUTHORITY".into(), "/run/fds/x11/authority".into()), + ], + )) + } + pub fn release(&mut self, removed: bool) -> Result<()> { + if !removed { + self.exclusive()?; + } + while let Some((id, path)) = self.caches.last_key_value() { + media::unmount(path, removed)?; + let id = id.clone(); + self.caches.remove(&id); + } + while let Some((number, payload)) = self.payloads.last_key_value() { + media::unmount(&payload.path, removed)?; + let number = *number; + self.payloads.remove(&number); + } + if Path::new(&self.root).exists() { + fs::remove_dir_all(&self.root)?; + } + Ok(()) + } +} +impl Drop for Mounted { + fn drop(&mut self) { + if !self.payloads.is_empty() || !self.caches.is_empty() { + if let Err(error) = self.release(false) { + eprintln!("Software cleanup: {error}"); + } + } + } +} diff --git a/rust/fds-cartridged/src/x11.rs b/rust/fds-cartridged/src/x11.rs new file mode 100644 index 0000000..83765e6 --- /dev/null +++ b/rust/fds-cartridged/src/x11.rs @@ -0,0 +1,194 @@ +//! Minimal authenticated X11 readiness observer. Subscribe before the snapshot, +//! including when the EWMH atom has not been created yet. No Xlib dependency. +//! Wire formats: X Window System Protocol, Appendix B (connection and requests). +use fds_common::{Error, Result}; +use std::{ + fs, + io::{Read, Write}, + os::{ + fd::{AsRawFd, RawFd}, + unix::net::UnixStream, + }, + time::Duration, +}; + +fn u16le(data: &[u8], at: usize) -> Result { + Ok(u16::from_le_bytes( + data.get(at..at + 2) + .ok_or_else(|| Error("Short X11 packet".into()))? + .try_into() + .unwrap(), + )) +} +fn u32le(data: &[u8], at: usize) -> Result { + Ok(u32::from_le_bytes( + data.get(at..at + 4) + .ok_or_else(|| Error("Short X11 packet".into()))? + .try_into() + .unwrap(), + )) +} +fn root_window(data: &[u8]) -> Result { + if data.len() < 32 || data[20] == 0 { + return Err(Error("X11 server has no screen".into())); + } + let start = 32 + (usize::from(u16le(data, 16)?) + 3) / 4 * 4 + usize::from(data[21]) * 8; + u32le(data, start) +} +struct Packet { + header: [u8; 32], + body: Vec, +} +pub struct Observer { + stream: UnixStream, + root: u32, + atom: u32, + sequence: u16, +} +impl AsRawFd for Observer { + fn as_raw_fd(&self) -> RawFd { + self.stream.as_raw_fd() + } +} +impl Observer { + pub fn connect() -> Result { + let authority = fs::read("/run/fds/x11/authority")?; + if authority.len() != 45 || &authority[9..27] != b"MIT-MAGIC-COOKIE-1" { + return Err(Error("Invalid FDS X authority record".into())); + } + let mut stream = UnixStream::connect("/tmp/.X11-unix/X0")?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + let mut setup = vec![b'l', 0, 11, 0, 0, 0, 18, 0, 16, 0, 0, 0]; + setup.extend_from_slice(b"MIT-MAGIC-COOKIE-1"); + setup.extend_from_slice(&[0, 0]); + setup.extend_from_slice(&authority[29..]); + stream.write_all(&setup)?; + let mut header = [0u8; 8]; + stream.read_exact(&mut header)?; + if header[0] != 1 || u16le(&header, 2)? != 11 { + return Err(Error("X11 authentication/setup failed".into())); + } + let mut body = vec![0; usize::from(u16le(&header, 6)?) * 4]; + stream.read_exact(&mut body)?; + let root = root_window(&body)?; + let mut observer = Self { + stream, + root, + atom: 0, + sequence: 0, + }; + // Event selection and InternAtom are ordered on this same connection. + // Its reply proves the subscription is installed before WindowMaker starts. + observer.send( + 2, + 0, + &[ + root.to_le_bytes(), + (1u32 << 11).to_le_bytes(), + (1u32 << 22).to_le_bytes(), + ] + .concat(), + )?; + let name = b"_NET_SUPPORTING_WM_CHECK"; + let mut data = Vec::from((name.len() as u16).to_le_bytes()); + data.extend_from_slice(&[0, 0]); + data.extend_from_slice(name); + data.resize((data.len() + 3) / 4 * 4, 0); + observer.send(16, 0, &data)?; + let packet = observer.reply()?; + observer.atom = u32le(&packet.header, 8)?; + if observer.atom == 0 { + return Err(Error("X11 could not allocate the readiness atom".into())); + } + Ok(observer) + } + fn send(&mut self, opcode: u8, detail: u8, body: &[u8]) -> Result<()> { + let mut data = vec![opcode, detail]; + data.extend_from_slice(&((body.len() / 4 + 1) as u16).to_le_bytes()); + data.extend_from_slice(body); + self.stream.write_all(&data)?; + self.sequence = self.sequence.wrapping_add(1); + Ok(()) + } + fn packet(&mut self) -> Result { + let mut header = [0u8; 32]; + self.stream.read_exact(&mut header)?; + if header[0] == 0 { + return Err(Error(format!( + "X11 request failed with error {}", + header[1] + ))); + } + let length = if header[0] == 1 || header[0] & 0x7f == 35 { + u32le(&header, 4)? as usize * 4 + } else { + 0 + }; + if length > 65536 { + return Err(Error("X11 reply exceeds readiness protocol limit".into())); + } + let mut body = vec![0; length]; + self.stream.read_exact(&mut body)?; + Ok(Packet { header, body }) + } + fn reply(&mut self) -> Result { + for _ in 0..1024 { + let packet = self.packet()?; + if packet.header[0] == 1 { + if u16le(&packet.header, 2)? != self.sequence { + return Err(Error("Unexpected X11 reply sequence".into())); + } + return Ok(packet); + } + } + Err(Error( + "Excessive X11 events while waiting for a reply".into(), + )) + } + pub fn ready(&mut self) -> Result { + self.send( + 20, + 0, + &[ + self.root.to_le_bytes(), + self.atom.to_le_bytes(), + 0u32.to_le_bytes(), + 0u32.to_le_bytes(), + 1u32.to_le_bytes(), + ] + .concat(), + )?; + let packet = self.reply()?; + Ok(packet.header[1] == 32 + && u32le(&packet.header, 8)? == 33 + && u32le(&packet.header, 16)? == 1 + && packet.body.len() == 4 + && u32le(&packet.body, 0)? != 0) + } + pub fn event(&mut self) -> Result { + let packet = self.packet()?; + if packet.header[0] & 0x7f == 28 + && u32le(&packet.header, 4)? == self.root + && u32le(&packet.header, 8)? == self.atom + { + self.ready() + } else { + Ok(false) + } + } +} +#[cfg(test)] +mod tests { + #[test] + fn setup_offsets_account_for_vendor_padding_and_pixmap_formats() { + let mut data = vec![0; 56]; + data[16] = 3; + data[20] = 1; + data[21] = 2; + data[52..56].copy_from_slice(&0x1234u32.to_le_bytes()); + assert_eq!(super::root_window(&data).unwrap(), 0x1234); + assert!(super::root_window(&data[..55]).is_err()); + assert!(super::root_window(&[]).is_err()); + } +} diff --git a/rust/fds-cli/.gitkeep b/rust/fds-cli/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rust/fds-cli/Cargo.toml b/rust/fds-cli/Cargo.toml new file mode 100644 index 0000000..951449a --- /dev/null +++ b/rust/fds-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fds-cli" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "FDS system and cartridge command interface" + +[[bin]] +name = "fds" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +fds-burn = { path = "../fds-burn" } +fds-common = { path = "../fds-common" } +serde_json = "1" +libc = "0.2" diff --git a/rust/fds-cli/src/cli.rs b/rust/fds-cli/src/cli.rs new file mode 100644 index 0000000..90748a8 --- /dev/null +++ b/rust/fds-cli/src/cli.rs @@ -0,0 +1,409 @@ +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use fds_burn::cli::{BurnCommand, FormatCommand}; +use fds_common::Bay; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(multicall = true)] +pub struct Cli { + #[command(subcommand)] + pub applet: Applet, +} + +#[derive(Debug, Subcommand)] +pub enum Applet { + #[command(version, about = "FDS/OS system and cartridge control")] + Fds(FdsArgs), + /// Inspect a bay, image, or cartridge manifest. + #[command(version)] + FdsInspect { + #[arg(long, global = true)] + json: bool, + #[command(flatten)] + args: InspectArgs, + }, + /// Unmount a cartridge before removal. + #[command(version)] + FdsEject { + #[arg(long, global = true)] + json: bool, + #[command(flatten)] + args: BayArgs, + }, + /// Prepare native shutdown or inspect its status. + #[command(version)] + FdsPower { + #[arg(long, global = true)] + json: bool, + #[command(flatten)] + args: PowerArgs, + }, +} +impl Cli { + pub fn into_command(self) -> (Option, bool) { + match self.applet { + Applet::Fds(args) => (args.command, args.json), + Applet::FdsInspect { json, args } => (Some(Action::Inspect(args)), json), + Applet::FdsEject { json, args } => (Some(Action::Eject(args)), json), + Applet::FdsPower { json, args } => (Some(Action::Power(args)), json), + } + } +} + +#[derive(Debug, Args)] +pub struct FdsArgs { + /// Emit machine-readable JSON where supported. + #[arg(long, global = true)] + pub json: bool, + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Action { + /// Show the system and tool identity. + Info, + /// Show all twelve bays. + Bays, + /// Show one bay and its cartridge state. + #[command(visible_alias = "cartridge")] + Bay(BayArgs), + /// Unmount a cartridge before removal. + Eject(BayArgs), + /// Select writable DATA. + Data { + #[command(subcommand)] + command: DataCommand, + }, + /// Start a managed DATA job or PROGRAM executable. + Run { + #[arg(value_parser = fds_burn::client::bay)] + bay: Bay, + /// Executable and its arguments; separate them from FDS options with --. + #[arg(last = true, required = true, num_args = 1.., value_name = "EXECUTABLE_AND_ARGS")] + arguments: Vec, + }, + /// Show desktop and network state. + Profiles, + /// Activate a desktop or return to the console. + Profile { + #[command(subcommand)] + command: ProfileCommand, + }, + /// Enable or stop DHCP networking. + Network { + #[arg(value_enum)] + state: Switch, + }, + /// Refresh cartridge inventory. + Rescan, + /// Show USB paths for bay calibration. + Topology, + /// Inspect a bay, image, or manifest file. + Inspect(InspectArgs), + /// Preview and confirm a cartridge write, or manage an existing operation. + Burn { + #[command(subcommand)] + command: BurnCommand, + }, + /// Create a cartridge and preview a confirmed write. + Format { + #[command(subcommand)] + command: FormatCommand, + }, + /// Manage persistent machine settings and saved diagnostics. + Machine { + #[command(subcommand)] + command: Option, + }, + /// Check DATA or confirm conservative repair from the root recovery console. + Recovery { + #[command(subcommand)] + command: Option, + }, + /// Verify DATA and request native shutdown. + Poweroff, + /// Verify DATA and request a reboot. + Reboot, + /// Show, resume, or request native shutdown preparation. + Power(PowerArgs), + /// Show measured boot events. + BootProfile, + /// Show the tool version. + Version, +} + +#[derive(Debug, Args)] +pub struct BayArgs { + #[arg(value_parser = fds_burn::client::bay)] + pub bay: Bay, +} +#[derive(Debug, Subcommand)] +pub enum DataCommand { + /// Select a DATA cartridge for /data. + Use(BayArgs), +} +#[derive(Debug, Subcommand)] +pub enum ProfileCommand { + /// Activate a profile. + Activate { + #[arg(value_parser = ["windowmaker", "cli"])] + name: String, + }, + /// Return to the console. + Deactivate, +} +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum Switch { + On, + Off, +} + +#[derive(Debug)] +pub struct InspectArgs { + pub target: Option, + pub command: Option, +} + +#[derive(Debug, Args)] +#[command(subcommand_negates_reqs = true)] +struct InspectParser { + /// A bay number (1–12 or BAY1–BAY12), or a manifest file path. + #[arg(required = true, value_name = "BAY_OR_MANIFEST")] + target: Option, + #[command(subcommand)] + command: Option, +} + +impl TryFrom for InspectArgs { + type Error = clap::Error; + fn try_from(parsed: InspectParser) -> Result { + // Clap's args_conflicts_with_subcommands also conflicts with global + // --json. Check only these two typed alternatives during conversion. + if parsed.target.is_some() && parsed.command.is_some() { + return Err(clap::Error::raw( + clap::error::ErrorKind::ArgumentConflict, + "Choose a bay/manifest target or the image subcommand, not both", + )); + } + Ok(Self { + target: parsed.target, + command: parsed.command, + }) + } +} +impl clap::FromArgMatches for InspectArgs { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + InspectParser::from_arg_matches(matches)?.try_into() + } + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + let mut parsed = InspectParser { + target: self.target.clone(), + command: self.command.clone(), + }; + parsed.update_from_arg_matches(matches)?; + *self = parsed.try_into()?; + Ok(()) + } +} +impl Args for InspectArgs { + fn augment_args(command: clap::Command) -> clap::Command { + InspectParser::augment_args(command) + } + fn augment_args_for_update(command: clap::Command) -> clap::Command { + InspectParser::augment_args_for_update(command) + } +} +#[derive(Clone, Debug, Subcommand)] +pub enum InspectCommand { + /// Validate a regular cartridge image and calculate its SHA-256 digest. + Image { image: PathBuf }, +} + +#[derive(Debug, Args)] +pub struct PowerArgs { + #[command(subcommand)] + pub command: Option, +} +#[derive(Debug, Subcommand)] +pub enum PowerCommand { + /// Show preparation status or its blocking error (the default). + Status, + /// Resume operations before native shutdown starts. + Resume, + /// Verify DATA and request native shutdown. + Poweroff, + /// Verify DATA and request a reboot. + Reboot, + #[command(long_flag = "shutdown-hook", hide = true)] + ShutdownHook, + #[command(long_flag = "hold-shutdown", hide = true)] + HoldShutdown, + #[command(long_flag = "record-final", hide = true)] + RecordFinal { + #[arg(value_parser = ["failed"])] + outcome: Option, + }, +} + +#[derive(Debug, Subcommand)] +pub enum RecoveryCommand { + /// Unmount and check DATA without repairs. + Check(BayArgs), + /// Preview repair, or confirm the exact token returned by the preview. + Repair { + #[arg(value_parser = fds_burn::client::bay)] + bay: Bay, + #[arg(long, value_name = "TOKEN")] + confirm: Option, + }, +} + +#[derive(Debug, Subcommand)] +pub enum MachineCommand { + /// Show the active machine identity and settings source. + Status, + /// Export this boot's active settings into a new directory. + Export { new_directory: PathBuf }, + /// Validate machine.toml, bays.toml and hardware-catalog.toml. + Validate { directory: PathBuf }, + /// Validate and pack settings into a new JSON file. + Pack { + directory: PathBuf, + new_json_file: PathBuf, + }, + /// Install settings for the next boot (root recovery console only). + Install { directory: PathBuf }, + /// Save an explicit diagnostic of up to 16 MiB (root only). + Store { name: String, file: PathBuf }, + /// Retrieve a saved diagnostic into a new file (root only). + Fetch { name: String, new_file: PathBuf }, + #[command(long_flag = "load", hide = true)] + Load, +} + +pub fn help(subcommand: Option<&str>) -> std::io::Result<()> { + let mut root = Cli::command(); + root.build(); + let fds = root.find_subcommand_mut("fds").expect("fds applet"); + let command = match subcommand { + Some(name) => fds.find_subcommand_mut(name).expect("known subcommand"), + None => fds, + }; + command.print_help()?; + println!(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_tree_and_installed_aliases() { + Cli::command().debug_assert(); + for arguments in [ + vec!["fds", "--json", "inspect", "BAY12"], + vec!["/usr/bin/fds-inspect", "BAY12", "--json"], + vec!["fds-inspect", "image", "card.img"], + vec!["fds-eject", "--json", "12"], + vec!["fds-power", "status", "--json"], + vec!["fds", "cartridge", "12"], + vec![ + "fds", + "recovery", + "repair", + "BAY1", + "--confirm", + "exact phrase", + ], + vec!["fds", "machine", "pack", "settings", "new.json"], + vec!["fds", "machine", "--load"], + vec!["fds", "power", "--shutdown-hook"], + vec!["fds-power", "--hold-shutdown"], + vec!["fds-power", "--record-final", "failed"], + ] { + assert!(Cli::try_parse_from(&arguments).is_ok(), "{arguments:?}"); + } + for name in ["fds", "fds-inspect", "fds-eject", "fds-power"] { + let error = Cli::try_parse_from([name, "--help"]).unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(error.to_string().contains(&format!("Usage: {name}"))); + } + } + + #[test] + fn run_preserves_every_child_argument_after_separator() { + let (command, json) = Cli::try_parse_from([ + "fds", "run", "BAY2", "--", "/bin/app", "--json", "--help", "-x", "", "--", "a b", + ]) + .unwrap() + .into_command(); + assert!(!json); + let Some(Action::Run { bay, arguments }) = command else { + panic!("run command") + }; + assert_eq!(bay, "2".parse::().unwrap()); + assert_eq!( + arguments, + ["/bin/app", "--json", "--help", "-x", "", "--", "a b"] + ); + let (_, json) = Cli::try_parse_from(["fds", "run", "--json", "2", "--", "/bin/app"]) + .unwrap() + .into_command(); + assert!(json); + } + + #[test] + fn inspect_image_accepts_global_options_in_every_position() { + for arguments in [ + vec!["fds", "--json", "inspect", "image", "card.img"], + vec!["fds", "inspect", "--json", "image", "card.img"], + vec!["fds", "inspect", "image", "card.img", "--json"], + vec!["fds-inspect", "--json", "image", "card.img"], + vec!["fds-inspect", "image", "--json", "card.img"], + ] { + let (command, json) = Cli::try_parse_from(&arguments).unwrap().into_command(); + assert!(json); + assert!(matches!( + command, + Some(Action::Inspect(InspectArgs { + command: Some(InspectCommand::Image { .. }), + target: None, + })) + )); + } + } + + #[test] + fn rejects_incomplete_or_conflicting_requests_before_execution() { + for arguments in [ + vec!["fds", "eject", "0"], + vec!["fds-eject", "13"], + vec!["fds", "run", "1", "/bin/app"], + vec!["fds", "run", "1", "--"], + vec!["fds", "inspect", "image"], + vec!["fds-inspect", "1", "image", "card.img"], + vec!["fds", "recovery", "check", "1", "--confirm", "token"], + vec!["fds", "recovery", "repair", "1", "--confirm"], + vec![ + "fds", + "recovery", + "repair", + "1", + "--confirm", + "one", + "--confirm", + "two", + ], + vec!["fds", "power", "--shutdown-hook", "--record-final"], + vec!["fds", "power", "--record-final", "success"], + vec!["fds", "machine", "--load", "settings"], + vec!["fds", "network", "maybe"], + vec!["fds", "profile", "activate", "unknown"], + vec!["fds", "unknown"], + ] { + assert!(Cli::try_parse_from(&arguments).is_err(), "{arguments:?}"); + } + } +} diff --git a/rust/fds-cli/src/machine.rs b/rust/fds-cli/src/machine.rs new file mode 100644 index 0000000..e948c62 --- /dev/null +++ b/rust/fds-cli/src/machine.rs @@ -0,0 +1,510 @@ +//! Internal NVMe access is explicit and isolated in this process's mount namespace. +use fds_common::{ + Error, Result, + machine::{self, Config}, + read_text, sysfs, +}; +use std::{ + ffi::CString, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + os::{ + fd::AsRawFd, + unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, +}; + +const RUNTIME: &str = "/run/fds/machine"; +const MOUNT: &str = "/run/fds/machine/internal"; + +fn c(text: &str) -> Result { + CString::new(text).map_err(|_| Error("NUL in mount argument".into())) +} +fn checked(value: libc::c_int, what: &str) -> Result<()> { + if value < 0 { + Err(Error(format!("{what}: {}", io::Error::last_os_error()))) + } else { + Ok(()) + } +} +fn root() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + return Err(Error("Machine storage operations require root".into())); + } + Ok(()) +} +fn prepare() -> Result { + root()?; + fs::create_dir_all(RUNTIME)?; + fs::set_permissions(RUNTIME, fs::Permissions::from_mode(0o755))?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(format!("{RUNTIME}/lock"))?; + checked( + unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + "Another machine storage operation is active", + )?; + Ok(lock) +} +fn parent(part: &sysfs::BlockPartition) -> Result { + let path = fs::canonicalize(format!("/sys/dev/block/{}:{}", part.major, part.minor))?; + path.parent() + .map(Path::to_path_buf) + .ok_or_else(|| Error("Partition has no parent disk".into())) +} +fn select() -> Result<(sysfs::BlockPartition, PathBuf, String)> { + let parts = sysfs::partitions(Path::new("/sys"))?; + let mut candidates = Vec::new(); + for part in parts.iter().filter(|p| p.partition_name == "FDS_INTERNAL") { + let disk = parent(part)?; + let buses: Vec<_> = disk + .ancestors() + .filter_map(|path| fs::read_link(path.join("subsystem")).ok()) + .filter_map(|path| path.file_name().map(|s| s.to_owned())) + .collect(); + if !buses.iter().any(|b| b == "nvme") || buses.iter().any(|b| b == "usb") { + continue; + } + if read_text(&disk.join("removable"), 32)?.trim() != "0" { + continue; + } + let siblings: Vec<_> = parts + .iter() + .filter(|p| parent(p).ok().as_ref() == Some(&disk)) + .collect(); + if siblings.len() != 3 { + return Err(Error( + "Internal NVMe must contain exactly FDS_BOOT, FDS_RECOVERY and FDS_INTERNAL".into(), + )); + } + for (number, label) in [(1, "FDS_BOOT"), (2, "FDS_RECOVERY"), (3, "FDS_INTERNAL")] { + let matches: Vec<_> = siblings + .iter() + .filter(|p| p.partition_name == label) + .collect(); + if matches.len() != 1 + || read_text( + &PathBuf::from(format!( + "/sys/dev/block/{}:{}/partition", + matches[0].major, matches[0].minor + )), + 32, + )? + .trim() + != number.to_string() + { + return Err(Error("Invalid internal NVMe partition layout".into())); + } + } + let sequence = read_text(&disk.join("diskseq"), 32)?.trim().to_owned(); + sequence + .parse::() + .map_err(|_| Error("Invalid internal disk sequence".into()))?; + candidates.push((part.clone(), disk, sequence)); + } + match candidates.len() { + 0 => Err(Error( + "No complete internal NVMe layout found; image defaults remain available".into(), + )), + 1 => Ok(candidates.pop().unwrap()), + _ => Err(Error( + "Multiple internal NVMe layouts found; refusing to choose one".into(), + )), + } +} +struct Internal { + file: File, + disk: PathBuf, + sequence: String, + mounted: bool, +} +impl Internal { + fn open(writable: bool) -> Result { + let (part, disk, sequence) = select()?; + let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?; + if mounts.lines().any(|line| { + line.split_whitespace().nth(2) == Some(&format!("{}:{}", part.major, part.minor)) + }) { + return Err(Error( + "Internal settings are already mounted; unmount them before continuing".into(), + )); + } + let file = OpenOptions::new() + .read(true) + .write(writable) + .custom_flags(libc::O_NOFOLLOW) + .open(&part.device)?; + let meta = file.metadata()?; + if !meta.file_type().is_block_device() + || libc::major(meta.rdev()) != part.major + || libc::minor(meta.rdev()) != part.minor + { + return Err(Error("Internal partition identity changed".into())); + } + let mut header = [0u8; 1024]; + file.read_exact_at(&mut header, 1024)?; + let word = |at| u16::from_le_bytes([header[at], header[at + 1]]); + let features = u32::from_le_bytes(header[96..100].try_into().unwrap()); + if word(56) != 0xef53 + || word(58) != 1 + || features & 4 != 0 + || &header[120..136] != b"FDS_INTERNAL\0\0\0\0" + { + return Err(Error("Internal ext4 is unclean, damaged or incorrectly labeled; offline filesystem maintenance is required".into())); + } + checked( + unsafe { libc::unshare(libc::CLONE_NEWNS) }, + "Isolate internal storage mounts", + )?; + checked( + unsafe { + libc::mount( + std::ptr::null(), + c("/")?.as_ptr(), + std::ptr::null(), + libc::MS_REC | libc::MS_PRIVATE, + std::ptr::null(), + ) + }, + "Make storage mount private", + )?; + fs::create_dir_all(MOUNT)?; + fs::set_permissions(MOUNT, fs::Permissions::from_mode(0o700))?; + let mut internal = Self { + file, + disk, + sequence, + mounted: false, + }; + internal.identity()?; + let options = c(if writable { + "errors=remount-ro" + } else { + "noload" + })?; + checked( + unsafe { + libc::mount( + c(&format!("/proc/self/fd/{}", internal.file.as_raw_fd()))?.as_ptr(), + c(MOUNT)?.as_ptr(), + c("ext4")?.as_ptr(), + libc::MS_NOSUID + | libc::MS_NODEV + | libc::MS_NOEXEC + | if writable { 0 } else { libc::MS_RDONLY }, + options.as_ptr().cast(), + ) + }, + "Mount internal settings", + )?; + internal.mounted = true; + internal.identity()?; + Ok(internal) + } + fn identity(&self) -> Result<()> { + if read_text(&self.disk.join("diskseq"), 32)?.trim() != self.sequence { + return Err(Error("Internal NVMe changed during the operation".into())); + } + Ok(()) + } + fn close(mut self, writable: bool) -> Result<()> { + self.identity()?; + if writable { + let directory = File::open(MOUNT)?; + checked( + unsafe { libc::syncfs(directory.as_raw_fd()) }, + "Flush internal settings filesystem", + )?; + } + checked( + unsafe { libc::umount(c(MOUNT)?.as_ptr()) }, + "Unmount internal settings", + )?; + self.mounted = false; + if writable { + self.file.sync_all()?; + } + Ok(()) + } +} +impl Drop for Internal { + fn drop(&mut self) { + if self.mounted { + // No lazy unmount. The private namespace is also destroyed on exit. + if let Ok(path) = c(MOUNT) { + unsafe { + libc::umount(path.as_ptr()); + } + } + } + } +} +fn atomic(path: &Path, bytes: &[u8], mode: u32) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| Error("Missing parent directory".into()))?; + let temporary = path.with_extension(format!("next-{}", std::process::id())); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .custom_flags(libc::O_NOFOLLOW) + .open(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + File::open(parent)?.sync_all()?; + Ok(()) +} +fn emulator_config() -> Result> { + if !read_text(Path::new("/proc/cmdline"), 65536)? + .split_whitespace() + .any(|s| s == "fds.emulator=1") + { + return Ok(None); + } + let compatible = fs::read("/proc/device-tree/compatible")?; + if !compatible + .split(|b| *b == 0) + .any(|s| s == b"linux,dummy-virt") + { + return Err(Error( + "fds.emulator=1 requires the QEMU virt machine".into(), + )); + } + let controller = fs::canonicalize("/sys/bus/pci/devices/0000:00:05.0")?; + if read_text(&controller.join("vendor"), 64)?.trim() != "0x1b36" + || read_text(&controller.join("device"), 64)?.trim() != "0x000d" + { + return Err(Error( + "Emulator requires QEMU xHCI at PCI address 00:05.0".into(), + )); + } + let identity = controller + .strip_prefix("/sys/devices") + .map_err(|_| Error("Unexpected emulator controller path".into()))? + .to_string_lossy(); + let mut config = Config::fallback()?; + config.name = "FDS QEMU workstation emulator".into(); + config.bays.clear(); + for protocol in ["usb2", "usb3"] { + config.bays.push_str(&format!( + "[{protocol}]\nhub = \"{identity}:{protocol}\"\n[{protocol}.ports]\n" + )); + for n in 1..=12 { + config.bays.push_str(&format!("{n} = {n}\n")); + } + } + config.validate()?; + Ok(Some(config)) +} +pub fn load() -> Result<()> { + let _lock = prepare()?; + if Path::new(machine::SNAPSHOT).exists() && Path::new(machine::STATUS).exists() { + return Ok(()); + } + let attempt = || -> Result<(Config, String)> { + let internal = Internal::open(false)?; + let config = Config::parse(&machine::trusted_text( + Path::new(&format!("{MOUNT}/config/machine.json")), + machine::MAX_BUNDLE, + )?)?; + let sequence = internal.sequence.clone(); + internal.close(false)?; + Ok((config, sequence)) + }; + let emulated = emulator_config()?; + let (config, status) = if let Some(config) = emulated { + let status = serde_json::json!({"source":"qemu_emulator", "name":config.name, "disk_sequence":null, "error":null}); + (config, status) + } else { + match attempt() { + Ok((config, sequence)) => { + let status = serde_json::json!({"source":"internal_nvme", "name":config.name, "disk_sequence":sequence, "error":null}); + (config, status) + } + Err(error) => { + eprintln!("FDS machine settings: {error}"); + let config = Config::fallback()?; + let status = serde_json::json!({"source":"image_defaults", "name":config.name, "disk_sequence":null, "error":error.to_string()}); + (config, status) + } + } + }; + atomic( + Path::new(machine::STATUS), + &serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?, + 0o644, + )?; + atomic(Path::new(machine::SNAPSHOT), &config.json()?, 0o644)?; + Ok(()) +} +fn install(directory: &Path) -> Result<()> { + root()?; + if !fds_common::recovery_mode()? { + return Err(Error("Installing machine settings requires the recovery console; changes take effect at the next boot".into())); + } + let config = Config::directory(directory)?; + let _lock = prepare()?; + let internal = Internal::open(true)?; + let current = PathBuf::from(format!("{MOUNT}/config/machine.json")); + // Validate ownership and preserve the previous bytes, even when its JSON is + // damaged: recovery must be able to replace invalid settings with valid ones. + let previous = machine::trusted_text(¤t, machine::MAX_BUNDLE)?; + atomic( + ¤t.with_file_name("previous.json"), + previous.as_bytes(), + 0o600, + )?; + atomic(¤t, &config.json()?, 0o600)?; + internal.close(true)?; + println!( + "Saved machine settings for {}. Reboot to activate them; the current bay mapping is unchanged.", + config.name + ); + Ok(()) +} +fn diagnostic_name(name: &str) -> Result<()> { + if name.is_empty() + || name.len() > 80 + || name.starts_with('.') + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + { + return Err(Error("Diagnostic name must be 1..80 letters, digits, dots, hyphens or underscores and must not start with a dot".into())); + } + Ok(()) +} +fn store(name: &str, input: &Path) -> Result<()> { + root()?; + diagnostic_name(name)?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(input)?; + if !file.metadata()?.is_file() { + return Err(Error("Diagnostic input must be a regular file".into())); + } + let mut bytes = Vec::new(); + file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?; + if bytes.len() > 16 * 1024 * 1024 { + return Err(Error("Diagnostic input exceeds 16 MiB".into())); + } + let _lock = prepare()?; + let internal = Internal::open(true)?; + let directory = PathBuf::from(format!("{MOUNT}/diagnostics")); + let meta = fs::symlink_metadata(&directory)?; + if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o077 != 0 { + return Err(Error("Untrusted diagnostics directory".into())); + } + let output = directory.join(name); + if output.symlink_metadata().is_ok() { + return Err(Error("Diagnostic already exists; choose a new name".into())); + } + atomic(&output, &bytes, 0o600)?; + internal.close(true)?; + println!("Saved diagnostics/{name} on internal NVMe; storage is flushed and unmounted."); + Ok(()) +} +pub fn run(command: crate::cli::MachineCommand, json: bool) -> Result<()> { + use crate::cli::MachineCommand; + match command { + MachineCommand::Status => { + let text = read_text(Path::new(machine::STATUS), 16384)?; + if json { + println!("{text}"); + } else { + let status: serde_json::Value = + serde_json::from_str(&text).map_err(|e| Error(e.to_string()))?; + println!( + "MACHINE {}\nSETTINGS {}", + status["name"].as_str().unwrap_or("unknown"), + status["source"].as_str().unwrap_or("unknown") + ); + if let Some(error) = status["error"].as_str() { + println!("DETAIL {error}"); + } + } + } + MachineCommand::Validate { directory } => { + let config = Config::directory(&directory)?; + println!("Valid machine settings: {}", config.name); + } + MachineCommand::Pack { + directory, + new_json_file: output, + } => { + let bytes = Config::directory(&directory)?.json()?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&output)?; + file.write_all(&bytes)?; + file.sync_all()?; + println!("Validated machine settings: {}", output.display()); + } + MachineCommand::Load => load()?, + MachineCommand::Install { directory } => install(&directory)?, + MachineCommand::Store { name, file: input } => store(&name, &input)?, + MachineCommand::Export { + new_directory: directory, + } => { + let config = Config::active()?; + let path = &directory; + fs::create_dir(path)?; + fs::write( + path.join("machine.toml"), + format!( + "format = 1\nname = {}\n", + serde_json::to_string(&config.name).map_err(|e| Error(e.to_string()))? + ), + )?; + fs::write(path.join("bays.toml"), config.bays)?; + fs::write(path.join("hardware-catalog.toml"), config.hardware_catalog)?; + println!( + "Exported this boot's machine settings to {}", + directory.display() + ); + } + MachineCommand::Fetch { + name, + new_file: output, + } => { + diagnostic_name(&name)?; + let _lock = prepare()?; + let internal = Internal::open(false)?; + let path = format!("{MOUNT}/diagnostics/{name}"); + let input = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)?; + let meta = input.metadata()?; + if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 { + return Err(Error("Untrusted diagnostic file".into())); + } + let mut bytes = Vec::new(); + input.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?; + if bytes.len() > 16 * 1024 * 1024 { + return Err(Error("Diagnostic exceeds 16 MiB".into())); + } + internal.close(false)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(output)?; + output.write_all(&bytes)?; + output.sync_all()?; + println!("Retrieved diagnostics/{name}"); + } + } + Ok(()) +} diff --git a/rust/fds-cli/src/main.rs b/rust/fds-cli/src/main.rs new file mode 100644 index 0000000..ab823ca --- /dev/null +++ b/rust/fds-cli/src/main.rs @@ -0,0 +1,271 @@ +mod cli; +mod machine; +mod power; +mod recovery; +use clap::Parser; +use cli::{ + Action, DataCommand, InspectCommand, PowerCommand, ProfileCommand, RecoveryCommand, Switch, +}; +use fds_common::{ + Error, Result, VERSION, + control::{self, Request}, + manifest::Manifest, + read_text, trace, +}; +use std::{path::Path, process::ExitCode}; + +fn run() -> Result<()> { + let (command, json) = cli::Cli::parse().into_command(); + let Some(command) = command else { + return Ok(cli::help(None)?); + }; + match command { + Action::Machine { + command: Some(command), + } => machine::run(command, json)?, + Action::Machine { command: None } => cli::help(Some("machine"))?, + Action::Recovery { command: None } => cli::help(Some("recovery"))?, + Action::Recovery { + command: Some(RecoveryCommand::Check(args)), + } => recovery::run(args.bay, false, None, json)?, + Action::Recovery { + command: Some(RecoveryCommand::Repair { bay, confirm }), + } => recovery::run(bay, true, confirm, json)?, + Action::Poweroff => power::client(Request::Poweroff, json)?, + Action::Reboot => power::client(Request::Reboot, json)?, + Action::Power(args) => match args.command.unwrap_or(PowerCommand::Status) { + PowerCommand::Poweroff => power::client(Request::Poweroff, json)?, + PowerCommand::Reboot => power::client(Request::Reboot, json)?, + PowerCommand::Status => power::client(Request::PowerStatus, json)?, + PowerCommand::Resume => power::client(Request::PowerResume, json)?, + PowerCommand::ShutdownHook => power::shutdown_hook()?, + PowerCommand::HoldShutdown => power::hold_shutdown()?, + PowerCommand::RecordFinal { outcome } => power::record_final(outcome.is_some())?, + }, + Action::Burn { command } => fds_burn::client::burn(command, json)?, + Action::Format { command } => fds_burn::client::format(command, json)?, + Action::Inspect(args) => { + if let Some(InspectCommand::Image { image: path }) = args.command { + let file = std::fs::File::open(path)?; + if !file.metadata()?.is_file() { + return Err(Error("Image inspection requires a regular file".into())); + } + let mut info = fds_burn::image::inspect(&file, file.metadata()?.len())?; + info.sha256 = Some(fds_burn::image::digest(&file, info.bytes, |_| Ok(()))?); + println!( + "{}", + serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))? + ); + } else { + let path = args + .target + .expect("Clap requires a target or image subcommand"); + if let Some(bay) = path + .to_str() + .and_then(|value| fds_burn::client::bay(value).ok()) + { + fds_burn::client::inspect_bay(bay, json)?; + } else { + inspect_manifest(&path, json)?; + } + } + } + Action::Profiles => cartridge(Request::Profiles, json)?, + Action::Profile { command } => cartridge( + Request::Profile { + profile: match command { + ProfileCommand::Activate { name } => name, + ProfileCommand::Deactivate => "cli".into(), + }, + }, + json, + )?, + Action::Network { state } => cartridge( + Request::Network { + enabled: matches!(state, Switch::On), + }, + json, + )?, + Action::Bays => cartridge(Request::Bays, json)?, + Action::Bay(args) => cartridge(Request::Bay { bay: args.bay }, json)?, + Action::Eject(args) => cartridge(Request::Eject { bay: args.bay }, json)?, + Action::Rescan => cartridge(Request::Rescan, json)?, + Action::Data { + command: DataCommand::Use(args), + } => cartridge(Request::DataUse { bay: args.bay }, json)?, + Action::Run { bay, arguments } => cartridge(Request::Run { bay, arguments }, json)?, + Action::Topology => cartridge(Request::Topology, json)?, + Action::Version => println!("fds {VERSION}"), + Action::BootProfile => trace::load(Path::new(trace::RUNTIME))?.print(json)?, + Action::Info => info(json)?, + } + Ok(()) +} +fn info(json: bool) -> Result<()> { + let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)? + .trim() + .to_owned(); + let pid1 = std::fs::read_link("/proc/1/exe") + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| { + read_text(Path::new("/proc/1/comm"), 4096) + .map(|s| s.trim().to_owned()) + .unwrap_or_else(|_| "unavailable".into()) + }); + let target = if cfg!(all( + target_arch = "aarch64", + target_env = "musl", + target_feature = "crt-static" + )) { + "aarch64 static-musl" + } else { + "host test build" + }; + if json { + println!( + "{}", + serde_json::json!({"fds_version": VERSION, "target": target, "kernel": kernel, "pid1": pid1}) + ); + } else { + println!("FDS/OS {VERSION}\nTOOLS {target}\nKERNEL {kernel}\nINIT {pid1}"); + } + Ok(()) +} +fn inspect_manifest(path: &Path, json: bool) -> Result<()> { + let manifest = Manifest::load(path)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&manifest).map_err(|e| Error(e.to_string()))? + ); + } else { + println!( + "{}\n{} {}\nID {}\nMEDIA {}", + manifest.cartridge.class.label(), + manifest.cartridge.name, + manifest.cartridge.version, + manifest.cartridge.id, + if manifest.media.writable { + "WRITABLE" + } else { + "READ-ONLY" + } + ); + if let Some(activation) = manifest.activation { + println!("PROFILE {}", activation.profile); + } + } + Ok(()) +} +fn cartridge(request: Request, json: bool) -> Result<()> { + let debug = matches!(request, Request::Topology); + let response = control::request(&request)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&response).map_err(|e| Error(e.to_string()))? + ); + } else { + if let Some(pid) = response.started_pid { + println!("STARTED {pid} Output: /run/log/cartridged/current"); + } + if let Some(p) = response.profiles { + println!( + "PROFILES cli, windowmaker\nDESKTOP {} {}\nNETWORK {}", + p.desktop, + if p.desktop == "windowmaker" { + if p.ready_ns.is_some() { + "READY" + } else { + "STARTING" + } + } else { + "" + }, + if p.network.is_empty() { + "OFF".into() + } else { + p.network.join(", ") + } + ); + if let Some(bay) = p.environment_bay { + println!("ENVIRONMENT BAY {bay}"); + } + if let (Some(start), Some(end)) = (p.activation_ns, p.ready_ns) { + println!( + "ACTIVATION TO READY {:.3} ms", + end.saturating_sub(start) as f64 / 1_000_000.0 + ); + } + if let Some(error) = p.error { + println!("ERROR {error}"); + } + return Ok(()); + } + for bay in response.bays { + println!( + "BAY {} {}{}", + bay.bay, + bay.state.to_uppercase().replace('_', " "), + bay.name + .as_ref() + .map(|n| format!(" {n}")) + .unwrap_or_default() + ); + if let Some(detail) = bay.detail { + println!(" {detail}"); + } + if let Some(manifest) = bay.manifest { + println!( + " {} {} {}", + manifest.cartridge.class.label(), + manifest.cartridge.id, + manifest.cartridge.version + ); + } + if let Some(catalogue) = bay.software { + for software in catalogue.software { + println!( + " SOFTWARE {} {} (partition {})", + software.id, software.version, software.partition + ); + for command in software.commands.keys() { + println!(" fds run {} -- {}:{}", bay.bay, software.id, command); + } + } + } + if let Some(mount) = bay.mount { + println!(" MOUNT {mount}"); + } + if bay.consumers > 0 { + println!(" MANAGED PROCESSES {}", bay.consumers); + } + if debug { + for device in bay.devices { + println!( + " {} USB {}:{}", + device.topology, device.vendor, device.product + ); + } + } + } + if debug { + for device in response.unmapped { + println!( + "UNMAPPED {} USB {}:{}", + device.topology, device.vendor, device.product + ); + } + } + } + Ok(()) +} +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("fds: {error}"); + ExitCode::from(2) + } + } +} diff --git a/rust/fds-cli/src/power.rs b/rust/fds-cli/src/power.rs new file mode 100644 index 0000000..ce93794 --- /dev/null +++ b/rust/fds-cli/src/power.rs @@ -0,0 +1,134 @@ +use fds_common::{ + Error, Result, + control::{self, PowerEvent, PowerState, Request}, + trace, +}; +use std::{ + ffi::CString, + fs, io, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd}, + unix::fs::PermissionsExt, + }, + path::Path, +}; + +const DIRECTORY: &str = "/run/fds/power"; +const RECORD: &str = "/run/fds/power/state.json"; +pub fn client(request: Request, json: bool) -> Result<()> { + let power = control::request(&request)? + .power + .ok_or_else(|| Error("Missing shutdown state".into()))?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&power).map_err(|e| Error(e.to_string()))? + ); + } else { + println!("POWER STATE: {}", power.phase); + if let Some(action) = &power.action { + println!("ACTION: {action}"); + } + if power.native_pending { + println!("Native s6 shutdown has been requested."); + } + if let Some(error) = &power.error { + println!( + "BLOCKED: {error}\nResolve the problem and retry, or use fds power resume before native shutdown starts." + ); + } + } + Ok(()) +} +fn root() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + Err(Error("This native shutdown helper requires root".into())) + } else { + Ok(()) + } +} +pub fn hold_shutdown() -> Result<()> { + root()?; + // Last-resort fail-closed path used only if the state watcher itself fails. + // A blocked signal wait consumes no CPU and does not advance native init. + let mut mask = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigfillset(&mut mask); + if libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) < 0 { + return Err(io::Error::last_os_error().into()); + } + loop { + libc::pause(); + } + } +} +fn state() -> Result { + serde_json::from_str(&fds_common::read_text(Path::new(RECORD), 32 * 1024)?) + .map_err(|e| Error(format!("Invalid shutdown record: {e}"))) +} +pub fn shutdown_hook() -> Result<()> { + root()?; + fs::create_dir_all(DIRECTORY)?; + fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?; + let fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) }; + if fd < 0 { + return Err(io::Error::last_os_error().into()); + } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let path = CString::new(DIRECTORY).unwrap(); + if unsafe { + libc::inotify_add_watch( + fd.as_raw_fd(), + path.as_ptr(), + libc::IN_CLOSE_WRITE | libc::IN_MOVED_TO, + ) + } < 0 + { + return Err(io::Error::last_os_error().into()); + } + // Record the irrevocable native request even if the cartridge daemon is + // currently restarting. Its next startup must keep operations frozen. + fs::write( + Path::new(DIRECTORY).join("native-pending"), + b"native shutdown requested\n", + )?; + if let Err(error) = control::request(&Request::PowerPrepare) { + eprintln!( + "Native shutdown is waiting: {error}. Resolve the problem and retry fds poweroff; DATA is not declared SAFE." + ); + } + loop { + if state().is_ok_and(|s| s.phase == "prepared" && s.native_pending) { + return Ok(()); + } + let mut bytes = [0u8; 4096]; + let count = unsafe { libc::read(fd.as_raw_fd(), bytes.as_mut_ptr().cast(), bytes.len()) }; + if count < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + if count <= 0 { + return Err(Error("Shutdown state watcher failed".into())); + } + } +} +pub fn record_final(failed: bool) -> Result<()> { + root()?; + let mut record = state()?; + if record.phase != "prepared" { + return Err(Error("Shutdown preparation was not completed".into())); + } + record.events.push(PowerEvent { + phase: if failed { + "service_stop_failed" + } else { + "services_stopped" + } + .into(), + at_ns: trace::now()?, + }); + println!( + "FDS_SHUTDOWN_FINAL {}", + serde_json::to_string(&record).map_err(|e| Error(e.to_string()))? + ); + Ok(()) +} diff --git a/rust/fds-cli/src/recovery.rs b/rust/fds-cli/src/recovery.rs new file mode 100644 index 0000000..009bca7 --- /dev/null +++ b/rust/fds-cli/src/recovery.rs @@ -0,0 +1,47 @@ +use fds_common::{ + Bay, Error, Result, + control::{self, Request}, +}; + +pub fn run(bay: Bay, repair: bool, confirmation: Option, json: bool) -> Result<()> { + let response = control::request(&Request::RecoveryData { + bay, + repair, + confirmation, + })?; + let report = response + .recovery + .ok_or_else(|| Error("Missing recovery response".into()))?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(|e| Error(e.to_string()))? + ); + } else { + println!( + "BAY {} {} {} bytes\nSERIAL {}", + report.disk.bay, + report.disk.model, + report.disk.bytes, + report.disk.serial.as_deref().unwrap_or("unavailable") + ); + if let Some(token) = report.confirmation { + println!( + "Repair preview: no filesystem changes made.\nAfter reviewing this device, run:\nfds recovery repair {bay} --confirm '{token}'" + ); + } else { + println!( + "{}\nSAFE TO REMOVE", + if report.repaired { + "DATA REPAIRED AND VERIFIED" + } else { + "DATA CHECK PASSED" + } + ); + } + if let Some(log) = report.log { + println!("LOG {log}"); + } + } + Ok(()) +} diff --git a/rust/fds-common/.gitkeep b/rust/fds-common/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rust/fds-common/Cargo.toml b/rust/fds-common/Cargo.toml new file mode 100644 index 0000000..22f99bc --- /dev/null +++ b/rust/fds-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fds-common" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Shared bounded configuration and Linux discovery for FDS tools" + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" +serde_json = "1" +libc = "0.2" diff --git a/rust/fds-common/src/boot.rs b/rust/fds-common/src/boot.rs new file mode 100644 index 0000000..5adb178 --- /dev/null +++ b/rust/fds-common/src/boot.rs @@ -0,0 +1,84 @@ +use crate::{Error, Result}; +use serde::Serialize; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BootMode { + #[default] + Normal, + Recovery, +} +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct BootOptions { + pub mode: BootMode, + pub debug: bool, + pub emulator: bool, +} +impl BootOptions { + pub fn parse(command_line: &str) -> Result { + if command_line.len() > 65536 || command_line.contains('\0') { + return Err(Error("Invalid kernel command line".into())); + } + let mut options = Self::default(); + let mut seen = std::collections::BTreeSet::new(); + for word in command_line + .split_ascii_whitespace() + .filter(|w| w.starts_with("fds.")) + { + let (key, value) = word + .split_once('=') + .ok_or_else(|| Error(format!("Expected key=value: {word}")))?; + if !seen.insert(key) { + return Err(Error(format!("Duplicate boot option: {key}"))); + } + match (key, value) { + ("fds.boot", "normal") => options.mode = BootMode::Normal, + ("fds.boot", "recovery") => options.mode = BootMode::Recovery, + ("fds.debug", "0") => options.debug = false, + ("fds.debug", "1") => options.debug = true, + ("fds.emulator", "0") => options.emulator = false, + ("fds.emulator", "1") => options.emulator = true, + _ => return Err(Error(format!("Unknown or invalid boot option: {word}"))), + } + } + Ok(options) + } + pub fn root_label(self) -> &'static str { + match self.mode { + BootMode::Normal => "FDS_SYSTEM", + BootMode::Recovery => "FDS_RECOVERY", + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn recovery_is_explicit_and_options_fail_closed() { + assert_eq!( + BootOptions::parse("console=ttyAMA0 ro") + .unwrap() + .root_label(), + "FDS_SYSTEM" + ); + assert_eq!( + BootOptions::parse("quiet fds.boot=recovery fds.debug=1") + .unwrap() + .root_label(), + "FDS_RECOVERY" + ); + assert!(BootOptions::parse("fds.emulator=1").unwrap().emulator); + assert!(!BootOptions::parse("fds.emulator=0").unwrap().emulator); + for bad in [ + "fds.emulator=2", + "fds.emulator=1 fds.emulator=0", + "fds.boot=anything", + "fds.debug=2", + "fds.boot=normal fds.boot=recovery", + "fds.boot", + "fds.execute=/bin/sh", + ] { + assert!(BootOptions::parse(bad).is_err(), "{bad}"); + } + } +} diff --git a/rust/fds-common/src/control.rs b/rust/fds-common/src/control.rs new file mode 100644 index 0000000..9ede8e0 --- /dev/null +++ b/rust/fds-common/src/control.rs @@ -0,0 +1,233 @@ +//! Bounded, versioned local control protocol. No client-supplied device paths. +use crate::{Bay, Error, Result, manifest::Manifest, topology::UsbDevice}; +use serde::{Deserialize, Serialize}; +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + time::Duration, +}; +pub const SOCKET: &str = "/run/fds/control.sock"; +pub const LIMIT: usize = 256 * 1024; +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)] +pub enum Request { + Poweroff, + Reboot, + PowerStatus, + PowerResume, + PowerPrepare, + RecoveryData { + bay: Bay, + repair: bool, + confirmation: Option, + }, + Bays, + Profiles, + Profile { + profile: String, + }, + Network { + enabled: bool, + }, + Bay { + bay: Bay, + }, + Disk { + bay: Bay, + }, + Topology, + Rescan, + Eject { + bay: Bay, + }, + DataUse { + bay: Bay, + }, + Run { + bay: Bay, + arguments: Vec, + }, + MediaPrepare { + bay: Bay, + image: String, + class: crate::manifest::Class, + }, + MediaStatus { + id: String, + after_sequence: Option, + }, + MediaConfirm { + id: String, + confirmation: String, + }, + MediaCancel { + id: String, + }, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaJob { + pub id: String, + pub bay: Bay, + pub sequence: u64, + pub phase: String, + pub diskseq: u64, + pub target_bytes: u64, + pub model: String, + pub serial: Option, + pub image_class: crate::manifest::Class, + pub image_bytes: Option, + pub image_sha256: Option, + pub progress_bytes: u64, + pub confirmation: Option, + pub error: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BayDisk { + pub bay: Bay, + pub diskseq: u64, + pub bytes: u64, + pub sector_bytes: u32, + pub model: String, + pub serial: Option, + pub protected: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryReport { + pub disk: BayDisk, + pub confirmation: Option, + pub checked: bool, + pub repaired: bool, + pub log: Option, +} +impl MediaJob { + pub fn finished(&self) -> bool { + matches!(self.phase.as_str(), "complete" | "failed" | "cancelled") + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BayState { + pub bay: Bay, + pub state: String, + pub name: Option, + pub detail: Option, + pub devices: Vec, + pub manifest: Option, + pub mount: Option, + pub consumers: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub software: Option, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProfileState { + pub desktop: String, + pub environment_bay: Option, + pub network: Vec, + pub manual_network: bool, + pub activation_ns: Option, + pub ready_ns: Option, + pub error: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PowerEvent { + pub phase: String, + pub at_ns: u64, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PowerState { + pub phase: String, + pub action: Option, + pub native_pending: bool, + pub error: Option, + pub events: Vec, +} +impl Default for PowerState { + fn default() -> Self { + Self { + phase: "idle".into(), + action: None, + native_pending: false, + error: None, + events: Vec::new(), + } + } +} +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + pub format: u32, + pub error: Option, + pub bays: Vec, + pub unmapped: Vec, + pub started_pid: Option, + #[serde(default)] + pub profiles: Option, + #[serde(default)] + pub media_job: Option, + #[serde(default)] + pub disk: Option, + #[serde(default)] + pub power: Option, + #[serde(default)] + pub recovery: Option, +} +impl Response { + pub fn failure(error: impl ToString) -> Self { + Self { + format: 1, + error: Some(error.to_string()), + bays: Vec::new(), + unmapped: Vec::new(), + started_pid: None, + profiles: None, + media_job: None, + disk: None, + power: None, + recovery: None, + } + } +} +pub fn request(request: &Request) -> Result { + let mut stream = UnixStream::connect(SOCKET) + .map_err(|e| Error(format!("Cartridge service unavailable: {e}")))?; + // Flushing real removable storage can legitimately outlast a status query. + // A timeout remains an error, never a substituted SAFE result. + stream.set_read_timeout(Some(Duration::from_secs( + if matches!(request, Request::RecoveryData { .. }) { + 1800 + } else if matches!( + request, + Request::Run { .. } + | Request::Eject { .. } + | Request::Poweroff + | Request::Reboot + | Request::PowerPrepare + | Request::Profile { .. } + | Request::Network { .. } + | Request::MediaStatus { .. } + ) { + 120 + } else { + 10 + }, + )))?; + stream.set_write_timeout(Some(Duration::from_secs(10)))?; + let mut data = serde_json::to_vec(request).map_err(|e| Error(e.to_string()))?; + if data.len() + 1 > LIMIT { + return Err(Error("Cartridge request exceeds protocol limit".into())); + } + data.push(b'\n'); + stream.write_all(&data)?; + let mut bytes = Vec::new(); + stream.take((LIMIT + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > LIMIT { + return Err(Error("Cartridge response exceeds protocol limit".into())); + } + let response: Response = serde_json::from_slice(&bytes) + .map_err(|e| Error(format!("Invalid cartridge response: {e}")))?; + if response.format != 1 { + return Err(Error("Unsupported cartridge protocol".into())); + } + if let Some(error) = &response.error { + return Err(Error(error.clone())); + } + Ok(response) +} diff --git a/rust/fds-common/src/lib.rs b/rust/fds-common/src/lib.rs new file mode 100644 index 0000000..f464189 --- /dev/null +++ b/rust/fds-common/src/lib.rs @@ -0,0 +1,102 @@ +//! Shared data contracts. Cartridge contents are data, never startup commands. +pub mod boot; +pub mod control; +pub mod machine; +pub mod manifest; +pub mod software; +pub mod sysfs; +pub mod topology; +pub mod trace; + +use std::{fmt, fs::File, io::Read, path::Path, str::FromStr}; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const MAX_CONFIG_BYTES: u64 = 64 * 1024; +pub type Result = std::result::Result; + +/// The profile marker is part of the immutable root, never supplied by media. +pub fn recovery_mode() -> Result { + Ok(read_text(Path::new("/usr/share/fds/image-profile"), 64)?.trim() == "recovery") +} + +#[derive(Debug)] +pub struct Error(pub String); +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for Error {} +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self(value.to_string()) + } +} + +/// Read at most the limit plus a sentinel byte, including on special files. +pub fn read_text(path: &Path, limit: u64) -> Result { + let file = File::open(path).map_err(|e| Error(format!("{}: {e}", path.display())))?; + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(Error(format!("{} exceeds {limit} bytes", path.display()))); + } + String::from_utf8(bytes).map_err(|_| Error(format!("{} is not UTF-8", path.display()))) +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[serde(try_from = "u8", into = "u8")] +pub struct Bay(u8); +impl Bay { + pub fn number(self) -> u8 { + self.0 + } +} +impl TryFrom for Bay { + type Error = Error; + fn try_from(value: u8) -> Result { + if (1..=12).contains(&value) { + Ok(Self(value)) + } else { + Err(Error("Bay must be between 1 and 12".into())) + } + } +} +impl From for u8 { + fn from(value: Bay) -> Self { + value.0 + } +} +impl FromStr for Bay { + type Err = Error; + fn from_str(value: &str) -> Result { + if value.is_empty() || value.len() > 2 || !value.bytes().all(|c| c.is_ascii_digit()) { + return Err(Error("Bay must be a number from 01 to 12".into())); + } + Self::try_from( + value + .parse::() + .map_err(|_| Error("Invalid bay".into()))?, + ) + } +} +impl fmt::Display for Bay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:02}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bay_identifiers_are_bounded() { + assert_eq!("02".parse::().unwrap().number(), 2); + assert_eq!(Bay::try_from(12).unwrap().to_string(), "12"); + for invalid in ["", "0", "13", "-1", "+2", " 2", "002", "a"] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + } +} diff --git a/rust/fds-common/src/machine.rs b/rust/fds-common/src/machine.rs new file mode 100644 index 0000000..c1abb1b --- /dev/null +++ b/rust/fds-common/src/machine.rs @@ -0,0 +1,217 @@ +//! A single atomic settings document; TOML contents remain declarative data. +use crate::{ + Error, MAX_CONFIG_BYTES, Result, read_text, + topology::{BayMap, Catalog}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io::Read, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::Path, +}; + +pub const SNAPSHOT: &str = "/run/fds/machine/config.json"; +pub const STATUS: &str = "/run/fds/machine/status.json"; +pub const MAX_BUNDLE: u64 = 256 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub format: u8, + pub name: String, + pub bays: String, + pub hardware_catalog: String, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Identity { + format: u8, + name: String, +} + +impl Config { + pub fn validate(&self) -> Result<()> { + if self.format != 1 + || self.name.is_empty() + || self.name.len() > 128 + || self.name.chars().any(char::is_control) + || self.bays.len() as u64 > MAX_CONFIG_BYTES + || self.hardware_catalog.len() as u64 > MAX_CONFIG_BYTES + { + return Err(Error( + "Invalid machine settings format, name or size".into(), + )); + } + BayMap::parse(&self.bays)?; + Catalog::parse(&self.hardware_catalog)?; + // JSON escaping can expand two individually valid TOML inputs beyond + // the loader's bundle limit. Reject that before an installation writes. + self.json()?; + Ok(()) + } + pub fn parse(text: &str) -> Result { + if text.len() as u64 > MAX_BUNDLE { + return Err(Error("Machine settings are too large".into())); + } + let result: Self = serde_json::from_str(text) + .map_err(|e| Error(format!("Invalid machine settings: {e}")))?; + result.validate()?; + Ok(result) + } + pub fn directory(path: &Path) -> Result { + let identity: Identity = toml::from_str(&source_text(&path.join("machine.toml"), 4096)?) + .map_err(|e| Error(format!("Invalid machine identity: {e}")))?; + let result = Self { + format: identity.format, + name: identity.name, + bays: source_text(&path.join("bays.toml"), MAX_CONFIG_BYTES)?, + hardware_catalog: source_text(&path.join("hardware-catalog.toml"), MAX_CONFIG_BYTES)?, + }; + result.validate()?; + Ok(result) + } + pub fn fallback() -> Result { + let result = Self { + format: 1, + name: "FDS image defaults".into(), + bays: read_text(Path::new("/etc/fds/bays.toml"), MAX_CONFIG_BYTES)?, + hardware_catalog: read_text( + Path::new("/etc/fds/hardware-catalog.toml"), + MAX_CONFIG_BYTES, + )?, + }; + result.validate()?; + Ok(result) + } + pub fn active() -> Result { + if Path::new(SNAPSHOT).exists() { + Self::parse(&trusted_text(Path::new(SNAPSHOT), MAX_BUNDLE)?) + } else { + Self::fallback() + } + } + pub fn json(&self) -> Result> { + let mut bytes = serde_json::to_vec_pretty(self).map_err(|e| Error(e.to_string()))?; + bytes.push(b'\n'); + if bytes.len() as u64 > MAX_BUNDLE { + return Err(Error("Encoded machine settings are too large".into())); + } + Ok(bytes) + } +} + +fn source_text(path: &Path, limit: u64) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)?; + if !file.metadata()?.is_file() { + return Err(Error( + "Machine settings inputs must be regular files".into(), + )); + } + let mut text = String::new(); + file.take(limit + 1).read_to_string(&mut text)?; + if text.len() as u64 > limit { + return Err(Error("Machine settings exceed the size limit".into())); + } + Ok(text) +} + +/// Reject symlinks, FIFOs, device nodes, and unprivileged-writable settings. +pub fn trusted_text(path: &Path, limit: u64) -> Result { + for parent in path.ancestors().skip(1) { + let meta = fs::symlink_metadata(parent)?; + if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o022 != 0 { + return Err(Error(format!( + "Untrusted machine settings directory: {}", + parent.display() + ))); + } + } + let file: File = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)?; + let meta = file.metadata()?; + if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 { + return Err(Error( + "Machine settings must be a root-owned regular file without group/other write access" + .into(), + )); + } + let mut text = String::new(); + file.take(limit + 1).read_to_string(&mut text)?; + if text.len() as u64 > limit { + return Err(Error("Machine settings exceed the size limit".into())); + } + Ok(text) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn source_settings_reject_symlinks_fifos_and_oversized_files() { + let path = std::env::temp_dir().join(format!("fds-machine-source-{}", std::process::id())); + fs::create_dir(&path).unwrap(); + fs::write(path.join("ordinary"), "hello").unwrap(); + std::os::unix::fs::symlink("ordinary", path.join("link")).unwrap(); + let fifo = std::ffi::CString::new(path.join("fifo").to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + assert!(source_text(&path.join("link"), 10).is_err()); + assert!(source_text(&path.join("fifo"), 10).is_err()); + assert!(source_text(&path.join("ordinary"), 4).is_err()); + assert_eq!(source_text(&path.join("ordinary"), 5).unwrap(), "hello"); + fs::remove_dir_all(path).unwrap(); + } + #[test] + fn bundle_reuses_strict_topology_and_catalog_contracts() { + let good = Config { + format: 1, + name: "FP-85".into(), + bays: "".into(), + hardware_catalog: "device=[]".into(), + }; + assert!(Config::parse(&String::from_utf8(good.json().unwrap()).unwrap()).is_ok()); + for bad in [ + Config { format: 2, ..good }, + Config { + format: 1, + name: "bad\nname".into(), + bays: "".into(), + hardware_catalog: "".into(), + }, + ] { + assert!(bad.validate().is_err()); + } + assert!( + Config::parse( + r#"{"format":1,"name":"FP-85","bays":"","hardware_catalog":"execute='sh'"}"# + ) + .is_err() + ); + assert!( + Config::parse( + r#"{"format":1,"name":"FP-85","bays":"","hardware_catalog":"","command":"sh"}"# + ) + .is_err() + ); + } + #[test] + fn escaped_toml_cannot_produce_an_unloadable_bundle() { + // Backslashes in TOML comments are legal, but JSON doubles each byte. + let comment = format!("#{}", "\\".repeat(MAX_CONFIG_BYTES as usize - 1)); + let config = Config { + format: 1, + name: "FP-85".into(), + bays: comment.clone(), + hardware_catalog: comment, + }; + assert!(BayMap::parse(&config.bays).is_ok()); + assert!(Catalog::parse(&config.hardware_catalog).is_ok()); + assert!(config.validate().is_err()); + assert!(config.json().is_err()); + } +} diff --git a/rust/fds-common/src/manifest.rs b/rust/fds-common/src/manifest.rs new file mode 100644 index 0000000..37d6d5c --- /dev/null +++ b/rust/fds-common/src/manifest.rs @@ -0,0 +1,168 @@ +use crate::{Error, MAX_CONFIG_BYTES, Result, read_text}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Class { + System, + Data, + Program, + Environment, + Hardware, + Utility, +} +impl Class { + pub fn label(self) -> &'static str { + match self { + Self::System => "SYSTEM", + Self::Data => "DATA", + Self::Program => "PROGRAM", + Self::Environment => "ENVIRONMENT", + Self::Hardware => "HARDWARE", + Self::Utility => "UTILITY", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Cartridge { + pub id: String, + pub name: String, + pub class: Class, + pub version: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Media { + pub writable: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Activation { + pub profile: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub format: u32, + pub cartridge: Cartridge, + pub media: Media, + #[serde(skip_serializing_if = "Option::is_none")] + pub activation: Option, +} + +pub fn identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c)) + && !value.contains("..") +} +fn display_text(value: &str, limit: usize) -> bool { + !value.trim().is_empty() && value.len() <= limit && !value.chars().any(char::is_control) +} +impl Manifest { + pub fn to_toml(&self) -> Result { + self.validate()?; + toml::to_string(self).map_err(|e| Error(e.to_string())) + } + pub fn load(path: &Path) -> Result { + Self::parse(&read_text(path, MAX_CONFIG_BYTES)?) + } + pub fn parse(input: &str) -> Result { + if input.len() as u64 > MAX_CONFIG_BYTES { + return Err(Error("Cartridge manifest exceeds 64 KiB".into())); + } + let value: Self = + toml::from_str(input).map_err(|e| Error(format!("Invalid cartridge manifest: {e}")))?; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<()> { + if self.format != 1 { + return Err(Error("Unsupported cartridge format; expected 1".into())); + } + if !identifier(&self.cartridge.id) { + return Err(Error("Invalid cartridge id".into())); + } + if !display_text(&self.cartridge.name, 128) || !display_text(&self.cartridge.version, 32) { + return Err(Error( + "Cartridge name/version is empty, too long, or contains control characters".into(), + )); + } + if self.cartridge.class == Class::Data && !self.media.writable { + return Err(Error("DATA requires writable media".into())); + } + if matches!( + self.cartridge.class, + Class::System | Class::Program | Class::Environment + ) && self.media.writable + { + return Err(Error( + "SYSTEM, PROGRAM and ENVIRONMENT media must be read-only".into(), + )); + } + match (&self.activation, self.cartridge.class) { + (Some(value), Class::Environment) if identifier(&value.profile) => (), + (None, Class::Environment) => { + return Err(Error("ENVIRONMENT requires an activation profile".into())); + } + (Some(_), _) => { + return Err(Error( + "Only ENVIRONMENT permits a valid declarative activation profile".into(), + )); + } + (None, _) => (), + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const VALID: &str = include_str!("../../../tests/fixtures/manifests/windowmaker.toml"); + #[test] + fn master_plan_manifest_roundtrips() { + let value = Manifest::parse(VALID).unwrap(); + assert_eq!(value.cartridge.class, Class::Environment); + assert_eq!(value.activation.as_ref().unwrap().profile, "windowmaker"); + assert_eq!( + Manifest::parse(&toml::to_string(&value).unwrap()) + .unwrap() + .cartridge + .id, + value.cartridge.id + ); + } + #[test] + fn untrusted_manifest_cannot_inject_actions_or_paths() { + for invalid in [ + VALID.replace("format = 1", "format = 2"), + VALID.replace("fds.windowmaker", "../../etc"), + VALID.replace("profile = \"windowmaker\"", "run = \"/FDS/autorun.sh\""), + VALID.replace("writable = false", "writable = true"), + VALID.replace("WINDOW SYSTEM", "WINDOW\\nSYSTEM"), + VALID.replace("profile = \"windowmaker\"", "profile = \"/bin/sh\""), + format!("{VALID}\n[autorun]\ncommand = 'id'\n"), + ] { + assert!(Manifest::parse(&invalid).is_err(), "{invalid}"); + } + assert!(Manifest::parse(&" ".repeat(65 * 1024)).is_err()); + } + #[test] + fn data_and_profile_classes_are_not_interchangeable() { + assert!(Manifest::parse(&VALID.replace("environment", "data")).is_err()); + let data = VALID + .split("[activation]") + .next() + .unwrap() + .replace("environment", "data") + .replace("writable = false", "writable = true"); + assert!(Manifest::parse(&data).is_ok()); + } +} diff --git a/rust/fds-common/src/software.rs b/rust/fds-common/src/software.rs new file mode 100644 index 0000000..703d529 --- /dev/null +++ b/rust/fds-common/src/software.rs @@ -0,0 +1,184 @@ +//! Software metadata is descriptive. Bundles never contain privileged build hooks. + +use crate::{Error, Result, manifest::identifier}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Component, Path}, +}; + +pub const MAX_ARCHIVE: u64 = 512 * 1024 * 1024; +pub const MAX_UNPACKED: u64 = 1024 * 1024 * 1024; +pub const MAX_ENTRIES: u32 = 65_536; +pub const MAX_CATALOGUE: usize = 64 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Software { + pub id: String, + pub name: String, + pub version: String, + pub architecture: String, + /// GPT partition number, starting at 2 after FDS_METADATA. + pub partition: u8, + pub archive_bytes: u64, + pub unpacked_bytes: u64, + pub entries: u32, + pub sha256: String, + /// Command names mapped to relative regular executables inside the bundle. + pub commands: BTreeMap, +} +impl Software { + pub fn archive_path(&self) -> String { + format!("bundles/{}.tar.xz", self.id) + } + pub fn validate(&self) -> Result<()> { + let display = |s: &str, max: usize| { + !s.trim().is_empty() && s.len() <= max && !s.chars().any(char::is_control) + }; + if !identifier(&self.id) + || !display(&self.name, 128) + || !display(&self.version, 32) + || !matches!(self.architecture.as_str(), "aarch64" | "any") + || !(2..=33).contains(&self.partition) + || !(1..=MAX_ARCHIVE).contains(&self.archive_bytes) + || self.unpacked_bytes > MAX_UNPACKED + || !(1..=MAX_ENTRIES).contains(&self.entries) + || self.sha256.len() != 64 + || !self + .sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || self.commands.is_empty() + || self.commands.len() > 64 + || self + .commands + .iter() + .any(|(name, path)| !identifier(name) || !relative(path)) + { + return Err(Error( + "Invalid software identity, architecture, partition, digest, limits or commands" + .into(), + )); + } + Ok(()) + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Catalogue { + pub format: u32, + pub software: Vec, +} +impl Catalogue { + pub fn parse(text: &str) -> Result { + if text.len() > MAX_CATALOGUE { + return Err(Error("Software catalogue exceeds 64 KiB".into())); + } + let value: Self = + toml::from_str(text).map_err(|e| Error(format!("Invalid software catalogue: {e}")))?; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<()> { + if self.format != 1 || self.software.is_empty() || self.software.len() > 128 { + return Err(Error( + "Software catalogue requires format 1 and 1..128 software entries".into(), + )); + } + let mut ids = BTreeSet::new(); + let mut partitions = BTreeSet::new(); + for software in &self.software { + software.validate()?; + if !ids.insert(&software.id) { + return Err(Error("Duplicate software id".into())); + } + partitions.insert(software.partition); + } + if partitions + .iter() + .copied() + .ne(2..=partitions.len() as u8 + 1) + { + return Err(Error( + "Every payload partition must be described, consecutively from partition 2".into(), + )); + } + Ok(()) + } + pub fn partition_count(&self) -> usize { + self.software.iter().map(|s| s.partition).max().unwrap_or(1) as usize + } + pub fn to_toml(&self) -> Result { + self.validate()?; + let result = toml::to_string(self).map_err(|e| Error(e.to_string()))?; + if result.len() > MAX_CATALOGUE { + return Err(Error("Software catalogue exceeds 64 KiB".into())); + } + Ok(result) + } +} +pub fn relative(value: &str) -> bool { + !value.is_empty() + && value.len() <= 1024 + && !value.chars().any(char::is_control) + && !value.contains('\\') + && !value + .split('/') + .any(|s| s.is_empty() || s == "." || s == "..") + && Path::new(value) + .components() + .all(|c| matches!(c, Component::Normal(_))) +} + +#[cfg(test)] +mod tests { + use super::*; + fn software(id: &str, partition: u8) -> Software { + Software { + id: id.into(), + name: id.into(), + version: "1".into(), + architecture: "aarch64".into(), + partition, + archive_bytes: 100, + unpacked_bytes: 200, + entries: 1, + sha256: "a".repeat(64), + commands: [("hello".into(), "bin/hello".into())].into(), + } + } + #[test] + fn multiple_programs_can_share_or_span_payload_partitions() { + let c = Catalogue { + format: 1, + software: vec![software("one", 2), software("two", 2), software("three", 3)], + }; + assert_eq!(Catalogue::parse(&c.to_toml().unwrap()).unwrap(), c); + assert_eq!(c.partition_count(), 3); + let mut bad = c.clone(); + bad.software[2].partition = 4; + assert!(bad.validate().is_err()); + let mut bad = c.clone(); + bad.software[2].id = "one".into(); + assert!(bad.validate().is_err()); + let mut bad = c.clone(); + bad.software[0].architecture = "x86_64".into(); + assert!(bad.validate().is_err()); + let mut bad = c.clone(); + bad.software[0] + .commands + .insert("escape".into(), "../bin/sh".into()); + assert!(bad.validate().is_err()); + assert!(Catalogue::parse(&(c.to_toml().unwrap() + "\n[autorun]\ncommand='sh'\n")).is_err()); + } + #[test] + fn paths_are_strictly_relative() { + for bad in [ + "", "/bin/sh", "../x", "a/../x", "a//x", "./x", "a/", "a\\b", "a\nb", + ] { + assert!(!relative(bad), "{bad:?}"); + } + assert!(relative("share/document with spaces.txt")); + } +} diff --git a/rust/fds-common/src/sysfs.rs b/rust/fds-common/src/sysfs.rs new file mode 100644 index 0000000..d7de497 --- /dev/null +++ b/rust/fds-common/src/sysfs.rs @@ -0,0 +1,115 @@ +use crate::{Error, Result, read_text}; +use serde::Serialize; +use std::{collections::BTreeMap, fs, path::Path}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BlockPartition { + pub device: String, + pub major: u32, + pub minor: u32, + pub partition_name: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "state", content = "devices", rename_all = "snake_case")] +pub enum Selection { + Missing, + Unique(BlockPartition), + Ambiguous(Vec), +} + +/// Read kernel-reported partition identities, never disk enumeration order. +pub fn partitions(sysfs: &Path) -> Result> { + let mut result = Vec::new(); + for entry in fs::read_dir(sysfs.join("class/block"))? { + let path = entry?.path(); + let input = match read_text(&path.join("uevent"), 16384) { + Ok(input) => input, + Err(_) if !path.exists() => continue, // Removed during the scan; the next event retries it. + Err(error) => return Err(error), + }; + let mut fields = BTreeMap::new(); + for line in input.lines() { + if let Some((key, value)) = line.split_once('=') { + if fields.insert(key, value).is_some() { + return Err(Error(format!("Duplicate sysfs field: {key}"))); + } + } + } + if fields.get("DEVTYPE") != Some(&"partition") { + continue; + } + let Some(name) = fields.get("PARTNAME") else { + continue; + }; + let device = fields + .get("DEVNAME") + .ok_or_else(|| Error("Missing sysfs DEVNAME".into()))?; + if device.is_empty() + || !device + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)) + { + return Err(Error("Unsafe sysfs device name".into())); + } + let number = |key| -> Result { + fields + .get(key) + .ok_or_else(|| Error(format!("Missing sysfs {key}")))? + .parse() + .map_err(|_| Error(format!("Invalid sysfs {key}"))) + }; + result.push(BlockPartition { + device: format!("/dev/{device}"), + major: number("MAJOR")?, + minor: number("MINOR")?, + partition_name: name.to_string(), + }); + } + result.sort_by_key(|device| (device.major, device.minor)); + for pair in result.windows(2) { + if (pair[0].major, pair[0].minor) == (pair[1].major, pair[1].minor) { + return Err(Error("Duplicate block device identity".into())); + } + } + Ok(result) +} +pub fn select(devices: &[BlockPartition], label: &str) -> Selection { + let mut candidates: Vec<_> = devices + .iter() + .filter(|d| d.partition_name == label) + .cloned() + .collect(); + match candidates.len() { + 0 => Selection::Missing, + 1 => Selection::Unique(candidates.pop().unwrap()), + _ => Selection::Ambiguous(candidates), + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn selection_never_chooses_the_first_of_multiple_systems() { + let a = BlockPartition { + device: "/dev/sdz1".into(), + major: 8, + minor: 241, + partition_name: "FDS_SYSTEM".into(), + }; + let b = BlockPartition { + device: "/dev/nvme0n1p2".into(), + major: 259, + minor: 2, + ..a.clone() + }; + assert_eq!(select(&[], "FDS_SYSTEM"), Selection::Missing); + assert_eq!( + select(&[a.clone()], "FDS_SYSTEM"), + Selection::Unique(a.clone()) + ); + assert!(matches!( + select(&[b, a], "FDS_SYSTEM"), + Selection::Ambiguous(_) + )); + } +} diff --git a/rust/fds-common/src/topology.rs b/rust/fds-common/src/topology.rs new file mode 100644 index 0000000..db9010f --- /dev/null +++ b/rust/fds-common/src/topology.rs @@ -0,0 +1,322 @@ +//! Controller/port identity independent of USB bus numbers and block names. +use crate::{Bay, Error, MAX_CONFIG_BYTES, Result, read_text}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Hub { + pub hub: String, + pub ports: BTreeMap, +} +#[derive(Debug, Default)] +pub struct BayMap(pub BTreeMap); +impl BayMap { + pub fn parse(text: &str) -> Result { + let groups: BTreeMap = + toml::from_str(text).map_err(|e| Error(format!("Invalid bay map: {e}")))?; + let mut map = BTreeMap::new(); + for group in groups.values() { + if group.hub.is_empty() + || group.hub.len() > 512 + || group.hub.chars().any(char::is_control) + || !group.hub.contains(":usb") + || group.hub.contains("..") + || group.hub.ends_with('/') + { + return Err(Error("Invalid stable hub identity".into())); + } + let mut seen = BTreeSet::new(); + for (port, bay) in &group.ports { + if port + .parse::() + .ok() + .filter(|n| *n > 0) + .map(|n| n.to_string()) + .as_ref() + != Some(port) + || !seen.insert(*bay) + { + return Err(Error("Invalid port or duplicate bay within a hub".into())); + } + let key = format!("{}/{}", group.hub, port); + if map.insert(key, *bay).is_some() { + return Err(Error("Duplicate physical port mapping".into())); + } + } + } + for key in map.keys() { + if map + .keys() + .any(|other| other != key && other.starts_with(&format!("{key}/"))) + { + return Err(Error("Bay mappings overlap a parent and child port".into())); + } + } + Ok(Self(map)) + } + pub fn load(path: &Path) -> Result { + Self::parse(&read_text(path, MAX_CONFIG_BYTES)?) + } + pub fn bay(&self, identity: &str) -> Option { + self.0 + .iter() + .find(|(key, _)| identity == key.as_str() || identity.starts_with(&format!("{key}/"))) + .map(|(_, bay)| *bay) + } + pub fn configured(&self, bay: Bay) -> bool { + self.0.values().any(|b| *b == bay) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsbDevice { + pub topology: String, + pub vendor: String, + pub product: String, + pub serial: Option, + pub class: String, + pub interfaces: Vec, + #[serde(skip)] + pub path: PathBuf, +} +fn attribute(path: &Path, name: &str) -> Result { + let text = read_text(&path.join(name), 4096)?.trim().to_owned(); + if text.chars().any(char::is_control) { + return Err(Error(format!("USB {name} contains control characters"))); + } + Ok(text) +} +fn hex(text: &str, len: usize) -> bool { + text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit()) +} +pub fn devices(sys: &Path) -> Result> { + let base = sys.join("devices").canonicalize()?; + let mut devices = Vec::new(); + let entries = match fs::read_dir(sys.join("bus/usb/devices")) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(devices), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let path = entry?.path(); + // Root hubs and interface directories are not cartridge devices. + if !path.join("idVendor").exists() + || path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("usb") + { + continue; + } + let inspect = || -> Result { + let path = path.canonicalize()?; + let root = path + .ancestors() + .find(|p| { + p.file_name().is_some_and(|n| { + let n = n.to_string_lossy(); + n.starts_with("usb") && n[3..].bytes().all(|c| c.is_ascii_digit()) + }) + }) + .ok_or_else(|| Error("USB device has no root hub".into()))?; + let controller = root + .parent() + .unwrap() + .strip_prefix(&base) + .map_err(|_| Error("USB path escapes sysfs".into()))?; + let version = attribute(root, "version")?; + let protocol = if version.starts_with('3') { + "usb3" + } else if version.starts_with('2') || version.starts_with('1') { + "usb2" + } else { + return Err(Error("Unknown USB root protocol".into())); + }; + let chain = attribute(&path, "devpath")?; + if chain.split('.').any(|p| { + p.parse::() + .ok() + .filter(|n| *n > 0) + .map(|n| n.to_string()) + .as_deref() + != Some(p) + }) { + return Err(Error("Invalid USB port chain".into())); + } + let vendor = attribute(&path, "idVendor")?.to_ascii_lowercase(); + let product = attribute(&path, "idProduct")?.to_ascii_lowercase(); + let class = attribute(&path, "bDeviceClass")?.to_ascii_lowercase(); + if !hex(&vendor, 4) || !hex(&product, 4) || !hex(&class, 2) { + return Err(Error("Invalid USB descriptor identity".into())); + } + let mut interfaces = Vec::new(); + for child in fs::read_dir(&path)? { + let child = child?.path(); + if child.join("bInterfaceClass").exists() { + let value = attribute(&child, "bInterfaceClass")?.to_ascii_lowercase(); + if !hex(&value, 2) { + return Err(Error("Invalid USB interface class".into())); + } + interfaces.push(value); + } + } + interfaces.sort(); + interfaces.dedup(); + Ok(UsbDevice { + topology: format!( + "{}:{protocol}/{}", + controller.display(), + chain.replace('.', "/") + ), + vendor, + product, + class, + interfaces, + serial: if path.join("serial").exists() { + // USB strings are data. JSON escapes control characters; + // a device's serial string must not stop global discovery. + Some(read_text(&path.join("serial"), 4096)?.trim().to_owned()) + } else { + None + }, + path, + }) + }; + match inspect() { + Ok(device) => devices.push(device), + Err(_) if !path.exists() => (), + Err(error) => eprintln!("Incomplete USB device {}: {error}", path.display()), + } + } + devices.sort_by(|a, b| a.topology.cmp(&b.topology)); + Ok(devices) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Hardware { + pub name: String, + pub vendor: String, + pub product: String, + pub serial: Option, + pub class: Option, +} +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Catalog { + #[serde(default)] + pub device: Vec, +} +impl Catalog { + pub fn load(path: &Path) -> Result { + Self::parse(&read_text(path, MAX_CONFIG_BYTES)?) + } + pub fn parse(text: &str) -> Result { + let value: Self = toml::from_str(text).map_err(|e| Error(e.to_string()))?; + for item in &value.device { + if item.name.is_empty() + || item.name.len() > 128 + || item.name.chars().any(char::is_control) + || !hex(&item.vendor, 4) + || !hex(&item.product, 4) + || item.class.as_ref().is_some_and(|c| !hex(c, 2)) + { + return Err(Error("Invalid hardware catalog entry".into())); + } + } + Ok(value) + } + pub fn identify(&self, usb: &UsbDevice) -> Result> { + let matches: Vec<_> = self + .device + .iter() + .filter(|h| { + h.vendor.eq_ignore_ascii_case(&usb.vendor) + && h.product.eq_ignore_ascii_case(&usb.product) + && h.serial + .as_ref() + .is_none_or(|s| Some(s) == usb.serial.as_ref()) + && h.class.as_ref().is_none_or(|c| { + c.eq_ignore_ascii_case(&usb.class) + || usb.interfaces.iter().any(|i| c.eq_ignore_ascii_case(i)) + }) + }) + .collect(); + if matches.len() > 1 { + return Err(Error("Ambiguous hardware catalog entries".into())); + } + Ok(matches.first().map(|h| h.name.clone())) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn topology_aliases_are_explicit_and_overlaps_rejected() { + let map = BayMap::parse("[front]\nhub='pci/controller:usb2/1'\n[front.ports]\n1=1\n2=2\n[fast]\nhub='pci/controller:usb3/1'\n[fast.ports]\n1=1\n").unwrap(); + assert_eq!(map.bay("pci/controller:usb3/1/1").unwrap().number(), 1); + assert_eq!(map.bay("pci/controller:usb2/1/2/3").unwrap().number(), 2); + assert!(map.bay("pci/controller:usb2/1/12").is_none()); + assert!(BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=1\n2=1").is_err()); + assert!(BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=13").is_err()); + assert!( + BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=1\n[b]\nhub='x:usb2/1'\n[b.ports]\n2=2") + .is_err() + ); + } +} + +#[cfg(test)] +mod fixture_tests { + use super::*; + use std::os::unix::fs::symlink; + #[test] + fn missing_usb_subsystem_is_an_empty_inventory() { + let sys = std::env::temp_dir().join(format!("fds-no-usb-{}", std::process::id())); + fs::create_dir_all(sys.join("devices")).unwrap(); + assert!(devices(&sys).unwrap().is_empty()); + fs::remove_dir_all(sys).unwrap(); + } + #[test] + fn enumeration_numbers_do_not_change_physical_identity() { + let base = std::env::temp_dir().join(format!("fds-topology-{}", std::process::id())); + fs::create_dir_all(&base).unwrap(); + for bus in [1, 7] { + let sys = base.join(bus.to_string()); + let root = sys.join(format!("devices/platform/controller/usb{bus}")); + let usb = root.join(format!("{bus}-2.4")); + let interface = usb.join(format!("{bus}-2.4:1.0")); + fs::create_dir_all(&interface).unwrap(); + fs::create_dir_all(sys.join("bus/usb/devices")).unwrap(); + fs::write(root.join("version"), " 2.00\n").unwrap(); + for (name, value) in [ + ("idVendor", "1234"), + ("idProduct", "abcd"), + ("bDeviceClass", "00"), + ("devpath", "2.4"), + ("serial", "UNIT-1"), + ] { + fs::write(usb.join(name), value).unwrap(); + } + fs::write(interface.join("bInterfaceClass"), "08").unwrap(); + symlink(&usb, sys.join(format!("bus/usb/devices/{bus}-2.4"))).unwrap(); + // Removal can leave a device directory visible after attributes + // vanish. One incomplete entry must not hide the healthy device. + let partial = root.join(format!("{bus}-5")); + fs::create_dir_all(&partial).unwrap(); + fs::write(partial.join("idVendor"), "1234").unwrap(); + symlink(&partial, sys.join(format!("bus/usb/devices/{bus}-5"))).unwrap(); + let result = devices(&sys).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].topology, "platform/controller:usb2/2/4"); + assert_eq!(result[0].interfaces, ["08"]); + } + fs::remove_dir_all(base).unwrap(); + } +} diff --git a/rust/fds-common/src/trace.rs b/rust/fds-common/src/trace.rs new file mode 100644 index 0000000..7df20c0 --- /dev/null +++ b/rust/fds-common/src/trace.rs @@ -0,0 +1,308 @@ +//! Boot observations use the kernel's monotonic boot clock, never wall-clock time. +use crate::{Error, Result, read_text}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path}; + +pub const EARLY: &str = "/dev/fds-early/boot-trace"; +pub const RUNTIME: &str = "/run/fds/boot-trace"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Point { + Stage0Start, + SystemFound, + RootMounted, + RootSwitch, + S6Start, + ConsoleReady, + DesktopReady, +} +impl Point { + pub const ALL: [Self; 7] = [ + Self::Stage0Start, + Self::SystemFound, + Self::RootMounted, + Self::RootSwitch, + Self::S6Start, + Self::ConsoleReady, + Self::DesktopReady, + ]; + pub fn name(self) -> &'static str { + match self { + Self::Stage0Start => "stage0-start", + Self::SystemFound => "system-found", + Self::RootMounted => "root-mounted", + Self::RootSwitch => "root-switch", + Self::S6Start => "s6-start", + Self::ConsoleReady => "console-ready", + Self::DesktopReady => "desktop-ready", + } + } + pub fn parse(name: &str) -> Result { + Self::ALL + .into_iter() + .find(|point| point.name() == name) + .ok_or_else(|| Error("Unknown boot event".into())) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Event { + pub format: u32, + pub boot_id: String, + pub point: Point, + pub boot_ns: u64, +} + +pub fn now() -> Result { + let mut clock: libc::timespec = unsafe { std::mem::zeroed() }; + if unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &mut clock) } < 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(clock.tv_sec as u64 * 1_000_000_000 + clock.tv_nsec as u64) +} +pub fn boot_id() -> Result { + Ok( + read_text(Path::new("/proc/sys/kernel/random/boot_id"), 128)? + .trim() + .into(), + ) +} +pub fn save(directory: &Path, point: Point, boot_ns: u64) -> Result<()> { + let event = Event { + format: 1, + boot_id: boot_id()?, + point, + boot_ns, + }; + fs::create_dir_all(directory)?; + let target = directory.join(format!("{}.json", point.name())); + let existing = || -> Result<()> { + if !fs::symlink_metadata(&target)?.is_file() { + return Err(Error("Boot trace record is not a regular file".into())); + } + let recorded: Event = + serde_json::from_str(&read_text(&target, 4096)?).map_err(|e| Error(e.to_string()))?; + if recorded.format != 1 || recorded.point != point || recorded.boot_id != event.boot_id { + return Err(Error( + "Existing event belongs to a different boot or point".into(), + )); + } + Ok(()) + }; + // A restarted console must not replace the first readiness observation. + if target.try_exists()? { + return existing(); + } + let temporary = directory.join(format!(".{}-{}.tmp", point.name(), std::process::id())); + let mut stream = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&temporary)?; + let result = (|| -> Result<()> { + serde_json::to_writer(&mut stream, &event).map_err(|e| Error(e.to_string()))?; + stream.write_all(b"\n")?; + stream.flush()?; + match fs::hard_link(&temporary, &target) { + Ok(()) => (), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => existing()?, + Err(error) => return Err(error.into()), + } + Ok(()) + })(); + let _ = fs::remove_file(temporary); + result +} +pub fn mark_early(point: Point, instant: u64) { + if let Err(error) = save(Path::new(EARLY), point, instant) { + eprintln!("Boot trace unavailable: {error}"); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Report { + pub format: u32, + pub clock: String, + pub boot_id: String, + pub platform: String, + pub kernel: String, + pub events_ns: BTreeMap, + pub durations_ns: BTreeMap, + pub missing_events: Vec, +} +impl Report { + pub fn from_events(events: &[Event], platform: String, kernel: String) -> Result { + let mut report = Self { + format: 1, + clock: "Linux CLOCK_BOOTTIME".into(), + boot_id: events + .first() + .map(|e| e.boot_id.clone()) + .unwrap_or_default(), + platform, + kernel, + events_ns: BTreeMap::new(), + durations_ns: BTreeMap::new(), + missing_events: Vec::new(), + }; + for event in events { + if event.format != 1 || event.boot_id != report.boot_id || event.boot_id.is_empty() { + return Err(Error("Incompatible boot trace records".into())); + } + if report + .events_ns + .insert(event.point.name().into(), event.boot_ns) + .is_some() + { + return Err(Error("Duplicate boot event".into())); + } + } + let mut previous = 0; + for point in Point::ALL { + if let Some(&instant) = report.events_ns.get(point.name()) { + if instant < previous { + return Err(Error("Boot events are out of order".into())); + } + previous = instant; + } else { + report.missing_events.push(point.name().into()); + } + } + for (label, start, end) in [ + ("kernel-to-console", None, Point::ConsoleReady), + ("kernel-to-desktop", None, Point::DesktopReady), + ("kernel-to-stage0", None, Point::Stage0Start), + ( + "system-discovery", + Some(Point::Stage0Start), + Point::SystemFound, + ), + ("root-mount", Some(Point::SystemFound), Point::RootMounted), + ("root-handoff", Some(Point::RootMounted), Point::S6Start), + ("s6-to-console", Some(Point::S6Start), Point::ConsoleReady), + ] { + let begin = match start { + None => Some(0), + Some(point) => report.events_ns.get(point.name()).copied(), + }; + if let (Some(begin), Some(&finish)) = (begin, report.events_ns.get(end.name())) { + report.durations_ns.insert(label.into(), finish - begin); + } + } + Ok(report) + } + pub fn print(&self, json: bool) -> Result<()> { + if json { + println!( + "{}", + serde_json::to_string_pretty(self).map_err(|e| Error(e.to_string()))? + ); + } else { + println!( + "FDS BOOT PROFILE\nPLATFORM {}\nCLOCK {}", + self.platform, self.clock + ); + for (name, ns) in &self.durations_ns { + println!("{name:22} {:8.3} ms", *ns as f64 / 1_000_000.0); + } + if !self.missing_events.is_empty() { + println!("NOT RECORDED {}", self.missing_events.join(", ")); + } + println!("Power-on and firmware time are outside this clock."); + } + Ok(()) + } +} +pub fn load(directory: &Path) -> Result { + let mut events = Vec::new(); + for point in Point::ALL { + let folder = if point == Point::ConsoleReady { + directory.join("console") + } else { + directory.to_owned() + }; + let path = folder.join(format!("{}.json", point.name())); + if !path.exists() { + continue; + } + let event: Event = + serde_json::from_str(&read_text(&path, 4096)?).map_err(|e| Error(e.to_string()))?; + if event.point != point { + return Err(Error( + "Boot event filename does not match its record".into(), + )); + } + events.push(event); + } + if events.is_empty() { + return Err(Error("No boot trace recorded".into())); + } + let platform = read_text(Path::new("/proc/device-tree/model"), 4096) + .unwrap_or_else(|_| "unknown platform".into()) + .trim_matches('\0') + .trim() + .to_owned(); + let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)? + .trim() + .to_owned(); + Report::from_events(&events, platform, kernel) +} + +#[cfg(test)] +mod tests { + use super::*; + fn event(point: Point, time: u64) -> Event { + Event { + format: 1, + boot_id: "fixture".into(), + point, + boot_ns: time, + } + } + #[test] + fn durations_are_measured_and_missing_events_are_not_zeroes() { + let result = Report::from_events( + &[event(Point::S6Start, 700), event(Point::ConsoleReady, 900)], + "VM fixture".into(), + "test".into(), + ) + .unwrap(); + assert_eq!(result.durations_ns["s6-to-console"], 200); + assert_eq!(result.durations_ns["kernel-to-console"], 900); + assert!(!result.durations_ns.contains_key("system-discovery")); + } + #[test] + fn mixed_boots_duplicates_and_backwards_events_are_rejected() { + let start = event(Point::Stage0Start, 100); + let mut finish = event(Point::ConsoleReady, 50); + assert!( + Report::from_events(&[start.clone(), finish.clone()], "VM".into(), "test".into()) + .is_err() + ); + finish.boot_ns = 200; + finish.boot_id = "different".into(); + assert!(Report::from_events(&[start.clone(), finish], "VM".into(), "test".into()).is_err()); + assert!(Report::from_events(&[start.clone(), start], "VM".into(), "test".into()).is_err()); + } + #[test] + fn restarting_the_console_preserves_the_first_ready_time() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = + std::env::temp_dir().join(format!("fds-trace-{}-{unique}", std::process::id())); + save(&directory, Point::ConsoleReady, 100).unwrap(); + save(&directory, Point::ConsoleReady, 200).unwrap(); + let record: Event = serde_json::from_str( + &fs::read_to_string(directory.join("console-ready.json")).unwrap(), + ) + .unwrap(); + assert_eq!(record.boot_ns, 100); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/rust/fds-release/Cargo.toml b/rust/fds-release/Cargo.toml new file mode 100644 index 0000000..6872034 --- /dev/null +++ b/rust/fds-release/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fds-release" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "FDS release manifest signing and explicit-key verification" + +[dependencies] +clap.workspace = true +fds-common = { path = "../fds-common" } +ed25519-dalek = { version = "=3.0.0", default-features = false, features = ["zeroize"] } +zeroize = { version = "1", features = ["alloc"] } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/rust/fds-release/src/lib.rs b/rust/fds-release/src/lib.rs new file mode 100644 index 0000000..4536027 --- /dev/null +++ b/rust/fds-release/src/lib.rs @@ -0,0 +1,402 @@ +//! Detached Ed25519 signatures bind bounded manifests and streamed artifact hashes. +//! The verifier's trust anchor is always an explicit public-key file. +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use fds_common::{Error, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + ffi::CString, + fs::{File, OpenOptions}, + io::{self, Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::{MetadataExt, OpenOptionsExt}, + }, + path::{Path, PathBuf}, +}; +use zeroize::Zeroizing; + +pub const MANIFEST: &str = "manifest.json"; +pub const SIGNATURE: &str = "manifest.sig"; +pub const CONTEXT: &[u8] = b"FDS/OS release manifest v1\0"; +const MAX_MANIFEST: u64 = 1024 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Artifact { + pub name: String, + pub bytes: u64, + pub sha256: String, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub format: u8, + pub version: String, + pub source_epoch: u64, + pub source_sha256: String, + pub void_commit: String, + pub hardware_validation: String, + pub files: Vec, +} +fn hex_valid(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +pub fn filename(value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 128 + || value.starts_with('.') + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + { + return Err(Error( + "Release artifact names must be simple filenames without paths or leading dots".into(), + )); + } + Ok(()) +} +impl Manifest { + pub fn validate(&self) -> Result<()> { + if self.format != 1 + || self.version != fds_common::VERSION + || self.source_epoch == 0 + || !hex_valid(&self.source_sha256, 64) + || !hex_valid(&self.void_commit, 40) + || self.hardware_validation != "deferred" + || self.files.is_empty() + || self.files.len() > 64 + { + return Err(Error("Unsupported or invalid FDS release manifest".into())); + } + let mut names = BTreeSet::new(); + for artifact in &self.files { + filename(&artifact.name)?; + if [MANIFEST, SIGNATURE].contains(&artifact.name.as_str()) + || !names.insert(&artifact.name) + || artifact.bytes == 0 + || !hex_valid(&artifact.sha256, 64) + { + return Err(Error( + "Invalid, reserved or duplicate release artifact".into(), + )); + } + } + Ok(()) + } +} +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} +fn unhex(text: &str) -> Result<[u8; N]> { + if !hex_valid(text, N * 2) { + return Err(Error("Invalid lowercase hexadecimal encoding".into())); + } + let mut bytes = [0; N]; + for (byte, pair) in bytes.iter_mut().zip(text.as_bytes().chunks_exact(2)) { + *byte = u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16) + .map_err(|e| Error(e.to_string()))?; + } + Ok(bytes) +} +fn regular(file: File) -> Result { + if !file.metadata()?.is_file() { + return Err(Error("Release inputs must be regular files".into())); + } + Ok(file) +} +fn open(path: &Path) -> Result { + regular( + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)?, + ) +} +fn bounded(mut file: File, limit: u64) -> Result> { + if file.metadata()?.len() > limit { + return Err(Error("Release input exceeds its size limit".into())); + } + let mut bytes = Vec::new(); + (&mut file).take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(Error("Release input exceeds its size limit".into())); + } + Ok(bytes) +} +struct Directory { + fd: File, +} +impl Directory { + fn open(path: &Path) -> Result { + Ok(Self { + fd: OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(path)?, + }) + } + fn file(&self, name: &str) -> Result { + filename(name)?; + let name = CString::new(name).map_err(|_| Error("NUL in filename".into()))?; + let fd = unsafe { + libc::openat( + self.fd.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error().into()); + } + regular(unsafe { File::from_raw_fd(fd) }) + } + fn create(&self, name: &str, bytes: &[u8]) -> Result<()> { + filename(name)?; + let name = CString::new(name).map_err(|_| Error("NUL in filename".into()))?; + let fd = unsafe { + libc::openat( + self.fd.as_raw_fd(), + name.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o644, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error().into()); + } + let mut file = unsafe { File::from_raw_fd(fd) }; + file.write_all(bytes)?; + file.sync_all()?; + self.fd.sync_all()?; + Ok(()) + } + fn manifest(&self) -> Result<(Vec, Manifest)> { + let bytes = bounded(self.file(MANIFEST)?, MAX_MANIFEST)?; + let manifest: Manifest = serde_json::from_slice(&bytes) + .map_err(|e| Error(format!("Invalid release manifest: {e}")))?; + manifest.validate()?; + Ok((bytes, manifest)) + } + fn verify_files(&self, manifest: &Manifest) -> Result<()> { + for artifact in &manifest.files { + let mut file = self.file(&artifact.name)?; + let before = file.metadata()?; + if before.len() != artifact.bytes { + return Err(Error(format!("Artifact size mismatch: {}", artifact.name))); + } + let mut hash = Sha256::new(); + let mut buffer = vec![0u8; 1024 * 1024]; + let mut remaining = artifact.bytes; + while remaining > 0 { + let size = (remaining as usize).min(buffer.len()); + file.read_exact(&mut buffer[..size])?; + hash.update(&buffer[..size]); + remaining -= size as u64; + } + let after = file.metadata()?; + if after.len() != before.len() + || after.mtime() != before.mtime() + || after.mtime_nsec() != before.mtime_nsec() + || after.ctime() != before.ctime() + || after.ctime_nsec() != before.ctime_nsec() + || hex(&hash.finalize()) != artifact.sha256 + { + return Err(Error(format!( + "Artifact changed or SHA-256 mismatch: {}", + artifact.name + ))); + } + } + Ok(()) + } +} +fn message(manifest: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(CONTEXT.len() + manifest.len()); + bytes.extend_from_slice(CONTEXT); + bytes.extend_from_slice(manifest); + bytes +} +fn secret(path: &Path) -> Result { + let mut file = open(path)?; + let meta = file.metadata()?; + if meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 || meta.len() != 32 { + return Err(Error( + "Signing key must be an owner-only 32-byte file belonging to the current user".into(), + )); + } + let mut bytes = Zeroizing::new([0u8; 32]); + file.read_exact(&mut *bytes)?; + Ok(SigningKey::from_bytes(&bytes)) +} +fn public(path: &Path) -> Result { + let bytes = bounded(open(path)?, 128)?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| Error("Public key must be lowercase hex followed by one newline".into()))?; + let key = unhex::<32>( + text.strip_suffix('\n') + .ok_or_else(|| Error("Public key needs a final newline".into()))?, + )?; + let key = + VerifyingKey::from_bytes(&key).map_err(|e| Error(format!("Invalid public key: {e}")))?; + if key.is_weak() { + return Err(Error("Weak release public keys are rejected".into())); + } + Ok(key) +} +fn write_new(path: &Path, bytes: &[u8], mode: u32) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + file.write_all(bytes)?; + file.sync_all()?; + File::open( + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")), + )? + .sync_all()?; + Ok(()) +} +pub fn public_key(private: &Path, output: &Path) -> Result { + let key = secret(private)?.verifying_key(); + write_new( + output, + format!("{}\n", hex(key.as_bytes())).as_bytes(), + 0o644, + )?; + Ok(hex(&Sha256::digest(key.as_bytes()))) +} +pub fn keygen(prefix: &Path) -> Result { + let mut private_name = prefix.as_os_str().to_os_string(); + private_name.push(".key"); + let mut public_name = prefix.as_os_str().to_os_string(); + public_name.push(".pub"); + let (private, public) = (PathBuf::from(private_name), PathBuf::from(public_name)); + if private.symlink_metadata().is_ok() || public.symlink_metadata().is_ok() { + return Err(Error( + "Key output already exists; keys are never overwritten".into(), + )); + } + let mut seed = Zeroizing::new([0u8; 32]); + let mut offset = 0; + while offset < seed.len() { + let count = + unsafe { libc::getrandom(seed[offset..].as_mut_ptr().cast(), seed.len() - offset, 0) }; + if count < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } + if count == 0 { + return Err(Error("Kernel randomness returned no bytes".into())); + } + offset += count as usize; + } + write_new(&private, &*seed, 0o600)?; + public_key(&private, &public) +} +pub fn sign(directory: &Path, private: &Path) -> Result { + let directory = Directory::open(directory)?; + let (bytes, manifest) = directory.manifest()?; + directory.verify_files(&manifest)?; + let key = secret(private)?; + let signature: Signature = key.sign(&message(&bytes)); + directory.create( + SIGNATURE, + format!("{}\n", hex(&signature.to_bytes())).as_bytes(), + )?; + Ok(hex(&Sha256::digest(key.verifying_key().as_bytes()))) +} +pub fn verify(directory: &Path, trusted_public: &Path) -> Result { + let key = public(trusted_public)?; + let directory = Directory::open(directory)?; + let (bytes, manifest) = directory.manifest()?; + let encoded = bounded(directory.file(SIGNATURE)?, 256)?; + let signature = + std::str::from_utf8(&encoded).map_err(|_| Error("Invalid signature encoding".into()))?; + let signature = Signature::from_bytes(&unhex::<64>( + signature + .strip_suffix('\n') + .ok_or_else(|| Error("Signature needs a final newline".into()))?, + )?); + key.verify_strict(&message(&bytes), &signature) + .map_err(|_| Error("Release signature does not match the supplied trusted key".into()))?; + directory.verify_files(&manifest)?; + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rfc8032_test_vector_one_and_malleability_rejection() { + // RFC 8032 section 7.1 public test data, never a release signing key. + let seed = unhex::<32>("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + .unwrap(); + let expected_key = + unhex::<32>("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a") + .unwrap(); + let expected_signature = unhex::<64>("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b").unwrap(); + let key = SigningKey::from_bytes(&seed); + assert_eq!(key.verifying_key().to_bytes(), expected_key); + let signature: Signature = key.sign(b""); + assert_eq!(signature.to_bytes(), expected_signature); + assert!(key.verifying_key().verify_strict(b"", &signature).is_ok()); + assert!( + key.verifying_key() + .verify_strict(b"changed", &signature) + .is_err() + ); + let mut altered = expected_signature; + altered[63] |= 0x80; + assert!( + key.verifying_key() + .verify_strict(b"", &Signature::from_bytes(&altered)) + .is_err() + ); + assert!( + key.verifying_key() + .verify_strict(&message(b""), &signature) + .is_err() + ); + } + #[test] + fn manifest_rejects_duplicate_reserved_and_traversing_paths() { + for name in ["", "../root", "/absolute", ".hidden", "a/b", "a\\b"] { + assert!(filename(name).is_err()); + } + let mut manifest = Manifest { + format: 1, + version: "0.1.0".into(), + source_epoch: 1, + source_sha256: "a".repeat(64), + void_commit: "b".repeat(40), + hardware_validation: "deferred".into(), + files: vec![Artifact { + name: "image.img".into(), + bytes: 1, + sha256: "c".repeat(64), + }], + }; + assert!(manifest.validate().is_ok()); + manifest.files.push(Artifact { + name: "image.img".into(), + bytes: 1, + sha256: "d".repeat(64), + }); + assert!(manifest.validate().is_err()); + manifest.files.pop(); + manifest.files[0].name = MANIFEST.into(); + assert!(manifest.validate().is_err()); + } +} diff --git a/rust/fds-release/src/main.rs b/rust/fds-release/src/main.rs new file mode 100644 index 0000000..f647ca7 --- /dev/null +++ b/rust/fds-release/src/main.rs @@ -0,0 +1,109 @@ +use clap::{CommandFactory, Parser, Subcommand}; +use fds_common::Result; +use std::{path::PathBuf, process::ExitCode}; + +#[derive(Parser)] +#[command( + version, + about = "Sign and verify FDS release artifacts", + after_help = "Keys and signatures are never overwritten. Verification checks every listed artifact. +An independently trusted public key is required; a bundled key is not automatically trusted." +)] +struct Cli { + #[command(subcommand)] + command: Option, +} +#[derive(Subcommand)] +enum Action { + /// Create a new private/public key pair (PREFIX.key and PREFIX.pub). + Keygen { new_prefix: PathBuf }, + /// Derive a new public-key file from an existing private key. + PublicKey { + private_key: PathBuf, + new_public_key: PathBuf, + }, + /// Sign a release directory using an explicit private key. + Sign { + directory: PathBuf, + #[arg(long)] + key: PathBuf, + }, + /// Verify the signature and every artifact using a trusted public key. + Verify { + directory: PathBuf, + #[arg(long)] + key: PathBuf, + }, +} +fn run() -> Result<()> { + match Cli::parse().command { + None => { + Cli::command().print_help()?; + println!(); + } + Some(Action::Keygen { new_prefix }) => println!( + "Created signing key and public key. Public-key SHA-256: {}", + fds_release::keygen(&new_prefix)? + ), + Some(Action::PublicKey { + private_key, + new_public_key, + }) => println!( + "Public-key SHA-256: {}", + fds_release::public_key(&private_key, &new_public_key)? + ), + Some(Action::Sign { directory, key }) => println!( + "Signed release manifest. Public-key SHA-256: {}", + fds_release::sign(&directory, &key)? + ), + Some(Action::Verify { directory, key }) => { + let manifest = fds_release::verify(&directory, &key)?; + println!( + "VERIFIED FDS/OS {}: signature and {} artifact hashes match the supplied key.\nHardware validation: {}.", + manifest.version, + manifest.files.len(), + manifest.hardware_validation + ); + } + } + Ok(()) +} +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("fds-release: {error}"); + ExitCode::from(2) + } + } +} + +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::{CommandFactory, Parser}; + #[test] + fn typed_command_contract() { + Cli::command().debug_assert(); + for action in ["sign", "verify"] { + assert!( + Cli::try_parse_from(["fds-release", action, "release", "--key", "key"]).is_ok() + ); + assert!(Cli::try_parse_from(["fds-release", action, "--key=key", "release"]).is_ok()); + assert!(Cli::try_parse_from(["fds-release", action, "release"]).is_err()); + assert!( + Cli::try_parse_from([ + "fds-release", + action, + "release", + "--key", + "one", + "--key", + "two" + ]) + .is_err() + ); + } + assert!(Cli::try_parse_from(["fds-release", "keygen", "new", "--force"]).is_err()); + } +} diff --git a/rust/fds-smoketest/Cargo.toml b/rust/fds-smoketest/Cargo.toml new file mode 100644 index 0000000..921cb26 --- /dev/null +++ b/rust/fds-smoketest/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "fds-smoketest" +version = "0.1.0" +edition = "2021" +publish = false diff --git a/rust/fds-smoketest/src/main.rs b/rust/fds-smoketest/src/main.rs new file mode 100644 index 0000000..7ce25df --- /dev/null +++ b/rust/fds-smoketest/src/main.rs @@ -0,0 +1,9 @@ +#[cfg(not(all(target_os = "linux", target_arch = "aarch64", target_env = "musl")))] +compile_error!("fds-smoketest requires aarch64-unknown-linux-musl"); + +#[cfg(not(target_feature = "crt-static"))] +compile_error!("fds-smoketest must be statically linked"); + +fn main() { + println!("FDS/OS M0: aarch64 static-musl OK"); +} diff --git a/rust/fds-software/Cargo.toml b/rust/fds-software/Cargo.toml new file mode 100644 index 0000000..522153d --- /dev/null +++ b/rust/fds-software/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fds-software" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Bounded software catalogues and verified xz tar bundles" + +[dependencies] +fds-common = { path = "../fds-common" } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +sha2 = "=0.10.9" +tar = { version = "=0.4.46", default-features = false } +libc = "0.2" diff --git a/rust/fds-software/src/archive.rs b/rust/fds-software/src/archive.rs new file mode 100644 index 0000000..0cb601f --- /dev/null +++ b/rust/fds-software/src/archive.rs @@ -0,0 +1,499 @@ +//! Streaming xz/ustar bundles with explicit file types, paths and resource limits. +use crate::{MAX_ARCHIVE, MAX_ENTRIES, MAX_UNPACKED, Software, relative}; +use fds_common::{Error, Result}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + io::{self, Read, Seek, SeekFrom, Write}, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::Path, + process::{Child, Command, Stdio}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stats { + pub bytes: u64, + pub entries: u32, +} + +pub fn digest(file: &File) -> Result { + let mut input = file.try_clone()?; + input.seek(SeekFrom::Start(0))?; + let mut hash = Sha256::new(); + let mut buffer = [0; 64 * 1024]; + loop { + let n = input.read(&mut buffer)?; + if n == 0 { + break; + } + hash.update(&buffer[..n]); + } + Ok(hash.finalize().iter().map(|b| format!("{b:02x}")).collect()) +} +pub fn open(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path)?; + if !file.metadata()?.is_file() || !(1..=MAX_ARCHIVE).contains(&file.metadata()?.len()) { + return Err(Error( + "Software archive must be a nonempty regular file of at most 512 MiB".into(), + )); + } + Ok(file) +} +struct Process(Child); +impl Drop for Process { + fn drop(&mut self) { + if self.0.try_wait().ok().flatten().is_none() { + let _ = self.0.kill(); + } + let _ = self.0.wait(); + } +} +struct Budget { + inner: R, + remaining: u64, +} +impl Read for Budget { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + if self.remaining == 0 { + let mut probe = [0]; + if self.inner.read(&mut probe)? == 0 { + return Ok(0); + } + return Err(io::Error::other( + "Software archive exceeds its decompression limit", + )); + } + let limit = (buffer.len() as u64).min(self.remaining) as usize; + let n = self.inner.read(&mut buffer[..limit])?; + self.remaining -= n as u64; + Ok(n) + } +} +fn elf(prefix: &[u8], architecture: &str) -> Result<()> { + if prefix.starts_with(b"\x7fELF") + && (architecture != "aarch64" + || prefix.len() < 20 + || prefix[4] != 2 + || prefix[5] != 1 + || prefix[18..20] != [183, 0]) + { + return Err(Error( + "Software contains an ELF executable/library for the wrong architecture".into(), + )); + } + Ok(()) +} + +/// Decode only directories and regular files. Ownership, timestamps, links, +/// sparse files, device nodes and extension records never control extraction. +/// DEST must be a newly created private directory, inaccessible to other users. +pub fn read_tar( + input: impl Read, + software: &Software, + destination: Option<&Path>, +) -> Result { + software.validate()?; + if let Some(root) = destination { + let metadata = root.symlink_metadata()?; + if !metadata.is_dir() + || metadata.permissions().mode() & 0o077 != 0 + || fs::read_dir(root)?.next().is_some() + { + return Err(Error( + "Software extraction requires an empty private directory".into(), + )); + } + } + let budget = software + .unpacked_bytes + .checked_add(u64::from(software.entries) * 1024 + 64 * 1024) + .ok_or_else(|| Error("Archive budget overflow".into()))?; + let mut archive = tar::Archive::new(Budget { + inner: input, + remaining: budget, + }); + let mut seen: BTreeMap = BTreeMap::new(); + let mut stats = Stats { + bytes: 0, + entries: 0, + }; + for entry in archive.entries()?.raw(true) { + let mut entry = entry?; + let kind = entry.header().entry_type(); + if !(kind.is_file() || kind.is_dir()) { + return Err(Error("Software tarballs permit only regular files and directories (no links or extensions)".into())); + } + let name = String::from_utf8(entry.path_bytes().into_owned()) + .map_err(|_| Error("Archive path is not UTF-8".into()))?; + let name = if kind.is_dir() { + name.strip_suffix('/').unwrap_or(&name) + } else { + &name + }; + if !relative(name) || seen.contains_key(name) || entry.header().mode()? & 0o7000 != 0 { + return Err(Error( + "Unsafe, duplicate or privileged software archive entry".into(), + )); + } + for parent in Path::new(name) + .ancestors() + .skip(1) + .filter(|p| !p.as_os_str().is_empty()) + { + if seen + .get(parent.to_str().unwrap()) + .is_some_and(|(directory, _)| !directory) + { + return Err(Error("Software entry has a non-directory parent".into())); + } + } + if kind.is_file() { + let prefix = format!("{name}/"); + if seen + .range(prefix.clone()..) + .next() + .is_some_and(|(path, _)| path.starts_with(&prefix)) + { + return Err(Error( + "Software file replaces a previously implied directory".into(), + )); + } + } + let size = entry.size(); + if kind.is_dir() && size != 0 { + return Err(Error("Directory entry contains data".into())); + } + stats.entries += 1; + stats.bytes = stats + .bytes + .checked_add(size) + .ok_or_else(|| Error("Software size overflow".into()))?; + if stats.entries > software.entries + || stats.entries > MAX_ENTRIES + || stats.bytes > software.unpacked_bytes + || stats.bytes > MAX_UNPACKED + { + return Err(Error("Software exceeds declared extraction limits".into())); + } + let executable = entry.header().mode()? & 0o111 != 0; + seen.insert(name.to_owned(), (kind.is_dir(), executable)); + let output = destination.map(|root| root.join(name)); + if kind.is_dir() { + if let Some(path) = output { + directories(destination.unwrap(), &path)?; + } + } else { + let mut prefix = vec![0; size.min(64) as usize]; + entry.read_exact(&mut prefix)?; + elf(&prefix, &software.architecture)?; + if let Some(path) = output { + directories(destination.unwrap(), path.parent().unwrap())?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(if executable { 0o755 } else { 0o644 }) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path)?; + file.write_all(&prefix)?; + io::copy(&mut entry, &mut file)?; + file.set_permissions(fs::Permissions::from_mode(if executable { + 0o755 + } else { + 0o644 + }))?; + } else { + io::copy(&mut entry, &mut io::sink())?; + } + } + } + // Reject a second tar archive or nonzero content following the tar end marker. + let mut input = archive.into_inner(); + let mut buffer = [0; 4096]; + loop { + let n = input.read(&mut buffer)?; + if n == 0 { + break; + } + if buffer[..n].iter().any(|b| *b != 0) { + return Err(Error("Unexpected trailing archive content".into())); + } + } + if stats.bytes != software.unpacked_bytes || stats.entries != software.entries { + return Err(Error( + "Software contents do not match declared sizes/counts".into(), + )); + } + for command in software.commands.values() { + if seen.get(command) != Some(&(false, true)) { + return Err(Error(format!( + "Declared command is not an executable regular file: {command}" + ))); + } + } + Ok(stats) +} + +fn directories(root: &Path, path: &Path) -> Result<()> { + let mut current = root.to_path_buf(); + for component in path + .strip_prefix(root) + .map_err(|_| Error("Extraction path escaped its root".into()))? + .components() + { + current.push(component); + match fs::create_dir(¤t) { + Ok(()) => fs::set_permissions(¤t, fs::Permissions::from_mode(0o755))?, + Err(error) + if error.kind() == io::ErrorKind::AlreadyExists + && fs::symlink_metadata(¤t)?.is_dir() => + { + () + } + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +pub fn verify(file: &File, software: &Software, destination: Option<&Path>) -> Result { + software.validate()?; + if !file.metadata()?.is_file() + || file.metadata()?.len() != software.archive_bytes + || digest(file)? != software.sha256 + { + return Err(Error("Software archive length or SHA-256 mismatch".into())); + } + let mut input = file.try_clone()?; + input.seek(SeekFrom::Start(0))?; + let mut child = Process( + Command::new("xz") + .args(["--decompress", "--stdout", "--memlimit-decompress=256MiB"]) + .stdin(Stdio::from(input)) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?, + ); + let result = read_tar(child.0.stdout.take().unwrap(), software, destination)?; + if !child.0.wait()?.success() { + return Err(Error("XZ decompression failed".into())); + } + if file.metadata()?.len() != software.archive_bytes || digest(file)? != software.sha256 { + return Err(Error("Software archive changed during verification".into())); + } + Ok(result) +} + +/// Workstation construction emits deterministic USTAR. In-tree file symlinks +/// are copied as regular files; directory links and escaping links are rejected. +pub fn write_tar(root: &Path, output: impl Write, architecture: &str) -> Result { + if !matches!(architecture, "aarch64" | "any") { + return Err(Error("Expected architecture aarch64 or any".into())); + } + let root = root.canonicalize()?; + let mut paths = BTreeSet::new(); + fn walk(root: &Path, dir: &Path, paths: &mut BTreeSet) -> Result<()> { + for item in fs::read_dir(dir)? { + let item = item?; + let path = item.path(); + let name = path + .strip_prefix(root) + .unwrap() + .to_str() + .ok_or_else(|| Error("Software paths must be UTF-8".into()))? + .to_owned(); + if !relative(&name) || name.len() > 255 { + return Err(Error( + "Software path is unsafe or exceeds USTAR's 255-byte limit".into(), + )); + } + let kind = item.file_type()?; + if kind.is_dir() { + walk(root, &path, paths)?; + } else if !(kind.is_file() + || kind.is_symlink() && path.canonicalize()?.starts_with(root) && path.is_file()) + { + return Err(Error( + "Software tree contains an unsupported node or an escaping/directory symlink" + .into(), + )); + } + paths.insert(name); + if paths.len() > MAX_ENTRIES as usize { + return Err(Error("Too many software files".into())); + } + } + Ok(()) + } + walk(&root, &root, &mut paths)?; + let mut builder = tar::Builder::new(output); + let mut stats = Stats { + bytes: 0, + entries: 0, + }; + for name in paths { + let path = root.join(&name); + let metadata = path.metadata()?; + let size = if metadata.is_dir() { 0 } else { metadata.len() }; + stats.bytes = stats + .bytes + .checked_add(size) + .ok_or_else(|| Error("Software too large".into()))?; + if stats.bytes > MAX_UNPACKED { + return Err(Error("Software tree exceeds 1 GiB".into())); + } + let mut header = tar::Header::new_ustar(); + header.set_path(&name)?; + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(size); + header.set_entry_type(if metadata.is_dir() { + tar::EntryType::Directory + } else { + tar::EntryType::Regular + }); + header.set_mode( + if metadata.is_dir() || metadata.permissions().mode() & 0o111 != 0 { + 0o755 + } else { + 0o644 + }, + ); + header.set_cksum(); + if metadata.is_dir() { + builder.append(&header, io::empty())?; + } else { + let mut input = File::open(&path)?; + let mut prefix = vec![0; size.min(64) as usize]; + input.read_exact(&mut prefix)?; + elf(&prefix, architecture)?; + input.seek(SeekFrom::Start(0))?; + builder.append(&header, &mut input)?; + } + stats.entries += 1; + } + builder.finish()?; + Ok(stats) +} + +#[cfg(test)] +mod tests { + use super::*; + fn metadata() -> Software { + Software { + id: "test.tool".into(), + name: "Tool".into(), + version: "1".into(), + architecture: "any".into(), + partition: 2, + archive_bytes: 100, + unpacked_bytes: 3, + entries: 1, + sha256: "a".repeat(64), + commands: [("tool".into(), "bin/tool".into())].into(), + } + } + fn tar(kind: tar::EntryType, path: &str, mode: u32) -> Vec { + let mut data = Vec::new(); + let mut b = tar::Builder::new(&mut data); + let mut h = tar::Header::new_ustar(); + h.set_path(path).unwrap(); + h.set_mode(mode); + h.set_uid(0); + h.set_gid(0); + h.set_mtime(0); + h.set_size(if kind.is_file() { 3 } else { 0 }); + h.set_entry_type(kind); + h.set_cksum(); + b.append(&h, if kind.is_file() { &b"abc"[..] } else { &[][..] }) + .unwrap(); + b.finish().unwrap(); + drop(b); + data + } + #[test] + fn file_cannot_replace_an_implicit_parent_directory() { + let mut data = tar(tar::EntryType::Regular, "bin/tool", 0o755); + data.truncate(1024); + data.extend(tar(tar::EntryType::Regular, "bin", 0o644)); + let mut software = metadata(); + software.entries = 2; + software.unpacked_bytes = 6; + assert!(read_tar(&data[..], &software, None).is_err()); + let root = std::env::temp_dir().join(format!("fds-archive-mode-{}", std::process::id())); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let good = tar(tar::EntryType::Regular, "bin/tool", 0o755); + read_tar(&good[..], &metadata(), Some(&root)).unwrap(); + assert_eq!( + root.join("bin").metadata().unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert_eq!( + root.join("bin/tool") + .metadata() + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn file_types_modes_counts_trailing_data_and_commands_are_checked() { + let good = tar(tar::EntryType::Regular, "bin/tool", 0o755); + assert_eq!( + read_tar(&good[..], &metadata(), None).unwrap(), + Stats { + bytes: 3, + entries: 1 + } + ); + for kind in [ + tar::EntryType::Symlink, + tar::EntryType::Link, + tar::EntryType::Fifo, + tar::EntryType::Char, + tar::EntryType::GNUSparse, + ] { + assert!(read_tar(&tar(kind, "bin/tool", 0o755)[..], &metadata(), None).is_err()); + } + assert!( + read_tar( + &tar(tar::EntryType::Regular, "bin/tool", 0o4755)[..], + &metadata(), + None + ) + .is_err() + ); + assert!( + read_tar( + &tar(tar::EntryType::Regular, "bin/tool", 0o644)[..], + &metadata(), + None + ) + .is_err() + ); + let mut short = metadata(); + short.unpacked_bytes = 2; + assert!(read_tar(&good[..], &short, None).is_err()); + let mut trailing = good.clone(); + trailing.extend_from_slice(b"unexpected"); + assert!(read_tar(&trailing[..], &metadata(), None).is_err()); + let mut unsafe_name = good.clone(); + unsafe_name[..100].fill(0); + unsafe_name[..9].copy_from_slice(b"../escape"); + unsafe_name[148..156].fill(b' '); + let checksum: u32 = unsafe_name[..512].iter().map(|b| u32::from(*b)).sum(); + unsafe_name[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes()); + assert!(read_tar(&unsafe_name[..], &metadata(), None).is_err()); + } +} diff --git a/rust/fds-software/src/lib.rs b/rust/fds-software/src/lib.rs new file mode 100644 index 0000000..ceae48b --- /dev/null +++ b/rust/fds-software/src/lib.rs @@ -0,0 +1,3 @@ +//! Verified software archives shared by workstation and guest tools. +pub use fds_common::software::*; +pub mod archive; diff --git a/rust/fds-stage0/.gitkeep b/rust/fds-stage0/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rust/fds-stage0/Cargo.toml b/rust/fds-stage0/Cargo.toml new file mode 100644 index 0000000..641202f --- /dev/null +++ b/rust/fds-stage0/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fds-stage0" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Static FDS early boot discovery and root handoff" + +[dependencies] +clap.workspace = true +fds-common = { path = "../fds-common" } +serde_json = "1" +libc = "0.2" diff --git a/rust/fds-stage0/src/linux.rs b/rust/fds-stage0/src/linux.rs new file mode 100644 index 0000000..a13e21c --- /dev/null +++ b/rust/fds-stage0/src/linux.rs @@ -0,0 +1,533 @@ +//! Linux PID-1 operations. No shell, external mount program or udev is used. +use fds_common::{ + Error, Result, + boot::BootOptions, + read_text, + sysfs::{self, BlockPartition, Selection}, + trace::{self, Point}, +}; +use std::{ + ffi::CString, + fs, + io::{self, Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd}, + unix::{ + fs::{FileTypeExt, MetadataExt, PermissionsExt}, + process::CommandExt, + }, + }, + path::Path, + process::{Child, Command, Stdio}, +}; + +fn c(value: &str) -> Result { + CString::new(value).map_err(|_| Error("NUL in syscall argument".into())) +} +fn checked(value: libc::c_int, operation: &str) -> Result<()> { + if value < 0 { + Err(Error(format!( + "{operation}: {}", + io::Error::last_os_error() + ))) + } else { + Ok(()) + } +} +fn mount(source: &str, target: &str, kind: &str, flags: libc::c_ulong) -> Result<()> { + let (source, target, kind) = (c(source)?, c(target)?, c(kind)?); + // All strings remain alive for the syscall; no data/options pointer is passed. + checked( + unsafe { + libc::mount( + source.as_ptr(), + target.as_ptr(), + kind.as_ptr(), + flags, + std::ptr::null(), + ) + }, + "mount", + ) +} +fn move_mount(source: &str, target: &str) -> Result<()> { + let (source, target) = (c(source)?, c(target)?); + checked( + unsafe { + libc::mount( + source.as_ptr(), + target.as_ptr(), + std::ptr::null(), + libc::MS_MOVE, + std::ptr::null(), + ) + }, + "move mount", + ) +} +fn unmount(target: &str) -> Result<()> { + checked(unsafe { libc::umount(c(target)?.as_ptr()) }, "unmount") +} +fn prepare() -> Result<()> { + if unsafe { libc::getpid() } != 1 || unsafe { libc::geteuid() } != 0 { + return Err(Error( + "Normal stage0 boot requires root PID 1 in an initramfs".into(), + )); + } + let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; + checked( + unsafe { libc::statfs(c("/")?.as_ptr(), &mut stat) }, + "inspect initial root", + )?; + // Linux UAPI linux/magic.h; libc exposes differing signed types by ABI. + if ![0x8584_58f6_u64, 0x0102_1994_u64].contains(&(stat.f_type as u64)) { + return Err(Error( + "Refusing root handoff: initial root is not ramfs/tmpfs".into(), + )); + } + for path in ["/proc", "/sys", "/dev", "/newroot"] { + fs::create_dir_all(path)?; + } + mount( + "proc", + "/proc", + "proc", + libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + )?; + mount( + "sysfs", + "/sys", + "sysfs", + libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + )?; + mount("devtmpfs", "/dev", "devtmpfs", libc::MS_NOSUID)?; + fs::create_dir_all("/dev/fds-early")?; + fs::set_permissions("/dev/fds-early", fs::Permissions::from_mode(0o700))?; + Ok(()) +} +fn netlink() -> Result { + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + checked(fd, "open kernel uevent socket")?; + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let mut address: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + address.nl_family = libc::AF_NETLINK as u16; + address.nl_groups = 1; + checked( + unsafe { + libc::bind( + fd.as_raw_fd(), + (&address as *const libc::sockaddr_nl).cast(), + std::mem::size_of_val(&address) as _, + ) + }, + "bind kernel uevent socket", + )?; + Ok(fd) +} +fn child_events() -> Result { + let mut signals: libc::sigset_t = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigemptyset(&mut signals); + libc::sigaddset(&mut signals, libc::SIGCHLD); + } + checked( + unsafe { libc::sigprocmask(libc::SIG_BLOCK, &signals, std::ptr::null_mut()) }, + "block SIGCHLD", + )?; + let fd = unsafe { libc::signalfd(-1, &signals, libc::SFD_CLOEXEC | libc::SFD_NONBLOCK) }; + checked(fd, "open child event descriptor")?; + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} +fn start_monitor(debug: bool) -> Result { + let mut command = Command::new("/sbin/dasungd"); + command + .args(["--config", "/etc/dasungd-early.toml", "daemon"]) + .env_clear(); + if !debug { + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + command + .spawn() + .map_err(|e| Error(format!("Start early Dasung controller: {e}"))) +} +fn stop_monitor(child: &mut Child) -> Result<()> { + if child.try_wait()?.is_some() { + return Ok(()); + } + let pid = child.id() as libc::pid_t; + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as libc::c_int; + checked(fd, "open early-controller process handle")?; + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + checked( + unsafe { libc::kill(pid, libc::SIGTERM) }, + "stop early monitor controller", + )?; + let mut poll = libc::pollfd { + fd: fd.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + loop { + // This is a process-exit deadline, not a delay before proceeding. + let ready = unsafe { libc::poll(&mut poll, 1, 2000) }; + if ready < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + checked(ready, "wait for early controller exit")?; + if ready == 0 { + child.kill()?; + } + child.wait()?; + return Ok(()); + } +} +fn try_root(device: &BlockPartition) -> Result<()> { + let node = fs::File::open(&device.device)?; + let stat = node.metadata()?; + if !stat.file_type().is_block_device() + || libc::major(stat.rdev()) != device.major + || libc::minor(stat.rdev()) != device.minor + { + return Err(Error("Block device changed during discovery".into())); + } + mount( + &format!("/proc/self/fd/{}", node.as_raw_fd()), + "/newroot", + "erofs", + libc::MS_RDONLY, + )?; + let verify = || -> Result<()> { + let identity = read_text(Path::new("/newroot/usr/lib/os-release"), 16384)?; + if !identity.lines().any(|line| line == "ID=fds") { + return Err(Error("Selected root is not an FDS image".into())); + } + for path in [ + "/newroot/sbin/init", + "/newroot/usr/bin/execlineb", + "/newroot/usr/bin/s6-linux-init", + ] { + let meta = fs::metadata(path)?; + if !meta.is_file() || meta.mode() & 0o111 == 0 { + return Err(Error(format!("Missing executable: {path}"))); + } + } + for path in ["/newroot/proc", "/newroot/sys", "/newroot/dev"] { + let meta = fs::symlink_metadata(path)?; + if !meta.is_dir() { + return Err(Error(format!("Invalid early mountpoint: {path}"))); + } + } + Ok(()) + }; + if let Err(error) = verify() { + unmount("/newroot")?; + return Err(error); + } + Ok(()) +} +fn remove_initial_tree(path: &Path, device: u64) -> Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + if metadata.dev() != device { + continue; + } + if metadata.is_dir() { + remove_initial_tree(&entry.path(), device)?; + fs::remove_dir(entry.path())?; + } else { + fs::remove_file(entry.path())?; + } + } + Ok(()) +} +fn handoff(mut monitor: Child, debug: bool) -> Result<()> { + stop_monitor(&mut monitor)?; + if let Ok(instant) = trace::now() { + trace::mark_early(Point::RootSwitch, instant); + } + for source in ["/proc", "/sys", "/dev"] { + move_mount(source, &format!("/newroot{source}"))?; + } + let old_device = fs::metadata("/")?.dev(); + if fs::metadata("/newroot")?.dev() == old_device { + return Err(Error("New root is not a separate filesystem".into())); + } + std::env::set_current_dir("/newroot")?; + // Only the initial ramfs/tmpfs is removed. Moved mounts and EROFS are skipped. + remove_initial_tree(Path::new("/"), old_device)?; + move_mount("/newroot", "/")?; + checked(unsafe { libc::chroot(c(".")?.as_ptr()) }, "enter new root")?; + std::env::set_current_dir("/")?; + let mut empty: libc::sigset_t = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigemptyset(&mut empty); + } + checked( + unsafe { libc::sigprocmask(libc::SIG_SETMASK, &empty, std::ptr::null_mut()) }, + "restore init signal mask", + )?; + if debug { + println!("FDS_STAGE0_HANDOFF: native s6 on read-only SYSTEM"); + } + let error = Command::new("/sbin/init") + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("LANG", "en_US.UTF-8") + .exec(); + Err(Error(format!("Execute native init: {error}"))) +} +fn power(command: &str) -> Result<()> { + let action = match command { + "reboot" => libc::LINUX_REBOOT_CMD_RESTART, + "poweroff" => libc::LINUX_REBOOT_CMD_POWER_OFF, + _ => return Err(Error("Invalid power command".into())), + }; + checked( + unsafe { libc::reboot(action) }, + "early-userspace power request", + ) +} +#[derive(Default)] +struct ConsoleInput { + pending: Vec, + discard: bool, +} +impl ConsoleInput { + fn accept(&mut self, bytes: &[u8]) -> Vec { + let mut commands = Vec::new(); + for &byte in bytes { + if byte == b'\n' || byte == b'\r' { + if !self.discard { + commands.push(String::from_utf8_lossy(&self.pending).trim().to_owned()); + } + self.pending.clear(); + self.discard = false; + } else if !self.discard && self.pending.len() < 256 { + self.pending.push(byte); + } else { + // Discard the complete overlong line, including any command suffix. + self.pending.clear(); + self.discard = true; + } + } + commands + } +} +fn read_console(input: &mut ConsoleInput) -> Result>> { + let mut bytes = [0; 256]; + let count = io::stdin().read(&mut bytes)?; + if count == 0 { + return Ok(None); + } + Ok(Some(input.accept(&bytes[..count]))) +} +fn block_event(fd: &OwnedFd) -> Result { + let mut bytes = [0; 65536]; + let mut from: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of_val(&from) as libc::socklen_t; + let count = unsafe { + libc::recvfrom( + fd.as_raw_fd(), + bytes.as_mut_ptr().cast(), + bytes.len(), + 0, + (&mut from as *mut libc::sockaddr_nl).cast(), + &mut length, + ) + }; + if count < 0 { + // Lost notifications require a fresh scan, never an arbitrary wait. + if io::Error::last_os_error().raw_os_error() == Some(libc::ENOBUFS) { + return Ok(true); + } + return Err(io::Error::last_os_error().into()); + } + Ok(from.nl_pid == 0 + && bytes[..count as usize] + .split(|b| *b == 0) + .any(|field| field == b"SUBSYSTEM=block")) +} +pub fn boot() -> Result<()> { + let entered = trace::now().ok(); + prepare()?; + if let Some(instant) = entered { + trace::mark_early(Point::Stage0Start, instant); + } + let mut options = BootOptions::parse(&read_text(Path::new("/proc/cmdline"), 65536)?)?; + let events = netlink()?; // Bind before scanning so insertion cannot fall into a gap. + let signals = child_events()?; + let mut monitor = start_monitor(options.debug)?; + println!("FELIS DATA SYSTEMS\nPORTABLE COMPUTER FP-85\n\nFDS BOOT ROM 0.1"); + println!("Commands while waiting: list, rescan, recovery, reboot, poweroff"); + let mut previous = String::new(); + let mut pending = ConsoleInput::default(); + let mut console_open = true; + let mut restart_count = 0; + loop { + let candidates = sysfs::partitions(Path::new("/sys"))?; + let selection = sysfs::select(&candidates, options.root_label()); + let state = match &selection { + Selection::Missing => format!( + "{} MEDIA NOT PRESENT\nINSERT {} CARTRIDGE", + options.root_label(), + options.root_label() + ), + Selection::Ambiguous(devices) => format!( + "MULTIPLE {} CARTRIDGES ({})\nREMOVE EXTRA MEDIA OR ENTER recovery", + options.root_label(), + devices.len() + ), + Selection::Unique(device) => { + let found = trace::now().ok(); + match try_root(device) { + Ok(()) => { + if let Some(instant) = found { + trace::mark_early(Point::SystemFound, instant); + } + if let Ok(instant) = trace::now() { + trace::mark_early(Point::RootMounted, instant); + } + if options.debug { + println!("FDS_STAGE0_ROOT: {}", options.root_label()); + } + println!("MEMORY ........ READY\nSYSTEM ........ FDS/OS 0.1"); + return handoff(monitor, options.debug); + } + Err(error) => { + format!("SYSTEM CANNOT BE USED: {error}\nREPLACE MEDIA OR ENTER recovery") + } + } + } + }; + if state != previous { + println!("{state}"); + if options.debug { + println!("FDS_STAGE0_WAIT"); + } + io::stdout().flush()?; + previous = state; + } + loop { + let mut fds = [ + libc::pollfd { + fd: events.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: if console_open { 0 } else { -1 }, + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: signals.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + let ready = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) }; + if ready < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + checked(ready, "wait for boot events")?; + if fds[2].revents & libc::POLLIN != 0 { + let mut info: libc::signalfd_siginfo = unsafe { std::mem::zeroed() }; + unsafe { + libc::read( + signals.as_raw_fd(), + (&mut info as *mut libc::signalfd_siginfo).cast(), + std::mem::size_of_val(&info), + ); + } + if let Some(status) = monitor.try_wait()? { + if restart_count >= 3 { + return Err(Error(format!( + "Early monitor controller repeatedly exited: {status}" + ))); + } + restart_count += 1; + monitor = start_monitor(options.debug)?; + } + } + let mut rescan = fds[0].revents & libc::POLLIN != 0 && block_event(&events)?; + if fds[1].revents & libc::POLLIN != 0 { + let commands = read_console(&mut pending)?; + console_open = commands.is_some(); + for command in commands.into_iter().flatten() { + match command.as_str() { + "recovery" => { + options.mode = fds_common::boot::BootMode::Recovery; + rescan = true; + } + "rescan" => rescan = true, + "list" => println!( + "{}", + serde_json::to_string(&candidates).map_err(|e| Error(e.to_string()))? + ), + "reboot" | "poweroff" => { + stop_monitor(&mut monitor)?; + power(&command)?; + } + "" => (), + _ => println!("Commands: list, rescan, recovery, reboot, poweroff"), + } + } + } + if fds[1].revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { + console_open = false; + } + if rescan { + break; + } + } + } +} +pub fn emergency(error: &Error) -> ! { + eprintln!("FDS_STAGE0_ERROR: {error}\nBoot stopped. Enter reboot or poweroff."); + let mut pending = ConsoleInput::default(); + let mut console_open = true; + loop { + let mut input = libc::pollfd { + fd: if console_open { 0 } else { -1 }, + events: libc::POLLIN, + revents: 0, + }; + let ready = unsafe { libc::poll(&mut input, 1, -1) }; + if ready > 0 && input.revents & libc::POLLIN != 0 { + if let Ok(commands) = read_console(&mut pending) { + console_open = commands.is_some(); + for command in commands.into_iter().flatten() { + if matches!(command.as_str(), "reboot" | "poweroff") { + let _ = power(&command); + } + } + } + } else if ready > 0 { + // With no console, wait for an external signal instead of busy looping. + unsafe { + libc::pause(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::ConsoleInput; + #[test] + fn console_handles_split_input_and_discards_overlong_command_suffixes() { + let mut input = ConsoleInput::default(); + assert!(input.accept(b"reco").is_empty()); + assert_eq!(input.accept(b"very\n"), ["recovery"]); + assert!(input.accept(&[b'x'; 256]).is_empty()); + assert!(input.accept(b"xpoweroff\n").is_empty()); + assert_eq!(input.accept(b"list\n"), ["list"]); + } +} diff --git a/rust/fds-stage0/src/main.rs b/rust/fds-stage0/src/main.rs new file mode 100644 index 0000000..d2f7e2e --- /dev/null +++ b/rust/fds-stage0/src/main.rs @@ -0,0 +1,104 @@ +mod linux; +use clap::{Parser, ValueEnum}; +use fds_common::{Error, MAX_CONFIG_BYTES, Result, boot::BootOptions, read_text, sysfs}; +use std::{path::PathBuf, process::ExitCode}; + +#[derive(Parser)] +#[command( + version, + about = "Early boot discovery and native s6 handoff", + after_help = "With no arguments, root PID 1 boots FDS. Diagnostic options only read their supplied inputs." +)] +struct Cli { + /// Validate a kernel command-line file without booting. + #[arg(long, value_name = "FILE", conflicts_with = "probe")] + check_cmdline: Option, + /// Inspect SYSTEM or RECOVERY discovery in the supplied sysfs tree. + #[arg(long, value_name = "SYSFS_ROOT")] + probe: Option, + #[arg(value_enum, requires = "probe", conflicts_with = "check_cmdline")] + label: Option