commit 99bc3d15c5971367291be9a812cf33c90b3eddc2 Author: Felis Date: Mon Sep 21 22:29:23 2026 +0800 FDS/OS 1.0 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 0000000..cc0c360 Binary files /dev/null and b/rust/dasungd/profiles/paperlike13k-37hz.edid differ diff --git a/rust/dasungd/src/config.rs b/rust/dasungd/src/config.rs new file mode 100644 index 0000000..b92021f --- /dev/null +++ b/rust/dasungd/src/config.rs @@ -0,0 +1,103 @@ +use crate::protocol::Parameter; +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub monitor_serial: String, + pub socket: PathBuf, + pub state_file: PathBuf, + pub transport: String, + pub serial_device: Option, + 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