From ff1a11dbd91de1e46f44e1bc4255ce38d4083391 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 01:32:05 +0200 Subject: [PATCH 1/5] Integration tests outside the kernel are cut, and unit and module tests hold what they checked The test sweep the orchestrator accepted on #893, under the owner's testing ruling (#890): a test that installs or launches software and drives it through steps, or integrates userspace programs, is cut; where what it checked matters, a unit or module test of its program holds it. Deleted, held already: consent_prompt, netstack_streams_e1000e, https_fetch, machine_shutdown, acpi_press_across_a_lock_wait, block_grants_reach_their_partitions, toyfetch_reports, shipped_client_departures, fs_large_file, home_backing_revoked and nvme_home_roundtrip. Replaced, each by the test that now holds it: netstack_streams (an echo past both windows in the node tests, judged against RFC 9293), libc_sockets (the stream-end rule moves into the SDK, read by libc and std alike; a datagram send's bind of a socket never bound moves into libc's dgramreq, host-tested), acpi_power_button (acpi_press_is_one_sci, the kernel's SCI), lent_device_memory_refused and lent_window_refused (the kernel asked directly, and fileserver's hello decision), nvme_disk_keeps_log_and_home (nvme_disk_boots), update_writes_the_idle_slot_through_the_block_service (update's install, over memory, judged by toyos-fat32-check), audio_idle_suspend and hda_client_stall (soundserver's wait, fill and deferral decisions), soundserver_log_stall (the SDK's lane against a full ring), fs_share, fs_escape, fs_stream_offset (fileserver::bounds), fs_cache_eviction (the cache's read-back), fs_turns (std's turnstile) and toybox_file_tools (toybox's cp, mv and hexdump). Narrowed to the kernel's arms: iommu_virtio_platform, usbd_drives_the_spare, process_tree, disk_backtrace, endowment_denied, file_mtime, fs_truncate_persist, hierarchy_paths, lseek_past_eof and stat_identity. process_tree's std arms, a launch under a place this process cannot copy refused rather than spawned directly and a named image run under its program's name, move into std's launch_route, where every launch not made is decided. Kept: launch_toctou, app_view and process_tree's launcher arms, recorded in the supervisor track's stage 2 as owed a cut; acpi_server_events, acpi_tables_loaded and battery_read, recorded in the ACPI track, whose replacement reads the firmware's own tables as the loader dumps them; hosted_clang_hello, a module test of the hosted toolchain. The lifts that let a host reach the decisions: fileserver::bounds, std's launch_route and turns, libc's dgramreq, and toybox's and update's own files compiled by #[path] in the test packages toybox-copies and update-copies, as toyos-libc-copies compiles libc's. A link's target stays UTF-8 alone; a path the server resolves is held canonical where it is resolved. The Disk trait moves into toyos-disk, which diskserver and update share, so update-copies links no SDK. Gone with the cuts: the consent, block-grant, log-stall and slots images, logkeeper's --stall-until, Profile::Desktop and HeadlessE1000e, BootOptions::second_slot and acpi_tables, Plan::second, run_test_paced, bootlog's lines_of, the HTTPS harness server and its dev-dependencies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- Cargo.lock | 250 +-- Cargo.toml | 14 +- build/bootlog.rs | 20 +- build/build.rs | 10 +- ...rvisor-is-host-tested-and-owns-the-stop.md | 15 + ...ine-in-acpi-mode-and-interprets-its-aml.md | 17 + lib/acpi/tests/fixtures.rs | 2 +- lib/disk/Cargo.toml | 9 + lib/disk/src/lib.rs | 45 + sdk/libc/arch/src/datagram_sends.rs | 74 + sdk/libc/arch/src/lib.rs | 16 +- sdk/libc/arch/src/stream_ends.rs | 30 - sdk/libc/src/dgramreq.rs | 23 + sdk/libc/src/lib.rs | 2 +- sdk/libc/src/socket.rs | 52 +- sdk/libc/src/streamend.rs | 38 - sdk/std/copies/Cargo.toml | 14 + sdk/std/copies/src/lib.rs | 14 + sdk/std/sys/fs.rs | 64 +- sdk/std/sys/launch_route.rs | 171 ++ sdk/std/sys/net/connection.rs | 28 +- sdk/std/sys/process.rs | 85 +- sdk/std/sys/turns.rs | 103 ++ sdk/toyos/src/log/proof.rs | 28 + sdk/toyos/src/net.rs | 63 + system/diskserver/Cargo.toml | 2 + system/diskserver/src/disk.rs | 42 +- system/fileserver/src/bounds.rs | 209 +++ system/fileserver/src/cache.rs | 28 + system/fileserver/src/data.rs | 22 + system/fileserver/src/fat.rs | 29 +- system/fileserver/src/lib.rs | 4 +- system/fileserver/src/main.rs | 95 +- system/logkeeper/src/main.rs | 55 +- system/logkeeper/src/origin.rs | 11 +- system/netstack/node/tests/streams.rs | 43 + system/soundserver/src/mix.rs | 125 +- system/toybox/copies/Cargo.toml | 19 + system/toybox/copies/src/copying.rs | 72 + system/toybox/copies/src/dumping.rs | 70 + system/toybox/copies/src/lib.rs | 25 + system/toybox/copies/src/moving.rs | 43 + system/toybox/copies/src/roster_reading.rs | 29 + system/toybox/src/cp.rs | 4 +- system/toybox/src/hexdump.rs | 62 +- system/toybox/src/main.rs | 1 + system/toybox/src/mv.rs | 33 +- system/toybox/src/ps.rs | 42 +- system/toybox/src/roster.rs | 46 + system/update/Cargo.toml | 2 + system/update/copies/Cargo.toml | 24 + system/update/copies/src/installing.rs | 132 ++ system/update/copies/src/lib.rs | 11 + system/update/src/install/mod.rs | 173 +++ system/update/src/{ => install}/volume.rs | 2 +- system/update/src/main.rs | 174 +-- tests/checks.rs | 2 +- tests/checks/audio.rs | 168 +- tests/checks/metal.rs | 2 +- tests/common/audio.rs | 264 +--- tests/common/compile.rs | 8 - tests/common/https.rs | 164 -- tests/common/iommu.rs | 135 +- tests/common/metal.rs | 4 +- tests/common/mod.rs | 2 - tests/common/power.rs | 199 --- tests/common/qemu.rs | 134 +- tests/common/serial.rs | 4 +- tests/guest/c/addr_order.c | 101 -- tests/guest/c/nodelay_kept.c | 108 -- tests/guest/c/sendto_unbound.c | 75 - tests/guest/c/stream_ends.c | 91 -- tests/guest/rust-tests/Cargo.lock | 185 --- tests/guest/rust-tests/Cargo.toml | 14 +- .../guest/rust-tests/src/bin/acpi_mediated.rs | 87 +- .../rust-tests/src/bin/audio_idle_suspend.rs | 78 - tests/guest/rust-tests/src/bin/consent.rs | 316 ---- .../rust-tests/src/bin/disk_backtrace.rs | 16 +- .../rust-tests/src/bin/empty_dir_stat.rs | 3 +- .../rust-tests/src/bin/endowment_denied.rs | 73 +- tests/guest/rust-tests/src/bin/file_mtime.rs | 34 +- .../rust-tests/src/bin/fs_cache_eviction.rs | 56 - tests/guest/rust-tests/src/bin/fs_escape.rs | 103 -- .../guest/rust-tests/src/bin/fs_large_file.rs | 67 - tests/guest/rust-tests/src/bin/fs_share.rs | 214 --- .../rust-tests/src/bin/fs_stream_offset.rs | 72 - .../rust-tests/src/bin/fs_truncate_persist.rs | 15 +- tests/guest/rust-tests/src/bin/fs_turns.rs | 42 - .../rust-tests/src/bin/hda_client_stall.rs | 127 -- .../rust-tests/src/bin/hierarchy_paths.rs | 31 +- .../src/bin/home_backing_revoked.rs | 93 -- tests/guest/rust-tests/src/bin/https_get.rs | 77 - .../src/bin/lent_device_memory_refused.rs | 29 +- .../rust-tests/src/bin/lent_window_refused.rs | 44 +- .../rust-tests/src/bin/lseek_past_eof.rs | 52 +- .../rust-tests/src/bin/netstack_streams.rs | 126 -- .../rust-tests/src/bin/nodelay_accepted.rs | 95 -- .../src/bin/null_sink_client_exits.rs | 62 - .../rust-tests/src/bin/nvme_home_roundtrip.rs | 31 - .../rust-tests/src/bin/partition_grant.rs | 119 -- .../guest/rust-tests/src/bin/process_tree.rs | 110 +- .../src/bin/soundserver_log_stall.rs | 121 -- .../guest/rust-tests/src/bin/stat_identity.rs | 47 +- .../rust-tests/src/bin/stream_ends_std.rs | 23 - .../rust-tests/src/bin/toybox_file_tools.rs | 320 ---- .../rust-tests/src/bin/update_idle_slot.rs | 22 - tests/guest/rust-tests/src/bin/usbd_spare.rs | 42 +- tests/guest/rust-tests/src/lent_hello.rs | 36 - tests/guest/rust-tests/src/stream_ends.rs | 126 -- tests/images/blockgrantcase.toml | 20 - tests/images/consentcase.toml | 61 - tests/images/logstallcase.toml | 45 - tests/images/netcase.toml | 3 +- tests/images/proctreecase.toml | 10 +- tests/images/slotscase.toml | 28 - tests/images/testcases.toml | 21 +- tests/toyos.rs | 1371 ++--------------- 117 files changed, 2215 insertions(+), 6729 deletions(-) create mode 100644 lib/disk/Cargo.toml create mode 100644 lib/disk/src/lib.rs create mode 100644 sdk/libc/arch/src/datagram_sends.rs delete mode 100644 sdk/libc/arch/src/stream_ends.rs create mode 100644 sdk/libc/src/dgramreq.rs delete mode 100644 sdk/libc/src/streamend.rs create mode 100644 sdk/std/copies/Cargo.toml create mode 100644 sdk/std/copies/src/lib.rs create mode 100644 sdk/std/sys/launch_route.rs create mode 100644 sdk/std/sys/turns.rs create mode 100644 system/fileserver/src/bounds.rs create mode 100644 system/toybox/copies/Cargo.toml create mode 100644 system/toybox/copies/src/copying.rs create mode 100644 system/toybox/copies/src/dumping.rs create mode 100644 system/toybox/copies/src/lib.rs create mode 100644 system/toybox/copies/src/moving.rs create mode 100644 system/toybox/copies/src/roster_reading.rs create mode 100644 system/toybox/src/roster.rs create mode 100644 system/update/copies/Cargo.toml create mode 100644 system/update/copies/src/installing.rs create mode 100644 system/update/copies/src/lib.rs create mode 100644 system/update/src/install/mod.rs rename system/update/src/{ => install}/volume.rs (99%) delete mode 100644 tests/common/https.rs delete mode 100644 tests/guest/c/addr_order.c delete mode 100644 tests/guest/c/nodelay_kept.c delete mode 100644 tests/guest/c/sendto_unbound.c delete mode 100644 tests/guest/c/stream_ends.c delete mode 100644 tests/guest/rust-tests/src/bin/audio_idle_suspend.rs delete mode 100644 tests/guest/rust-tests/src/bin/consent.rs delete mode 100644 tests/guest/rust-tests/src/bin/fs_cache_eviction.rs delete mode 100644 tests/guest/rust-tests/src/bin/fs_escape.rs delete mode 100644 tests/guest/rust-tests/src/bin/fs_large_file.rs delete mode 100644 tests/guest/rust-tests/src/bin/fs_share.rs delete mode 100644 tests/guest/rust-tests/src/bin/fs_stream_offset.rs delete mode 100644 tests/guest/rust-tests/src/bin/fs_turns.rs delete mode 100644 tests/guest/rust-tests/src/bin/hda_client_stall.rs delete mode 100644 tests/guest/rust-tests/src/bin/home_backing_revoked.rs delete mode 100644 tests/guest/rust-tests/src/bin/https_get.rs delete mode 100644 tests/guest/rust-tests/src/bin/netstack_streams.rs delete mode 100644 tests/guest/rust-tests/src/bin/nodelay_accepted.rs delete mode 100644 tests/guest/rust-tests/src/bin/null_sink_client_exits.rs delete mode 100644 tests/guest/rust-tests/src/bin/nvme_home_roundtrip.rs delete mode 100644 tests/guest/rust-tests/src/bin/partition_grant.rs delete mode 100644 tests/guest/rust-tests/src/bin/soundserver_log_stall.rs delete mode 100644 tests/guest/rust-tests/src/bin/stream_ends_std.rs delete mode 100644 tests/guest/rust-tests/src/bin/toybox_file_tools.rs delete mode 100644 tests/guest/rust-tests/src/bin/update_idle_slot.rs delete mode 100644 tests/guest/rust-tests/src/lent_hello.rs delete mode 100644 tests/guest/rust-tests/src/stream_ends.rs delete mode 100644 tests/images/blockgrantcase.toml delete mode 100644 tests/images/consentcase.toml delete mode 100644 tests/images/logstallcase.toml delete mode 100644 tests/images/slotscase.toml diff --git a/Cargo.lock b/Cargo.lock index e139e2ed673..cc037e8b5dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,45 +203,6 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -290,15 +251,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -940,26 +892,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "derive-into-owned" version = "0.2.0" @@ -1003,6 +935,7 @@ dependencies = [ "toyos-abi", "toyos-blockhold", "toyos-blockring", + "toyos-disk", "toyos-gpt", ] @@ -1022,17 +955,6 @@ dependencies = [ "objc2 0.6.4", ] -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - [[package]] name = "dlib" version = "0.5.3" @@ -2093,12 +2015,6 @@ dependencies = [ "toyos-tco", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2210,16 +2126,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "ntapi" version = "0.4.3" @@ -2238,16 +2144,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2264,12 +2160,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - [[package]] name = "num-derive" version = "0.4.2" @@ -2674,15 +2564,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2984,12 +2865,6 @@ dependencies = [ "universal-hash 0.6.1", ] -[[package]] -name = "powerfmt" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3161,20 +3036,6 @@ name = "raw-window-handle" version = "0.6.2" source = "git+https://github.com/ToyOSOrg/raw-window-handle?branch=toyos-0.6.2#2a5d102bdbd5dc987b653a7ffff5bedbbbfc53b3" -[[package]] -name = "rcgen" -version = "0.14.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -3402,15 +3263,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - [[package]] name = "rustix" version = "0.38.44" @@ -3960,17 +3812,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "sysinfo" version = "0.39.6" @@ -4065,36 +3906,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tokio" version = "1.51.1" @@ -4202,6 +4013,14 @@ dependencies = [ "toyos-window", ] +[[package]] +name = "toybox-copies" +version = "0.1.0" +dependencies = [ + "toyos-abi", + "toyos-tmpdir", +] + [[package]] name = "toyfetch" version = "0.1.0" @@ -4278,15 +4097,12 @@ dependencies = [ "libc", "libm", "pem", - "rcgen", - "rustls", "serde", "serde_json", "tar", "toml", "toyos-abi", "toyos-blackbox", - "toyos-desktop", "toyos-elf", "toyos-fat32", "toyos-fat32-check", @@ -4335,6 +4151,10 @@ dependencies = [ "toyos-net-wire", ] +[[package]] +name = "toyos-disk" +version = "0.1.0" + [[package]] name = "toyos-dma" version = "0.1.0" @@ -4598,6 +4418,10 @@ dependencies = [ "ring", ] +[[package]] +name = "toyos-std-copies" +version = "0.1.0" + [[package]] name = "toyos-swap" version = "0.1.0" @@ -4833,11 +4657,23 @@ version = "0.1.0" dependencies = [ "diskserver", "toyos", + "toyos-disk", "toyos-fat32", "toyos-sha2-hw", "toyos-update", ] +[[package]] +name = "update-copies" +version = "0.1.0" +dependencies = [ + "toyos-disk", + "toyos-fat32", + "toyos-fat32-check", + "toyos-sha2-hw", + "toyos-update", +] + [[package]] name = "ureq" version = "3.4.2" @@ -5612,24 +5448,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - [[package]] name = "xattr" version = "1.6.1" @@ -5665,16 +5483,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - [[package]] name = "zerocopy" version = "0.8.47" diff --git a/Cargo.toml b/Cargo.toml index 333021bd1b0..daaac56ffae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ members = [ "lib/blockring/transport", "lib/bootmap", "lib/device-memory", + "lib/disk", "lib/elf", "lib/elide", "lib/fat32", @@ -80,6 +81,7 @@ members = [ "sdk/font", "sdk/keymap", "sdk/libc/arch", + "sdk/std/copies", "sdk/osrelease", "sdk/toyos", "sdk/window", @@ -119,8 +121,10 @@ members = [ "system/symbolize", "system/terminal", "system/toybox", + "system/toybox/copies", "system/trace", "system/update", + "system/update/copies", "system/usbd", "tests/guest/kernelprobe", "tests/guest/metalprobe", @@ -235,8 +239,7 @@ toyos-ssh = { path = "build/ssh" } # disagree about which files on it are `logkeeper`'s. toyos-wallclock = { path = "lib/wallclock" } # Every SHA-256 the build takes: `NOTICE`'s record of a committed file, a -# store key, a ROOT's name, a release asset's digest, and the harness's of a -# body a guest fetches over TLS. +# store key, a ROOT's name and a release asset's digest. toyos-sha2-hw = { path = "lib/sha2-hw" } # The toolchain release's tarball, packed in-process (`build/release.rs`): Rust's # own tar and gzip, where the binaries are hosts' tools. @@ -269,13 +272,6 @@ toyos-xhci = { path = "lib/xhci" } # The ACPI server's count line, so the judge reads the words the server writes # and the job that waits for them matches. acpiserver-api = { path = "lib/acpiserver-api" } -# The desktop's geometry and the prompt's colour, so `consent_prompt` clicks -# where the compositor draws and finds the prompt by what the compositor paints. -toyos-desktop = { path = "system/compositor/desktop" } -# `common::https`'s server, the peer a guest's HTTPS client is judged -# against, and the authority and certificates it presents, made per run. -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -rcgen = "0.14" # The metal loop, the suite's command line, and what the metal profile # derives, times and reads off a boot. toyos-metal = { path = "tests/metal" } diff --git a/build/bootlog.rs b/build/bootlog.rs index e94bc7863f1..679cefcc716 100644 --- a/build/bootlog.rs +++ b/build/bootlog.rs @@ -246,16 +246,6 @@ pub fn kernel_records(log: &str) -> String { log.split_inclusive('\n').filter(|line| !is_program_line(line)).collect() } -/// One program's lines, by the name the supervisor started it under, as `logkeeper` read them -/// out of its log ring: each line's text, newline-terminated. -pub fn lines_of(log: &str, name: &str) -> String { - log.lines() - .filter_map(toyos_logstream::program_line) - .filter(|said| said.tag == name) - .map(|said| format!("{}\n", said.text)) - .collect() -} - /// The AP bring-up record, in `kernel/src/arch/x86_64/smp.rs`. A reader asks for the /// trailing ` online` as a separate word: the same head carries the failure. pub const AP_BRINGUP: &str = "SMP: AP cpu"; @@ -268,7 +258,7 @@ const NAME_LEN: usize = 28; /// truncated to what [`NAME_LEN`] holds. /// /// **A predicate looking for the whole name finds nothing on a good boot**: -/// `test_rs_null_sink_client_exits` is `test_rs_null_sink_client_ex` on the wire. +/// `test_rs_munmap_reissues_read_window` is `test_rs_munmap_reissues_rea` on the wire. pub fn recorded_name(binary: &str) -> String { let base = binary.rsplit('/').next().unwrap_or(binary); base[..base.len().min(NAME_LEN - 1)].to_string() @@ -800,8 +790,8 @@ mod tests { } /// A program writing a kernel verdict's words, or another program's head, - /// is left out of the kernel's records and read as its own; a kernel - /// record's continuation line is the kernel's. + /// is left out of the kernel's records; a kernel record's continuation + /// line is the kernel's. #[test] fn a_programs_line_is_never_the_kernels() { let log = "[2026-09-08 16:08:23 2.100 cpu0 kernel] exit: test_rs_job pid=4 code=3 cpu=1ms\n\ @@ -812,8 +802,6 @@ mod tests { let kernel = kernel_records(log); assert!(!kernel.contains("code=0"), "{kernel}"); assert!(kernel.contains("code=3") && kernel.contains(" its second line\n"), "{kernel}"); - assert_eq!(lines_of(log, "netstack"), "netstack: MAC 52:54:00:12:34:56\n"); - assert_eq!(lines_of(log, "test-runner").lines().count(), 2); assert!(is_program_line(log.lines().nth(3).expect("five lines"))); } @@ -821,7 +809,7 @@ mod tests { #[test] fn a_long_binarys_record_is_the_prefix_the_kernel_keeps() { assert_eq!(recorded_name("test_rs_mkdir_cap"), "test_rs_mkdir_cap"); - assert_eq!(recorded_name("test_rs_null_sink_client_exits"), "test_rs_null_sink_client_ex"); + assert_eq!(recorded_name("test_rs_munmap_reissues_read_window"), "test_rs_munmap_reissues_rea"); assert_eq!(recorded_name("/system/bin/echo"), "echo"); assert_eq!(recorded_name("").len(), 0); } diff --git a/build/build.rs b/build/build.rs index d377d81206d..0a0019a16c4 100644 --- a/build/build.rs +++ b/build/build.rs @@ -973,8 +973,6 @@ pub struct Plan { pub params: Vec, /// The version the image's signed header names. pub version: u64, - /// Room for a second slot, or none: a machine that cannot update itself. - pub second: Option, } impl Plan { @@ -985,7 +983,6 @@ impl Plan { features: features.iter().map(|f| (*f).to_string()).collect(), params: params.iter().map(|p| (*p).to_string()).collect(), version: image::version_now(), - second: None, } } } @@ -1014,7 +1011,6 @@ pub fn plan_for(root: &Path, boot: &Boot, debug: bool, args: &[String]) -> Plan features: features.split(',').filter(|f| !f.is_empty()).map(Into::into).collect(), params: param, version: image::version_now(), - second: None, } } @@ -1950,7 +1946,7 @@ pub fn build_test_image( &parts.root, &plan.params.join(","), image::Signing { key: crate::signing::key(), version: plan.version }, - plan.second, + None, ) } @@ -3016,18 +3012,14 @@ mod tests { "images/diag.toml", "images/console.toml", "tests/images/acpicase.toml", - "tests/images/blockgrantcase.toml", - "tests/images/consentcase.toml", "tests/images/hostedclangcase.toml", "tests/images/jobcase.toml", "tests/images/latencycase.toml", - "tests/images/logstallcase.toml", "tests/images/metalcase.toml", "tests/images/metaldevicecase.toml", "tests/images/netcase.toml", "tests/images/panelcase.toml", "tests/images/proctreecase.toml", - "tests/images/slotscase.toml", "tests/images/testcases.toml", "tests/images/virtjobcase.toml", "tests/images/virtpaniccase.toml", diff --git a/issues/the-supervisor-is-host-tested-and-owns-the-stop.md b/issues/the-supervisor-is-host-tested-and-owns-the-stop.md index 8e57885ed84..354a1890436 100644 --- a/issues/the-supervisor-is-host-tested-and-owns-the-stop.md +++ b/issues/the-supervisor-is-host-tested-and-owns-the-stop.md @@ -68,6 +68,21 @@ nothing. the crate for it, named per decision in the PR; `toyos_manifest::launch`, who may start what and the launcher's badge codec, moves into the crate and out of `toyos-manifest`. No decision exists in two places. + **The guest arms this stage cuts.** The owner's testing ruling refuses + them, and they stand until this stage's host tests hold what they judge: + the `launch_toctou` metal row (a launch resolves to its row's own image, + never the caller's reopened path), the `app_view` metal row (an installed + package's view, the launcher refusing a package whose folder is a file, + grants refused in the machine's session) and `process_tree`'s launcher arms + (D launched and E asked of the supervisor, a launch whose place is a pipe + refused, and a `cat` a shell `detach`es outliving it). Two arms the test + sweep cut are this stage's to hold: the supervisor stopping the machine on + `acpiserver`'s press (`acpi_power_button`), and its grant of the idle + slot's partitions to `update` through the block service + (`update_writes_the_idle_slot_through_the_block_service`). + **Exit**, beside the one above: each behaviour named here has a host test + in the crate and a mutation that reds it, and the PR deletes the two rows + and the arms. 3. **The supervisor owns the stop.** It asks each service it started to finish by a quit with reason terminate (`issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md` diff --git a/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md b/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md index 8756debd7ae..ef5af199aab 100644 --- a/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md +++ b/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md @@ -159,6 +159,23 @@ embedded-controller query the tables define, a T14 row reads the battery's state as the interpreter evaluated it beside Linux's reading of the same machine, and the press issue is closed by its own exit. +**Three T14 rows drive the server on a boot, which the testing ruling +refuses** (owner, #890: integration between modules is tested only for the +kernel): `acpi_server_events` reads each embedded-controller query number +once with its count, `acpi_tables_loaded` the 14 definition blocks loaded +and `\_S5`'s `SLP_TYPa` of 7, and `battery_read` the battery and adapter as +the interpreter evaluated them over the controller. They stay until host +tests hold what each reads, because nothing else reads the T14's firmware. +The oracle is the firmware's own table bytes, not another OS's reading of +them, and a dump the loader takes is preferred to one taken under Ubuntu +(the orchestrator, ruling on #893); what the tree keeps of it is extracts +only, as "Extracts only" says. Owner: this stage. **Exit**: the loader +dumps the T14's DSDT and SSDTs; extracts of that dump, masked, are +committed with its provenance; host tests load them into the interpreter +over a scripted host and controller and hold each block's load and +`\_S5`, each query method the controller raises, and the battery's and +adapter's methods; and the three rows leave `tests/toyos.rs` in that diff. + What that check found, which whoever builds on the interpreter would otherwise pay to find again: diff --git a/lib/acpi/tests/fixtures.rs b/lib/acpi/tests/fixtures.rs index 6f49d646aad..d9683902b85 100644 --- a/lib/acpi/tests/fixtures.rs +++ b/lib/acpi/tests/fixtures.rs @@ -213,7 +213,7 @@ fn a_second_firmwares_bytes_decode_to_that_machines_own_windows() { } /// q35's ICH9 power-management block at the base QEMU's firmware put it, -/// 0x600, as the FADT names it: the blocks the `acpi_power_button` guest test +/// 0x600, as the FADT names it: the blocks the `acpi_press_is_one_sci` guest test /// takes its SCI through, and the 32-bit fields and their `X_` twins agreeing. #[test] fn the_q35_fadt_names_the_fixed_hardware_its_sci_is_served_through() { diff --git a/lib/disk/Cargo.toml b/lib/disk/Cargo.toml new file mode 100644 index 00000000000..fa5f26ba467 --- /dev/null +++ b/lib/disk/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "toyos-disk" +description = "A partition as its reader and writer holds it, in whole 4 KiB blocks, whoever serves it: the trait, pure." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[lib] +doctest = false diff --git a/lib/disk/src/lib.rs b/lib/disk/src/lib.rs new file mode 100644 index 00000000000..5fabead500b --- /dev/null +++ b/lib/disk/src/lib.rs @@ -0,0 +1,45 @@ +//! A partition as a program that reads and writes its blocks holds it, in +//! whole 4 KiB blocks, whoever serves it: the one trait the block service's +//! sessions, a claim on the kernel's disk and every test's memory implement. +//! +//! **A block is 4 KiB here, on every source.** A partition that is not whole +//! 4 KiB blocks is refused before it is served, so nothing below has a second +//! block size. + +pub const BLOCK: usize = 4096; + +/// Why a disk did not do what it was asked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiskError { + /// The device's own word: the transfer was attempted and did not happen, + /// or a flush left writes the device would not keep. + Device, + /// The block service is gone and would not come back. + Gone, + /// Past the end of the partition. + Range, + /// A write the block service's grant to this holder does not make. + ReadOnly, +} + +/// A partition, a block at a time or several. +pub trait Disk { + fn blocks(&self) -> u64; + /// `out.len()` is whole blocks. + fn read(&mut self, first: u64, out: &mut [u8]) -> Result<(), DiskError>; + /// `data.len()` is whole blocks; durable once [`Disk::flush`] answers `Ok`. + fn write(&mut self, first: u64, data: &[u8]) -> Result<(), DiskError>; + /// Every write answered before this, out of the device's cache. + fn flush(&mut self) -> Result<(), DiskError>; +} + +/// How many blocks `len` bytes from `first` are, or `Range` past the end of +/// a partition `blocks` long. +pub fn span(first: u64, len: usize, blocks: u64) -> Result { + assert!(len.is_multiple_of(BLOCK), "a transfer of {len} bytes is no whole blocks"); + let count = (len / BLOCK) as u64; + match first.checked_add(count) { + Some(end) if end <= blocks => Ok(count), + _ => Err(DiskError::Range), + } +} diff --git a/sdk/libc/arch/src/datagram_sends.rs b/sdk/libc/arch/src/datagram_sends.rs new file mode 100644 index 00000000000..17dc3af8ea1 --- /dev/null +++ b/sdk/libc/arch/src/datagram_sends.rs @@ -0,0 +1,74 @@ +//! What a datagram socket asks netstack when it sends: a socket never bound is +//! bound once, at any address to a port the stack chooses, before its first +//! datagram, and a bind refused sends nothing. + +use crate::dgramreq::{send_to, Datagrams}; + +/// What a socket asked of netstack, in order. +#[derive(Debug, PartialEq, Eq)] +enum Asked { + Bind([u8; 4], u16), + Send(Vec, [u8; 4], u16), +} + +/// A socket over a netstack that answers every call, or refuses binds. +struct Socket { + held: bool, + binds: bool, + asked: Vec, +} + +impl Socket { + fn new(held: bool) -> Self { + Socket { held, binds: true, asked: Vec::new() } + } +} + +impl Datagrams for Socket { + type Error = &'static str; + fn held(&self) -> bool { + self.held + } + fn bind(&mut self, ip: [u8; 4], port: u16) -> Result<(), Self::Error> { + self.asked.push(Asked::Bind(ip, port)); + match self.binds { + true => { + self.held = true; + Ok(()) + } + false => Err("no port free"), + } + } + fn send(&mut self, data: &[u8], ip: [u8; 4], port: u16) -> Result { + self.asked.push(Asked::Send(data.to_vec(), ip, port)); + Ok(data.len()) + } +} + +const PEER: [u8; 4] = [10, 0, 2, 2]; + +#[test] +fn a_socket_never_bound_is_bound_to_a_port_the_stack_chooses_before_its_first_send() { + let mut socket = Socket::new(false); + assert_eq!(send_to(&mut socket, b"ping", PEER, 7), Ok(4)); + assert_eq!(send_to(&mut socket, b"again", PEER, 7), Ok(5)); + assert_eq!( + socket.asked, + [Asked::Bind([0; 4], 0), Asked::Send(b"ping".to_vec(), PEER, 7), Asked::Send(b"again".to_vec(), PEER, 7)], + "one bind, before the first send alone", + ); +} + +#[test] +fn a_bound_socket_sends_with_no_bind() { + let mut socket = Socket::new(true); + assert_eq!(send_to(&mut socket, b"ping", PEER, 7), Ok(4)); + assert_eq!(socket.asked, [Asked::Send(b"ping".to_vec(), PEER, 7)]); +} + +#[test] +fn a_refused_bind_sends_nothing() { + let mut socket = Socket { binds: false, ..Socket::new(false) }; + assert_eq!(send_to(&mut socket, b"ping", PEER, 7), Err("no port free")); + assert_eq!(socket.asked, [Asked::Bind([0; 4], 0)]); +} diff --git a/sdk/libc/arch/src/lib.rs b/sdk/libc/arch/src/lib.rs index b45faf12d75..d279f59bd5f 100644 --- a/sdk/libc/arch/src/lib.rs +++ b/sdk/libc/arch/src/lib.rs @@ -8,9 +8,9 @@ //! IEEE 754's rounding of hexadecimal input computed exactly, AArch64's //! `long double` widening against compiler-builtins', the errno codes against //! `include/errno.h`, IPv4 addresses and their texts against the host C -//! library's, and the socket option rule against the host's `setsockopt` and -//! `getsockopt`, the rule that reads a stream's end, and `realpath`'s -//! resolution over a tree laid out here. +//! library's, the socket option rule against the host's `setsockopt` and +//! `getsockopt`, a datagram send's bind, and `realpath`'s resolution over a +//! tree laid out here. #[cfg(test)] extern crate alloc; @@ -27,6 +27,9 @@ mod arch; #[allow(dead_code)] mod errcode; #[cfg(test)] +#[path = "../../src/dgramreq.rs"] +mod dgramreq; +#[cfg(test)] #[path = "../../src/elfsym.rs"] mod elfsym; #[cfg(test)] @@ -60,9 +63,6 @@ mod sigmask; #[path = "../../src/sockopt.rs"] mod sockopt; #[cfg(test)] -#[path = "../../src/streamend.rs"] -mod streamend; -#[cfg(test)] #[path = "../../src/strtonum.rs"] mod strtonum; #[cfg(test)] @@ -75,6 +75,8 @@ mod utf8; #[cfg(test)] mod alarm_requests; #[cfg(test)] +mod datagram_sends; +#[cfg(test)] mod descriptor_requests; #[cfg(test)] mod dladdr_symbols; @@ -107,8 +109,6 @@ mod signal_masks; #[cfg(test)] mod socket_options; #[cfg(test)] -mod stream_ends; -#[cfg(test)] mod strtonum_differential; #[cfg(test)] mod text_differential; diff --git a/sdk/libc/arch/src/stream_ends.rs b/sdk/libc/arch/src/stream_ends.rs deleted file mode 100644 index d25480b3203..00000000000 --- a/sdk/libc/arch/src/stream_ends.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! libc's rule for a stream's end (`streamend.rs`): a read of 0 is the peer's FIN only while the -//! send pipe still has its reader, a write the send pipe refuses for its reader's leaving is a -//! reset, and nothing else either pipe says is read as one. - -use toyos_abi::syscall::SyscallError; - -use crate::streamend::{read_end, write_refused, Refusal}; - -/// Every refusal but the two a pipe answers on purpose. -const OTHERS: [SyscallError; 4] = - [SyscallError::InvalidArgument, SyscallError::PermissionDenied, SyscallError::BadAddress, SyscallError::Io]; - -#[test] -fn a_read_of_zero_is_the_peers_fin_only_while_the_send_pipe_is_read() { - assert_eq!(read_end(Ok(0)), Ok(())); - assert_eq!(read_end(Err(SyscallError::WouldBlock)), Ok(()), "a full send pipe still has its reader"); - assert_eq!(read_end(Err(SyscallError::Gone)), Err(Refusal::Reset)); - for other in OTHERS { - assert_eq!(read_end(Err(other)), Err(Refusal::Other), "{other:?}"); - } -} - -#[test] -fn a_write_the_send_pipe_refuses_for_its_reader_is_a_reset() { - assert_eq!(write_refused(SyscallError::Gone), Refusal::Reset); - assert_eq!(write_refused(SyscallError::WouldBlock), Refusal::Again); - for other in OTHERS { - assert_eq!(write_refused(other), Refusal::Other, "{other:?}"); - } -} diff --git a/sdk/libc/src/dgramreq.rs b/sdk/libc/src/dgramreq.rs new file mode 100644 index 00000000000..ce65ceb8c3a --- /dev/null +++ b/sdk/libc/src/dgramreq.rs @@ -0,0 +1,23 @@ +//! What a datagram send asks netstack, in order. It reads nothing but what it +//! is handed, so the host tests it (`toyos-libc-copies`). + +/// A datagram socket's calls of netstack. +pub(crate) trait Datagrams { + type Error; + /// Whether netstack holds the socket. + fn held(&self) -> bool; + /// Bind it at `ip` and `port`, port 0 asking the stack to choose one. + fn bind(&mut self, ip: [u8; 4], port: u16) -> Result<(), Self::Error>; + /// Send `data` to `ip` and `port`: the bytes it took. + fn send(&mut self, data: &[u8], ip: [u8; 4], port: u16) -> Result; +} + +/// `data` to `ip` and `port`. POSIX `sendto`: a socket not yet bound is bound +/// at its first send, at any address, to a port the stack chooses; a +/// connected socket's `send` is this, to its peer. +pub(crate) fn send_to(socket: &mut D, data: &[u8], ip: [u8; 4], port: u16) -> Result { + if !socket.held() { + socket.bind([0; 4], 0)?; + } + socket.send(data, ip, port) +} diff --git a/sdk/libc/src/lib.rs b/sdk/libc/src/lib.rs index cadff47273e..e895d7b54c6 100644 --- a/sdk/libc/src/lib.rs +++ b/sdk/libc/src/lib.rs @@ -8,6 +8,7 @@ mod alarm; mod alarmreq; mod arch; mod ctype; +mod dgramreq; mod elfsym; mod errcode; mod errno; @@ -32,7 +33,6 @@ mod sigmask; mod socket; mod sockopt; mod stdio; -mod streamend; mod string; mod strtonum; mod text; diff --git a/sdk/libc/src/socket.rs b/sdk/libc/src/socket.rs index 2df42608e83..ff1c86131c1 100644 --- a/sdk/libc/src/socket.rs +++ b/sdk/libc/src/socket.rs @@ -11,8 +11,9 @@ use crate::errcode::{ EACCES, EADDRINUSE, EAFNOSUPPORT, EAGAIN, EBADF, ECONNREFUSED, ECONNRESET, EFAULT, EINVAL, EIO, ENOMEM, ENOPROTOOPT, ENOSPC, ENOTCONN, EOPNOTSUPP, EPIPE, ETIMEDOUT, }; -use crate::streamend::{self, Refusal}; +use toyos::net::{read_end, write_refused, StreamRefusal}; use crate::inaddr::{self, SockaddrIn, AF_INET}; +use crate::dgramreq; use crate::sockopt::{self, Kept}; // C types matching POSIX @@ -153,6 +154,23 @@ fn bind_datagram(entry: &mut SocketEntry, ip: [u8; 4], port: u16) -> Result<(), Ok(()) } +/// A datagram socket's calls of netstack, answered in `errno`s: its bytes go +/// down its pipe, then the request that names where they go. +impl dgramreq::Datagrams for SocketEntry { + type Error = i32; + fn held(&self) -> bool { + self.netstack_id != 0 + } + fn bind(&mut self, ip: [u8; 4], port: u16) -> Result<(), i32> { + bind_datagram(self, ip, port).map_err(net_err_to_errno) + } + fn send(&mut self, data: &[u8], ip: [u8; 4], port: u16) -> Result { + syscall::write(RawHandle(self.tx_fd as u32), data).map_err(|_| EIO)?; + let sent = toyos::net::udp_send_to(UdpSocketId(self.netstack_id), ip, port, data.len() as u16); + sent.map(|sent| sent as usize).map_err(net_err_to_errno) + } +} + // BSD socket API #[no_mangle] @@ -361,7 +379,7 @@ pub unsafe extern "C" fn send(fd: i32, buf: *const u8, len: usize, _flags: i32) let data = core::slice::from_raw_parts(buf, len); match syscall::write(RawHandle(entry.tx_fd as u32), data) { Ok(n) => n as isize, - Err(e) => { set_errno(stream_errno(streamend::write_refused(e))); -1 } + Err(e) => { set_errno(stream_errno(write_refused(e))); -1 } } } SocketKind::Udp => { @@ -396,8 +414,8 @@ pub unsafe extern "C" fn recv(fd: i32, buf: *mut u8, len: usize, _flags: i32) -> return 0; } let data = core::slice::from_raw_parts_mut(buf, len); - let read = syscall::read(RawHandle(entry.rx_fd as u32), data).map_err(|_| Refusal::Other).and_then(|n| match n { - 0 => streamend::read_end(syscall::write_nonblock(RawHandle(entry.tx_fd as u32), &[])).map(|()| 0), + let read = syscall::read(RawHandle(entry.rx_fd as u32), data).map_err(|_| StreamRefusal::Other).and_then(|n| match n { + 0 => read_end(syscall::write_nonblock(RawHandle(entry.tx_fd as u32), &[])).map(|()| 0), n => Ok(n), }); match read { @@ -442,24 +460,10 @@ pub unsafe extern "C" fn sendto( Some(v) => v, None => { set_errno(EINVAL); return -1; } }; - // POSIX: a socket not yet bound is bound at its first send, to a - // port the stack chooses. - if entry.netstack_id == 0 { - if let Err(e) = bind_datagram(entry, [0; 4], 0) { - set_errno(net_err_to_errno(e)); - return -1; - } - } - // Write data to tx pipe let data = core::slice::from_raw_parts(buf, len); - if let Err(_) = syscall::write(RawHandle(entry.tx_fd as u32), data) { - set_errno(EIO); - return -1; - } - // Send control message with metadata - match toyos::net::udp_send_to(UdpSocketId(entry.netstack_id), ip, port, len as u16) { + match dgramreq::send_to(entry, data, ip, port) { Ok(sent) => sent as isize, - Err(e) => { set_errno(net_err_to_errno(e)); -1 } + Err(errno) => { set_errno(errno); -1 } } } } @@ -543,11 +547,11 @@ const SHUT_RD: i32 = 0; const SHUT_WR: i32 = 1; const SHUT_RDWR: i32 = 2; -fn stream_errno(refusal: Refusal) -> i32 { +fn stream_errno(refusal: StreamRefusal) -> i32 { match refusal { - Refusal::Reset => ECONNRESET, - Refusal::Again => EAGAIN, - Refusal::Other => EIO, + StreamRefusal::Reset => ECONNRESET, + StreamRefusal::Again => EAGAIN, + StreamRefusal::Other => EIO, } } diff --git a/sdk/libc/src/streamend.rs b/sdk/libc/src/streamend.rs deleted file mode 100644 index 0831218f804..00000000000 --- a/sdk/libc/src/streamend.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! How a stream socket's end reaches its caller. A stream is two of netstack's pipes, and the -//! receive pipe says only that it ended: netstack lets the send pipe go first when the connection -//! failed, and keeps it after an orderly end for as long as its writer holds it, so what a -//! zero-byte write into it answers after the end is the end's kind. It reads nothing but what it -//! is handed, so the host tests it (`toyos-libc-copies`). - -use toyos_abi::syscall::SyscallError; - -/// What a call on a stream answers instead of bytes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Refusal { - /// `ECONNRESET`: the connection failed. - Reset, - /// `EAGAIN`. - Again, - /// `EIO`: the handle is not the pipe end the socket was made with. - Other, -} - -/// A read of 0 from the receive pipe, given what a zero-byte write into the send pipe then -/// answered: `Ok` is the peer's FIN, after its last byte. -pub(crate) fn read_end(probe: Result) -> Result<(), Refusal> { - match probe { - Ok(_) | Err(SyscallError::WouldBlock) => Ok(()), - Err(SyscallError::Gone) => Err(Refusal::Reset), - Err(_) => Err(Refusal::Other), - } -} - -/// A write the send pipe refused. One after a shutdown of the sending half is refused before it -/// reaches the pipe, which netstack reads no more but keeps. -pub(crate) fn write_refused(refused: SyscallError) -> Refusal { - match refused { - SyscallError::Gone => Refusal::Reset, - SyscallError::WouldBlock => Refusal::Again, - _ => Refusal::Other, - } -} diff --git a/sdk/std/copies/Cargo.toml b/sdk/std/copies/Cargo.toml new file mode 100644 index 00000000000..d01f5082746 --- /dev/null +++ b/sdk/std/copies/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "toyos-std-copies" +description = "A host test of std's ToyOS platform: its modules that name nothing but core and std's own sync, compiled as they are." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +# A package of its own because `sdk/std` is std's, built into the sysroot by +# a `#[path]` out of the fork: this compiles only the files that name nothing +# std alone has, the way `toyos-libc-copies` compiles libc's. + +[lib] +doctest = false diff --git a/sdk/std/copies/src/lib.rs b/sdk/std/copies/src/lib.rs new file mode 100644 index 00000000000..8dd1a6f10d5 --- /dev/null +++ b/sdk/std/copies/src/lib.rs @@ -0,0 +1,14 @@ +//! std's ToyOS modules that name nothing but `core` and std's own `sync` and +//! `ops`, on the host: the turnstile threads sharing one directory's +//! connection are served through in turn, and where a spawn goes. + +// What the modules below name as std's own, which a host's std is. +#[cfg(test)] +use std::{ops, sync}; + +#[cfg(test)] +#[path = "../../sys/launch_route.rs"] +mod launch_route; +#[cfg(test)] +#[path = "../../sys/turns.rs"] +mod turns; diff --git a/sdk/std/sys/fs.rs b/sdk/std/sys/fs.rs index b4967e35a1d..ea084e166cf 100644 --- a/sdk/std/sys/fs.rs +++ b/sdk/std/sys/fs.rs @@ -21,7 +21,7 @@ use crate::fs::TryLockError; use crate::hash::Hash; use crate::io::{self, BorrowedCursor, IoSlice, IoSliceMut, SeekFrom}; use crate::path::{Path, PathBuf}; -use crate::sync::{Arc, Condvar, Mutex, MutexGuard}; +use crate::sync::{Arc, Mutex}; pub use crate::sys::fs::common::Dir; use crate::sys::time::SystemTime; use crate::sys::to_io_error; @@ -53,67 +53,23 @@ struct Position { offset: u64, } -/// One directory capability this process holds, connected. +#[path = "turns.rs"] +mod turns; + +/// One directory capability this process holds, connected: threads sharing it +/// are served in turn. struct Capability { prefix: String, - dir: Mutex, - /// Hands `dir` over in ticket order: std's mutex lets the thread that let - /// go barge ahead of the one it woke. - turns: Mutex, - served: Condvar, -} - -struct Turns { - next: u64, - serving: u64, -} - -/// A directory's connection, held in turn. -struct Held<'a> { - // Dropped before `_turn`, so the next ticket finds `dir` free. - dir: MutexGuard<'a, toyos::fs::Dir>, - _turn: Turn<'a>, + dir: turns::Turnstile, } -struct Turn<'a>(&'a Capability); - impl Capability { fn new(prefix: &str, dir: toyos::fs::Dir) -> Self { - Self { - prefix: String::from(prefix), - dir: Mutex::new(dir), - turns: Mutex::new(Turns { next: 0, serving: 0 }), - served: Condvar::new(), - } - } - - fn lock(&self) -> Held<'_> { - let mut turns = self.turns.lock().unwrap(); - let ticket = turns.next; - turns.next += 1; - drop(self.served.wait_while(turns, |t| t.serving != ticket).unwrap()); - let turn = Turn(self); - Held { dir: self.dir.lock().unwrap(), _turn: turn } + Self { prefix: String::from(prefix), dir: turns::Turnstile::new(dir) } } -} - -impl Drop for Turn<'_> { - fn drop(&mut self) { - self.0.turns.lock().unwrap().serving += 1; - self.0.served.notify_all(); - } -} - -impl crate::ops::Deref for Held<'_> { - type Target = toyos::fs::Dir; - fn deref(&self) -> &toyos::fs::Dir { - &self.dir - } -} -impl crate::ops::DerefMut for Held<'_> { - fn deref_mut(&mut self) -> &mut toyos::fs::Dir { - &mut self.dir + fn lock(&self) -> turns::Turn<'_, toyos::fs::Dir> { + self.dir.lock() } } diff --git a/sdk/std/sys/launch_route.rs b/sdk/std/sys/launch_route.rs new file mode 100644 index 00000000000..8256a091bdf --- /dev/null +++ b/sdk/std/sys/launch_route.rs @@ -0,0 +1,171 @@ +//! Where std's spawn takes a child, decided before anything is sent: refused, +//! spawned directly, or asked of the launcher; and what a launch the launcher +//! did not make leaves a caller. +//! +//! **The supervisor starts a child only by a launch**, and a launch runs its +//! row's own program with no authority but its row's and the connectors +//! provided beside it. So a command asked of the supervisor that carries an +//! endowment or an extra slot, or names its image, is refused, as is one that +//! names its image and provides a connector; one that decided what its child +//! holds or runs is spawned directly; and one whose launch was not made is +//! spawned directly only where it asked nothing a launch alone gives. +//! +//! It names nothing outside `core`, so a host compiles it as it is +//! (`sdk/std/copies`). + +/// What a command asked for before its spawn. +#[derive(Clone, Copy, Default)] +pub struct Asked { + /// `under_supervisor`. + pub supervisor: bool, + /// An `endow`. + pub endowed: bool, + /// An `inherit_handle` slot beyond stdio. + pub extra_slots: bool, + /// An `image_from`. + pub image_named: bool, + /// A `provide`. + pub provided: bool, +} + +/// Where a spawn goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Route { + /// Spawned by this process. + Direct, + /// Asked of the launcher, where this process holds one. + Launch, + /// Refused `PermissionDenied`, for this reason. + Refused(&'static str), +} + +pub fn route(asked: Asked) -> Route { + if asked.supervisor && (asked.endowed || asked.extra_slots) { + return Route::Refused("the supervisor starts only a launch, which carries no endowment and no extra slot"); + } + if asked.image_named && (asked.supervisor || asked.provided) { + return Route::Refused("a launch runs its row's own program, so it takes no image the caller names"); + } + match asked.endowed || asked.extra_slots || asked.image_named { + true => Route::Direct, + false => Route::Launch, + } +} + +/// Why a launch was not made. +pub enum Unmade { + /// This process holds no launcher; `provided` is a command that provided a + /// connector, which only a launch carries. + NoLauncher { provided: bool }, + /// No row declares the program. + NotDeclared, + /// The launch never left this process. + NotSent, + /// The copy of its place a launch carries could not be made, for this + /// reason. + PlaceNotCopied(E), +} + +/// What a command whose launch was not made gets. +#[derive(Debug, PartialEq, Eq)] +pub enum Unlaunched { + /// The direct spawn. + Direct, + /// Refused `PermissionDenied`, for this reason. + Refused(&'static str), + /// Refused as the copy of its place was. + Failed(E), +} + +/// A launch not made is spawned directly only where the command asked +/// nothing a launch alone gives: not the supervisor, not a connector beside +/// its row, and not a place the launch was to carry, under which a direct +/// child would hold this process's namespace and not its row's. +pub fn unmade(why: Unmade, supervisor: bool) -> Unlaunched { + match why { + Unmade::PlaceNotCopied(e) => Unlaunched::Failed(e), + Unmade::NoLauncher { provided: true } => { + Unlaunched::Refused("only a launch carries a provided connector, and this process holds no launcher") + } + Unmade::NoLauncher { provided: false } | Unmade::NotDeclared | Unmade::NotSent => match supervisor { + true => Unlaunched::Refused("the supervisor did not launch it"), + false => Unlaunched::Direct, + }, + } +} + +/// What a direct spawn runs, and the name it runs it under: the image the +/// command named, else its program; `argv[0]` is the program either way. +pub fn image_and_name<'a, T: ?Sized>(program: &'a T, image: Option<&'a T>) -> (&'a T, &'a T) { + (image.unwrap_or(program), program) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SUPERVISOR: Asked = Asked { supervisor: true, endowed: false, extra_slots: false, image_named: false, provided: false }; + + /// What the supervisor would not start is refused here, and nothing is + /// spawned in its place. + #[test] + fn the_supervisor_is_asked_only_by_a_launch_of_its_row_s_own_program() { + for asked in [ + Asked { endowed: true, ..SUPERVISOR }, + Asked { extra_slots: true, ..SUPERVISOR }, + Asked { image_named: true, ..SUPERVISOR }, + Asked { image_named: true, provided: true, ..Asked::default() }, + ] { + assert!(matches!(route(asked), Route::Refused(_))); + } + assert_eq!(route(SUPERVISOR), Route::Launch); + for why in [Unmade::NoLauncher { provided: false }, Unmade::NotDeclared, Unmade::NotSent] { + assert_eq!(unmade::<()>(why, true), Unlaunched::Refused("the supervisor did not launch it")); + } + } + + /// A command that decided what its child holds or runs is spawned + /// directly, launcher or not; one that decided nothing is asked of the + /// launcher, and spawned directly where it is not launched. + #[test] + fn a_command_that_decided_its_child_is_spawned_directly() { + for asked in [ + Asked { endowed: true, ..Asked::default() }, + Asked { extra_slots: true, ..Asked::default() }, + Asked { image_named: true, ..Asked::default() }, + ] { + assert_eq!(route(asked), Route::Direct); + } + assert_eq!(route(Asked::default()), Route::Launch); + for why in [Unmade::NoLauncher { provided: false }, Unmade::NotDeclared, Unmade::NotSent] { + assert_eq!(unmade::<()>(why, false), Unlaunched::Direct); + } + } + + /// A launch carries a copy of its place: one this process cannot copy + /// refuses the launch as the copy was refused, and nothing is spawned + /// directly in its place, whoever the command asked for. + #[test] + fn a_launch_whose_place_cannot_be_copied_is_refused_and_not_spawned_directly() { + for supervisor in [false, true] { + assert_eq!(unmade(Unmade::PlaceNotCopied("no DUP"), supervisor), Unlaunched::Failed("no DUP")); + } + } + + /// Only a launch carries a provided connector: with no launcher to carry + /// it, the command is refused rather than spawned without it. + #[test] + fn a_provided_connector_with_no_launcher_is_refused() { + for supervisor in [false, true] { + assert!(matches!(unmade::<()>(Unmade::NoLauncher { provided: true }, supervisor), Unlaunched::Refused(_))); + } + } + + /// A command that names its image runs that image under its program's + /// name: `argv[0]` is the program, never the image's path. + #[test] + fn a_named_image_runs_under_its_program_s_name() { + assert_eq!(image_and_name("/bin/cat", Some("/tmp/other")), ("/tmp/other", "/bin/cat")); + assert_eq!(image_and_name("/bin/cat", None), ("/bin/cat", "/bin/cat")); + } +} diff --git a/sdk/std/sys/net/connection.rs b/sdk/std/sys/net/connection.rs index c0ab4d82cfe..e0e07ab1d9d 100644 --- a/sdk/std/sys/net/connection.rs +++ b/sdk/std/sys/net/connection.rs @@ -1,5 +1,5 @@ use toyos::AsHandle; -use toyos::net::{NetError, TcpSocketId, UdpSocketId}; +use toyos::net::{NetError, StreamRefusal, TcpSocketId, UdpSocketId}; use toyos::poller::{Poller, READABLE, WRITABLE}; use toyos_abi::RawHandle; use toyos_abi::syscall::{self, SyscallError}; @@ -88,6 +88,15 @@ const TIMED_OUT: io::Error = io::const_error!(io::ErrorKind::TimedOut, "timed ou /// The connection was reset, or ended by netstack: its send pipe has no reader. const RESET: io::Error = io::const_error!(io::ErrorKind::ConnectionReset, "connection reset"); +/// What std answers for a stream's refusal. +fn stream_refused(refusal: StreamRefusal) -> io::Error { + match refusal { + StreamRefusal::Reset => RESET, + StreamRefusal::Again => io::ErrorKind::WouldBlock.into(), + StreamRefusal::Other => io::Error::new(io::ErrorKind::Other, "syscall error"), + } +} + const SHUT_DOWN: io::Error = io::const_error!(io::ErrorKind::BrokenPipe, "the stream was shut down for writing"); @@ -221,16 +230,10 @@ impl TcpStream { } } - /// Whether the receive pipe's end was the peer's FIN or not. netstack ends - /// the send pipe too, and first, when the connection did not end in - /// order — a reset, a timeout — so a send pipe with no - /// reader behind a receive pipe at its end is a reset. + /// Whether the receive pipe's end was the peer's FIN or not + /// (`toyos::net::read_end`). fn ended(&self) -> io::Result<()> { - match syscall::write_nonblock(self.raw_handle(), &[]) { - Ok(_) | Err(SyscallError::WouldBlock) => Ok(()), - Err(SyscallError::Gone) => Err(RESET), - Err(e) => Err(syscall_err(e)), - } + toyos::net::read_end(syscall::write_nonblock(self.raw_handle(), &[])).map_err(stream_refused) } pub fn read_buf(&self, mut cursor: BorrowedCursor<'_, u8>) -> io::Result<()> { @@ -264,10 +267,7 @@ impl TcpStream { .ok_or(TIMED_OUT)?, } }; - written.map_err(|e| match e { - SyscallError::Gone => RESET, - e => syscall_err(e), - }) + written.map_err(|e| stream_refused(toyos::net::write_refused(e))) } pub fn write_vectored(&self, bufs: &[IoSlice<'_>]) -> io::Result { diff --git a/sdk/std/sys/process.rs b/sdk/std/sys/process.rs index ce482abfd78..378ac8cfe80 100644 --- a/sdk/std/sys/process.rs +++ b/sdk/std/sys/process.rs @@ -9,6 +9,9 @@ use crate::sys::fs::File; use crate::sys::pipe::Pipe; use crate::{fmt, io}; +#[path = "launch_route.rs"] +mod launch_route; + //////////////////////////////////////////////////////////////////////////////// // Command //////////////////////////////////////////////////////////////////////////////// @@ -131,7 +134,7 @@ impl Command { pub fn prepare(&mut self) -> io::Result<()> { let program = self.resolve_program()?; let cwd = self.child_cwd()?; - let source = self.image_path.clone().unwrap_or_else(|| program.clone()); + let source = launch_route::image_and_name(program.as_os_str(), self.image_path.as_deref()).0.to_owned(); let kept = self.image.as_ref().is_some_and(|(read, _)| *read == source); if !kept { self.image = None; @@ -281,24 +284,15 @@ impl Command { default: Stdio, _needs_stdin: bool, ) -> io::Result<(Process, StdioPipes)> { - // The supervisor is reached only by a launch, which carries no endowment and no - // slot beyond stdio. - if matches!(self.parent, Parent::Supervisor) - && (!self.endowments.is_empty() || !self.extra_slots.is_empty()) - { - return Err(io::const_error!( - io::ErrorKind::PermissionDenied, - "the supervisor starts only a launch, which carries no endowment and no extra slot", - )); - } - // A launch runs the program its row names, never one the caller chose. - if self.image_path.is_some() - && (matches!(self.parent, Parent::Supervisor) || !self.provided.is_empty()) - { - return Err(io::const_error!( - io::ErrorKind::PermissionDenied, - "a launch runs its row's own program, so it takes no image the caller names", - )); + let route = launch_route::route(launch_route::Asked { + supervisor: matches!(self.parent, Parent::Supervisor), + endowed: !self.endowments.is_empty(), + extra_slots: !self.extra_slots.is_empty(), + image_named: self.image_path.is_some(), + provided: !self.provided.is_empty(), + }); + if let launch_route::Route::Refused(why) = route { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, why)); } let prepared = self.prepared.take(); let image = self.image.take().map(|(_, image)| image); @@ -306,8 +300,9 @@ impl Command { Some(Prepared { program, cwd }) => (program, cwd, image), None => (self.resolve_program()?, self.child_cwd()?, None), }; + let (source, name) = launch_route::image_and_name(resolved.as_os_str(), self.image_path.as_deref()); let mut argv_buf = Vec::new(); - argv_buf.extend_from_slice(resolved.as_encoded_bytes()); + argv_buf.extend_from_slice(name.as_encoded_bytes()); for arg in &self.args[1..] { argv_buf.push(0); argv_buf.extend_from_slice(arg.as_encoded_bytes()); @@ -337,20 +332,11 @@ impl Command { let capture = self.env.capture(); let env_buf = env_blob(&capture); - // **The routing rule.** - // A caller that endowed a handle, named an extra slot or named the image - // has decided what its child holds or runs, and the launcher would - // overwrite that decision with a manifest row — so those spawn directly. - // Everything else asks the launcher when it holds one, and falls back for - // a program the image does not declare. A caller with no `launcher` connector gets plain - // inheritance, which is what a program endowed nothing should get — - // of everything but `HOME` (`direct_env`). A child asked of the supervisor is a - // launch or nothing. - let decided = !self.endowments.is_empty() - || !self.extra_slots.is_empty() - || self.image_path.is_some(); + // A caller with no `launcher` connector gets plain inheritance, which is + // what a program endowed nothing should get — of everything but `HOME` + // (`direct_env`). let mut home_from_supervisor = None; - if !decided { + if route == launch_route::Route::Launch { match self.launch(&resolved, &argv_buf, &env_buf, &cwd, &slot_map)? { Routed::Started(process) => { drop(child_pipes); @@ -393,7 +379,6 @@ impl Command { // A program on a file server is read here, into a memory object of // this process's own that the kernel pages the child from: the kernel // opens only what it serves itself. - let source = self.image_path.as_deref().unwrap_or(&resolved); let image = match prepared_image { Some(image) => Some(image), None if crate::sys::fs::is_served(Path::new(source)) => { @@ -508,16 +493,11 @@ impl Command { let for_supervisor = matches!(self.parent, Parent::Supervisor); // Where a launch that is not made goes: the direct spawn, which places - // the child where this one would have, or nowhere for the supervisor. - let direct = |home: Option| { - if for_supervisor { - Err(io::const_error!( - io::ErrorKind::PermissionDenied, - "the supervisor did not launch it" - )) - } else { - Ok(Routed::Direct { home }) - } + // the child where this one would have, or a refusal. + let unmade = |why, home: Option| match launch_route::unmade(why, for_supervisor) { + launch_route::Unlaunched::Direct => Ok(Routed::Direct { home }), + launch_route::Unlaunched::Refused(why) => Err(io::Error::new(io::ErrorKind::PermissionDenied, why)), + launch_route::Unlaunched::Failed(e) => Err(crate::sys::to_io_error(e)), }; // A `provide` is a statement that the child's authority comes from its @@ -527,11 +507,7 @@ impl Command { let Some(conn) = toyos::endow::launcher().and_then(|held| held.open(toyos::launch::LAUNCHER).ok()) else { - return if self.provided.is_empty() { - direct(None) - } else { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - }; + return unmade(launch_route::Unmade::NoLauncher { provided: !self.provided.is_empty() }, None); }; // The whole path, not a key: `/bin/ls` is a symlink to `/bin/toybox` @@ -556,9 +532,10 @@ impl Command { }; let parent = match place { None => launch::Parent::Supervisor, - Some(place) => launch::Parent::Place( - toyos_abi::syscall::dup(place).map_err(crate::sys::to_io_error)?, - ), + Some(place) => match toyos_abi::syscall::dup(place) { + Ok(copy) => launch::Parent::Place(copy), + Err(e) => return unmade(launch_route::Unmade::PlaceNotCopied(e), None), + }, }; let release = |slots: &[(u32, toyos_abi::RawHandle)]| { for (_, h) in slots { @@ -610,14 +587,14 @@ impl Command { // is where that is written down. // It carries the `HOME` the supervisor decided for the program, which the // direct spawn hands on in place of this process's own. - Ok(Outcome::NotDeclared { home }) => direct(Some(home.into())), + Ok(Outcome::NotDeclared { home }) => unmade(launch_route::Unmade::NotDeclared, Some(home.into())), Ok(Outcome::Refused) | Err(LaunchError::Sent(_)) => { Err(io::Error::from(io::ErrorKind::Other)) } Ok(Outcome::Gone) => Err(crate::sys::to_io_error(toyos_abi::syscall::SyscallError::Gone)), Err(LaunchError::NotSent(_)) => { release(&slots); - direct(None) + unmade(launch_route::Unmade::NotSent, None) } } } diff --git a/sdk/std/sys/turns.rs b/sdk/std/sys/turns.rs new file mode 100644 index 00000000000..d6033d9be1e --- /dev/null +++ b/sdk/std/sys/turns.rs @@ -0,0 +1,103 @@ +//! A value held in turn: each `lock` takes a ticket, and the value goes to the +//! tickets in the order they were taken. std's mutex alone lets the thread +//! that let go take it straight back ahead of the one it woke, so threads +//! sharing one directory's connection would not be served in turn. +//! +//! It names nothing but `crate::sync`, so a host compiles it as it is +//! (`sdk/std/copies`). + +use crate::sync::{Condvar, Mutex, MutexGuard}; + +pub struct Turnstile { + value: Mutex, + turns: Mutex, + served: Condvar, +} + +struct Turns { + next: u64, + serving: u64, +} + +/// The value, held for one turn. +pub struct Turn<'a, T> { + // Dropped before `_ticket`, so the next ticket finds the value free. + value: MutexGuard<'a, T>, + _ticket: Ticket<'a, T>, +} + +struct Ticket<'a, T>(&'a Turnstile); + +impl Turnstile { + pub const fn new(value: T) -> Self { + Self { value: Mutex::new(value), turns: Mutex::new(Turns { next: 0, serving: 0 }), served: Condvar::new() } + } + + /// The value, once every ticket taken before this one has had its turn. + pub fn lock(&self) -> Turn<'_, T> { + let mut turns = self.turns.lock().unwrap(); + let ticket = turns.next; + turns.next += 1; + drop(self.served.wait_while(turns, |t| t.serving != ticket).unwrap()); + let ticket = Ticket(self); + Turn { value: self.value.lock().unwrap(), _ticket: ticket } + } +} + +impl Drop for Ticket<'_, T> { + fn drop(&mut self) { + self.0.turns.lock().unwrap().serving += 1; + self.0.served.notify_all(); + } +} + +impl crate::ops::Deref for Turn<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.value + } +} + +impl crate::ops::DerefMut for Turn<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.value + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::{Duration, Instant}; + + use super::*; + + /// A thread that lets the value go and asks for it again waits behind + /// the threads that asked first, in the order they asked. + #[test] + fn the_value_goes_to_the_tickets_in_the_order_they_were_taken() { + let stile = Arc::new(Turnstile::new(Vec::new())); + let mut held = stile.lock(); + held.push("first"); + let queued: Vec<_> = ["second", "third"] + .into_iter() + .enumerate() + .map(|(i, name)| { + let theirs = Arc::clone(&stile); + let thread = std::thread::spawn(move || theirs.lock().push(name)); + // The next waits for this one's ticket, so the two are taken in order. + let asked = Instant::now(); + while stile.turns.lock().unwrap().next < i as u64 + 2 { + assert!(asked.elapsed() < Duration::from_secs(10), "{name} never took a ticket"); + std::thread::yield_now(); + } + thread + }) + .collect(); + drop(held); + stile.lock().push("first again"); + for thread in queued { + thread.join().unwrap(); + } + assert_eq!(*stile.lock(), ["first", "second", "third", "first again"]); + } +} diff --git a/sdk/toyos/src/log/proof.rs b/sdk/toyos/src/log/proof.rs index 1b5eef89b84..eeaef169893 100644 --- a/sdk/toyos/src/log/proof.rs +++ b/sdk/toyos/src/log/proof.rs @@ -322,6 +322,34 @@ fn a_real_time_write_allocates_nothing() { assert_eq!(allocated, 0, "a write allocated"); } +/// **soundserver's mix thread never waits on its log.** Its lines are composed +/// on its own stack and pushed into the lane it claimed (`say_lane`): with +/// nobody reading, the lane takes its slots and every line after is refused at +/// once, a count its reader says, and the writer comes back each time. +#[test] +fn a_full_lane_counts_the_mix_thread_s_line_and_returns() { + let (words, ring) = region(); + let (words, ring, answers) = never_waits(move || { + let lane = ring.claim_lane(7, 3).expect("a fresh ring has free lanes"); + let mut answers = Vec::new(); + for window in 0..LANE_SLOTS + 4 { + compose(Severity::Info, format_args!("soundserver: wakes={window} underruns=0"), &mut |body: &mut Body| { + answers.push(lane.push(body)); + }); + } + (words, ring, answers) + }); + let kept = LANE_SLOTS as usize; + assert!(answers[..kept].iter().all(|a| *a == Pushed::Written), "{answers:?}"); + assert!(answers[kept..].iter().all(|a| *a == Pushed::Refused), "{answers:?}"); + let (lane, mut reader) = (ring.lane(0), Reader::new()); + let read: Vec = std::iter::from_fn(|| reader.next_lane(&lane)).collect(); + assert_eq!(read.len(), kept); + assert_eq!(read[0].text(), b"soundserver: wakes=0 underruns=0"); + assert_eq!(reader.refused(&lane), 4, "a line the full lane refused went uncounted"); + drop(words); +} + /// The real layout, end to end: what one view pushes the reader reads back /// whole, from the shared ring and from a lane. #[test] diff --git a/sdk/toyos/src/net.rs b/sdk/toyos/src/net.rs index e51359f5cc2..0912cd6be90 100644 --- a/sdk/toyos/src/net.rs +++ b/sdk/toyos/src/net.rs @@ -391,6 +391,46 @@ fn hangup(e: IpcError) -> NetError { } } +/// What a call on a stream answers instead of bytes, for libc and std alike. +/// +/// A stream is two of netstack's pipes, and the receive pipe says only that it +/// ended: netstack lets the send pipe go first when the connection failed, and +/// keeps it after an orderly end for as long as its writer holds it, so what a +/// zero-byte write into it answers after the end is the end's kind — the +/// peer's FIN (RFC 9293 §3.6) or a reset (§3.5.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamRefusal { + /// The connection failed: `ECONNRESET`, `ConnectionReset`. + Reset, + /// The pipe has no room or nothing to read yet. + Again, + /// The handle is not the pipe end the stream was made with. + Other, +} + +/// A read of 0 from a stream's receive pipe, given what a zero-byte write into +/// its send pipe then answered: `Ok` is the peer's FIN, after its last byte. +pub fn read_end(probe: Result) -> Result<(), StreamRefusal> { + use toyos_abi::syscall::SyscallError; + match probe { + Ok(_) | Err(SyscallError::WouldBlock) => Ok(()), + Err(SyscallError::Gone) => Err(StreamRefusal::Reset), + Err(_) => Err(StreamRefusal::Other), + } +} + +/// A write a stream's send pipe refused. One after a shutdown of the sending +/// half is refused before it reaches the pipe, which netstack reads no more +/// but keeps. +pub fn write_refused(refused: toyos_abi::syscall::SyscallError) -> StreamRefusal { + use toyos_abi::syscall::SyscallError; + match refused { + SyscallError::Gone => StreamRefusal::Reset, + SyscallError::WouldBlock => StreamRefusal::Again, + _ => StreamRefusal::Other, + } +} + pub struct PendingResponse(NetstackConn); impl PendingResponse { @@ -625,6 +665,29 @@ mod tests { use super::*; use toyos_abi::syscall::SyscallError; + /// Every refusal but the two a pipe answers on purpose. + const OTHERS: [SyscallError; 4] = + [SyscallError::InvalidArgument, SyscallError::PermissionDenied, SyscallError::BadAddress, SyscallError::Io]; + + #[test] + fn a_read_of_zero_is_the_peer_s_fin_only_while_the_send_pipe_is_read() { + assert_eq!(read_end(Ok(0)), Ok(())); + assert_eq!(read_end(Err(SyscallError::WouldBlock)), Ok(()), "a full send pipe still has its reader"); + assert_eq!(read_end(Err(SyscallError::Gone)), Err(StreamRefusal::Reset)); + for other in OTHERS { + assert_eq!(read_end(Err(other)), Err(StreamRefusal::Other), "{other:?}"); + } + } + + #[test] + fn a_write_the_send_pipe_refuses_for_its_reader_is_a_reset() { + assert_eq!(write_refused(SyscallError::Gone), StreamRefusal::Reset); + assert_eq!(write_refused(SyscallError::WouldBlock), StreamRefusal::Again); + for other in OTHERS { + assert_eq!(write_refused(other), StreamRefusal::Other, "{other:?}"); + } + } + /// `SYS_HANDLE_SEND` into a connection whose server end has gone. The first /// refusal a `tcp_bind` can meet, because a request that hands netstack pipe /// ends moves the handles before it writes the frame. diff --git a/system/diskserver/Cargo.toml b/system/diskserver/Cargo.toml index c5b586b0eb2..38c0f59575b 100644 --- a/system/diskserver/Cargo.toml +++ b/system/diskserver/Cargo.toml @@ -21,6 +21,8 @@ toyos = { path = "../../sdk/toyos" } toyos-blockring = { path = "../../lib/blockring" } toyos-blockhold = { path = "../../lib/blockhold" } toyos-gpt = { path = "../../lib/gpt" } +# The trait every partition is read and written through. +toyos-disk = { path = "../../lib/disk" } [package.metadata.toyos.host] exempt.owns = "the NVMe controller ToyOS claims for it" diff --git a/system/diskserver/src/disk.rs b/system/diskserver/src/disk.rs index e3979fe8760..8afef8ec6a3 100644 --- a/system/diskserver/src/disk.rs +++ b/system/diskserver/src/disk.rs @@ -2,10 +2,6 @@ //! block service serves ([`Served`]), or one the kernel's own disk serves //! through a claim ([`Claimed`]), until usbd serves the stick. //! -//! **A block is 4 KiB here, on every source.** A partition that is not whole -//! 4 KiB blocks is refused before it is served (diskserver's table read, the -//! kernel's claim), so nothing below has a second block size. -//! //! **A block service that ends is survived, not hidden**: a [`Served`] disk //! whose session ended reconnects through the same name and issues again every //! write no flush had covered (`toyos_blockring::client`), then asks what it @@ -18,43 +14,7 @@ use toyos::PartitionDev; use toyos_abi::part::{Block, MAX_BLOCKS_PER_CALL}; use toyos_blockring::MAX_REQUEST_BLOCKS; -pub const BLOCK: usize = 4096; - -/// Why a disk did not do what it was asked. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DiskError { - /// The device's own word: the transfer was attempted and did not happen, - /// or a flush left writes the device would not keep. - Device, - /// The block service is gone and would not come back. - Gone, - /// Past the end of the partition. - Range, - /// A write the block service's grant to this holder does not make. - ReadOnly, -} - -/// A partition, a block at a time or several. -pub trait Disk { - fn blocks(&self) -> u64; - /// `out.len()` is whole blocks. - fn read(&mut self, first: u64, out: &mut [u8]) -> Result<(), DiskError>; - /// `data.len()` is whole blocks; durable once [`Disk::flush`] answers `Ok`. - fn write(&mut self, first: u64, data: &[u8]) -> Result<(), DiskError>; - /// Every write answered before this, out of the device's cache. - fn flush(&mut self) -> Result<(), DiskError>; -} - -/// How many blocks `len` bytes from `first` are, or `Range` past the end of -/// a partition `blocks` long. -pub fn span(first: u64, len: usize, blocks: u64) -> Result { - assert!(len % BLOCK == 0, "a transfer of {len} bytes is no whole blocks"); - let count = (len / BLOCK) as u64; - match first.checked_add(count) { - Some(end) if end <= blocks => Ok(count), - _ => Err(DiskError::Range), - } -} +pub use toyos_disk::{span, Disk, DiskError, BLOCK}; /// A partition the kernel's own disk serves: the stick, until usbd serves it. pub struct Claimed { diff --git a/system/fileserver/src/bounds.rs b/system/fileserver/src/bounds.rs new file mode 100644 index 00000000000..c8293d33cdf --- /dev/null +++ b/system/fileserver/src/bounds.rs @@ -0,0 +1,209 @@ +//! What a client may take of the server, and what a request's words may name, +//! decided before the server does anything for it. +//! +//! **One share cannot take the server.** Beneath each machine-wide bound — +//! connections waiting on their hello, connections served, streams — each +//! share a grant names has a quarter of its own, so one holding all it may +//! leaves the rest of each bound to three more. Past a bound a hello or a +//! stream is answered `ResourceExhausted`, and a connection taken past its +//! share's part of the handshakes is answered so and let go. +//! +//! **Every client number is bounded where it is kept**: an offset a request +//! names, and an offset a stream grows by what it drains, stay at or below the +//! largest a file has ([`MAX_FILE_BYTES`]). +//! +//! **A path is the window's bytes the request names, UTF-8**, and one the +//! server resolves is canonical ([`toyos::fs::canonical`]): a `..` a client +//! puts on the wire past std is refused before anything is looked up. A +//! link's target is stored as it was written, never resolved here, so it is +//! held to UTF-8 alone. + +use toyos::fs::{canonical, MAX_FILE_BYTES, MAX_PATH, WINDOW_BYTES}; +use toyos::shm::Lent; +use toyos_abi::syscall::SyscallError; + +/// Clients served at once, machine-wide: one connection per directory per +/// process. +pub const MAX_SERVED: usize = 128; + +/// Connections taken and not yet answered at their hello. While this many +/// wait, the next waits in the port's queue. +pub const MAX_HANDSHAKES: usize = 32; + +/// Streams served at once, machine-wide. +pub const MAX_STREAMS: usize = 64; + +/// One share's parts of [`MAX_SERVED`], [`MAX_HANDSHAKES`] and [`MAX_STREAMS`]. +pub const SERVED_SHARE: usize = MAX_SERVED / 4; +pub const HANDSHAKE_SHARE: usize = MAX_HANDSHAKES / 4; +pub const STREAM_SHARE: usize = MAX_STREAMS / 4; + +/// How a hello is answered. +#[derive(Debug, PartialEq, Eq)] +pub enum Hello { + /// Served, through the window it lent. + Served(W), + /// Answered `ResourceExhausted` and let go, for this reason. + Refused(String), + /// Let go unanswered, for this reason. + Dropped(&'static str), +} + +/// A connection just taken, while `waiting` of its share's are not yet +/// answered at their hello: `Err` is why it is answered `ResourceExhausted` +/// and let go. +pub fn handshake(share: u64, waiting: usize) -> Result<(), String> { + match waiting >= HANDSHAKE_SHARE { + true => Err(format!("share {share} has {HANDSHAKE_SHARE} waiting on their hello")), + false => Ok(()), + } +} + +/// A hello, while `served` clients are served and `of_share` of them are its +/// share's. `window` is the region it lent as the kernel describes it, `None` +/// for a hello that lent none, and is asked only once the bounds have passed. +pub fn hello(served: usize, of_share: usize, window: impl FnOnce() -> Option>) -> Hello { + if served >= MAX_SERVED { + return Hello::Refused(format!("it is refused, since {MAX_SERVED} clients are served already")); + } + if of_share >= SERVED_SHARE { + return Hello::Refused(format!("it is refused, since its share is served {SERVED_SHARE} clients already")); + } + match window() { + None => Hello::Dropped("its hello carried no window"), + Some(Ok(window)) => Hello::Served(window), + Some(Err(Lent::Kernel(_))) => Hello::Dropped("its window would not map"), + Some(Err(Lent::NotRam)) => Hello::Dropped("its window is not memory the kernel allocated"), + Some(Err(Lent::Length { .. })) => Hello::Dropped("its window is not WINDOW_BYTES long"), + } +} + +/// The end of `len` bytes from `at` in a file, at or below the largest offset +/// a file has, or `InvalidArgument`. +pub fn in_a_file(at: u64, len: u64) -> Result { + at.checked_add(len).filter(|&end| end <= MAX_FILE_BYTES).ok_or(SyscallError::InvalidArgument) +} + +/// A stream asked to start at `at`, while `streams` are served and `of_share` +/// of them are its share's. +pub fn stream(at: u64, streams: usize, of_share: usize) -> Result<(), SyscallError> { + in_a_file(at, 0)?; + match streams >= MAX_STREAMS || of_share >= STREAM_SHARE { + true => Err(SyscallError::ResourceExhausted), + false => Ok(()), + } +} + +/// Where in the window a path of `len` bytes at `at` is, or `InvalidArgument` +/// for one longer than a path or past the window. +pub fn path_span(at: u64, len: u64) -> Result<(usize, usize), SyscallError> { + let (at, len) = (usize::try_from(at), usize::try_from(len)); + match (at, len) { + (Ok(at), Ok(len)) if len <= MAX_PATH && at.checked_add(len).is_some_and(|end| end <= WINDOW_BYTES) => Ok((at, len)), + _ => Err(SyscallError::InvalidArgument), + } +} + +/// The path a request's bytes spell: UTF-8, or `InvalidArgument`. +pub fn path(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_| SyscallError::InvalidArgument) +} + +/// `rel`, a path the server resolves from a client's directory: canonical, or +/// `InvalidArgument`. +pub fn relative(rel: &str) -> Result<(), SyscallError> { + match canonical(rel) { + true => Ok(()), + false => Err(SyscallError::InvalidArgument), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A hello whose window nothing asks for: the bounds alone answer it. + fn unasked() -> Option> { + panic!("a hello past a bound was asked for its window") + } + + #[test] + fn a_share_at_its_part_of_the_handshakes_is_refused_and_one_below_is_taken() { + assert_eq!(handshake(7, HANDSHAKE_SHARE - 1), Ok(())); + assert_eq!(handshake(7, HANDSHAKE_SHARE), Err(format!("share 7 has {HANDSHAKE_SHARE} waiting on their hello"))); + } + + #[test] + fn a_hello_past_the_server_s_bound_or_its_share_s_part_is_refused_before_its_window_is_asked() { + assert!(matches!(hello(MAX_SERVED, 0, unasked), Hello::Refused(why) if why.contains("128 clients are served"))); + assert!(matches!(hello(SERVED_SHARE, SERVED_SHARE, unasked), Hello::Refused(why) if why.contains("its share is served 32"))); + assert_eq!(hello(MAX_SERVED - 1, SERVED_SHARE - 1, || Some(Ok(()))), Hello::Served(())); + } + + /// The kernel's answer for a lent region that is not one region of + /// `WINDOW_BYTES` lets the client go unanswered, whatever it says it lent: + /// a device's registers, another kind of object, another length, or none. + #[test] + fn a_hello_whose_window_is_no_region_of_window_bytes_is_let_go_unanswered() { + let refused: [(Option>, &str); 4] = [ + (Some(Err(Lent::NotRam)), "its window is not memory the kernel allocated"), + (Some(Err(Lent::Length { is: 2 * WINDOW_BYTES, bytes: WINDOW_BYTES })), "its window is not WINDOW_BYTES long"), + (Some(Err(Lent::Kernel(SyscallError::InvalidArgument))), "its window would not map"), + (None, "its hello carried no window"), + ]; + for (window, why) in refused { + assert_eq!(hello(0, 0, || window), Hello::Dropped(why)); + } + } + + /// A stream asked to start past the largest offset a file has is refused, + /// since its offset would grow by what it drains; and the same bound holds + /// a write's end and a truncation. + #[test] + fn an_offset_past_the_largest_a_file_has_is_refused() { + for at in [MAX_FILE_BYTES + 1, u64::MAX - 1] { + assert_eq!(stream(at, 0, 0), Err(SyscallError::InvalidArgument), "a stream at {at:#x}"); + } + assert_eq!(stream(MAX_FILE_BYTES, 0, 0), Ok(())); + assert_eq!(in_a_file(MAX_FILE_BYTES - 2, 4), Err(SyscallError::InvalidArgument), "a write straddling the ceiling"); + assert_eq!(in_a_file(u64::MAX, 1), Err(SyscallError::InvalidArgument), "a write whose end overflows"); + assert_eq!(in_a_file(MAX_FILE_BYTES - 4, 4), Ok(MAX_FILE_BYTES)); + } + + #[test] + fn a_stream_past_the_server_s_bound_or_its_share_s_part_is_refused() { + assert_eq!(stream(0, MAX_STREAMS, 0), Err(SyscallError::ResourceExhausted)); + assert_eq!(stream(0, STREAM_SHARE, STREAM_SHARE), Err(SyscallError::ResourceExhausted)); + assert_eq!(stream(0, MAX_STREAMS - 1, STREAM_SHARE - 1), Ok(())); + } + + /// A path put on the wire past std is held to what std would have sent + /// before anything is looked up: a `..` that climbs, a `.` and an + /// absolute path are refused. + #[test] + fn a_path_on_the_wire_that_climbs_or_is_absolute_is_refused() { + for bad in ["../state/secret", "a/../../state/secret", "/state", "./a", "a//b"] { + assert_eq!(relative(bad), Err(SyscallError::InvalidArgument), "{bad:?}"); + } + assert_eq!(relative("a/b.txt"), Ok(())); + assert_eq!(relative(""), Ok(()), "the directory itself"); + } + + /// A link's target is the client's text, stored as written: an absolute + /// target and one that climbs are taken, and only bytes that are no UTF-8 + /// are refused. + #[test] + fn a_link_s_target_is_taken_absolute_or_climbing() { + assert_eq!(path(b"/home/x".to_vec()), Ok("/home/x".to_string())); + assert_eq!(path(b"../x".to_vec()), Ok("../x".to_string())); + assert_eq!(path(b"\xff".to_vec()), Err(SyscallError::InvalidArgument)); + } + + #[test] + fn a_path_longer_than_a_path_or_past_the_window_is_refused() { + assert_eq!(path_span(0, MAX_PATH as u64), Ok((0, MAX_PATH))); + assert_eq!(path_span(0, MAX_PATH as u64 + 1), Err(SyscallError::InvalidArgument)); + assert_eq!(path_span((WINDOW_BYTES - 1) as u64, 2), Err(SyscallError::InvalidArgument)); + assert_eq!(path_span(u64::MAX, 1), Err(SyscallError::InvalidArgument)); + } +} diff --git a/system/fileserver/src/cache.rs b/system/fileserver/src/cache.rs index 021e2aef5e0..4bba175944a 100644 --- a/system/fileserver/src/cache.rs +++ b/system/fileserver/src/cache.rs @@ -353,6 +353,34 @@ mod tests { assert_eq!(out, vec![1; BLOCK]); } + /// Block `b`'s bytes: its index first, so a block read back from anywhere + /// else is seen, and the rest varying with the offset in it. + fn pattern(b: u64) -> [u8; BLOCK] { + let mut data = [0u8; BLOCK]; + data[..8].copy_from_slice(&b.to_le_bytes()); + for (i, byte) in data.iter_mut().enumerate().skip(8) { + *byte = (b as usize ^ i.wrapping_mul(7)) as u8; + } + data + } + + #[test] + fn every_evicted_clean_block_reads_back_off_the_disk_byte_for_byte() { + let blocks = (CLEAN_LIMIT + 2048) as u64; + let c = Cache::new(Counting { ram: Ram::new(blocks), reads: 0, writes: 0, flushes: 0 }); + for b in 0..blocks { + c.write(b, &pattern(b)).unwrap(); + } + c.flush().unwrap(); + assert_eq!(c.counts().dirty, 0, "a flushed cache holds no dirty block"); + let mut out = [0u8; BLOCK]; + for b in 0..blocks { + c.read_block(b, &mut out).unwrap(); + assert!(out == pattern(b), "block {b} read back other bytes than were written"); + } + assert!(c.inner.borrow().disk.reads > 0, "no block was evicted, so none came back off the disk"); + } + /// A disk that takes writes until it is told to refuse them. struct Refusing { ram: Ram, diff --git a/system/fileserver/src/data.rs b/system/fileserver/src/data.rs index bbe4affe619..f3383ec1a40 100644 --- a/system/fileserver/src/data.rs +++ b/system/fileserver/src/data.rs @@ -992,6 +992,28 @@ mod tests { assert_eq!(v.lstat("home/b/f").unwrap().size, 0, "replaced"); } + /// A clock that reads no whole second. + fn nanosecond_clock() -> u64 { + 1_717_245_296_123_456_789 + } + + /// DATA stamps a write with the nanosecond its clock read, and the entry + /// keeps it whole across a remount: a stamp of whole seconds would round + /// it away. + #[test] + fn a_write_s_stamp_keeps_its_nanosecond_across_a_remount() { + let mut v = DataVolume::format(Ram::new(4096), &["home"], nanosecond_clock).unwrap(); + let n = v.open("home/stamped", CREATE).unwrap(); + v.write(n, 0, b"stamped").unwrap(); + v.close(n).unwrap(); + v.sync().unwrap(); + let DataVolume { fs, cache, .. } = v; + drop(fs); + let disk = Rc::try_unwrap(cache).ok().expect("one owner").into_disk(); + let Probed::Mounted(mut again) = DataVolume::probe(disk, &["home"], nanosecond_clock) else { panic!("remount") }; + assert_eq!(again.lstat("home/stamped").unwrap().mtime, nanosecond_clock()); + } + fn remount(v: DataVolume) -> DataVolume { let DataVolume { fs, cache, .. } = v; drop(fs); diff --git a/system/fileserver/src/fat.rs b/system/fileserver/src/fat.rs index 2d8d8ec6293..7d797d69234 100644 --- a/system/fileserver/src/fat.rs +++ b/system/fileserver/src/fat.rs @@ -548,6 +548,11 @@ mod tests { /// The specification's fixture volume, writable, on a disk with room past /// it for [`evict`] to read, and the handle that moves its refused block. fn spec_fat() -> (FatVolume, Rc>, spec_volume::Volume) { + spec_fat_on(|| 1_717_245_296 * NANOS_PER_SEC) + } + + /// [`spec_fat`], stamping by `clock`. + fn spec_fat_on(clock: fn() -> u64) -> (FatVolume, Rc>, spec_volume::Volume) { let spec = spec_volume::fixture(); let blocks = spec.bytes.len().div_ceil(BLOCK); let mut image = spec.bytes.clone(); @@ -555,10 +560,32 @@ mod tests { let mut ram = Ram::new((blocks + 2 * CLEAN_LIMIT) as u64); ram.write(0, &image).unwrap(); let refused = Rc::new(Cell::new(u64::MAX)); - let v = FatVolume::mount(Refusing { ram, refused: Rc::clone(&refused) }, true, || 1_717_245_296 * NANOS_PER_SEC).unwrap(); + let v = FatVolume::mount(Refusing { ram, refused: Rc::clone(&refused) }, true, clock).unwrap(); (v, refused, spec) } + /// What [`the_flush_stamps_its_even_second_and_not_the_write_s`]'s volume + /// reads as the time, which only that test sets. + static NOW: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + /// FAT stamps a file's entry with the flush that levels it, read back off + /// the disk: the whole second of that flush, kept to the even one at or + /// before it (`FatTime`'s two-second resolution), and never the write's. + #[test] + fn the_flush_stamps_its_even_second_and_not_the_write_s() { + use std::sync::atomic::Ordering::Relaxed; + const CREATE: OpenHow = OpenHow { create: true, create_new: false, truncate: false }; + let (mut v, _, _) = spec_fat_on(|| NOW.load(Relaxed)); + NOW.store(1_717_245_296 * NANOS_PER_SEC, Relaxed); + let a = v.open("a.txt", CREATE).unwrap(); + v.write(a, 0, &[1; 3000]).unwrap(); + NOW.store(1_717_245_301 * NANOS_PER_SEC + 500_000_000, Relaxed); + assert_eq!(v.sync(), Ok(Vec::new())); + v.close(a).unwrap(); + evict(&v); + assert_eq!(v.lstat("a.txt").unwrap().mtime, 1_717_245_300 * NANOS_PER_SEC); + } + /// Every clean block out of the cache, so the next read of one asks the disk. fn evict(v: &FatVolume) { let mut block = [0u8; BLOCK]; diff --git a/system/fileserver/src/lib.rs b/system/fileserver/src/lib.rs index c8eb2f0d62b..659d3e34650 100644 --- a/system/fileserver/src/lib.rs +++ b/system/fileserver/src/lib.rs @@ -4,7 +4,8 @@ //! can serve ([`data`] for the bcachefs DATA role, [`fat`] for FAT32's LOG and //! BOOT, [`absent`] for a role with no volume this boot), and the resolver that //! keeps every path inside the directory a connection was given ([`resolve`]), -//! and which requests a read-only one is refused ([`rights`]). +//! which requests a read-only one is refused ([`rights`]), and what a client +//! may take of the server and name in a request ([`bounds`]). //! //! **This is the page cache.** A block of the volume — a btree node, a FAT //! sector, a file's data — is read into [`cache::Cache`] once and served from @@ -15,6 +16,7 @@ //! each connection, its clients and their windows, and the wire (`toyos::fs`). pub mod absent; +pub mod bounds; pub mod cache; pub mod data; pub mod fat; diff --git a/system/fileserver/src/main.rs b/system/fileserver/src/main.rs index e3dcaf14849..8e76470e4f6 100644 --- a/system/fileserver/src/main.rs +++ b/system/fileserver/src/main.rs @@ -19,12 +19,9 @@ //! it holds, on a read-only grant or a read-only volume, is refused before the //! volume sees it (`fileserver::rights`). //! -//! **One share cannot take the server.** Beneath each machine-wide bound — -//! connections waiting on their hello, connections served, streams — each -//! share a grant names has a part of its own, so one holding all it may leaves -//! the rest of each bound to the others. A share is one service the -//! supervisor started itself or one login session, with every child spawned -//! in it and every launch made from it that opens no session +//! **One share cannot take the server** (`fileserver::bounds`). A share is one +//! service the supervisor started itself or one login session, with every +//! child spawned in it and every launch made from it that opens no session //! (`toyos_manifest::launch`). Four at their parts take the server //! (`issues/four-shares-take-a-file-server.md`). //! @@ -43,6 +40,7 @@ use std::collections::BTreeMap; use std::time::{Duration, Instant}; use fileserver::absent::Absent; +use fileserver::bounds::{self, Hello, MAX_HANDSHAKES, MAX_SERVED, MAX_STREAMS}; use fileserver::data::{DataVolume, Located, Probed}; use diskserver::disk::{Claimed, Disk, Served}; use fileserver::ram::Ram; @@ -56,43 +54,22 @@ use toyos::fs::*; use toyos::ipc::{self, Connection, RxStep, TrySendError}; use toyos::poller::{Poller, READABLE}; use toyos::port::Acceptor; -use toyos::shm::{Lent, SharedMemory}; +use toyos::shm::SharedMemory; use toyos::volatile::Window; use toyos::Pipe; use toyos_abi::syscall::{SyscallError, DEV_PREFIX, MAX_BADGE, SERVE_PREFIX}; -/// Clients served at once, machine-wide: one connection per directory per -/// process. The next is answered `ResourceExhausted` at its hello and let go, -/// by name. -const MAX_SERVED: usize = 128; - -/// Connections taken and not yet answered at their hello. While this many -/// wait, the next waits in the port's queue — for at most -/// [`HANDSHAKE_TIMEOUT`], by which each of these is answered or let go. -const MAX_HANDSHAKES: usize = 32; - /// Files one client holds open at once. const MAX_FIDS: usize = 1024; -/// Streams served at once, machine-wide. -const MAX_STREAMS: usize = 64; - -/// One share's parts of [`MAX_SERVED`], [`MAX_HANDSHAKES`] and -/// [`MAX_STREAMS`]. Past its part of served clients a hello is answered -/// `ResourceExhausted`, and past its part of streams a `STREAM` is; past its -/// part of handshakes a connection is, as it is taken, and let go. A quarter -/// of each bound, so a share at its parts leaves the bound to three more. -const SERVED_SHARE: usize = MAX_SERVED / 4; -const HANDSHAKE_SHARE: usize = MAX_HANDSHAKES / 4; -const STREAM_SHARE: usize = MAX_STREAMS / 4; - /// What one turn of a stream appends at most. const STREAM_READ: usize = 64 * 1024; // One wait watches the acceptor, every connection and every stream at once. const _: () = assert!(1 + MAX_SERVED + MAX_HANDSHAKES + MAX_STREAMS <= Poller::MAX_HANDLES as usize); -/// How long an accepted connection may take to lend its window. +/// How long an accepted connection may take to lend its window: while +/// [`MAX_HANDSHAKES`] wait, the next waits in the port's queue for at most this. const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2); /// Why a client asking for a request number the wire does not have is let go, @@ -453,13 +430,10 @@ impl Server { Err(why) => return println!("fileserver: letting a connection go: it carries no grant ({why:?})"), }; let mine = self.clients.values().filter(|c| c.share == grant.share && c.window.is_none()).count(); - if mine >= HANDSHAKE_SHARE { + if let Err(why) = bounds::handshake(grant.share, mine) { // Let go whether or not the refusal went: either way it is said. let _ = conn.try_send(REPLY, &Reply::refused(SyscallError::ResourceExhausted)); - return println!( - "fileserver: letting a connection go: share {} has {HANDSHAKE_SHARE} waiting on their hello", - grant.share - ); + return println!("fileserver: letting a connection go: {why}"); } let id = self.next_client; self.next_client += 1; @@ -537,20 +511,15 @@ impl Server { /// A path from the window: `len` bytes at `at`, UTF-8. fn path(&self, id: u64, at: u64, len: u64) -> Result { let window = self.clients[&id].window.as_ref().ok_or(SyscallError::InvalidArgument)?; - let (at, len) = (at as usize, len as usize); - if len > MAX_PATH || at.checked_add(len).is_none_or(|end| end > WINDOW_BYTES) { - return Err(SyscallError::InvalidArgument); - } + let (at, len) = bounds::path_span(at, len)?; let mut bytes = vec![0u8; len]; window_take(window_of(window), at, &mut bytes); - String::from_utf8(bytes).map_err(|_| SyscallError::InvalidArgument) + bounds::path(bytes) } /// `rel`, from the client's directory, as a volume path. fn resolve(&mut self, id: u64, rel: &str, follow_last: bool) -> Result { - if !canonical(rel) { - return Err(SyscallError::InvalidArgument); - } + bounds::relative(rel)?; let root = self.clients[&id].root.clone(); let volume = &mut self.volume; let mut lookup = |p: &str| match volume.lstat(p) { @@ -581,22 +550,11 @@ impl Server { if client.window.is_some() { return Answer::Drop("it lent a second window"); } - if all >= MAX_SERVED { - let why = format!("it is refused, since {MAX_SERVED} clients are served already"); - return Answer::Refuse(SyscallError::ResourceExhausted, why); - } - if mine >= SERVED_SHARE { - let why = format!("it is refused, since its share is served {SERVED_SHARE} clients already"); - return Answer::Refuse(SyscallError::ResourceExhausted, why); - } - let Some([lent]) = client.conn.recv_handles_exact::<1>() else { - return Answer::Drop("its hello carried no window"); - }; - match SharedMemory::lent(lent, WINDOW_BYTES) { - Ok(window) => client.window = Some(window), - Err(Lent::Kernel(_)) => return Answer::Drop("its window would not map"), - Err(Lent::NotRam) => return Answer::Drop("its window is not memory the kernel allocated"), - Err(Lent::Length { .. }) => return Answer::Drop("its window is not WINDOW_BYTES long"), + let lent = || client.conn.recv_handles_exact::<1>().map(|[lent]| SharedMemory::lent(lent, WINDOW_BYTES)); + match bounds::hello(all, mine, lent) { + Hello::Served(window) => client.window = Some(window), + Hello::Refused(why) => return Answer::Refuse(SyscallError::ResourceExhausted, why), + Hello::Dropped(why) => return Answer::Drop(why), } let granted = if client.writes { RIGHT_WRITE } else { 0 }; // The volume is this start's pid, which the kernel never issues @@ -708,9 +666,7 @@ impl Server { let window = window_of(self.clients[&id].window.as_ref().expect("lent")); window_take(window, 0, &mut self.scratch[..len]); let at = if append { self.volume.node_meta(node)?.size } else { r.offset }; - if at.checked_add(len as u64).is_none_or(|end| end > MAX_FILE_BYTES) { - return Err(SyscallError::InvalidArgument); - } + bounds::in_a_file(at, len as u64)?; self.volume.write(node, at, &self.scratch[..len])?; self.dirtied(); Ok(Answer::Reply(Reply { value: len as u64, value2: at + len as u64, ..Reply::ok() })) @@ -726,9 +682,7 @@ impl Server { return Err(SyscallError::PermissionDenied); } let node = f.node; - if r.offset > MAX_FILE_BYTES { - return Err(SyscallError::InvalidArgument); - } + bounds::in_a_file(r.offset, 0)?; self.volume.truncate(node, r.offset)?; self.dirtied(); Ok(Answer::Reply(Reply::ok())) @@ -743,17 +697,10 @@ impl Server { if !f.write { return Err(SyscallError::PermissionDenied); } - // Every client number this server keeps is bounded where it is - // kept: a stream's offset grows by what it drains. - if r.offset > MAX_FILE_BYTES { - return Err(SyscallError::InvalidArgument); - } let node = f.node; let share = self.clients[&id].share; let mine = self.streams.values().filter(|s| s.share == share).count(); - if self.streams.len() >= MAX_STREAMS || mine >= STREAM_SHARE { - return Err(SyscallError::ResourceExhausted); - } + bounds::stream(r.offset, self.streams.len(), mine)?; let (read, write) = toyos::pipe_pair()?; self.volume.hold(node); let sid = self.next_stream; @@ -888,7 +835,7 @@ impl Server { Err(e) => Some(format!("its pipe would not read ({e:?})")), Ok(n) => { let (node, at) = (stream.node, stream.offset); - match at.checked_add(n as u64).filter(|&end| end <= MAX_FILE_BYTES) { + match bounds::in_a_file(at, n as u64).ok() { None => Some("it reached the largest offset a file has".to_string()), Some(end) => { stream.offset = end; diff --git a/system/logkeeper/src/main.rs b/system/logkeeper/src/main.rs index d1bfcbdbb2f..a2526933d85 100644 --- a/system/logkeeper/src/main.rs +++ b/system/logkeeper/src/main.rs @@ -182,7 +182,6 @@ fn main() { degraded: false, boot_secs, hub, - stall: Stall::from_args(), stopping: None, }; log.run(&published); @@ -209,7 +208,6 @@ struct Log { degraded: bool, boot_secs: Option, hub: Arc, - stall: Option, /// The supervisor's flush was answered and the machine stops: the file's text held /// back since, which a refused stop writes, to [`STOPPING_BYTES`]. stopping: Option, @@ -326,10 +324,7 @@ impl Log { // SAFETY: the kernel moved this handle into this table with // the frame that names it, and nothing else answers for it. let alive = unsafe { Pipe::from_raw(alive) }; - match &mut self.stall { - Some(stall) if stall.origin == name => stall.held.push((registration.pid, ring, alive)), - _ => self.take(registration.tag, registration.pid, ring, alive), - } + self.take(registration.tag, registration.pid, ring, alive); } // **The flush runs before the next frame is read**: the supervisor sends // RESUME when the stop it flushed for was refused, which can be @@ -435,7 +430,6 @@ impl Log { gone.push(i); } ended.clear(); - self.release_stall(&read); let tags: Vec<(Arc, u32)> = self.origins.iter().map(|o| (Arc::from(o.tag.as_str()), o.pid)).collect(); @@ -691,59 +685,12 @@ impl Log { self.to_volume(held.text.as_bytes(), true); self.hub.append(held.text.as_bytes()); } - - /// End a `--stall` once any program has said its `--stall-until` line. - fn release_stall(&mut self, read: &[Said]) { - let Some(stall) = self.stall.take_if(|s| read.iter().any(|r| r.text == s.until.as_bytes())) else { - return; - }; - let origin = stall.origin; - for (pid, ring, alive) in stall.held { - self.take(Tag::new(&origin).expect("a name the supervisor registered is a tag"), pid, ring, alive); - } - let (waiting, slots) = - self.origins.iter().find(|o| o.tag == origin).map_or((0, 0), |o| o.waiting()); - say!( - "logkeeper: reading {origin} again, as `--stall-until` asked, with {waiting} of its \ - ring's {slots} records waiting" - ); - } } fn ahead_note(ahead: u64, tag: &str) -> String { format!("logkeeper: {ahead} record(s) of {tag}'s were stamped ahead of the clock and are written as read") } -/// The origin this program leaves unread (`--stall=`), and the exact -/// line, from any program, that ends that (`--stall-until=`): a test's -/// actuator, armed by nothing but a boot config's `args`. -/// -/// It is how a boot stages a `logkeeper` that stops reading one program while the -/// rest of the log — the test's own lines among them — still flows: that -/// program's ring fills and its writes are refused, which is the state a -/// writer that must never wait on logging is tested in. -struct Stall { - origin: String, - until: String, - /// Its rings as the supervisor registered them, taken only once the stall ends, so - /// nothing a ring's writers are kept to rests on this program's reading. - held: Vec<(u32, toyos::RawHandle, Pipe)>, -} - -impl Stall { - fn from_args() -> Option { - let arg = |key: &str| std::env::args().find_map(|a| a.strip_prefix(key).map(str::to_string)); - match (arg("--stall="), arg("--stall-until=")) { - (Some(origin), Some(until)) => Some(Stall { origin, until, held: Vec::new() }), - (None, None) => None, - (origin, until) => panic!( - "logkeeper: `--stall` and `--stall-until` are armed together or not at all, and this \ - boot gave {origin:?} and {until:?}" - ), - } - } -} - /// This boot's file stem, and the epoch second its counter started at: where /// every line's time counts from. /// diff --git a/system/logkeeper/src/origin.rs b/system/logkeeper/src/origin.rs index 0ae1e96fd18..d6e7045af42 100644 --- a/system/logkeeper/src/origin.rs +++ b/system/logkeeper/src/origin.rs @@ -19,8 +19,8 @@ //! at most [`ROUND_RECORDS`] records a round from each program so none can hold //! the loop. -use toyos::log::region::{Body, Ring, LANES, RING_BYTES, SHARED_SLOTS}; -use toyos::log::ring::{Reader, Slots}; +use toyos::log::region::{Body, Ring, LANES, RING_BYTES}; +use toyos::log::ring::Reader; use toyos::log::Severity; use toyos::shm::SharedMemory; use toyos::Pipe; @@ -250,13 +250,6 @@ impl Origin { pub fn say_held(&mut self, out: &mut Vec) { self.joins.say_held(out); } - - /// Records its shared ring holds unread, as its writers' `head` says, of - /// the ring's slots: a report, bounded, never a position. - pub fn waiting(&self) -> (u64, u64) { - let head = self.ring.head().load(core::sync::atomic::Ordering::Relaxed); - (head.saturating_sub(self.shared.position()).min(SHARED_SLOTS), SHARED_SLOTS) - } } #[cfg(test)] diff --git a/system/netstack/node/tests/streams.rs b/system/netstack/node/tests/streams.rs index 89bbc81b462..61f7a8991eb 100644 --- a/system/netstack/node/tests/streams.rs +++ b/system/netstack/node/tests/streams.rs @@ -821,6 +821,49 @@ fn room_in_the_pipe_opens_the_window_at_the_next_opportunity() { assert_eq!((update.ack, update.window, update.text.len()), (Some(net.far.snd_nxt), 65_535, 0)); } +// RFC 9293 §3.8.6 and §3.6: an echo of more than both windows moves whole both ways at once, +// each end sending only what the other's window allows, and the peer's FIN and then the client's +// end close it with nothing reset. +#[test] +fn an_echo_past_both_windows_arrives_whole_both_ways_and_ends_in_both_fins() { + let (mut net, _, client) = established(); + client.borrow_mut().room = 4_096; + let sent = text(200_000); + let mut offset = 0; + for _ in 0..100_000 { + if net.far.received.len() == sent.len() { + break; + } + let len = net.far.open().min(1_460).min(sent.len() - offset); + if len > 0 { + let frame = net.far.text(&sent[offset..offset + len]); + offset += len; + net.deliver(&frame); + continue; + } + // The client echoes what its pipe holds; with nothing to echo the clock moves. + let taken = std::mem::take(&mut client.borrow_mut().inbox); + client.borrow_mut().outbox.extend(taken); + let before = (net.far.received.len(), client.borrow().outbox.len()); + net.bridge(); + if (net.far.received.len(), client.borrow().outbox.len()) == before { + let at = net.node.next_deadline().expect("the lease's renewal, if nothing else"); + net.fire(at); + } + } + assert_eq!(offset, sent.len(), "the node's window let all of it through"); + assert!(net.far.received == sent, "the echo is the peer's stream byte for byte: {} of {} bytes", net.far.received.len(), sent.len()); + + let frame = net.far.fin(); + net.deliver(&frame); + client.borrow_mut().writer_gone = true; + net.bridge(); + assert!(net.far.fin, "the client's end is a FIN"); + assert_eq!(net.far.acked, net.far.snd_nxt, "the peer's FIN is acknowledged"); + assert_eq!((dropped(&client), net.node.streams()), ((true, true), 0)); + assert!(net.far.resets.is_empty(), "{:?}", net.far.resets); +} + // ---- the stream's ends ---- // RFC 9293 §3.6, case 1: our FIN follows our last byte, and in FIN-WAIT-2 the peer's text is diff --git a/system/soundserver/src/mix.rs b/system/soundserver/src/mix.rs index 4f2b18362cb..3e146d1627d 100644 --- a/system/soundserver/src/mix.rs +++ b/system/soundserver/src/mix.rs @@ -137,6 +137,55 @@ fn retain_active(streams: &mut Vec) { }); } +/// How long one wait of the mix loop may last, and the grid point it is armed +/// on, `now` being the instant it is armed. **With no client it arms no timer +/// at all**, so an audience of zero costs no wake; before the DLL locks there is +/// no prediction, and the wait is one period. +fn armed(streaming: bool, dll: &Dll, period_nanos: u64, now: f64) -> (u64, Option) { + if !streaming { + return (u64::MAX, None); + } + match dll.t_estimated { + None => (period_nanos, None), + Some(t_est) => { + let target = if t_est > now { + t_est + } else { + // Past due: arm for the next future grid point, not a + // blind full period from now. + let k = ((now - t_est) / dll.period).floor() + 1.0; + t_est + k * dll.period + }; + // timeout 0 is the kernel's non-blocking sentinel + (((target - now) as u64).max(1), Some(t_est)) + } + } +} + +/// The free list a wake fills from. On a [`Pipeline::Ring`] it is the engine's +/// to state and never a tally kept across a wake: a stopped engine holds no +/// period, so the whole ring is the next client's prime; a running one with no +/// client to mix for is given every period back rather than holding one it +/// would replay. +fn fillable(pipeline: Pipeline, started: bool, streaming: bool, free_mask: u32, num_buffers: usize) -> u32 { + match pipeline { + Pipeline::Queue => free_mask, + Pipeline::Ring if !started => (1u32 << num_buffers) - 1, + Pipeline::Ring if !streaming => 0, + Pipeline::Ring => free_mask, + } +} + +/// Whether a free period is left unfilled for a client that is mid-refill, +/// which `mid_refill` is asked only where it could decide: on a +/// [`Pipeline::Queue`], and only while what is already on the wire +/// (`unplayed_nanos`) outlasts the deferral floor. A ring's engine reaches the +/// period again whatever soundserver does, so there a deferral buys the very +/// gap it exists to avoid. +fn defers(pipeline: Pipeline, floor: Option, mid_refill: impl FnOnce() -> bool, unplayed_nanos: u64) -> bool { + pipeline == Pipeline::Queue && floor.is_some_and(|floor| unplayed_nanos >= floor) && mid_refill() +} + pub(crate) fn mix_thread( backend: &mut dyn Backend, cmd_ring: &CommandRing, @@ -262,29 +311,8 @@ pub(crate) fn mix_thread( // Lateness is only defined relative to an instant soundserver asked to be // woken at, and two waits name none: the idle path arms no timer // at all, and before the DLL locks there is no prediction to arm on. - let mut armed_on: Option = None; - - let timeout = if streams.is_empty() { - u64::MAX - } else { - match dll.t_estimated { - None => period_nanos, - Some(t_est) => { - let now = toyos_abi::clock::nanos_since_boot() as f64; - let target = if t_est > now { - t_est - } else { - // Past due: arm for the next future grid point, not a - // blind full period from now. - let k = ((now - t_est) / dll.period).floor() + 1.0; - t_est + k * dll.period - }; - armed_on = Some(t_est); - // timeout 0 is the kernel's non-blocking sentinel - ((target - now) as u64).max(1) - } - } - }; + let (timeout, armed_on) = + armed(!streams.is_empty(), &dll, period_nanos, toyos_abi::clock::nanos_since_boot() as f64); poller.watch_raw(backend.handle(), READABLE, TOKEN_AUDIO); poller.watch_raw(cmd_pipe_read, READABLE, TOKEN_CMD); @@ -440,13 +468,7 @@ pub(crate) fn mix_thread( // soundserver's: that is the free list the next client's prime fills, and // it starts at the cursor because the engine froze inside the period // after the last one it completed and carries on there. - if pipeline == Pipeline::Ring { - if !started { - free_mask = (1u32 << num_buffers) - 1; - } else if streams.is_empty() { - free_mask = 0; - } - } + free_mask = fillable(pipeline, started, !streams.is_empty(), free_mask, num_buffers); // Where the fill starts: the beginning of the run soundserver may write, // which is as many periods back from where the engine now stands as // there are of them. A stopped engine's whole lap lands on the same @@ -499,13 +521,8 @@ pub(crate) fn mix_thread( // in time: the client's period lands one period later than the // engine wanted it either way. let now = toyos_abi::clock::nanos_since_boot(); - let mid_refill = pipeline == Pipeline::Queue - && refill_floor_nanos.is_some() - && streams.iter().any(|s| s.is_streaming() && s.slot_reader.peek().is_none()); - if mid_refill - && refill_floor_nanos - .is_some_and(|floor| playout_until_ns.saturating_sub(now) >= floor) - { + let mid_refill = || streams.iter().any(|s| s.is_streaming() && s.slot_reader.peek().is_none()); + if defers(pipeline, refill_floor_nanos, mid_refill, playout_until_ns.saturating_sub(now)) { deferred |= 1 << idx; stats.deferred += 1; continue; @@ -799,6 +816,40 @@ pub(crate) fn null_sink_thread( mod tests { use super::*; + /// **soundserver with no client costs no CPU and starts no stream.** Its + /// wait arms no timer, and a ring it was playing on is given every period + /// back, so nothing is filled: the stream stops once the pipeline plays + /// out, and only a client's connect, a command, wakes it. + #[test] + fn with_no_client_the_mix_loop_arms_no_timer_and_fills_nothing() { + let mut locked = Dll::new(1e6); + locked.update(5e6, 1); + for dll in [Dll::new(1e6), locked] { + assert_eq!(armed(false, &dll, 1_000_000, 4e6), (u64::MAX, None)); + } + assert_eq!(fillable(Pipeline::Ring, true, false, 0b1111, 4), 0, "a running ring with no client is handed every period back"); + assert_eq!(armed(true, &Dll::new(1e6), 1_000_000, 4e6), (1_000_000, None), "a client's first wait is a period"); + } + + /// **A client stalled past a lap of a ring that replays every period.** No + /// period is deferred for it, however much is on the wire, so each the + /// engine hands back is filled — the stall's silence counted, never a + /// replay — and after a suspend the resume primes the whole ring; a queue + /// defers for the same client while the wire outlasts its floor. + #[test] + fn a_ring_defers_no_period_for_a_stalled_client_and_resumes_from_the_whole_ring() { + let floor = Some(2_000_000); + let stalled = || true; + assert!(!defers(Pipeline::Ring, floor, stalled, u64::MAX)); + assert!(defers(Pipeline::Queue, floor, stalled, 2_000_000)); + assert!(!defers(Pipeline::Queue, floor, || false, u64::MAX), "a client that has a period ready is mixed"); + assert!(!defers(Pipeline::Queue, floor, stalled, 1_999_999), "past the floor a queue fills with silence"); + assert!(!defers(Pipeline::Queue, None, stalled, u64::MAX), "with no floor nothing is deferred"); + assert_eq!(fillable(Pipeline::Ring, false, true, 0, 4), 0b1111, "a stopped ring's resume primes every period"); + assert_eq!(fillable(Pipeline::Ring, true, true, 0b0110, 4), 0b0110, "a running ring fills what it handed back"); + assert_eq!(fillable(Pipeline::Queue, false, true, 0b0010, 4), 0b0010); + } + fn window(submitted: u32, underruns: u32, drains: u32, late_wakes: u32) -> MixStats { let mut stats = MixStats::default(); stats.submitted = submitted; diff --git a/system/toybox/copies/Cargo.toml b/system/toybox/copies/Cargo.toml new file mode 100644 index 00000000000..6795c7d596c --- /dev/null +++ b/system/toybox/copies/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "toybox-copies" +description = "A host test of toybox's cp, mv, hexdump and the roster ps reads: its files that name nothing of ToyOS's SDK, compiled as they are." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +# A package of its own because toybox builds on no host: this compiles only +# the files a host builds, the way `toyos-libc-copies` compiles libc's. + +[lib] +doctest = false + +[dev-dependencies] +# The roster's header and entry, as the kernel lays them out. +toyos-abi = { path = "../../../sdk/abi" } +# A directory of each test's own, gone with it. +toyos-tmpdir = { path = "../../../lib/tmpdir" } diff --git a/system/toybox/copies/src/copying.rs b/system/toybox/copies/src/copying.rs new file mode 100644 index 00000000000..380cb6b8017 --- /dev/null +++ b/system/toybox/copies/src/copying.rs @@ -0,0 +1,72 @@ +//! `cp`: a copy lands whole under its name or not at all, and leaves no +//! partial beside it. + +use std::fs; +use std::path::Path; + +use crate::cp::*; +use toyos_tmpdir::TempDir; + +/// Every name in `dir` that marks a copy in progress. +fn partials(dir: &Path) -> Vec { + fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".part")) + .collect() +} + +/// Larger than the flush interval twice over and no page multiple, so the +/// periodic flush and the partial tail both run; it lands whole under the +/// destination's name, and no partial is left beside it. +#[test] +fn a_copy_past_the_flush_interval_lands_whole_and_leaves_no_partial() { + let dir = TempDir::new("cp-whole"); + let data: Vec = (0..2 * FLUSH_BYTES as usize + 137).map(|i| (i.wrapping_mul(31) ^ (i >> 9)) as u8).collect(); + let (source, dest) = (dir.join("source"), dir.join("dest")); + fs::write(&source, &data).unwrap(); + copy(&source, &dest).unwrap(); + assert!(fs::read(&dest).unwrap() == data, "the copy is not the source byte for byte"); + assert_eq!(partials(&dir), Vec::::new()); +} + +/// A refused copy leaves the destination as it was and no partial: a +/// missing source, a source that is a directory, a destination whose +/// directory is not there, and one the finished copy cannot be renamed +/// onto are each refused by name. +#[test] +fn a_refused_copy_leaves_the_destination_as_it_was_and_no_partial() { + let dir = TempDir::new("cp-refused"); + let kept = dir.join("kept"); + fs::write(&kept, b"the destination, untouched").unwrap(); + + let missing = copy(&dir.join("absent"), &kept).unwrap_err(); + assert!(missing.contains("absent"), "{missing}"); + let from_a_dir = copy(&dir, &dir.join("from-a-dir")).unwrap_err(); + assert!(from_a_dir.contains("is a directory"), "{from_a_dir}"); + let nowhere = copy(&kept, &dir.join("no-such-dir").join("dest")).unwrap_err(); + assert!(nowhere.contains("no-such-dir"), "{nowhere}"); + let occupied = dir.join("occupied"); + fs::create_dir(&occupied).unwrap(); + fs::write(occupied.join("inside"), b"x").unwrap(); + let onto_a_dir = copy(&kept, &occupied).unwrap_err(); + assert!(onto_a_dir.contains("occupied"), "{onto_a_dir}"); + assert!(occupied.join("inside").exists(), "a refused copy replaced a directory"); + + assert_eq!(fs::read(&kept).unwrap(), b"the destination, untouched"); + assert!(!dir.join("from-a-dir").exists(), "a refused copy made its destination"); + assert_eq!(partials(&dir), Vec::::new()); +} + +/// A destination that is a directory takes the source's own name. +#[test] +fn a_copy_into_a_directory_keeps_the_source_s_name() { + let dir = TempDir::new("cp-into"); + let (source, into) = (dir.join("named"), dir.join("into")); + fs::write(&source, b"x").unwrap(); + fs::create_dir(&into).unwrap(); + let dest = destination(&source, &into); + assert_eq!(dest, into.join("named")); + copy(&source, &dest).unwrap(); + assert_eq!(fs::read(dest).unwrap(), b"x"); +} diff --git a/system/toybox/copies/src/dumping.rs b/system/toybox/copies/src/dumping.rs new file mode 100644 index 00000000000..cc3a93245bf --- /dev/null +++ b/system/toybox/copies/src/dumping.rs @@ -0,0 +1,70 @@ +//! `hexdump` against a recorded `xxd`, and its refusals by name. + +use std::path::Path; + +use crate::hexdump::*; +use toyos_tmpdir::TempDir; + +/// The 25 bytes the host's `xxd` was run against. +const FIXTURE: &[u8] = b"Hello, world! ABCDEFGH\x00\x01\xff"; + +/// `xxd fixture`, verbatim, from `xxd 2025-08-24 by Juergen Weigert et al.` +/// (macOS's `/usr/bin/xxd`). +const XXD_ALL: &str = "\ +00000000: 4865 6c6c 6f2c 2077 6f72 6c64 2120 4142 Hello, world! AB +00000010: 4344 4546 4748 0001 ff CDEFGH... +"; + +/// `xxd -s 4 -l 8 fixture`, verbatim, from the same `xxd`. +const XXD_WINDOW: &str = "\ +00000004: 6f2c 2077 6f72 6c64 o, world +"; + +fn args(words: &[&str]) -> Vec { + words.iter().map(|w| w.to_string()).collect() +} + +/// What `hexdump` says for `words`, against a file holding [`FIXTURE`], +/// whose path stands in for `F`. +fn hexdump(words: &[&str]) -> Result { + let dir = TempDir::new("hexdump"); + let file = dir.join("fixture"); + fs_write(&file); + let words: Vec = words.iter().map(|w| if *w == "F" { file.display().to_string() } else { w.to_string() }).collect(); + let (path, skip, length) = request(&words)?; + let mut out = Vec::new(); + dump(Path::new(&path.expect("a file named")), skip, length, &mut out)?; + Ok(String::from_utf8(out).unwrap()) +} + +fn fs_write(path: &Path) { + std::fs::write(path, FIXTURE).unwrap(); +} + +#[test] +fn a_dump_is_xxd_s_byte_for_byte_whole_and_in_a_window() { + assert_eq!(hexdump(&["F"]).unwrap(), XXD_ALL); + assert_eq!(hexdump(&["-s", "4", "-l", "8", "F"]).unwrap(), XXD_WINDOW); + assert_eq!(hexdump(&["-s", "0x4", "-l", "0x8", "F"]).unwrap(), XXD_WINDOW, "hex offsets"); + assert_eq!(hexdump(&["-l", "4096", "F"]).unwrap(), XXD_ALL, "a length past the end is the file's"); +} + +/// Every request that does not name one file and numbers is refused by +/// what is wrong with it, as is an offset past the end. +#[test] +fn a_bad_request_is_refused_by_name() { + let past = (FIXTURE.len() + 1).to_string(); + for (words, why) in [ + (&["-s", past.as_str(), "F"][..], "past the end"), + (&["-s", "twelve", "F"], "not a number"), + (&["-l"], "needs a number"), + (&["-q", "F"], "unknown option"), + (&["F", "F"], "one file at a time"), + ] { + let refusal = hexdump(words).unwrap_err(); + assert!(refusal.contains(why), "{words:?}: {refusal}"); + } + assert_eq!(request(&[]), Ok((None, 0, None)), "no file is main's usage line"); + assert_eq!(request(&args(&["/no/such/file"])), Ok((Some("/no/such/file".to_string()), 0, None))); + assert!(dump(Path::new("/no/such/file"), 0, None, &mut Vec::new()).is_err_and(|why| why.contains("/no/such/file"))); +} diff --git a/system/toybox/copies/src/lib.rs b/system/toybox/copies/src/lib.rs new file mode 100644 index 00000000000..ca59a614745 --- /dev/null +++ b/system/toybox/copies/src/lib.rs @@ -0,0 +1,25 @@ +//! toybox's applets that name nothing of ToyOS's SDK, on the host: `cp`'s +//! copy and its refusals, `mv`'s refusal of a rename with nothing copied +//! behind it, `hexdump` against a recorded `xxd`, and the roster `ps` reads. + +#[cfg(test)] +#[path = "../../src/cp.rs"] +pub mod cp; +#[cfg(test)] +#[path = "../../src/hexdump.rs"] +pub mod hexdump; +#[cfg(test)] +#[path = "../../src/mv.rs"] +pub mod mv; +#[cfg(test)] +#[path = "../../src/roster.rs"] +pub mod roster; + +#[cfg(test)] +mod copying; +#[cfg(test)] +mod dumping; +#[cfg(test)] +mod moving; +#[cfg(test)] +mod roster_reading; diff --git a/system/toybox/copies/src/moving.rs b/system/toybox/copies/src/moving.rs new file mode 100644 index 00000000000..2ea2fae6fb3 --- /dev/null +++ b/system/toybox/copies/src/moving.rs @@ -0,0 +1,43 @@ +//! `mv`: a rename refused is the move refused, with nothing copied behind it. + +use std::fs; + +use crate::mv::*; +use toyos_tmpdir::TempDir; + +/// A rename that is refused is the move refused, by name and with what to +/// do instead, and nothing is copied behind the caller's back: the source +/// stays, and nothing lands at the destination. +#[test] +fn a_move_the_rename_refuses_copies_nothing() { + let dir = TempDir::new("mv-refused"); + let source = dir.join("stays"); + fs::write(&source, b"this file stays").unwrap(); + let dest = dir.join("elsewhere").join("moved"); + let refusal = move_file(&source, &dest).unwrap_err(); + assert!(refusal.contains("different mounts") && refusal.contains("cp then rm"), "{refusal}"); + assert_eq!(fs::read(&source).unwrap(), b"this file stays"); + assert!(!dest.exists(), "a refused move put the file at its destination"); +} + +#[test] +fn a_missing_source_is_refused_before_anything_is_renamed() { + let dir = TempDir::new("mv-missing"); + let refusal = move_file(&dir.join("absent"), &dir.join("dest")).unwrap_err(); + assert!(refusal.starts_with("mv: ") && refusal.contains("absent"), "{refusal}"); + assert!(!refusal.contains("different mounts"), "a missing source was refused as a rename: {refusal}"); + assert!(!dir.join("dest").exists()); +} + +/// A move into a directory keeps the source's name, and the old name is +/// gone. +#[test] +fn a_move_into_a_directory_keeps_its_name_and_leaves_no_old_one() { + let dir = TempDir::new("mv-into"); + let (source, into) = (dir.join("named"), dir.join("into")); + fs::write(&source, b"moved, not copied").unwrap(); + fs::create_dir(&into).unwrap(); + move_file(&source, &into).unwrap(); + assert!(!source.exists(), "the old name is still there"); + assert_eq!(fs::read(into.join("named")).unwrap(), b"moved, not copied"); +} diff --git a/system/toybox/copies/src/roster_reading.rs b/system/toybox/copies/src/roster_reading.rs new file mode 100644 index 00000000000..38ada99eed6 --- /dev/null +++ b/system/toybox/copies/src/roster_reading.rs @@ -0,0 +1,29 @@ +//! The roster `ps` reads: a refused one is no row, a granted one its +//! header and each whole entry. + +use toyos_abi::syscall::{SYSINFO_ENTRY_SIZE as ENTRY, SYSINFO_HEADER_SIZE as HEADER}; + +use crate::roster::*; + +/// A refused roster is nothing at all: no header and no row. +#[test] +fn a_refused_roster_is_no_row() { + assert_eq!(rows(&[]), None); + assert_eq!(rows(&[0; HEADER - 1]), None, "short of a header is refused as well"); +} + +/// A roster the kernel granted: the header's two clocks, then each whole +/// entry; a trailing part of one is no row. +#[test] +fn a_granted_roster_is_its_header_and_each_whole_entry() { + let mut answer = vec![0u8; HEADER + ENTRY + ENTRY / 2]; + let header = toyos_abi::syscall::SysinfoHeader::decode(answer[..HEADER].try_into().unwrap()); + let entry = &mut answer[HEADER..HEADER + ENTRY]; + entry[..4].copy_from_slice(&7u32.to_le_bytes()); + entry[4..8].copy_from_slice(&1u32.to_le_bytes()); + entry[32..37].copy_from_slice(b"shell"); + let (uptime, cpu, rows) = rows(&answer).expect("a granted roster"); + assert_eq!((uptime, cpu), (header.uptime_ns, header.total_cpu_ns)); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].pid, rows[0].tid, rows[0].name.as_str()), (7, 1, "shell")); +} diff --git a/system/toybox/src/cp.rs b/system/toybox/src/cp.rs index 296faa70860..ee3d1b2f1aa 100644 --- a/system/toybox/src/cp.rs +++ b/system/toybox/src/cp.rs @@ -16,7 +16,7 @@ const BUF_BYTES: usize = 64 * 1024; /// unbounded one is not a bound. 256 pages, against a file cache whose smallest /// possible budget is the 2048-page floor in `block::file_cache_pages`. Policy, /// not physics; reaching it costs a flush, never a refusal. -const FLUSH_BYTES: u64 = 1024 * 1024; +pub const FLUSH_BYTES: u64 = 1024 * 1024; pub fn main(args: Vec) { let [source, dest] = args.as_slice() else { @@ -57,7 +57,7 @@ fn is_dir(path: &Path) -> bool { /// straight to the destination would instead leave a short file wearing the /// name of a complete one, which nothing downstream can tell from the real /// thing. -fn copy(source: &Path, dest: &Path) -> Result<(), String> { +pub fn copy(source: &Path, dest: &Path) -> Result<(), String> { let meta = fs::metadata(source).map_err(|e| format!("{}: {e}", source.display()))?; if meta.is_dir() { return Err(format!("{}: is a directory", source.display())); diff --git a/system/toybox/src/hexdump.rs b/system/toybox/src/hexdump.rs index b208465cb1d..12db2155405 100644 --- a/system/toybox/src/hexdump.rs +++ b/system/toybox/src/hexdump.rs @@ -13,9 +13,28 @@ const HEX_WIDTH: usize = LINE * 2 + LINE.div_ceil(2) - 1; /// One read buffer, whole lines, never derived from the file's size or from /// `-l`: a dump of a whole disk allocates what a dump of one byte does. const BUF_BYTES: usize = 4096; -const _: () = assert!(BUF_BYTES % LINE == 0, "the buffer must hold whole lines"); +const _: () = assert!(BUF_BYTES.is_multiple_of(LINE), "the buffer must hold whole lines"); pub fn main(args: Vec) { + let (path, skip, length) = request(&args).unwrap_or_else(|refusal| refuse(refusal)); + let Some(path) = path else { + eprintln!("Usage: hexdump [-s offset] [-l length] "); + process::exit(1); + }; + let stdout = io::stdout(); + if let Err(refusal) = dump(Path::new(&path), skip, length, &mut BufWriter::new(stdout.lock())) { + refuse(refusal); + } +} + +fn refuse(what: String) -> ! { + eprintln!("hexdump: {what}"); + process::exit(1); +} + +/// The file, the offset and the length `args` ask for — no file where they +/// name none — or what is wrong with them. +pub fn request(args: &[String]) -> Result<(Option, u64, Option), String> { let mut skip = 0u64; let mut length: Option = None; let mut path: Option<&str> = None; @@ -23,46 +42,31 @@ pub fn main(args: Vec) { let mut args = args.iter(); while let Some(arg) = args.next() { match arg.as_str() { - "-s" => skip = number(args.next(), "-s"), - "-l" => length = Some(number(args.next(), "-l")), - _ if arg.starts_with('-') => refuse(format!("{arg}: unknown option")), + "-s" => skip = number(args.next(), "-s")?, + "-l" => length = Some(number(args.next(), "-l")?), + _ if arg.starts_with('-') => return Err(format!("{arg}: unknown option")), _ => match path { None => path = Some(arg), - Some(first) => refuse(format!("{arg}: one file at a time, already given {first}")), + Some(first) => return Err(format!("{arg}: one file at a time, already given {first}")), }, } } - - let Some(path) = path else { - eprintln!("Usage: hexdump [-s offset] [-l length] "); - process::exit(1); - }; - - if let Err(refusal) = dump(Path::new(path), skip, length) { - eprintln!("hexdump: {refusal}"); - process::exit(1); - } -} - -fn refuse(what: String) -> ! { - eprintln!("hexdump: {what}"); - process::exit(1); + Ok((path.map(str::to_string), skip, length)) } /// Decimal, or hexadecimal with an `0x` prefix — the two forms xxd takes. -fn number(arg: Option<&String>, flag: &str) -> u64 { - let Some(text) = arg else { refuse(format!("{flag}: needs a number")) }; +fn number(arg: Option<&String>, flag: &str) -> Result { + let text = arg.ok_or_else(|| format!("{flag}: needs a number"))?; let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { Some(hex) => u64::from_str_radix(hex, 16), None => text.parse(), }; - match parsed { - Ok(n) => n, - Err(_) => refuse(format!("{flag}: {text} is not a number")), - } + parsed.map_err(|_| format!("{flag}: {text} is not a number")) } -fn dump(path: &Path, skip: u64, length: Option) -> Result<(), String> { +/// `length` bytes of `path` from `skip`, at most what the file holds, onto +/// `out` as xxd lays them out. +pub fn dump(path: &Path, skip: u64, length: Option, out: &mut impl Write) -> Result<(), String> { let mut file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; let size = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.len(); @@ -77,8 +81,6 @@ fn dump(path: &Path, skip: u64, length: Option) -> Result<(), String> { // loop below has a byte count and not a "no limit" marker to test against. let mut left = length.map_or(size - skip, |l| l.min(size - skip)); - let stdout = io::stdout(); - let mut out = BufWriter::new(stdout.lock()); let mut buf = vec![0u8; BUF_BYTES]; let mut offset = skip; @@ -89,7 +91,7 @@ fn dump(path: &Path, skip: u64, length: Option) -> Result<(), String> { // end of the dump. file.read_exact(&mut buf[..want]).map_err(|e| format!("{}: {e}", path.display()))?; for line in buf[..want].chunks(LINE) { - write_line(&mut out, offset, line).map_err(|e| format!("stdout: {e}"))?; + write_line(out, offset, line).map_err(|e| format!("stdout: {e}"))?; offset += line.len() as u64; } left -= want as u64; diff --git a/system/toybox/src/main.rs b/system/toybox/src/main.rs index ad92fea301b..506d8d4adf9 100644 --- a/system/toybox/src/main.rs +++ b/system/toybox/src/main.rs @@ -12,6 +12,7 @@ mod ps; mod pwd; mod reboot; mod rm; +mod roster; mod screen; mod shutdown; mod stats; diff --git a/system/toybox/src/mv.rs b/system/toybox/src/mv.rs index 70ce7c9b3bd..8a8868dd51e 100644 --- a/system/toybox/src/mv.rs +++ b/system/toybox/src/mv.rs @@ -18,28 +18,31 @@ pub fn main(args: Vec) { eprintln!("Usage: mv "); process::exit(1); }; + if let Err(refusal) = move_file(Path::new(source), Path::new(dest)) { + eprintln!("{refusal}"); + process::exit(1); + } +} - let source = Path::new(source); - let dest = crate::cp::destination(source, Path::new(dest)); +/// `source` renamed onto where `dest` lands it ([`crate::cp::destination`]), +/// or the refusal, said as `mv` says it. +pub fn move_file(source: &Path, dest: &Path) -> Result<(), String> { + let dest = crate::cp::destination(source, dest); // Asked before the rename so the commonest cause gets its own answer // rather than the one the kernel returns for everything. if let Err(e) = fs::metadata(source) { - eprintln!("mv: {}: {e}", source.display()); - process::exit(1); + return Err(format!("mv: {}: {e}", source.display())); } - if let Err(e) = fs::rename(source, &dest) { - eprintln!( - "mv: {} -> {}: {e} — but the source is there, so it is the rename being refused", + fs::rename(source, &dest).map_err(|e| { + format!( + "mv: {} -> {}: {e} — but the source is there, so it is the rename being refused\n\ + mv: most likely the two paths are on different mounts, which is not one rename. \ + SYS_RENAME reports one error for every cause, so mv cannot tell that from a rename \ + defect and will not copy behind your back — use cp then rm.", source.display(), dest.display() - ); - eprintln!( - "mv: most likely the two paths are on different mounts, which is not one rename. \ - SYS_RENAME reports one error for every cause, so mv cannot tell that from a rename \ - defect and will not copy behind your back — use cp then rm." - ); - process::exit(1); - } + ) + }) } diff --git a/system/toybox/src/ps.rs b/system/toybox/src/ps.rs index 0ffae625901..8e1e07cc6c3 100644 --- a/system/toybox/src/ps.rs +++ b/system/toybox/src/ps.rs @@ -6,6 +6,7 @@ //! `roster` there builds an image whose `ps` says it cannot and changes nothing //! else. Nothing here asks for the capability: it is either in the endowment //! table `/system/bin/supervisor` filled at spawn or it does not exist for this process. +//! What the roster says is [`crate::roster`]'s to read. //! //! `free` is the other half of the same syscall and needs none of this: the //! machine header is ambient. @@ -17,6 +18,7 @@ use toyos::endow::{Endowments, SYSCAP_LABEL}; use toyos::syscap::SysCap; use toyos::system; +use crate::roster::{rows, Row}; const HEADER: usize = system::SYSINFO_HEADER_SIZE; const ENTRY: usize = system::SYSINFO_ENTRY_SIZE; @@ -31,45 +33,11 @@ const ENTRY: usize = system::SYSINFO_ENTRY_SIZE; /// a command may keep somebody waiting. const SAMPLE_NS: u64 = 100_000_000; -/// One live thread, as the roster describes it. -struct Row { - pid: u32, - tid: u32, - state: u8, - is_thread: bool, - memory: u64, - cpu_ns: u64, - name: String, -} - -/// The machine's uptime, the CPU nanoseconds it has ever run, and every row. -/// -/// `None` is a capability carrying no `ROSTER`: the kernel answers the ambient -/// header alone, which is short of one entry and says so by its length. +/// The machine's uptime, the CPU nanoseconds it has ever run, and every row, +/// out of the roster the kernel answered. fn sample(cap: &SysCap, buf: &mut [u8]) -> Option<(u64, u64, Vec)> { let n = cap.roster(buf); - if n < HEADER { - return None; - } - let header = toyos_abi::syscall::SysinfoHeader::decode(buf[..HEADER].try_into().unwrap()); - let (uptime_ns, total_cpu_ns) = (header.uptime_ns, header.total_cpu_ns); - let mut rows = Vec::new(); - let mut pos = HEADER; - while pos + ENTRY <= n { - let name_bytes = &buf[pos + 32..pos + 60]; - let name_len = name_bytes.iter().position(|&b| b == 0).unwrap_or(28); - rows.push(Row { - pid: u32::from_le_bytes(buf[pos..pos + 4].try_into().unwrap()), - tid: u32::from_le_bytes(buf[pos + 4..pos + 8].try_into().unwrap()), - state: buf[pos + 8], - is_thread: buf[pos + 9] != 0, - memory: u64::from_le_bytes(buf[pos + 16..pos + 24].try_into().unwrap()), - cpu_ns: u64::from_le_bytes(buf[pos + 24..pos + 32].try_into().unwrap()), - name: std::str::from_utf8(&name_bytes[..name_len]).unwrap_or("?").to_string(), - }); - pos += ENTRY; - } - Some((uptime_ns, total_cpu_ns, rows)) + rows(&buf[..n]) } fn format_cpu_time(ns: u64) -> String { diff --git a/system/toybox/src/roster.rs b/system/toybox/src/roster.rs new file mode 100644 index 00000000000..1ab2ea5bc86 --- /dev/null +++ b/system/toybox/src/roster.rs @@ -0,0 +1,46 @@ +//! The roster `ps` prints, as the kernel answers it: the machine's header, +//! then one entry per live thread, or nothing to a capability carrying no +//! `ROSTER`. + +use toyos_abi::syscall::{SYSINFO_ENTRY_SIZE as ENTRY, SYSINFO_HEADER_SIZE as HEADER}; + +/// One live thread, as the roster describes it. +#[derive(Debug, PartialEq, Eq)] +pub struct Row { + pub pid: u32, + pub tid: u32, + pub state: u8, + pub is_thread: bool, + pub memory: u64, + pub cpu_ns: u64, + pub name: String, +} + +/// The machine's uptime, the CPU nanoseconds it has ever run, and every row of +/// `answer`, a roster the kernel wrote. `None` is a capability carrying no +/// `ROSTER`, which the kernel answers with nothing. +pub fn rows(answer: &[u8]) -> Option<(u64, u64, Vec)> { + let (buf, n) = (answer, answer.len()); + if n < HEADER { + return None; + } + let header = toyos_abi::syscall::SysinfoHeader::decode(buf[..HEADER].try_into().unwrap()); + let (uptime_ns, total_cpu_ns) = (header.uptime_ns, header.total_cpu_ns); + let mut rows = Vec::new(); + let mut pos = HEADER; + while pos + ENTRY <= n { + let name_bytes = &buf[pos + 32..pos + 60]; + let name_len = name_bytes.iter().position(|&b| b == 0).unwrap_or(28); + rows.push(Row { + pid: u32::from_le_bytes(buf[pos..pos + 4].try_into().unwrap()), + tid: u32::from_le_bytes(buf[pos + 4..pos + 8].try_into().unwrap()), + state: buf[pos + 8], + is_thread: buf[pos + 9] != 0, + memory: u64::from_le_bytes(buf[pos + 16..pos + 24].try_into().unwrap()), + cpu_ns: u64::from_le_bytes(buf[pos + 24..pos + 32].try_into().unwrap()), + name: std::str::from_utf8(&name_bytes[..name_len]).unwrap_or("?").to_string(), + }); + pos += ENTRY; + } + Some((uptime_ns, total_cpu_ns, rows)) +} diff --git a/system/update/Cargo.toml b/system/update/Cargo.toml index 26c110b26a9..1ee276091c9 100644 --- a/system/update/Cargo.toml +++ b/system/update/Cargo.toml @@ -10,6 +10,8 @@ toyos = { path = "../../sdk/toyos" } # A slot's partitions, claimed on a disk the kernel drives or opened through # the block service. diskserver = { path = "../diskserver" } +# A slot's partition, read and written in whole blocks, whoever drives it. +toyos-disk = { path = "../../lib/disk" } # The format, the verifier and the slot table the loader reads the same way. toyos-update = { path = "../../lib/update" } # A slot's volume is FAT, written with the driver the kernel mounts FAT with. diff --git a/system/update/copies/Cargo.toml b/system/update/copies/Cargo.toml new file mode 100644 index 00000000000..d567cb0e3c7 --- /dev/null +++ b/system/update/copies/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "update-copies" +description = "A host test of update's install over a disk in memory: its module that names nothing of ToyOS's SDK, compiled as it is." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +# A package of its own because `update` reads its key with `env!`, which a +# host's test step sets for no package, and links the SDK: this compiles only +# its install, the way `toyos-libc-copies` compiles libc's modules. + +[lib] +doctest = false + +[dev-dependencies] +toyos-disk = { path = "../../../lib/disk" } +# The tests sign the image they install, as the host that holds the key does. +toyos-update = { path = "../../../lib/update", features = ["sign"] } +toyos-fat32 = { path = "../../../lib/fat32" } +toyos-sha2-hw = { path = "../../../lib/sha2-hw" } +# The volume an install wrote, judged by the checker that shares no code with +# the driver it was written with. +toyos-fat32-check = { path = "../../../lib/fat32/check" } diff --git a/system/update/copies/src/installing.rs b/system/update/copies/src/installing.rs new file mode 100644 index 00000000000..35812eb1ca4 --- /dev/null +++ b/system/update/copies/src/installing.rs @@ -0,0 +1,132 @@ +//! update's install over partitions in memory: a signed image written into +//! the idle slot and marked, and one another key signed writing nothing. + +use toyos_disk::DiskError; +use toyos_update::slots::{Slot, Table}; + +use toyos_disk::{Disk, BLOCK}; +use toyos_update::image::{Header, SIGNED_BYTES}; +use toyos_update::slots::{self, Which}; +use toyos_update::sig; + +use crate::install::*; + +/// A FAT32 volume built from fatgen103 by the checker's tests, and not by the +/// driver an install writes with. +#[path = "../../../../lib/fat32/check/tests/common/mod.rs"] +mod spec_volume; + +/// A partition in memory. +struct Mem(Vec); + +impl Disk for Mem { + fn blocks(&self) -> u64 { + (self.0.len() / BLOCK) as u64 + } + fn read(&mut self, first: u64, out: &mut [u8]) -> Result<(), DiskError> { + let at = first as usize * BLOCK; + out.copy_from_slice(self.0.get(at..at + out.len()).ok_or(DiskError::Range)?); + Ok(()) + } + fn write(&mut self, first: u64, data: &[u8]) -> Result<(), DiskError> { + let at = first as usize * BLOCK; + self.0.get_mut(at..at + data.len()).ok_or(DiskError::Range)?.copy_from_slice(data); + Ok(()) + } + fn flush(&mut self) -> Result<(), DiskError> { + Ok(()) + } +} + +/// The seed the image is signed with, and the one an image no machine trusts +/// is. +const SEED: [u8; 32] = [0x42; 32]; +const STRANGER: [u8; 32] = [0x24; 32]; + +const SLOT_A: Slot = Slot { boot: [1; 16], root: [2; 16], version: 100 }; +const SLOT_B: Slot = Slot { boot: [3; 16], root: [4; 16], version: 0 }; + +const KERNEL: &[u8] = b"an update's kernel, whose bytes are no ELF"; +const CMDLINE: &[u8] = b"root=update-test"; + +/// A machine running slot A, its B empty: the table's partition, and B's FAT +/// volume and ROOT. +fn machine() -> (Mem, Mem, Mem) { + let mut table = vec![0u8; 2 * BLOCK]; + table[..BLOCK].copy_from_slice(&Table { sequence: 1, marked: Which::A, slots: [Some(SLOT_A), Some(SLOT_B)] }.encode()); + let mut volume = spec_volume::Volume::new(); + volume.finish(); + let mut boot = volume.bytes; + boot.resize(boot.len().next_multiple_of(BLOCK), 0); + (Mem(table), Mem(boot), Mem(vec![0; 16 * BLOCK])) +} + +/// Three blocks of ROOT, each opening with its own index. +fn root_bytes() -> Vec { + (0..3 * BLOCK).map(|i| if i % BLOCK == 0 { (i / BLOCK) as u8 } else { (i * 7) as u8 }).collect() +} + +/// The image `ssh update` takes: the signed header, then the kernel, +/// the boot parameter and ROOT. +fn image(seed: &[u8; 32], version: u64) -> Vec { + let root = root_bytes(); + let header = Header::of(version, KERNEL, CMDLINE, &root).encode(); + [&header[..], &sig::sign(seed, &header), KERNEL, CMDLINE, &root].concat() +} + +fn install_on(table: &mut Mem, boot: &mut Mem, root: &mut Mem, image: &[u8]) -> Result { + install( + &sig::public_of(&SEED), + table, + Partition { disk: boot, unique: SLOT_B.boot }, + Partition { disk: root, unique: SLOT_B.root }, + &mut &image[..], + 1_717_245_296, + |_| {}, + ) +} + +/// The file at `path` on `boot`'s volume, read with the driver. +fn file_on(boot: &mut Mem, path: &str) -> Vec { + let mut fs = toyos_fat32::Fat32::mount(crate::install::volume::Cached::new(boot)).expect("the volume mounts"); + let len = fs.metadata(path).unwrap_or_else(|e| panic!("{path}: {e:?}")).len; + let mut file = fs.open(path).unwrap_or_else(|e| panic!("{path}: {e:?}")); + let mut bytes = vec![0u8; len as usize]; + assert_eq!(fs.read(&mut file, 0, &mut bytes).unwrap_or_else(|e| panic!("{path}: {e:?}")), bytes.len()); + bytes +} + +/// A signed image is written into the idle slot — ROOT onto its partition, +/// the kernel, the boot parameter and the signed header onto its volume, +/// which the checker that shares no code with the driver finds clean — and +/// the slot is marked at the image's version. +#[test] +fn a_signed_image_is_written_into_the_idle_slot_and_marked() { + let (mut table, mut boot, mut root) = machine(); + let image = image(&SEED, 200); + let installed = install_on(&mut table, &mut boot, &mut root, &image).unwrap(); + assert_eq!((installed.version, installed.slot, installed.root_bytes), (200, Which::B, 3 * BLOCK as u64)); + + let (marked, _) = slots::read(|copies| table.read(0, copies.as_flattened_mut())).unwrap(); + assert_eq!(marked.marked, Which::B); + assert_eq!(marked.slot(Which::B).map(|s| s.version), Some(200)); + assert_eq!(marked.slot(Which::A), Some(SLOT_A), "the running slot is untouched"); + assert!(root.0[..3 * BLOCK] == root_bytes(), "slot B's ROOT is not the image's"); + + let complaints = toyos_fat32_check::check(&boot.0[..spec_volume::VOLUME_BYTES]); + assert!(complaints.is_empty(), "{}", toyos_fat32_check::describe(&complaints)); + assert_eq!(file_on(&mut boot, slots::KERNEL_FILE), KERNEL); + assert_eq!(file_on(&mut boot, slots::CMDLINE_FILE), CMDLINE); + assert_eq!(file_on(&mut boot, slots::SIGNED_FILE), image[..SIGNED_BYTES]); +} + +/// An image another key signed is refused before anything is written: every +/// partition reads as it did, so the machine boots what it booted. +#[test] +fn an_image_another_key_signed_writes_nothing() { + let (mut table, mut boot, mut root) = machine(); + let before = (table.0.clone(), boot.0.clone(), root.0.clone()); + let refused = install_on(&mut table, &mut boot, &mut root, &image(&STRANGER, 200)); + assert!(refused.is_err_and(|why| why.contains("signature")), "an image another key signed was installed"); + assert!((table.0, boot.0, root.0) == before, "a refused image wrote a partition"); +} diff --git a/system/update/copies/src/lib.rs b/system/update/copies/src/lib.rs new file mode 100644 index 00000000000..406bc42db6c --- /dev/null +++ b/system/update/copies/src/lib.rs @@ -0,0 +1,11 @@ +//! update's install on the host, over partitions in memory: a signed image +//! written into the idle slot and marked, ROOT byte for byte and the volume +//! judged by `toyos-fat32-check`, and an image another key signed writing +//! nothing. + +#[cfg(test)] +#[path = "../../src/install/mod.rs"] +pub mod install; + +#[cfg(test)] +mod installing; diff --git a/system/update/src/install/mod.rs b/system/update/src/install/mod.rs new file mode 100644 index 00000000000..319ac3d0be1 --- /dev/null +++ b/system/update/src/install/mod.rs @@ -0,0 +1,173 @@ +//! `/system/bin/update`'s install: a signed image read off its input and +//! written into the slot a machine is not running, over whatever disk holds +//! each partition. It writes, in order: +//! +//! 1. nothing, until the signed header's signature is the key's and its +//! version is newer than the running image and no older than the idle +//! slot's (`toyos_update::policy::installable`); +//! 2. ROOT, streamed onto the idle ROOT partition as it arrives and held to +//! the header's hash once whole; +//! 3. the kernel, its boot parameter and the signed header onto the idle FAT +//! volume, each held to its hash before it is written; +//! 4. a flush of each partition, which answers for these writes and no other +//! process's; +//! 5. the slot table, marking the idle slot — the copy that is not current, +//! so a torn write leaves the old mark — and its flush. +//! +//! A refusal at any step leaves the mark where it was, so the machine boots +//! what it booted before. The loader checks every byte again at the next boot +//! and refuses the slot by name if anything here was wrong. + +use std::io::Read; +use std::time::Instant; + +use toyos_disk::{Disk, BLOCK}; +use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; +use toyos_update::slots::{self, Which}; +use toyos_update::{policy, sig}; + +pub(crate) mod volume; + +/// One partition of the idle slot: its blocks, and its unique GUID. +pub struct Partition<'a> { + pub disk: &'a mut dyn Disk, + pub unique: [u8; 16], +} + +/// What an install wrote, and how long ROOT took to stream. +#[derive(Debug)] +pub struct Installed { + pub version: u64, + pub slot: Which, + pub root_bytes: u64, + pub root_ms: u128, +} + +/// The image on `input`, signed with `key`'s seed, into the slot `boot` and +/// `root` are, and that slot marked on `table`'s partition. `begun` hears the +/// line said once the image is known installable and before anything is +/// written; `now` is the Unix second the volume's files are stamped with. +pub fn install( + key: &[u8; 32], + table: &mut dyn Disk, + boot: Partition<'_>, + root: Partition<'_>, + input: &mut impl Read, + now: u64, + begun: impl FnOnce(String), +) -> Result { + let (current_table, current) = + slots::read(|copies| table.read(0, copies.as_flattened_mut())).map_err(|why| why.to_string())?; + let idle = [Which::A, Which::B] + .into_iter() + .find(|&w| current_table.slot(w).is_some_and(|s| s.boot == boot.unique && s.root == root.unique)) + .ok_or("the partitions this process holds are no slot the table names")?; + let running = current_table.slot(idle.other()).ok_or("the table carries no running slot")?; + + let mut signed = [0u8; SIGNED_BYTES]; + input.read_exact(&mut signed).map_err(|e| format!("the input ended before a signed header: {e}"))?; + let header = Header::parse(&signed).map_err(|why| why.to_string())?; + let header_bytes: &[u8; HEADER_BYTES] = signed[..HEADER_BYTES].try_into().expect("a signed header begins with its header"); + sig::verify(key, header_bytes, &toyos_update::image::signature_of(&signed)).map_err(|why| why.to_string())?; + let idle_version = current_table.slot(idle).map(|s| s.version).filter(|&v| v != 0); + policy::installable(header.version, running.version, idle_version).map_err(|why| why.to_string())?; + if header.root().len > root.disk.blocks() * BLOCK as u64 { + return Err(format!( + "ROOT is {} bytes and slot {}'s ROOT partition holds {}", + header.root().len, + idle.letter(), + root.disk.blocks() * BLOCK as u64 + )); + } + begun(format!( + "update: version {} is signed by this machine's key; writing slot {} (running {} at version {})", + header.version, + idle.letter(), + idle.other().letter(), + running.version + )); + + let kernel = take(input, header.kernel().len, header.kernel().sha256, "kernel")?; + let cmdline = take(input, header.cmdline().len, header.cmdline().sha256, "cmdline")?; + let streamed = Instant::now(); + stream_root(input, &mut *root.disk, header.root().len, header.root().sha256)?; + let root_ms = streamed.elapsed().as_millis(); + let mut rest = [0u8; 1]; + match input.read(&mut rest) { + Ok(0) => {} + Ok(_) => return Err("the input carries more bytes than its signed header names".into()), + Err(e) => return Err(format!("the input's end would not read: {e}")), + } + + write_volume(&mut *boot.disk, &kernel, &cmdline, &signed, now)?; + root.disk.flush().map_err(|e| format!("slot {}'s ROOT is not durable: {e:?}", idle.letter()))?; + boot.disk.flush().map_err(|e| format!("slot {}'s volume is not durable: {e:?}", idle.letter()))?; + + let mut next = current_table; + next.marked = idle; + let mut slot = current_table.slot(idle).expect("the idle slot is in the table"); + slot.version = header.version; + next.slots[idle.index()] = Some(slot); + let (copy, block) = slots::next_write((current_table, current), next); + table.write(copy as u64, &block).map_err(|e| format!("the slot table's copy {copy} would not write: {e:?}"))?; + table.flush().map_err(|e| format!("the slot table is not durable: {e:?}"))?; + Ok(Installed { version: header.version, slot: idle, root_bytes: header.root().len, root_ms }) +} + +/// Exactly `len` bytes of the input, held to `sha256`. +fn take(input: &mut impl Read, len: u64, sha256: toyos_update::Digest, section: &str) -> Result, String> { + let mut bytes = vec![0u8; len as usize]; + input.read_exact(&mut bytes).map_err(|e| format!("the input ended inside the {section}: {e}"))?; + if toyos_update::sha256(&bytes) != sha256 { + return Err(format!("the {section} is not the bytes its signed header names")); + } + Ok(bytes) +} + +/// The blocks of ROOT [`stream_root`] reads and writes at a time. +const STREAM_BLOCKS: usize = 32; + +/// ROOT onto the idle ROOT partition as it arrives, [`STREAM_BLOCKS`] at a +/// time, and held to `sha256` once whole. +fn stream_root(input: &mut impl Read, root: &mut dyn Disk, len: u64, sha256: toyos_update::Digest) -> Result<(), String> { + let mut hasher = toyos_sha2_hw::sha256(); + let mut run = vec![0u8; STREAM_BLOCKS * BLOCK]; + let blocks = len / BLOCK as u64; + let mut at = 0u64; + while at < blocks { + let n = (blocks - at).min(STREAM_BLOCKS as u64) as usize; + let bytes = &mut run[..n * BLOCK]; + input.read_exact(bytes).map_err(|e| format!("the input ended inside ROOT, at block {at}: {e}"))?; + hasher.update(&*bytes); + root.write(at, bytes).map_err(|e| format!("ROOT's blocks from {at} would not write: {e:?}"))?; + at += n as u64; + } + if hasher.finalize() != sha256 { + return Err("ROOT is not the bytes its signed header names; the mark is not moved".into()); + } + Ok(()) +} + +/// The kernel, its boot parameter and the signed header onto the idle slot's +/// FAT volume, replacing whatever was there. +fn write_volume(boot: &mut dyn Disk, kernel: &[u8], cmdline: &[u8], signed: &[u8], now: u64) -> Result<(), String> { + let mut fs = toyos_fat32::Fat32::mount(volume::Cached::new(boot)) + .map_err(|e| format!("the idle slot's volume does not mount: {e:?}"))?; + let time = toyos_fat32::FatTime::from_unix_secs(now); + fs.create_dir_all("toyos", time).map_err(|e| format!("toyos/ on the idle volume: {e:?}"))?; + for (path, bytes) in [ + (slots::SIGNED_FILE, signed), + (slots::KERNEL_FILE, kernel), + (slots::CMDLINE_FILE, cmdline), + ] { + if fs.exists(path).map_err(|e| format!("{path}: {e:?}"))? { + fs.remove(path).map_err(|e| format!("removing the old {path}: {e:?}"))?; + } + let mut file = fs.create(path, time).map_err(|e| format!("creating {path}: {e:?}"))?; + fs.write(&mut file, 0, bytes).map_err(|e| format!("writing {path}: {e:?}"))?; + fs.flush_meta(&mut file, time).map_err(|e| format!("recording {path}: {e:?}"))?; + } + // Writes every block the volume holds (`Cached::flush`). + fs.sync().map_err(|e| format!("the idle volume's blocks: {e:?}")) +} + diff --git a/system/update/src/volume.rs b/system/update/src/install/volume.rs similarity index 99% rename from system/update/src/volume.rs rename to system/update/src/install/volume.rs index f8274c57942..eb1b6637945 100644 --- a/system/update/src/volume.rs +++ b/system/update/src/install/volume.rs @@ -10,7 +10,7 @@ use std::collections::BTreeMap; -use diskserver::disk::{Disk, BLOCK}; +use toyos_disk::{Disk, BLOCK}; use toyos_fat32::{BlockAccess, IoError}; pub struct Cached<'a> { diff --git a/system/update/src/main.rs b/system/update/src/main.rs index e96bcef535f..2ada13e1e45 100644 --- a/system/update/src/main.rs +++ b/system/update/src/main.rs @@ -11,34 +11,19 @@ //! volume and ROOT, as claims the supervisor endows under their labels on a //! disk the kernel drives, or as connectors in its namespace under the same //! names, each minted for its one partition on the block service's port; the -//! slot this boot runs is never among them (`toyos_update::slots::idle`). So -//! it writes, in order: -//! -//! 1. nothing, until the signed header's signature is this machine's key's and -//! its version is newer than the running image and no older than the idle -//! slot's (`toyos_update::policy::installable`); -//! 2. ROOT, streamed onto the idle ROOT partition as it arrives and held to -//! the header's hash once whole; -//! 3. the kernel, its boot parameter and the signed header onto the idle FAT -//! volume, each held to its hash before it is written; -//! 4. a flush of each partition, which answers for these writes and no other -//! process's; -//! 5. the slot table, marking the idle slot — the copy that is not current, -//! so a torn write leaves the old mark — and its fsync. -//! -//! A refusal at any step leaves the mark where it was, so the machine boots -//! what it booted before. The loader checks every byte again at the next boot -//! and refuses the slot by name if anything here was wrong. +//! slot this boot runs is never among them (`toyos_update::slots::idle`). What +//! it writes there, and in what order, is [`install`]'s. + +mod install; -use std::io::Read; use std::time::Instant; -use diskserver::disk::{Claimed, Disk, Served, BLOCK}; +use diskserver::disk::{Claimed, Disk, Served}; use toyos::endow::{self, Endowments}; use toyos::PartitionDev; -use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; -use toyos_update::slots::{self, Which}; -use toyos_update::{policy, sig}; +use toyos_update::sig; +use toyos_update::slots; +use install::Partition; /// The key an image must be signed with: the same the loader embeds. const KEY: [u8; 32] = sig::key_from_hex(env!("TOYOS_IMAGE_KEY")); @@ -84,134 +69,23 @@ fn held(label: &str) -> Result { } fn run(began: Instant) -> Result { - let (mut table_part, mut boot, mut root) = (held(slots::TABLE_LABEL)?, held(slots::BOOT_LABEL)?, held(slots::ROOT_LABEL)?); - let (table, current) = - slots::read(|copies| table_part.disk.read(0, copies.as_flattened_mut())).map_err(|why| why.to_string())?; - let idle = [Which::A, Which::B] - .into_iter() - .find(|&w| table.slot(w).is_some_and(|s| s.boot == boot.unique && s.root == root.unique)) - .ok_or("the partitions this process holds are no slot the table names")?; - let running = table.slot(idle.other()).ok_or("the table carries no running slot")?; - - let mut input = std::io::stdin().lock(); - let mut signed = [0u8; SIGNED_BYTES]; - input.read_exact(&mut signed).map_err(|e| format!("the input ended before a signed header: {e}"))?; - let header = Header::parse(&signed).map_err(|why| why.to_string())?; - let header_bytes: &[u8; HEADER_BYTES] = signed[..HEADER_BYTES].try_into().expect("a signed header begins with its header"); - sig::verify(&KEY, header_bytes, &toyos_update::image::signature_of(&signed)).map_err(|why| why.to_string())?; - let idle_version = table.slot(idle).map(|s| s.version).filter(|&v| v != 0); - policy::installable(header.version, running.version, idle_version).map_err(|why| why.to_string())?; - if header.root().len > root.disk.blocks() * BLOCK as u64 { - return Err(format!( - "ROOT is {} bytes and slot {}'s ROOT partition holds {}", - header.root().len, - idle.letter(), - root.disk.blocks() * BLOCK as u64 - )); - } - println!( - "update: version {} is signed by this machine's key; writing slot {} (running {} at version {})", - header.version, - idle.letter(), - idle.other().letter(), - running.version - ); - - let kernel = take(&mut input, header.kernel().len, header.kernel().sha256, "kernel")?; - let cmdline = take(&mut input, header.cmdline().len, header.cmdline().sha256, "cmdline")?; - let streamed = Instant::now(); - stream_root(&mut input, &mut *root.disk, header.root().len, header.root().sha256)?; - let root_ms = streamed.elapsed().as_millis(); - let mut rest = [0u8; 1]; - match input.read(&mut rest) { - Ok(0) => {} - Ok(_) => return Err("the input carries more bytes than its signed header names".into()), - Err(e) => return Err(format!("the input's end would not read: {e}")), - } - - write_volume(&mut *boot.disk, &kernel, &cmdline, &signed)?; - root.disk.flush().map_err(|e| format!("slot {}'s ROOT is not durable: {e:?}", idle.letter()))?; - boot.disk.flush().map_err(|e| format!("slot {}'s volume is not durable: {e:?}", idle.letter()))?; - - let mut next = table; - next.marked = idle; - let mut slot = table.slot(idle).expect("the idle slot is in the table"); - slot.version = header.version; - next.slots[idle.index()] = Some(slot); - let (copy, block) = slots::next_write((table, current), next); - table_part - .disk - .write(copy as u64, &block) - .map_err(|e| format!("the slot table's copy {copy} would not write: {e:?}"))?; - table_part.disk.flush().map_err(|e| format!("the slot table is not durable: {e:?}"))?; - + let (mut table, mut boot, mut root) = (held(slots::TABLE_LABEL)?, held(slots::BOOT_LABEL)?, held(slots::ROOT_LABEL)?); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |since| since.as_secs()); + let installed = install::install( + &KEY, + &mut *table.disk, + Partition { disk: &mut *boot.disk, unique: boot.unique }, + Partition { disk: &mut *root.disk, unique: root.unique }, + &mut std::io::stdin().lock(), + now, + |line| println!("{line}"), + )?; Ok(format!( - "{INSTALLED} version {} in slot {} ({} bytes of ROOT in {root_ms} ms, {} ms in all); it boots at the next reboot", - header.version, - idle.letter(), - header.root().len, + "{INSTALLED} version {} in slot {} ({} bytes of ROOT in {} ms, {} ms in all); it boots at the next reboot", + installed.version, + installed.slot.letter(), + installed.root_bytes, + installed.root_ms, began.elapsed().as_millis() )) } - -/// Exactly `len` bytes of the input, held to `sha256`. -fn take(input: &mut impl Read, len: u64, sha256: toyos_update::Digest, section: &str) -> Result, String> { - let mut bytes = vec![0u8; len as usize]; - input.read_exact(&mut bytes).map_err(|e| format!("the input ended inside the {section}: {e}"))?; - if toyos_update::sha256(&bytes) != sha256 { - return Err(format!("the {section} is not the bytes its signed header names")); - } - Ok(bytes) -} - -/// The blocks of ROOT [`stream_root`] reads and writes at a time. -const STREAM_BLOCKS: usize = 32; - -/// ROOT onto the idle ROOT partition as it arrives, [`STREAM_BLOCKS`] at a -/// time, and held to `sha256` once whole. -fn stream_root(input: &mut impl Read, root: &mut dyn Disk, len: u64, sha256: toyos_update::Digest) -> Result<(), String> { - let mut hasher = toyos_sha2_hw::sha256(); - let mut run = vec![0u8; STREAM_BLOCKS * BLOCK]; - let blocks = len / BLOCK as u64; - let mut at = 0u64; - while at < blocks { - let n = (blocks - at).min(STREAM_BLOCKS as u64) as usize; - let bytes = &mut run[..n * BLOCK]; - input.read_exact(bytes).map_err(|e| format!("the input ended inside ROOT, at block {at}: {e}"))?; - hasher.update(&*bytes); - root.write(at, bytes).map_err(|e| format!("ROOT's blocks from {at} would not write: {e:?}"))?; - at += n as u64; - } - if hasher.finalize() != sha256 { - return Err("ROOT is not the bytes its signed header names; the mark is not moved".into()); - } - Ok(()) -} - -/// The kernel, its boot parameter and the signed header onto the idle slot's -/// FAT volume, replacing whatever was there. -fn write_volume(boot: &mut dyn Disk, kernel: &[u8], cmdline: &[u8], signed: &[u8]) -> Result<(), String> { - let mut fs = toyos_fat32::Fat32::mount(volume::Cached::new(boot)) - .map_err(|e| format!("the idle slot's volume does not mount: {e:?}"))?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |since| since.as_secs()); - let time = toyos_fat32::FatTime::from_unix_secs(now); - fs.create_dir_all("toyos", time).map_err(|e| format!("toyos/ on the idle volume: {e:?}"))?; - for (path, bytes) in [ - (slots::SIGNED_FILE, signed), - (slots::KERNEL_FILE, kernel), - (slots::CMDLINE_FILE, cmdline), - ] { - if fs.exists(path).map_err(|e| format!("{path}: {e:?}"))? { - fs.remove(path).map_err(|e| format!("removing the old {path}: {e:?}"))?; - } - let mut file = fs.create(path, time).map_err(|e| format!("creating {path}: {e:?}"))?; - fs.write(&mut file, 0, bytes).map_err(|e| format!("writing {path}: {e:?}"))?; - fs.flush_meta(&mut file, time).map_err(|e| format!("recording {path}: {e:?}"))?; - } - // Writes every block the volume holds (`Cached::flush`). - fs.sync().map_err(|e| format!("the idle volume's blocks: {e:?}")) -} - -mod volume; diff --git a/tests/checks.rs b/tests/checks.rs index c3d7c966979..4f2c993c481 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -1153,7 +1153,7 @@ mod checks { #[test] fn a_shared_chunk_stages_every_binary_its_members_name() { let source = "const CHILD: &str = \"/system/bin/test_rs_child\";\n"; - let bins: Vec<(String, Vec)> = ["parent", "child", "fs_large_file", "libfoo.so"] + let bins: Vec<(String, Vec)> = ["parent", "child", "stranger", "libfoo.so"] .iter() .map(|name| ((*name).to_string(), Vec::new())) .collect(); diff --git a/tests/checks/audio.rs b/tests/checks/audio.rs index e9b816f2529..e11bab76dfa 100644 --- a/tests/checks/audio.rs +++ b/tests/checks/audio.rs @@ -10,10 +10,10 @@ pub fn judges_verdict() -> Result<(), String> { (Err(why), true) => Err(format!("{what} refused a log it has to pass: {why}\n{log}")), } }; - let stats = |underruns: u32, clients: u32, deferred: u32| { + let stats = |underruns: u32, clients: u32| { format!( "[ 2.000 soundserver] soundserver: wakes=9 completions=9 submitted=9 underruns={underruns} \ - drains=0 max_wake_lat_us=9 max_batch=1 clients={clients} deferred={deferred} \ + drains=0 max_wake_lat_us=9 max_batch=1 clients={clients} deferred=0 \ starve_max=0 worst_irq_late_us=0 worst_pickup_us=0 worst_empty=0 worst_batch=1 \ late_wakes=0\n" ) @@ -21,10 +21,10 @@ pub fn judges_verdict() -> Result<(), String> { let spawn = |job: &str| format!("[ 1.000 cpu0 kernel] spawn: /system/bin/{job} pid=7\n"); // One stream through soundserver: `playing` is the stats line while it plays, // and `ended` the flush once it has left. - let session = |playing: String, how: &str, ended: String| { + let session = |playing: String, ended: String| { format!( "[ 1.000 soundserver] soundserver: client 0 connected (id=1)\n[ 1.000 soundserver] soundserver: \ - resumed\n{playing}[ 3.000 soundserver] soundserver: client 1 removed ({how})\n{ended}\ + resumed\n{playing}[ 3.000 soundserver] soundserver: client 1 removed (closed)\n{ended}\ [ 3.000 soundserver] soundserver: suspended\n" ) }; @@ -32,20 +32,20 @@ pub fn judges_verdict() -> Result<(), String> { let configured = "[ 0.500 soundserver] soundserver: hda path configured in 3 ms\n"; let tone = |ended: String| { - format!("{configured}{}{}", spawn("test_rs_audio_tone"), session(stats(0, 1, 0), "closed", ended)) + format!("{configured}{}{}", spawn("test_rs_audio_tone"), session(stats(0, 1), ended)) }; - judged("the tone", tone_on_metal, &format!("{}{next}", tone(stats(0, 0, 0))), true)?; - judged("a tone short of periods", tone_on_metal, &format!("{}{next}", tone(stats(2, 0, 0))), false)?; + judged("the tone", tone_on_metal, &format!("{}{next}", tone(stats(0, 0))), true)?; + judged("a tone short of periods", tone_on_metal, &format!("{}{next}", tone(stats(2, 0))), false)?; judged( "a tone off no hda path", tone_on_metal, - &format!("{}{next}", tone(stats(0, 0, 0))).replace(configured, ""), + &format!("{}{next}", tone(stats(0, 0))).replace(configured, ""), false, )?; judged( "a tone on the null sink", tone_on_metal, - &format!("{}[ 0.600 soundserver] {NULL_SINK}\n{next}", tone(stats(0, 0, 0))), + &format!("{}[ 0.600 soundserver] {NULL_SINK}\n{next}", tone(stats(0, 0))), false, )?; // The next job's stream connected before soundserver let the tone's go, so the @@ -59,9 +59,9 @@ pub fn judges_verdict() -> Result<(), String> { [ 3.000 soundserver] soundserver: client 1 removed (closed)\n{}\ [ 4.000 soundserver] soundserver: client 2 removed (closed)\n{}", spawn("test_rs_audio_tone"), - stats(0, 1, 0), - stats(0, 1, 0), - stats(0, 0, 0), + stats(0, 1), + stats(0, 1), + stats(0, 0), ), false, )?; @@ -71,8 +71,8 @@ pub fn judges_verdict() -> Result<(), String> { &format!( "{configured}{}[ 0.900 soundserver] soundserver: client 0 removed (closed)\n{}{}{next}", spawn("test_rs_audio_tone"), - stats(0, 0, 0), - session(stats(0, 1, 0), "closed", stats(0, 0, 0)) + stats(0, 0), + session(stats(0, 1), stats(0, 0)) ), false, )?; @@ -87,146 +87,10 @@ pub fn judges_verdict() -> Result<(), String> { [ 2.500 soundserver] soundserver: client 0 removed (closed)\n\ [ 3.000 soundserver] soundserver: client 1 removed (closed)\n{}{next}", spawn("test_rs_audio_tone"), - stats(0, 2, 0), - stats(0, 0, 0), + stats(0, 2), + stats(0, 0), ), false, )?; - - let stall = |second: String, rest: &str| { - format!( - "{}{}{second}{rest}", - spawn("test_rs_hda_client_stall"), - session(stats(3, 1, 0), "closed", stats(0, 0, 0)) - ) - }; - let second = session(stats(1, 1, 0), "closed", stats(0, 0, 0)); - judged("the stalled client", client_stall_on_metal, &stall(second.clone(), &next), true)?; - judged( - "a stalled client whose second stream never resumed soundserver", - client_stall_on_metal, - &stall(second.replace("soundserver: resumed\n", "soundserver: client 0 streaming\n"), &next), - false, - )?; - judged( - "a stalled client soundserver filled no period for", - client_stall_on_metal, - &stall(second.clone(), &next).replace("underruns=3", "underruns=0").replace("underruns=1", "underruns=0"), - false, - )?; - // The next job spawned ahead of soundserver's last word on the second stream. - let (playing, tail) = second.split_at(second.find("[ 3.000 soundserver] soundserver: client 1 removed").expect("staged")); - judged( - "a stalled client soundserver deferred for after the next job began", - client_stall_on_metal, - &stall(format!("{playing}{next}{}", tail.replace("deferred=0", "deferred=1")), ""), - false, - )?; - judged( - "a stalled client over a repeated completion", - client_stall_on_metal, - &stall(second.clone(), &format!("[ 2.500 soundserver] soundserver: repeated completion for free buffer\n{next}")), - false, - )?; - judged("the T14's stalled client", client_stall_on_metal, T14_STALL, true)?; - judged( - "the T14's stalled client, its second stream opened on a running soundserver", - client_stall_on_metal, - &T14_STALL - .replace("[2026-10-03 07:05:06 7.865 soundserver] soundserver: suspended\n", "") - .replace("[2026-10-03 07:05:07 8.143 soundserver] soundserver: resumed\n", ""), - false, - )?; - - let departures = |second: &str| { - format!( - "{}{}{}{next}", - spawn("test_rs_null_sink_client_exits"), - session(stats(0, 1, 0), "closed", stats(0, 0, 0)), - session(stats(0, 1, 0), second, stats(0, 0, 0)) - ) - }; - judged("two departures", departures_on_metal, &departures("signal pipe gone"), true)?; - judged("a departure soundserver did not establish", departures_on_metal, &departures("died"), false)?; - judged( - "a death soundserver claimed beside a departure it established", - departures_on_metal, - &departures("closed").replacen( - "[ 3.000 soundserver] soundserver: client 1 removed", - "[ 3.000 soundserver] soundserver: client 1 died\n[ 3.000 soundserver] soundserver: client 1 removed", - 1, - ), - false, - )?; - judged( - "a departure soundserver never reported", - departures_on_metal, - &departures("closed").replacen("[ 3.000 soundserver] soundserver: client 1 removed (closed)\n", "", 1), - false, - )?; - judged( - "one departure of two", - departures_on_metal, - &format!( - "{}{}{next}", - spawn("test_rs_null_sink_client_exits"), - session(stats(0, 1, 0), "closed", stats(0, 0, 0)) - ), - false, - )?; - - let idle = |said: &str| format!("{}{said}{next}", spawn("test_rs_audio_idle_suspend")); - judged("an idle soundserver", idle_suspend_on_metal, &idle("[ 1.000 soundserver] soundserver: suspended\n"), true)?; - judged( - "an idle soundserver that started the device", - idle_suspend_on_metal, - &idle(&format!("[ 1.000 soundserver] {DEVICE_STARTED}\n")), - false, - )?; - - // Eleven lines owed after the boot and the two others: six refusals and - // both others in `/log`, and five counted unwritten. - let stalled = |held: u32, unwritten: u32, refusals: usize| { - format!( - "[ 1.000 test_rs_soundserver_log_stall] flooded soundserver with 10 refusals\n\ - [ 1.000 test_rs_soundserver_log_stall] soundserver refused 1 probe\n\ - {}[ 1.100 soundserver] soundserver: protocol violation (msg 7)\n\ - [ 1.200 soundserver] soundserver: opening stream: 48000 Hz\n\ - [ 2.000 logkeeper] logkeeper: reading soundserver again, as `--stall-until` asked, with {held} of \ - its ring's 64 slots waiting\n\ - [ 2.100 logkeeper] logkeeper: {unwritten} record(s) of soundserver's found its ring full\n", - "[ 1.050 soundserver] soundserver: refusing connection, too many\n".repeat(refusals) - ) - }; - judged("the stalled log", log_stall_on_metal, &stalled(64, 5, 6), true)?; - judged("a log stall that never filled the ring", log_stall_on_metal, &stalled(40, 5, 6), false)?; - judged("a log stall that lost a line silently", log_stall_on_metal, &stalled(64, 4, 6), false)?; - judged("a log stall that counted nothing unwritten", log_stall_on_metal, &stalled(64, 0, 11), false)?; Ok(()) } - -/// The T14's `testcases` boot, verbatim: the tone's last records, and the -/// stalled client's from its spawn to its exit. Its first stream opens 3 ms -/// after the tone's left, on a soundserver that has not suspended. -const T14_STALL: &str = r"[2026-10-03 07:05:04 5.681 soundserver] soundserver: client 0 removed (closed) -[2026-10-03 07:05:04 5.681 soundserver] soundserver: wakes=573 completions=415 submitted=415 underruns=0 drains=0 max_wake_lat_us=2307 max_batch=2 clients=0 deferred=0 starve_max=0 worst_irq_late_us=2288 worst_pickup_us=18 worst_empty=0 worst_batch=2 late_wakes=0 -[2026-10-03 07:05:04 5.682 cpu4 kernel] exit: test_rs_audio_tone pid=11 code=0 cpu=2ms -[2026-10-03 07:05:04 5.683 cpu7 kernel] spawn: /system/bin/test_rs_hda_client_stall pid=12 tid=0 dst=6 base=0x10000000000 entry=0x1000003df40 root=0x83d6000 symbols=2048KiB (layout=0ms relocs=0ms deps=0ms tls=0ms total=1ms) -[2026-10-03 07:05:04 5.684 soundserver tid=1] soundserver: opening stream: 44100Hz 2ch fmt=0 -[2026-10-03 07:05:04 5.684 soundserver] soundserver: client 0 connected (id=1) -[2026-10-03 07:05:06 7.685 soundserver] soundserver: wakes=842 completions=690 submitted=690 underruns=104 drains=0 max_wake_lat_us=1746 max_batch=2 clients=1 deferred=0 starve_max=13 worst_irq_late_us=1724 worst_pickup_us=22 worst_empty=0 worst_batch=2 late_wakes=0 -[2026-10-03 07:05:06 7.842 soundserver] soundserver: client 1 removed (closed) -[2026-10-03 07:05:06 7.842 soundserver] soundserver: wakes=85 completions=54 submitted=54 underruns=0 drains=0 max_wake_lat_us=70 max_batch=1 clients=0 deferred=0 starve_max=0 worst_irq_late_us=17 worst_pickup_us=53 worst_empty=1 worst_batch=1 late_wakes=0 -[2026-10-03 07:05:06 7.842 cpu7 kernel tid=1] exit: test_rs_hda_client_stall tid=1 code=0 cpu=15ms -[2026-10-03 07:05:06 7.865 soundserver] soundserver: suspended -[2026-10-03 07:05:07 8.142 soundserver tid=1] soundserver: opening stream: 44100Hz 2ch fmt=0 -[2026-10-03 07:05:07 8.142 soundserver] soundserver: client 0 connected (id=2) -[2026-10-03 07:05:07 8.143 soundserver] soundserver: resumed -[2026-10-03 07:05:08 9.051 soundserver] soundserver: client 2 removed (closed) -[2026-10-03 07:05:08 9.051 soundserver] soundserver: wakes=425 completions=313 submitted=321 underruns=26 drains=0 max_wake_lat_us=88 max_batch=1 clients=0 deferred=0 starve_max=13 worst_irq_late_us=53 worst_pickup_us=34 worst_empty=1 worst_batch=1 late_wakes=0 -[2026-10-03 07:05:08 9.051 cpu0 kernel tid=2] exit: test_rs_hda_client_stall tid=2 code=0 cpu=3ms -[2026-10-03 07:05:08 9.079 soundserver] soundserver: suspended -[2026-10-03 07:05:08 9.079 soundserver] soundserver: idle wake 1 (1 records) -[2026-10-03 07:05:08 9.351 test-runner pid=12] stalled 8 then 2 times, soundserver survived -[2026-10-03 07:05:08 9.352 cpu6 kernel] exit: test_rs_hda_client_stall pid=12 code=0 cpu=2ms -"; diff --git a/tests/checks/metal.rs b/tests/checks/metal.rs index 333c7ab8f61..0bc6089d57f 100644 --- a/tests/checks/metal.rs +++ b/tests/checks/metal.rs @@ -439,7 +439,7 @@ fn member(job: &str, adds_ms: u64) -> metal::Member { pub fn a_failing_shared_member_fails_itself_alone() { let dir = toyos_tmpdir::TempDir::new("metal-readbacks"); let root = recorded(); - let jobs = ["test_rs_std_tls_multi_crate", "test_rs_fs_large_file"]; + let jobs = ["test_rs_std_tls_multi_crate", "test_rs_a_failing_member"]; let exits = |code: i32| { format!( "{BOOTED}[2026-09-29 18:22:41 2.310 cpu3 kernel] exit: {} pid=12 code=0 cpu=4ms\n\ diff --git a/tests/common/audio.rs b/tests/common/audio.rs index 02bafc1a9ff..912ba282bf2 100644 --- a/tests/common/audio.rs +++ b/tests/common/audio.rs @@ -4,50 +4,41 @@ use super::serial::Serial; /// The text of `log` from the kernel's record of `job`'s spawn to the end of -/// what soundserver said about the `sessions` streams it played, one after another. +/// what soundserver said about the one stream it played. /// -/// A session ends at soundserver's flush on its last client leaving — the stats line -/// that carries `clients=0`, which soundserver writes after the removal — and not at -/// the next job's spawn, which can land before it. A job that plays nothing -/// (`sessions` of 0) ends at the next test binary's spawn. +/// The session ends at soundserver's flush on its last client leaving — the stats +/// line that carries `clients=0`, which soundserver writes after the removal — and +/// not at the next job's spawn, which can land before it. /// -/// Sessions are refused unless each is one stream: soundserver counts one window -/// across every client it holds, so a session another job's stream shared has -/// no count that is the job's alone. -fn job_window<'a>(log: &'a str, job: &str, sessions: usize) -> Result<&'a str, String> { +/// It is refused unless it is one stream: soundserver counts one window across +/// every client it holds, so a session another job's stream shared has no count +/// that is the job's alone. +fn job_window<'a>(log: &'a str, job: &str) -> Result<&'a str, String> { let head = format!("spawn: /system/bin/{job} "); let at = log.find(&head).ok_or_else(|| format!("no `{head}` record: {job} never ran"))?; let rest = &log[at + head.len()..]; - if sessions == 0 { - return Ok(&rest[..rest.find("spawn: /system/bin/test_rs_").unwrap_or(rest.len())]); - } - let mut end = 0; - for session in 1..=sessions { - let flushed = rest[end..] - .match_indices(SESSION_ENDED) - .find(|&(line_at, _)| is_soundserver_stats(&rest[end..], line_at)) - .map(|(line_at, _)| end + line_at) - .ok_or_else(|| { - format!( - "soundserver never said session {session} of {job}'s {sessions} ended (a stats \ - line with `{}`):\n{rest}", - SESSION_ENDED.trim() - ) - })?; - end = rest[flushed..].find('\n').map_or(rest.len(), |nl| flushed + nl + 1); - } - let window = &rest[..end]; + let flushed = rest + .match_indices(SESSION_ENDED) + .find(|&(line_at, _)| is_soundserver_stats(rest, line_at)) + .map(|(line_at, _)| line_at) + .ok_or_else(|| { + format!( + "soundserver never said {job}'s session ended (a stats line with `{}`):\n{rest}", + SESSION_ENDED.trim() + ) + })?; + let window = &rest[..rest[flushed..].find('\n').map_or(rest.len(), |nl| flushed + nl + 1)]; let said = |what: &str| { window.lines().filter(|l| l.contains("soundserver: client ") && l.contains(what)).count() }; // A removal counts too: a stream soundserver held before the spawn connected // outside the window and leaves inside it. let (streams, removed) = (said(" connected (id="), said(" removed (")); - if streams != sessions || removed != sessions { + if streams != 1 || removed != 1 { return Err(format!( - "soundserver connected {streams} and removed {removed} stream(s) in {job}'s {sessions} \ - session(s): another job's stream shared soundserver with {job}'s, and no count in the \ - window is {job}'s alone:\n{window}" + "soundserver connected {streams} and removed {removed} stream(s) in {job}'s session: \ + another job's stream shared soundserver with {job}'s, and no count in the window is \ + {job}'s alone:\n{window}" )); } Ok(window) @@ -69,7 +60,7 @@ fn is_soundserver_stats(text: &str, at: usize) -> bool { pub fn tone_on_metal(log: &Serial) -> Result<(), String> { log.must_say("soundserver: hda path configured in")?; log.must_not_say(NULL_SINK)?; - let window = job_window(log.text(), "test_rs_audio_tone", 1)?; + let window = job_window(log.text(), "test_rs_audio_tone")?; let underruns = sum_field(window, "underruns"); if underruns != 0 { return Err(format!( @@ -83,75 +74,6 @@ pub fn tone_on_metal(log: &Serial) -> Result<(), String> { /// soundserver's own word for the sink it took when the machine has none. pub const NULL_SINK: &str = "soundserver: no audio device, presenting a null sink"; -/// soundserver with no client costs no CPU, and the device is not what is running: -/// in a window no client connects in, soundserver never started the stream. A zero -/// CPU delta is the signature of a suspended soundserver and equally of one wedged -/// with the device running; the start line tells them apart. -pub fn idle_suspend_on_metal(log: &Serial) -> Result<(), String> { - let window = job_window(log.text(), "test_rs_audio_idle_suspend", 0)?; - if window.contains(DEVICE_STARTED) { - return Err(format!( - "`{DEVICE_STARTED}` with no client connected — soundserver's zero CPU is the device left \ - running, not a suspend:\n{window}" - )); - } - Ok(()) -} - -/// What soundserver says as it starts the stream, before the first submit. -pub(crate) const DEVICE_STARTED: &str = "soundserver: resumed"; - -/// The T14's panic, staged on its own HDA ring: a client that stops producing -/// for longer than the DMA ring takes to come round. The engine replays every -/// buffer it completes, so soundserver has to fill the periods the client did not -/// cover (`underruns`) and may hold none of them back (`deferred`), across a -/// suspend and a resume. -/// -/// The resume is the second stream's, which the job stages. Whether the first -/// stream finds soundserver suspended is the job before it: on a shared boot it can -/// open while soundserver still plays that job's tail out. -pub fn client_stall_on_metal(log: &Serial) -> Result<(), String> { - const JOB: &str = "test_rs_hda_client_stall"; - log.must_not_say("repeated completion for free buffer")?; - let first = job_window(log.text(), JOB, 1)?; - let window = job_window(log.text(), JOB, 2)?; - if !window[first.len()..].contains(DEVICE_STARTED) { - return Err(format!( - "no `{DEVICE_STARTED}` after the first stream's end — the second stream did not find \ - a suspended daemon, so nothing here tests a resume:\n{window}" - )); - } - if !window.contains("soundserver: wakes=") { - return Err(format!("soundserver reported no stats window while the client ran:\n{window}")); - } - if sum_field(window, "underruns") == 0 { - return Err(format!( - "soundserver filled no period the stalled client had not covered, so this boot staged \ - nothing:\n{window}" - )); - } - let deferred = sum_field(window, "deferred"); - if deferred != 0 { - return Err(format!( - "soundserver deferred {deferred} period(s) on a ring that replays every one and completes \ - it again, which is the panic this exists for:\n{window}" - )); - } - Ok(()) -} - -/// Two clients through soundserver, and what soundserver said about each leaving: every -/// removal names a departure soundserver established, and none claims a death. -pub fn departures_on_metal(log: &Serial) -> Result<(), String> { - const CLIENTS: usize = 2; - let window = job_window(log.text(), "test_rs_null_sink_client_exits", CLIENTS)?; - let problems = check_departures(window, CLIENTS); - if problems.is_empty() { - return Ok(()); - } - Err(format!("{}\n{window}", problems.join("\n"))) -} - /// Sum one `soundserver:` counter across every stats window. fn sum_field(serial: &str, key: &str) -> u32 { let needle = format!(" {key}="); @@ -164,141 +86,3 @@ fn sum_field(serial: &str, key: &str) -> u32 { }) .sum() } - -/// Every way a client left, as soundserver reported it: one entry per -/// `soundserver: client {id} removed ({how})` in `serial`. -/// -/// The reason is read from the same line — a removal that names none yields the -/// empty string, which is what [`check_departures`] reds on. -fn departures(serial: &str) -> Vec { - serial - .lines() - .filter(|l| l.contains("soundserver: client ") && l.contains(" removed")) - .map(|l| { - let after = l.split(" removed").nth(1).unwrap_or("").trim(); - after - .strip_prefix('(') - .and_then(|r| r.split(')').next()) - .unwrap_or("") - .to_string() - }) - .collect() -} - -/// **soundserver may not report a departure it did not establish.** -/// -/// A crash and a clean exit close the same descriptors in the same order, so -/// the mix loop's broken signal pipe witnesses neither. -/// Two things are asserted here, and the -/// second is the one with teeth: every removal names how the stream ended, in -/// the fixed departure vocabulary, and no line claims a death. -/// -/// `expect` is how many removals the window must carry: a capture where no -/// client ever left would otherwise satisfy every check above it vacuously. -fn check_departures(serial: &str, expect: usize) -> Vec { - const KNOWN: [&str; 4] = ["closed", "refused", "disconnected", "signal pipe gone"]; - let mut problems = Vec::new(); - - let seen = departures(serial); - if seen.len() != expect { - problems.push(format!( - "soundserver reported {} client removals, expected {expect}", - seen.len() - )); - } - for how in &seen { - if !KNOWN.contains(&how.as_str()) { - problems.push(format!( - "a client was removed with no departure soundserver established ({how:?}); \ - the four soundserver establishes are {KNOWN:?}" - )); - } - } - // The word itself, whatever line carries it: soundserver cannot see a death and - // must not print one. - for line in serial.lines().filter(|l| l.contains("soundserver: ")) { - if line.contains(" died") || line.contains(" crashed") { - problems.push(format!( - "soundserver claimed a client death it cannot distinguish from a clean exit: \ - {line:?}" - )); - } - } - problems -} - -/// soundserver's mix thread never waits on the log: `tests/images/logstallcase.toml`'s `logkeeper` -/// reads nothing of soundserver's until the job says the tone has played, and the -/// job fills soundserver's ring with soundserver's own refusals before it plays. Judged -/// off `/log`: -/// -/// 1. **The ring was full** — the premise: `logkeeper` found every one of its slots -/// waiting when the stall ended. -/// 2. **Nothing went unwritten silently**: every line soundserver's control thread -/// said after the boot is in `/log` or among the records `logkeeper` counted -/// unwritten, exactly, and some were counted — the flood is larger than the -/// ring. -pub fn log_stall_on_metal(log: &Serial) -> Result<(), String> { - const REFUSAL: &str = "soundserver: refusing connection,"; - // `logkeeper`'s counts of soundserver's shared-ring records it could not write: its - // lanes are the mix thread's, counted apart. - const UNWRITTEN: [&str; 2] = - [" record(s) of soundserver's found its ring full", " record(s) of soundserver's past its"]; - const RELEASED: &str = "logkeeper: reading soundserver again, as `--stall-until` asked, with "; - const OF_SLOTS: &str = " of its ring's "; - // The control thread's lines after the flood that are not refusals: the - // probe it accepted, and the tone's stream. Either is in `/log` or counted. - const AFTER_FLOOD: [&str; 2] = ["soundserver: protocol violation (msg ", "soundserver: opening stream: "]; - let number_before = |line: &str, marker: &str| -> Option { - let at = line.find(marker)?; - line[..at].rsplit(' ').next()?.parse().ok() - }; - let number_after = |line: &str, marker: &str| -> Option { - let rest = &line[line.find(marker)? + marker.len()..]; - rest.split(' ').next()?.parse().ok() - }; - let text = log.text(); - let said_by_job = |marker: &str| -> Result { - text.lines() - .find_map(|l| number_after(l, marker)) - .ok_or_else(|| format!("the job never said {marker:?}:\n{text}")) - }; - // Every refusal the job heard or provoked is one line soundserver said. - let owed = said_by_job("flooded soundserver with ")? + said_by_job("soundserver refused ")?; - - let (mut refusals, mut unsaid, mut others, mut waiting) = (0u64, 0u64, 0u64, None); - let soundserver = toyos_build::bootlog::lines_of(text, "soundserver"); - let logkeeper = toyos_build::bootlog::lines_of(text, "logkeeper"); - for line in soundserver.lines().chain(logkeeper.lines()) { - if line.contains(REFUSAL) { - refusals += 1; - } - unsaid += UNWRITTEN.iter().filter_map(|m| number_before(line, m)).sum::(); - if AFTER_FLOOD.iter().any(|m| line.contains(m)) { - others += 1; - } - if let (Some(held), Some(slots)) = (number_after(line, RELEASED), number_after(line, OF_SLOTS)) { - waiting = Some((held, slots)); - } - } - match waiting { - Some((held, slots)) if held == slots && slots > 0 => {} - Some((held, slots)) => { - return Err(format!( - "logkeeper found {held} of soundserver's {slots} ring slots waiting when its stall ended: \ - the tone was not played to a full ring" - )) - } - None => return Err("/log never says logkeeper's stall on soundserver ended".to_string()), - } - let said = owed + AFTER_FLOOD.len() as u64; - if refusals + others + unsaid != said || unsaid == 0 { - return Err(format!( - "soundserver's control thread said {said} lines after the boot ({owed} refusals and {} \ - others); /log holds {refusals} refusals and {others} of the others, and logkeeper \ - counted {unsaid} unwritten", - AFTER_FLOOD.len(), - )); - } - Ok(()) -} diff --git a/tests/common/compile.rs b/tests/common/compile.rs index 9914a1b46d7..9b82b322773 100644 --- a/tests/common/compile.rs +++ b/tests/common/compile.rs @@ -89,14 +89,6 @@ pub fn compile_c(name: &str) -> Vec { .collect() } -/// Compile `name.c` in `dir`, a C program of ToyOS's own outside the corpus, -/// with every warning an error, and return its object. -pub fn compile_own_c(dir: &Path, name: &str) -> Vec { - let object = scratch(name, ".o"); - compile_one(&c_sysroot(), dir, &["-Wall", "-Wextra", "-Werror"], &format!("{name}.c"), &object); - vec![object] -} - /// Link flags a case needs beyond the corpus's, by case name. /// /// An image whose lowest address is 0 hides a table reported from the load bias diff --git a/tests/common/https.rs b/tests/common/https.rs deleted file mode 100644 index 234fa7f7ac9..00000000000 --- a/tests/common/https.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! The HTTPS server a guest's client is judged against: `rustls` on `ring` on -//! the host, listening on loopback and reached from the guest through slirp at -//! [`HOST`], presenting a certificate an [`Authority`] made for this run. -//! -//! It is the client's peer, not its oracle: both ends are `rustls` on `ring`. -//! What it reports is what only the peer sees — that a connection arrived, -//! the protocol version the client agreed to and the `User-Agent` it sent. - -use std::io::{BufRead, BufReader, Write}; -use std::net::{IpAddr, TcpListener, TcpStream}; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use rcgen::{BasicConstraints, CertificateParams, CertifiedIssuer, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; -use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; - -/// The host as slirp shows it to the guest; it carries a connection to this -/// address to the host's loopback. -pub const HOST: &str = "10.0.2.2"; - -/// The bytes the server answers every request with: several hundred KiB of a -/// fixed xorshift stream, so a dropped, duplicated or reordered segment -/// changes the hash, and the same every run. -pub fn body() -> Vec { - const BYTES: usize = 384 * 1024; - let mut state: u64 = 0x9e37_79b9_7f4a_7c15; - let mut body = Vec::with_capacity(BYTES); - while body.len() < BYTES { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - body.extend_from_slice(&state.to_le_bytes()); - } - body.truncate(BYTES); - body -} - -/// A certificate authority made for this run alone, whose key never leaves -/// this process. -pub struct Authority(CertifiedIssuer<'static, KeyPair>); - -impl Authority { - pub fn new(name: &str) -> Self { - let mut params = CertificateParams::default(); - params.distinguished_name.push(DnType::CommonName, name); - params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::DigitalSignature]; - let key = KeyPair::generate().expect("an authority's key"); - Self(CertifiedIssuer::self_signed(params, key).expect("an authority's certificate")) - } - - /// Its certificate, as one PEM block. - pub fn pem(&self) -> String { - self.0.pem() - } - - /// A certificate for the one address `san` and its key, signed by this - /// authority. - pub fn leaf(&self, san: IpAddr) -> (CertificateDer<'static>, PrivateKeyDer<'static>) { - let mut params = CertificateParams::default(); - params.subject_alt_names = vec![SanType::IpAddress(san)]; - let key = KeyPair::generate().expect("a server's key"); - let cert = params.signed_by(&key, &self.0).expect("a server's certificate"); - (cert.der().clone(), PrivatePkcs8KeyDer::from(key.serialize_der()).into()) - } -} - -/// What the server saw of a connection: [`Seen::Accepted`] as it arrives, -/// then one of the other two as it ends. -#[derive(Debug)] -pub enum Seen { - Accepted, - /// A handshake that completed and a request answered with the whole body. - Served { version: Option, path: String, user_agents: Vec }, - /// A handshake the server could not complete. - Failed, -} - -/// A server on a loopback port of its own, answering each connection in turn -/// with [`body`]-shaped bytes, until this process ends. -pub struct Server { - pub port: u16, - seen: Receiver, -} - -impl Server { - pub fn start(cert: (CertificateDer<'static>, PrivateKeyDer<'static>), body: Arc>) -> Result { - let (chain, key) = cert; - let config = rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) - .with_safe_default_protocol_versions() - .and_then(|b| b.with_no_client_auth().with_single_cert(vec![chain], key)) - .map_err(|e| format!("the HTTPS server's configuration: {e}"))?; - let config = Arc::new(config); - let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("the HTTPS server: {e}"))?; - let port = listener.local_addr().map_err(|e| format!("the HTTPS server's port: {e}"))?.port(); - let (tx, seen) = mpsc::channel(); - // Ends with the process, or once nobody reads what it saw. - thread::spawn(move || { - for tcp in listener.incoming() { - let tcp = tcp.expect("accept a connection on the HTTPS server's port"); - if tx.send(Seen::Accepted).is_err() || tx.send(serve(&config, tcp, &body)).is_err() { - return; - } - } - }); - Ok(Self { port, seen }) - } - - /// The next thing seen, waited for up to `within`. - pub fn next(&self, within: Duration) -> Result { - self.seen.recv_timeout(within).map_err(|e| match e { - RecvTimeoutError::Timeout => format!("the server on port {} saw nothing more within {within:?}", self.port), - RecvTimeoutError::Disconnected => format!("the HTTPS server on port {} is gone", self.port), - }) - } - - /// What it has seen past what was read, without waiting. - pub fn more(&self) -> Vec { - self.seen.try_iter().collect() - } -} - -/// One connection: the handshake, one request read to its blank line, and -/// the body after a header that gives its length. -fn serve(config: &Arc, mut tcp: TcpStream, body: &[u8]) -> Seen { - let mut conn = rustls::ServerConnection::new(config.clone()).expect("a server connection"); - while conn.is_handshaking() { - if conn.complete_io(&mut tcp).is_err() { - return Seen::Failed; - } - } - let version = conn.protocol_version(); - let mut stream = rustls::Stream::new(&mut conn, &mut tcp); - let mut request = BufReader::new(&mut stream); - let mut first = String::new(); - request.read_line(&mut first).expect("the request line"); - let path = first.split_whitespace().nth(1).unwrap_or_default().to_string(); - let mut user_agents = Vec::new(); - loop { - let mut line = String::new(); - request.read_line(&mut line).expect("a request header"); - let line = line.trim_end(); - if line.is_empty() { - break; - } - if let Some((name, value)) = line.split_once(':') { - if name.eq_ignore_ascii_case("user-agent") { - user_agents.push(value.trim().to_string()); - } - } - } - drop(request); - let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); - stream.write_all(head.as_bytes()).expect("the response's head"); - stream.write_all(body).expect("the response's body"); - stream.flush().expect("the response"); - conn.send_close_notify(); - while conn.wants_write() { - conn.write_tls(&mut tcp).expect("the close_notify"); - } - Seen::Served { version, path, user_agents } -} diff --git a/tests/common/iommu.rs b/tests/common/iommu.rs index 44a66384f47..3f8e6ebd9e8 100644 --- a/tests/common/iommu.rs +++ b/tests/common/iommu.rs @@ -1,24 +1,19 @@ -//! `iommu_virtio_platform`: whether each virtio function QEMU creates behind -//! its emulated VT-d unit, and none created without one, negotiated -//! `VIRTIO_F_ACCESS_PLATFORM`, and that a process's claim on one is refused -//! where no unit is. `amdvi_firmware_left`: whether an AMD-Vi unit firmware -//! left blocking is switched off before the boot stick is driven. +//! `iommu_virtio_platform`: whether each virtio function the kernel drives +//! itself negotiated `VIRTIO_F_ACCESS_PLATFORM` behind QEMU's emulated VT-d +//! unit and none without one, and that a process's claim on one is handed +//! over behind the unit and refused where no unit is. +//! `amdvi_firmware_left`: whether an AMD-Vi unit firmware left blocking is +//! switched off before the boot stick is driven. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use super::qemu::{self, BootOptions, Profile, QemuInstance}; +use super::qemu::{self, is_kernel_line, BootOptions, Profile, QemuInstance}; use super::serial::Serial; /// The function `tests/images/netcase.toml`'s netstack claims, as its `devices` row spells it. const NETSTACK_CLAIMS: &str = "1af4:1041"; -/// soundserver's feature line, the kernel's shape under soundserver's name. -const SOUNDSERVER_NEGOTIATED: &str = "soundserver: VirtIO: PCI "; - -/// fileserver's word for DATA's directories served from memory. -const IN_MEMORY: &str = "are in memory and will not survive a reboot"; - /// The boot config that runs `netstack` with a virtio NIC in front of it. fn netcase() -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/images/netcase.toml") @@ -30,8 +25,10 @@ fn netcase() -> std::path::PathBuf { /// whatever the tables say — unless it is created with `iommu_platform=on` /// (`hw/virtio/virtio-bus.c:86-99`, `hw/virtio/virtio-pci.c:1400-1405` at /// v11.1.1), and under identity mapping the two are indistinguishable. So the -/// argv says which functions were created behind a unit and the console says -/// which negotiated `VIRTIO_F_ACCESS_PLATFORM`. On [`Profile::HeadlessNoIommu`] +/// argv says which functions were created behind a unit and the kernel's +/// records say which of those it drives negotiated `VIRTIO_F_ACCESS_PLATFORM`; +/// the NIC's and the sound function's drivers are processes', whose +/// negotiation is `toyos-virtio`'s and host-tested there. On [`Profile::HeadlessNoIommu`] /// the guest reports `n` because QEMU never *offers* the bit /// (`hw/virtio/virtio-bus.c:87-94`), not because the driver declined — it offers /// blindly; the independence comes from [`declining_is_not_free`]. @@ -66,28 +63,18 @@ pub fn iommu_virtio_platform(test_config: &Path) -> Result<(), String> { } // `netcase`: the NIC's driver is a process, and this is the one config - // that runs it. A daemon's line is waited for on the stream: the boot - // log ends at test-runner's `===READY===`, its first act, and nothing - // orders that against what the programs the supervisor started before it print. - // And the kernel's records of a claim with them: those reach the - // console by the kernel's own road, which nothing orders against a - // program's line either. + // that runs it. The kernel's records of a claim are waited for on the + // stream: the boot log ends at test-runner's `===READY===`, its first + // act, and nothing orders that against the claims of the programs the + // supervisor started before it. let mut qemu = QemuInstance::boot_with_options(&netcase(), &[], &[], options); let said: Vec = if behind_unit { - vec![ - NETSTACK_NEGOTIATED.to_string(), - SOUNDSERVER_NEGOTIATED.to_string(), - bar_moved(), - msix_armed(), - ] + vec![bar_moved(), msix_armed()] } else { vec![ - DISKSERVER_REFUSED.to_string(), - FILESERVER_WITHOUT_DATA.to_string(), not_handed_over(CLAIMED_AT, NOT_REMAPPED), not_handed_over(NVME_AT, NOT_REMAPPED), not_handed_over(SOUND_AT, NOT_REMAPPED), - super::audio::NULL_SINK.to_string(), ] }; let mut text = qemu.boot_log().to_string(); @@ -98,7 +85,6 @@ pub fn iommu_virtio_platform(test_config: &Path) -> Result<(), String> { let log = Serial::named("boot console", text); log.must_be_clean()?; log.must_say("Boot: complete")?; - log.must_say("supervisor: started netstack")?; // The CPU's own source in the generator's key, which firmware's seed // alone would key without it wherever firmware answers EFI_RNG_PROTOCOL. log.must_say("random: RDRAND is mixed into the generator's key")?; @@ -107,25 +93,23 @@ pub fn iommu_virtio_platform(test_config: &Path) -> Result<(), String> { // machine's answer, not a choice.** A process driving a device writes // addresses into descriptors, so the substrate refuses a claim on a // function this machine cannot give an address space of its own — and - // on a machine with no unit that is every function. So the arm with a - // unit has three negotiators, one of them across the boundary, and the - // arm without one has two and a refusal. - let expected = if behind_unit { + // on a machine with no unit that is every function. + if behind_unit { // The two things a hand-over spends, on the same function and the // same machine the arm below requires to be unspent. Without this // pair those `must_not_say`s would pass against a kernel that had // stopped writing either line. log.must_say(&bar_moved())?; log.must_say(&msix_armed())?; - created.len() } else { no_unit_is_no_claim(&log)?; - // The NIC's and the sound function's claims, refused. - created.len() - 2 - }; + } + // Every function the kernel drives itself: all QEMU created but the + // NIC and the sound function, whose drivers are processes. + let expected = created.len() - 2; let mut negotiated = Vec::new(); - for line in log.text().lines() { + for line in log.text().lines().filter(|line| is_kernel_line(line)) { let Some(rest) = line.split("VirtIO: PCI ").nth(1) else { continue }; let Some((who, _)) = rest.split_once(' ') else { return Err(format!("{name}: unreadable feature line: {line:?}")); @@ -136,8 +120,8 @@ pub fn iommu_virtio_platform(test_config: &Path) -> Result<(), String> { } if negotiated.len() != expected { return Err(format!( - "{name}: QEMU created {} virtio function(s), {expected} of them for a driver \ - this machine can bring up, and the guest negotiated features with {} — \ + "{name}: QEMU created {} virtio function(s), {expected} of them for the kernel's \ + own drivers, and the kernel negotiated features with {} — \ {negotiated:?} against {created:?}", created.len(), negotiated.len() @@ -158,38 +142,15 @@ pub fn iommu_virtio_platform(test_config: &Path) -> Result<(), String> { )); } } - let sound = class_function(&log, "0401") - .ok_or_else(|| format!("{name}: this machine enumerated no audio function"))?; - if sound != SOUND_AT { - return Err(format!("{name}: the audio function is {sound}, where {SOUND_AT} is owed")); - } - if behind_unit != negotiated.iter().any(|(who, _)| *who == sound) { - return Err(format!( - "{name}: the audio function {sound} negotiated features = {}, where a process's \ - claim on it is owed = {behind_unit}: {negotiated:?}", - !behind_unit - )); - } eprintln!( - " [iommu] {name}: {} virtio function(s) negotiated, behind a unit = {behind_unit}; {}", + " [iommu] {name}: the kernel's {} virtio function(s) negotiated, behind a unit = {behind_unit}; {}", negotiated.len(), - if behind_unit { - format!("the audio function {sound} among them") - } else { - format!("the NIC's and the audio function {sound}'s claims refused for want of a unit") - } + if behind_unit { "the NIC's claim handed over" } else { "every claim refused for want of a unit" } ); } declining_is_not_free(test_config) } -/// netstack's feature line, the kernel's shape under netstack's name. -const NETSTACK_NEGOTIATED: &str = "netstack: VirtIO: PCI "; -const DISKSERVER_REFUSED: &str = - "diskserver: NOT SERVING — pci:1b36:0010 is on this machine and the kernel refused this service its claim"; -const FILESERVER_WITHOUT_DATA: &str = - "fileserver: the block service would not list its partitions (Refused(ClaimRefused)); DATA is absent this boot"; - /// The slot QEMU's `-device` order puts `tests/images/netcase.toml`'s NVMe controller on, /// the one its diskserver row claims. const NVME_AT: &str = "00:02.0"; @@ -206,31 +167,24 @@ fn not_handed_over(at: &str, why: &str) -> String { format!("pcidev: PCI {at} NOT HANDED OVER — {why}") } -/// **A machine with no unit hands no function to a process**, and says so -/// three times over. +/// **A machine with no unit hands no function to a process**, and the +/// supervisor that asked is told so. /// /// The ordering ruling this whole stage stands on /// (`issues/every-driver-is-still-in-the-kernel.md`) is that moving a /// driver out without the unit costs security: a message nothing remaps raises /// any vector on any CPU, and a descriptor holding a physical address is an -/// arbitrary read and write over all of memory. So the kernel -/// refuses the claim by name, the supervisor says which device it could not -/// mint, and netstack exits rather than driving anything — and the machine -/// finishes booting, which is the half a refusal that panicked would fail. The -/// NVMe controller is refused the same, and DATA with it by name: a disk that -/// is there and cannot be used is never answered with memory. So is the -/// virtio-sound function, and soundserver takes its null sink. +/// arbitrary read and write over all of memory. So the kernel refuses the +/// claim by name and the supervisor says which device it could not mint — and +/// the machine finishes booting, which is the half a refusal that panicked +/// would fail. The NVMe controller and the virtio-sound function are refused +/// the same. fn no_unit_is_no_claim(log: &Serial) -> Result<(), String> { - // netstack's own exit is the third saying, and is not read here. refused_claim(log, NETSTACK_CLAIMS, NOT_REMAPPED, &[NVME_AT, SOUND_AT])?; log.must_say(¬_handed_over(SOUND_AT, NOT_REMAPPED))?; log.must_say("supervisor: soundserver: pci:1af4:1059 is on this machine and could not be handed over")?; - log.must_say(super::audio::NULL_SINK)?; log.must_say(¬_handed_over(NVME_AT, NOT_REMAPPED))?; log.must_say("supervisor: diskserver: pci:1b36:0010 is on this machine and could not be handed over")?; - log.must_say(DISKSERVER_REFUSED)?; - log.must_say(FILESERVER_WITHOUT_DATA)?; - log.must_not_say(IN_MEMORY)?; // And this machine handed *nothing* over, which is more than the claim's // own refusal says: with no unit there is no function any process could be // given an address space for. @@ -405,27 +359,6 @@ fn enumerated_functions(log: &Serial) -> BTreeSet { .collect() } -/// The one function `pci::enumerate` printed with this class, or none. -/// -/// `None` rather than a first match over several: two controllers of one class -/// would make "the one the actuator skipped" ambiguous, and a gate that picked -/// either would be asserting against a guess. -fn class_function(log: &Serial, class: &str) -> Option { - let mut found: Option = None; - for line in log.text().lines() { - let Some((bdf, tail)) = line.split("PCI ").nth(1).and_then(|r| r.split_once(' ')) else { - continue; - }; - if tail.starts_with(&format!("[{class}]")) { - if found.is_some() { - return None; - } - found = Some(bdf.to_string()); - } - } - found -} - /// What the kernel says of the one unit QEMU's IVRS describes, as an 11h over /// a superseded 10h (`lib/acpi/tests/ivrs.rs` decodes the same table). const AMDVI_UNIT: &str = "iommu: amdvi unit0 @0xfed80000 seg=0 dev=00:02.0 ivhd=0x11"; diff --git a/tests/common/metal.rs b/tests/common/metal.rs index ea08ff2f549..9842aab4e27 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -403,8 +403,8 @@ impl Readback { /// **The name is the file's, truncated the way the kernel truncates it.** /// `ProcessEntry`'s name is `THREAD_NAME_LEN` bytes and the loader fills it /// from the path's last component, so a binary whose name is longer than - /// that is recorded under a prefix: the staged `null_sink_client_exits` - /// ends `…_client_ex` on the wire, and a predicate looking for the whole + /// that is recorded under a prefix: the staged `munmap_reissues_read_window` + /// ends `…_reissues_rea` on the wire, and a predicate looking for the whole /// name would find nothing on a perfectly good boot. /// /// **And the name alone does not identify one process.** A guest binary that diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 744e69b50fe..24a72a3500e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -8,8 +8,6 @@ pub mod compile; pub mod devices; pub mod faults; pub mod hostedclang; -/// The HTTPS server a guest's client fetches from. -pub mod https; pub mod iommu; pub mod irqcensus; /// The `isa` claim's rows on the T14. diff --git a/tests/common/power.rs b/tests/common/power.rs index 6659738ad5f..61ff0aa316e 100644 --- a/tests/common/power.rs +++ b/tests/common/power.rs @@ -46,34 +46,6 @@ pub fn ended( Ok(()) } -/// A process holding `POWER` runs `shutdown` and the machine powers off: the -/// kernel declared q35's PM1a block, `acpiserver` evaluated the DSDT's `\_S5` -/// and handed the kernel its `SLP_TYPa`, which the kernel has from nowhere -/// else, and QEMU stops for `guest-shutdown`, which neither a reset nor a -/// halt is. -pub fn machine_shutdown(test_config: &Path) -> Result<(), String> { - let options = BootOptions { qmp: true, ..Default::default() }; - let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); - - let boot = serial::Serial::boot(&qemu); - boot.must_be_clean()?; - // The values, so a wrong one fails here and not as a machine that stayed - // up; and the event the shutdown waits on, since one asked before it is - // refused. - let mut supplied = boot.text().to_string(); - qemu::await_marker(&mut qemu, &mut supplied, Q35_S5_SUPPLIED, "the ACPI server to hand the kernel \\_S5")?; - - let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET)); - writeln!(qemu.stdin_mut(), "run shutdown").expect("write to QEMU stdin"); - qemu.flush_stdin(); - let mut console = String::new(); - ended(&mut qemu, &mut stop, &mut console, SHUTTING_DOWN, "guest-shutdown")?; - serial::Serial::named("shutdown drain", console).must_be_clean()?; - - eprintln!(" [power] shutdown: QEMU stopped the guest for guest-shutdown"); - Ok(()) -} - /// What the stop alerts where the console's wire was kept through the stop's /// whole budget, in `kernel/src/log/console.rs`. const WIRE_KEPT: &str = "kept the wire through the stop's"; @@ -679,10 +651,6 @@ pub fn usb_reset_on_metal(arms: &[&super::metal::Readback]) -> Result<(), String /// whole, before it loads the tables. const ACPI_ARMED: &str = "acpiserver: armed: power button served"; -/// The server's line for the press, naming the SCI it came on. -const ACPI_PRESSED: &str = - "acpiserver: the power button was pressed, on SCI 1 of this boot; asking the supervisor to power off"; - /// What `/system/bin/acpiserver` says of each definition block, after /// `acpiserver: `: its place, how many there are, which it is, and what /// became of it. @@ -722,170 +690,3 @@ pub fn acpi_tables_loaded(log: &serial::Serial, kernel: &serial::Serial, supplie eprintln!(" [power] {count} table(s) loaded; {} beside {}", handed.trim(), kept.trim()); Ok(count) } - -/// A press of q35's power button stops the machine through ToyOS's own path: -/// the kernel put the machine in ACPI mode for the server's claim, the press -/// arrives as the server's first SCI and the only one, since the kernel masks -/// the level line until the server has served it, and the server has the -/// supervisor stop the machine, which QEMU reports as the guest's own -/// power-off. The press is sent as soon as the server has armed, which is -/// before it loads the machine's tables: the press is latched across the -/// load, whose lines and `\_S5` are read here, on the one firmware's tables a -/// guest has, and powers the machine off with the sleep type the load handed -/// the kernel. -pub fn acpi_power_button(test_config: &Path) -> Result<(), String> { - let options = BootOptions { qmp: true, ..Default::default() }; - let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); - let boot = serial::Serial::boot(&qemu); - boot.must_be_clean()?; - let mut console = boot.text().to_string(); - // A wait each, as in `machine_shutdown_short_stop`: the server's line and - // the kernel's record reach the console by different roads. - qemu::await_marker(&mut qemu, &mut console, ACPI_ARMED, "the ACPI server arming")?; - qemu::await_marker(&mut qemu, &mut console, HANDED_OVER_IN_ACPI_MODE, "the kernel's record of the mode it was handed over in")?; - - let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET)); - stop.power_button(); - let pressed_at = console.len(); - ended(&mut qemu, &mut stop, &mut console, SHUTTING_DOWN, "guest-shutdown")?; - let after = serial::Serial::named("the press", console[pressed_at..].to_string()); - after.must_say(ACPI_PRESSED)?; - if !toyos_build::bootlog::asked_to_power_off(after.text()) { - return Err(format!("the supervisor's last word after the press is not a power-off:\n{}", after.text())); - } - after.must_be_clean()?; - let whole = serial::Serial::named("the boot and the press", console); - acpi_tables_loaded(&whole, &whole, Q35_S5_SUPPLIED)?; - whole.must_say_after(ACPI_S5_HANDED, ACPI_PRESSED)?; - eprintln!(" [power] the press: {ACPI_PRESSED}"); - Ok(()) -} - -/// The PM1a event block QEMU's q35 firmware sets, whose status -/// [`ssdt_acquiring_the_global_lock`] reads: the table is built before the -/// boot, which then says the block it found. -const Q35_PM1A_EVENTS: u16 = 0x600; - -/// The Acquire's TimeoutValue, in ms; the wait for the press before it has -/// the rest of the 10 s one evaluation may spend asleep, past which the -/// load refuses the table. -const ACQUIRE_MS: u16 = 2000; - -/// What the server says of the table's Notify, which runs only where the -/// Acquire before it timed out. -const ACQUIRE_TIMED_OUT: &str = "Notify(\\_SB_.GLW_, 0x80) for the first time"; - -/// What it says of the load's takes: one, and it found the firmware holding -/// the lock. -const ONE_TAKE_CONTENDED: &str = "; took the Global Lock 1 times, 1 of them from the firmware;"; - -/// An SSDT (ACPI 6.5 §5.2.11.2) whose definition block declares the device -/// `\_SB.GLW` and, as it loads, waits for `PWRBTN_STS` (Table 4.13) in the -/// PM1a event block at `pm1`, then Acquires `\_GL` within `ms`, giving it -/// back where that took it and Notifying `\_SB.GLW` with 0x80 where it timed -/// out: -/// -/// ```text -/// Device (\_SB.GLW) {} -/// OperationRegion (PM1S, SystemIO, pm1, 2) -/// Field (PM1S, WordAcc, NoLock, Preserve) { Offset (1), PBST, 1 } -/// While (!PBST) { Sleep (1) } -/// If (Acquire (\_GL, ms)) { Notify (\_SB.GLW, 0x80) } Else { Release (\_GL) } -/// ``` -fn ssdt_acquiring_the_global_lock(pm1: u16, ms: u16) -> Vec { - const GL: &[u8] = b"\\_GL_"; - // `\` and a DualNamePrefix (§20.2.2). - const GLW: &[u8] = b"\\\x2E_SB_GLW_"; - const PM1S: &[u8] = b"PM1S"; - const PBST: &[u8] = b"PBST"; - // A PkgLength of one byte (§20.2.4): itself and what follows, under 64. - let package = |op: &[u8], body: &[u8]| [op, &[u8::try_from(body.len() + 1).expect("a short package")], body].concat(); - // SystemIO (1) at a WordConst, of a ByteConst's length. - let region = [&[0x5B, 0x80], PM1S, &[0x01, 0x0B], &pm1.to_le_bytes(), &[0x0A, 0x02]].concat(); - // WordAcc (2); a ReservedField (0) of 8 bits, then PBST of 1. - let field = package(&[0x5B, 0x81], &[PM1S, &[0x02, 0x00, 0x08], PBST, &[0x01]].concat()); - // LNot (0x92) of PBST; Sleep of One (0x01). - let wait = package(&[0xA2], &[&[0x92], PBST, &[0x5B, 0x22, 0x01]].concat()); - let acquire = [&[0x5B, 0x23], GL, &ms.to_le_bytes()].concat(); - let notify = [&[0x86], GLW, &[0x0A, 0x80]].concat(); - let release = [&[0x5B, 0x27], GL].concat(); - let aml = [ - package(&[0x5B, 0x82], GLW), - region, - field, - wait, - package(&[0xA0], &[acquire, notify].concat()), - package(&[0xA1], &release), - ] - .concat(); - let length = u32::try_from(36 + aml.len()).expect("a short table"); - let mut table = [b"SSDT".as_slice(), &length.to_le_bytes(), &[2, 0], b"TOYOS ", b"GLWAIT ", &1u32.to_le_bytes(), b"TOYS", &1u32.to_le_bytes(), &aml].concat(); - // The byte at 9 makes the whole table sum to zero. - table[9] = table.iter().fold(0u8, |sum, &b| sum.wrapping_sub(b)); - table -} - -/// The number after `after` on a line, in hex. -fn hex_after(line: &str, after: &str) -> Result { - line.split_once(after) - .map(|(_, rest)| rest.trim_start_matches("0x")) - .and_then(|rest| u64::from_str_radix(&rest[..rest.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(rest.len())], 16).ok()) - .ok_or_else(|| format!("no number after {after:?} on {line:?}")) -} - -/// A press latched before the server's wait for the firmware's release of -/// the Global Lock is served once the wait ends at its Acquire's timeout: the -/// wait clears the press's enable once it finds its status latched, takes -/// the SCI the press raised, and puts every enable back after, so the press's -/// status raises the SCI again for `serve`. `tests/images/acpicase.toml` with the test -/// kernel's actuator for the firmware's side of the lock, which -/// `acpi_mediated`'s `held` arm stages owned before it hands the server the -/// claim; the table QEMU adds waits as it loads for the press, sent once the -/// server has armed, and only then Acquires the lock within [`ACQUIRE_MS`]. -/// Then the press must power the machine off, after the Acquire said it timed -/// out. -pub fn acpi_press_across_a_lock_wait(probe: (String, Vec)) -> Result<(), String> { - let case = super::compile::repo_root().join("tests/images/acpicase.toml"); - let mut qemu = QemuInstance::boot_with_options( - &case, - &[], - &[], - BootOptions { - // The test kernel, for the Global Lock's actuator, as `acpi_mediated_access` boots it. - kernel_params: &["i8042-withheld"], - ready_marker: "acpi: the Global Lock is the FACS's at ", - extra_root_files: vec![ - probe, - // The arm's name is the file's: the probe asks whether it is there. - ("share/acpi_mediated_held".to_string(), b"held\n".to_vec()), - ], - acpi_tables: vec![ssdt_acquiring_the_global_lock(Q35_PM1A_EVENTS, ACQUIRE_MS)], - qmp: true, - ..Default::default() - }, - ); - let boot = serial::Serial::named("the boot", qemu.boot_log().to_string()); - let pm1 = hex_after(boot.must_say("acpi: the ACPI row: PM1a events ")?, "PM1a events ")?; - if pm1 != u64::from(Q35_PM1A_EVENTS) { - return Err(format!("the PM1a event block is at {pm1:#x}, where the table QEMU was handed reads {Q35_PM1A_EVENTS:#x}")); - } - // Opened before the press: QMP delivers no event emitted before its - // client connected. - let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET)); - let mut console = format!("{}\n", qemu.boot_log()); - qemu::await_marker(&mut qemu, &mut console, ACPI_ARMED, "the ACPI server arming")?; - stop.power_button(); - let pressed_at = console.len(); - ended(&mut qemu, &mut stop, &mut console, SHUTTING_DOWN, "guest-shutdown")?; - let after = serial::Serial::named("the press", console[pressed_at..].to_string()); - after.must_say(ONE_TAKE_CONTENDED)?; - after.must_say_after(ACQUIRE_TIMED_OUT, ACPI_PRESSED)?; - after.must_be_clean()?; - let whole = serial::Serial::named("the boot and the press", console); - if acpi_tables_loaded(&whole, &whole, Q35_S5_SUPPLIED)? < 2 { - return Err(format!("the server loaded no table beside the DSDT, so not the one QEMU was handed:\n{}", whole.text())); - } - eprintln!(" [power] {}", whole.must_say(ONE_TAKE_CONTENDED)?.trim()); - eprintln!(" [power] {}", whole.must_say_after(ACQUIRE_TIMED_OUT, ACPI_PRESSED)?.trim()); - Ok(()) -} diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index c68087bb7f5..99b646adc29 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -344,7 +344,7 @@ impl Liveness { /// this buys is that the summary says which of the two kinds of red it is. /// /// It lives here rather than beside the classifier because [`Liveness`] is what -/// produces the evidence for it, and [`QemuInstance::run_test_paced`] is a +/// produces the evidence for it, and [`QemuInstance::run_test`] is a /// second producer: a test's own ceiling is a guard of exactly this kind. pub const STALLED: &str = "STALLED:"; @@ -677,11 +677,6 @@ pub enum Profile { /// `iommu_platform=on`, and the harness sets that only where a unit exists, /// so the guest's own negotiation comes out the other way here. HeadlessNoIommu, - /// [`Profile::Headless`] with QEMU's `e1000e` for its NIC, the 82574L - /// `toyos-i219` drives beside the T14's I219: the one guest in which - /// netstack runs its Intel driver, a ring of fifteen frames and a wake - /// for transmit room. - HeadlessE1000e, /// [`Profile::Headless`] with no USB controller: the machine whose NVMe /// disk is the only storage it has, and the only device its firmware can /// boot. @@ -704,13 +699,6 @@ pub enum Profile { /// ===TEST_START=== protocol like any other. [`BootOptions::mute`] takes /// it away for the one test that certifies the T14's literal shape. Metal, - /// [`Profile::Metal`]'s machine with a USB keyboard and an absolute USB - /// tablet beside its i8042, on a smaller panel: the desktop driven as a - /// person at QEMU's window drives it, so a test injects keys and a pointer - /// position through the compositor's own devices and reads the panel back. - /// The i8042 stays; QEMU activates one input handler per device class, so - /// every injected keystroke and position goes to the USB device. - Desktop, /// [`Profile::Metal`] on a CPU whose CPUID offers no x2APIC, emulated /// whatever the host, so its local APICs run in xAPIC mode: the machine /// an AMD laptop without x2APIC is. @@ -770,12 +758,10 @@ impl Profile { } Self::Headless | Self::HeadlessNoIommu - | Self::HeadlessE1000e | Self::HeadlessNoUsb | Self::HeadlessVirtioGpu | Self::HeadlessUsbSpare | Self::Metal - | Self::Desktop | Self::MetalNoX2apic | Self::MetalAmdVi => Arch::X86_64, } @@ -887,8 +873,6 @@ impl Virtio { enum Nic { Absent, Virtio, - /// QEMU's model of the 82574L. - E1000e, } /// Everything a profile decides about the machine, in one table. A new @@ -1036,15 +1020,9 @@ impl Profile { rng: false, virtio_gpu: false, }, - Self::Desktop => Shape { - panel: Some((1280, 800)), - usb: &["usb-kbd,bus=xhci.0", "usb-tablet,bus=xhci.0"], - ..Self::Metal.shape() - }, Self::MetalNoX2apic => Self::Metal.shape(), Self::MetalAmdVi => Shape { iommu: Some(Unit::AmdVi), ..Self::Metal.shape() }, Self::HeadlessNoIommu => Shape { iommu: None, ..Self::Headless.shape() }, - Self::HeadlessE1000e => Shape { nic: Nic::E1000e, ..Self::Headless.shape() }, Self::HeadlessNoUsb => Shape { xhci: &[], usb: &[], ..Self::Headless.shape() }, Self::HeadlessVirtioGpu => Shape { virtio_gpu: true, ..Self::Headless.shape() }, Self::HeadlessUsbSpare => Shape { @@ -1104,14 +1082,6 @@ pub struct BootOptions { /// calling CPU's affinity: the firmware side's own account of what the /// kernel asked of it. pub psci_trace: Option, - /// A second slot beside the one the image boots, with room for an - /// update's ROOT of this many bytes: a machine that updates itself. - /// `None` for every guest whose subject is not the update. - pub second_slot: Option, - /// Whole ACPI tables, header and checksum included, that QEMU lists - /// beside its own (`-acpitable`): firmware AML a guest's own tables do - /// not carry. q35's alone. - pub acpi_tables: Vec>, /// The instant the machine's RTC starts at, as QEMU's `-rtc base=` takes /// it, and the host's own UTC with `None`. pub rtc_base: Option<&'static str>, @@ -1152,8 +1122,6 @@ impl Default for BootOptions { ready_marker: DEFAULT_READY, extra_root_files: Vec::new(), psci_trace: None, - second_slot: None, - acpi_tables: Vec::new(), rtc_base: None, } } @@ -1200,10 +1168,6 @@ pub struct QemuInstance { smp: u32, /// The test binaries this boot put on ROOT, by the name `run` takes. carried: BTreeSet, - /// What this machine was booted with, and the 16550's file: what a - /// second wait for its ready marker reads. - options: BootOptions, - uart_log: PathBuf, } /// Which of [`DECLARED_KERNEL_BUILDS`] this boot wants. @@ -1236,7 +1200,6 @@ fn build_boot_image_with( kernel_features: &[&str], kernel_params: &[&str], debug_wait: bool, - second_slot: Option, ) -> Vec { // **The two fields have the same type, so swapping them compiles.** It // happened once, in this file's own conversion: the shared boot handed @@ -1302,8 +1265,7 @@ fn build_boot_image_with( assert!(config.is_file(), "no image config at {}", config.display()); let quiet = !VERBOSE.load(Ordering::Relaxed); - let mut plan = toyos_build::build::Plan::new(arch, config, kernel_features, kernel_params); - plan.second = second_slot.map(|root_bytes| toyos_build::image::SecondSlot { root_bytes }); + let plan = toyos_build::build::Plan::new(arch, config, kernel_features, kernel_params); toyos_build::build::build_test_image(&compile::repo_root(), &plan, quiet, &extra_files) } @@ -1385,7 +1347,6 @@ impl QemuInstance { &features, ¶ms, options.debug_wait, - options.second_slot, ); let storage = options.profile.shape().storage; match storage { @@ -1432,9 +1393,6 @@ impl QemuInstance { }; let sockets = Sockets::new(&options); - for (i, table) in options.acpi_tables.iter().enumerate() { - fs::write(acpi_table(&sockets.dir, i), table).expect("[qemu] write an added ACPI table"); - } let screendump = test_dir.join(format!("screen-{seq}.ppm")); // Per-instance, not a fixed /tmp path: a screen test waits on this @@ -1465,20 +1423,6 @@ impl QemuInstance { ) } - /// From here a reset this guest asks for resets the machine, its memory - /// and its disks kept, where `-no-reboot` would have ended QEMU with it. - pub fn reset_on_reboot(&mut self) { - let socket = self.sockets.qmp.clone().expect("reset_on_reboot needs BootOptions { qmp: true }"); - Qmp::connect(&socket).execute("{\"execute\":\"set-action\",\"arguments\":{\"reboot\":\"reset\"}}"); - } - - /// Wait for this machine to reach its ready marker again, on the boot - /// after a reset: [`Self::boot_log`] is that boot's from here, opening - /// with what the boot before it said last. - pub fn await_boot(&mut self) { - self.boot_log = wait_for_ready(&mut self.child, &self.rx, &self.options, &self.uart_log); - } - /// A span of the guest's *physical* memory, as QEMU reads it. /// /// **The oracle for anything a guest leaves in DRAM for a later boot.** The @@ -1554,12 +1498,6 @@ impl QemuInstance { dump(&mut qmp, &out) } - /// Where [`Self::screendump`] has QEMU write the scanout, for a - /// [`QmpInput::screendump`] made from inside a paced step. - pub fn screendump_file(&self) -> PathBuf { - self.screendump.clone() - } - /// Screendump until the decoded screen carries `needle`, or the timeout. /// /// The panic handler's own path paints after the drain that emits the @@ -1690,27 +1628,7 @@ impl QemuInstance { self.sockets.qmp.as_deref().expect("qmp_socket needs BootOptions { qmp: true }") } - /// The disk this guest booted from, which it writes: read back while the - /// guest runs, since the instance's end deletes it. - pub fn boot_image(&self) -> &Path { - &self.boot_image - } - pub fn run_test(&mut self, name: &str, timeout: Duration) -> TestResult { - self.run_test_paced(name, timeout, |_, _| {}) - } - - /// `run_test`, with `step` run on every console line the guest prints. - /// - /// A step driven by the guest's own output can stay behind it, which is - /// how an injection test costs a slow guest wall-clock instead of a - /// verdict. - pub fn run_test_paced( - &mut self, - name: &str, - timeout: Duration, - mut step: impl FnMut(Option<&Path>, &str), - ) -> TestResult { writeln!(self.stdin, "run {name}").expect("Failed to write to QEMU stdin"); self.stdin.flush().expect("Failed to flush QEMU stdin"); @@ -1772,7 +1690,6 @@ impl QemuInstance { Ok(line) => { last_line = Instant::now(); lines += 1; - step(self.sockets.qmp.as_deref(), &line); if dying.is_none() && super::serial::died(&line) == Some(super::serial::Died::Kernel) { @@ -2141,39 +2058,8 @@ impl QmpInput { self.send(&body); } - /// The pointer to pixel `at` of a `screen`-sized panel, by an absolute - /// tablet position, which the compositor maps back to the pixel - /// (`toyos_desktop::cursor_from_abs`): the least position at or past it. - pub fn point(&mut self, at: (i32, i32), screen: (usize, usize)) { - let abs = |pixel: i32, size: usize| { - let size = size as i64; - ((i64::from(pixel) * ABS_RANGE + size - 1) / size).min(ABS_RANGE - 1) - }; - let (x, y) = (abs(at.0, screen.0), abs(at.1, screen.1)); - self.send(&[ - format!("{{\"type\":\"abs\",\"data\":{{\"axis\":\"x\",\"value\":{x}}}}}"), - format!("{{\"type\":\"abs\",\"data\":{{\"axis\":\"y\",\"value\":{y}}}}}"), - ]); - } - - /// The left button pressed at `at`, then released, each its own report. - pub fn click(&mut self, at: (i32, i32), screen: (usize, usize)) { - self.point(at, screen); - for down in [true, false] { - self.send(&[format!("{{\"type\":\"btn\",\"data\":{{\"down\":{down},\"button\":\"left\"}}}}")]); - } - } - - /// [`QemuInstance::screendump`] on this connection, into `out`, for a - /// step that holds no instance. - pub fn screendump(&mut self, out: &Path) -> super::screen::Ppm { - dump(&mut self.0, out) - } } -/// The span of QEMU's absolute axes, which the USB tablet reports unscaled. -const ABS_RANGE: i64 = 32768; - /// The argv `options` would launch QEMU with, built against placeholder /// paths. A profile's claim about which devices exist is a claim about this /// list and nothing else — no screendump can see a device that is present but @@ -2404,9 +2290,6 @@ fn qemu_command( "virtio-net-pci-non-transitional,netdev=net0{platform}" )); } - Nic::E1000e => { - qemu.arg("-netdev").arg("user,id=net0").arg("-device").arg("e1000e,netdev=net0"); - } } if shape.virtio.present() { if shape.virtio.sound() { @@ -2447,10 +2330,6 @@ fn qemu_command( if options.gdb_stub { qemu.arg("-s"); } - assert!(options.acpi_tables.is_empty() || arch == Arch::X86_64, "an added ACPI table is q35's"); - for i in 0..options.acpi_tables.len() { - qemu.arg("-acpitable").arg(format!("file={}", acpi_table(socket_dir, i).display())); - } if let Some(socket) = qmp_socket { qemu.arg("-qmp") .arg(format!("unix:{},server,nowait", socket.display())); @@ -2482,11 +2361,6 @@ impl Sockets { } } -/// The `i`th of [`BootOptions::acpi_tables`], as a file in a boot's own `dir`. -fn acpi_table(dir: &Path, i: usize) -> PathBuf { - dir.join(format!("acpi-{i}.aml")) -} - /// The QMP socket `options` asks for, named in `dir`. fn qmp_socket(dir: &Path, options: &BootOptions) -> Option { options.qmp.then(|| dir.join("qmp.sock")) @@ -2613,8 +2487,6 @@ fn spawn_and_wait_ready(mut qemu: Command, options: BootOptions, files: Files) - boot_log, smp: options.smp, carried, - options, - uart_log, } } @@ -2694,7 +2566,7 @@ fn wait_for_ready( // The words are the same words — `panicked at` — and who wrote the // line is the whole of what tells them apart. `super::serial::died` // is where that is decided, for this wait and for [`await_guest`] - // and [`QemuInstance::run_test_paced`] alike, so the three cannot + // and [`QemuInstance::run_test`] alike, so the three cannot // drift into disagreeing about a spelling. Ok(ref line) if panic_aborts diff --git a/tests/common/serial.rs b/tests/common/serial.rs index 597cfae6276..8142b8be1b8 100644 --- a/tests/common/serial.rs +++ b/tests/common/serial.rs @@ -30,7 +30,7 @@ use super::qemu::{is_kernel_line, QemuInstance}; /// Whose death a console line reports. /// /// The distinction is not decoration — it is the whole of what -/// [`QemuInstance::run_test_paced`] was missing. A machine that has halted +/// [`QemuInstance::run_test`] was missing. A machine that has halted /// answers nothing else the run asks; a process that died is what half this /// suite is *for*. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -130,7 +130,7 @@ pub(crate) const DEATHS: &[(&str, Died, Died)] = &[ /// What this console line says died, if anything. /// /// The one answer. `wait_for_ready` ends a boot on a death of any kind the -/// machine cannot come back from; `run_test_paced` and `await_guest` end a run +/// machine cannot come back from; `run_test` and `await_guest` end a run /// on [`Died::Kernel`] alone, because a program is allowed to die without /// taking the machine with it; and [`Serial::must_be_clean`] refuses a capture /// carrying one. Four questions, one vocabulary, and no way for them to diff --git a/tests/guest/c/addr_order.c b/tests/guest/c/addr_order.c deleted file mode 100644 index 7276c94e843..00000000000 --- a/tests/guest/c/addr_order.c +++ /dev/null @@ -1,101 +0,0 @@ -/* An IPv4 address is in network byte order wherever a C program hands libc - one or is handed one: connect reaches the same host for an address built - with inet_pton, with htonl, with inet_addr and by getaddrinfo, and - getpeername answers it. argv: the address, as four decimal octets, and the - port of a host that accepts and holds each connection. */ -#include -#include -#include -#include -#include -#include -#include - -static int wrong; - -static void said(const char *what, long got, long want) { - printf("%s: %ld%s\n", what, got, got == want ? "" : " <-- WRONG"); - if (got != want) wrong++; -} - -/* connect a fresh stream socket to `addr` at `port`; the socket, or -1. */ -static int dial(struct in_addr addr, in_port_t port) { - struct sockaddr_in to; - memset(&to, 0, sizeof to); - to.sin_family = AF_INET; - to.sin_port = port; - to.sin_addr = addr; - int fd = socket(AF_INET, SOCK_STREAM, 0); - return connect(fd, (struct sockaddr *)&to, sizeof to) == 0 ? fd : -1; -} - -static void text_of(const char *what, in_addr_t host_order, const char *want) { - struct in_addr a; - char text[INET_ADDRSTRLEN]; - a.s_addr = htonl(host_order); - const char *got = inet_ntop(AF_INET, &a, text, sizeof text); - printf("%s: %s%s\n", what, got ? got : "(null)", got && strcmp(got, want) == 0 ? "" : " <-- WRONG"); - if (!got || strcmp(got, want) != 0) wrong++; -} - -int main(int argc, char **argv) { - if (argc != 3) return 2; - in_port_t port = htons((uint16_t)atoi(argv[2])); - unsigned char octets[4], in_memory[4]; - struct in_addr by_pton, by_htonl, by_inet_addr; - char text[INET_ADDRSTRLEN]; - - /* The octets, read from the text by nothing of the network's. */ - char *at = argv[1]; - for (int i = 0; i < 4; i++) { - octets[i] = (unsigned char)strtoul(at, &at, 10); - at++; - } - in_addr_t host_order = (in_addr_t)octets[0] << 24 | (in_addr_t)octets[1] << 16 | (in_addr_t)octets[2] << 8 | octets[3]; - - said("inet_pton reads the address", inet_pton(AF_INET, argv[1], &by_pton), 1); - memcpy(in_memory, &by_pton, 4); - said("into its octets in memory order", memcmp(in_memory, octets, 4) == 0, 1); - by_htonl.s_addr = htonl(host_order); - said("htonl builds the same four bytes", memcmp(&by_htonl, octets, 4) == 0, 1); - by_inet_addr.s_addr = inet_addr(argv[1]); - said("and so does inet_addr", memcmp(&by_inet_addr, octets, 4) == 0, 1); - said("ntohl gives the number back", ntohl(by_pton.s_addr) == host_order, 1); - - int a = dial(by_pton, port); - said("connect to inet_pton's address", a >= 0, 1); - said("connect to htonl's", dial(by_htonl, port) >= 0, 1); - said("connect to inet_addr's", dial(by_inet_addr, port) >= 0, 1); - - struct sockaddr_in name; - socklen_t len = sizeof name; - memset(&name, 0, sizeof name); - said("getpeername", getpeername(a, (struct sockaddr *)&name, &len), 0); - said("answers the address", memcmp(&name.sin_addr, octets, 4) == 0, 1); - said("and the port", name.sin_port == port, 1); - const char *shown = inet_ntop(AF_INET, &name.sin_addr, text, sizeof text); - said("which inet_ntop writes as it was given", shown && strcmp(shown, argv[1]) == 0, 1); - - struct addrinfo hints, *found = NULL; - memset(&hints, 0, sizeof hints); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - said("getaddrinfo of the address", getaddrinfo(argv[1], NULL, &hints, &found), 0); - if (found) { - struct in_addr by_getaddrinfo = ((struct sockaddr_in *)found->ai_addr)->sin_addr; - said("answers the same four bytes", memcmp(&by_getaddrinfo, octets, 4) == 0, 1); - said("connect to getaddrinfo's", dial(by_getaddrinfo, port) >= 0, 1); - freeaddrinfo(found); - } - - text_of("INADDR_LOOPBACK", INADDR_LOOPBACK, "127.0.0.1"); - text_of("INADDR_BROADCAST", INADDR_BROADCAST, "255.255.255.255"); - text_of("INADDR_ANY", INADDR_ANY, "0.0.0.0"); - - if (wrong) { - printf("addr_order: %d wrong\n", wrong); - return 1; - } - printf("addr_order: ok\n"); - return 0; -} diff --git a/tests/guest/c/nodelay_kept.c b/tests/guest/c/nodelay_kept.c deleted file mode 100644 index c13226b40f2..00000000000 --- a/tests/guest/c/nodelay_kept.c +++ /dev/null @@ -1,108 +0,0 @@ -/* A TCP_NODELAY set on a stream socket before connect is kept and holds for - the connection, and one cleared stays cleared. A listener's is the option - of each connection that begins while it holds it, set before bind or after: - a connection accepted before a set or a clear keeps what it has, and the - next one dialled has what the listener holds then. argv: the address and - the port of a host that accepts and holds each connection, then the two - ports to listen on, which that host dials once at WAITING and once more at - AGAIN. */ -#include -#include -#include -#include -#include -#include -#include - -#define WAITING "nodelay_kept: both listeners wait for a peer" -#define AGAIN "nodelay_kept: both listeners wait for a second peer" - -static int wrong; - -static void said(const char *what, long got, long want) { - printf("%s: %ld%s\n", what, got, got == want ? "" : " <-- WRONG"); - if (got != want) wrong++; -} - -/* Whether TCP_NODELAY is set on `fd`: 1 or 0, or -1 where the read fails. */ -static int nodelay(int fd) { - int value = -7; - socklen_t len = sizeof value; - if (getsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &value, &len) != 0) return -1; - return value != 0; -} - -int main(int argc, char **argv) { - if (argc != 5) return 2; - struct sockaddr_in peer; - int on = 1, off = 0; - - memset(&peer, 0, sizeof peer); - peer.sin_family = AF_INET; - peer.sin_port = htons((uint16_t)atoi(argv[2])); - said("the peer's address is one", inet_pton(AF_INET, argv[1], &peer.sin_addr), 1); - - int t = socket(AF_INET, SOCK_STREAM, 0); - said("a fresh stream socket's TCP_NODELAY", nodelay(t), 0); - said("set before connect", setsockopt(t, IPPROTO_TCP, TCP_NODELAY, &on, sizeof on), 0); - said("read before connect", nodelay(t), 1); - said("connect", connect(t, (struct sockaddr *)&peer, sizeof peer), 0); - said("read after connect", nodelay(t), 1); - said("cleared on the connection", setsockopt(t, IPPROTO_TCP, TCP_NODELAY, &off, sizeof off), 0); - said("read after the clear", nodelay(t), 0); - said("set on the connection", setsockopt(t, IPPROTO_TCP, TCP_NODELAY, &on, sizeof on), 0); - said("read after the set", nodelay(t), 1); - - int u = socket(AF_INET, SOCK_STREAM, 0); - said("set on a second socket", setsockopt(u, IPPROTO_TCP, TCP_NODELAY, &on, sizeof on), 0); - said("and cleared before connect", setsockopt(u, IPPROTO_TCP, TCP_NODELAY, &off, sizeof off), 0); - said("connect", connect(u, (struct sockaddr *)&peer, sizeof peer), 0); - said("read after connect", nodelay(u), 0); - - struct sockaddr_in any; - memset(&any, 0, sizeof any); - any.sin_family = AF_INET; - int held = socket(AF_INET, SOCK_STREAM, 0); - said("set on a third socket before bind", setsockopt(held, IPPROTO_TCP, TCP_NODELAY, &on, sizeof on), 0); - any.sin_port = htons((uint16_t)atoi(argv[3])); - said("bind it", bind(held, (struct sockaddr *)&any, sizeof any), 0); - said("listen on it", listen(held, 1), 0); - said("read from that listener", nodelay(held), 1); - int l = socket(AF_INET, SOCK_STREAM, 0); - any.sin_port = htons((uint16_t)atoi(argv[4])); - said("bind a fourth socket", bind(l, (struct sockaddr *)&any, sizeof any), 0); - said("listen on it", listen(l, 1), 0); - said("read from that listener", nodelay(l), 0); - - printf("%s\n", WAITING); - fflush(stdout); - int with = accept(held, NULL, NULL); - said("accept from the listener that held the option", with >= 0, 1); - said("read from its connection", nodelay(with), 1); - int without = accept(l, NULL, NULL); - said("accept from the listener that held none", without >= 0, 1); - said("read from its connection", nodelay(without), 0); - - said("set on the listener that held none", setsockopt(l, IPPROTO_TCP, TCP_NODELAY, &on, sizeof on), 0); - said("read from the listener", nodelay(l), 1); - said("read from the connection it accepted before", nodelay(without), 0); - said("cleared on the listener that held the option", setsockopt(held, IPPROTO_TCP, TCP_NODELAY, &off, sizeof off), 0); - said("read from the listener", nodelay(held), 0); - said("read from the connection it accepted before", nodelay(with), 1); - - printf("%s\n", AGAIN); - fflush(stdout); - int set = accept(l, NULL, NULL); - said("accept from the listener after its set", set >= 0, 1); - said("read from its connection", nodelay(set), 1); - int cleared = accept(held, NULL, NULL); - said("accept from the listener after its clear", cleared >= 0, 1); - said("read from its connection", nodelay(cleared), 0); - - if (wrong) { - printf("nodelay_kept: %d wrong\n", wrong); - return 1; - } - printf("nodelay_kept: ok\n"); - return 0; -} diff --git a/tests/guest/c/sendto_unbound.c b/tests/guest/c/sendto_unbound.c deleted file mode 100644 index 9311a5f92cf..00000000000 --- a/tests/guest/c/sendto_unbound.c +++ /dev/null @@ -1,75 +0,0 @@ -/* A datagram socket never bound is bound by its first send, to a port the - stack chooses, as POSIX has it: the usual C broadcast sequence, socket, - setsockopt, sendto, and a connected socket's send. argv: the address and - the port of a host that answers each datagram with itself. */ -#include -#include -#include -#include -#include -#include - -static int wrong; - -static void said(const char *what, long got, long want) { - printf("%s: %ld%s\n", what, got, got == want ? "" : " <-- WRONG"); - if (got != want) wrong++; -} - -/* The port `fd` is bound to, in host order. */ -static unsigned bound_port(int fd) { - struct sockaddr_in name; - socklen_t len = sizeof name; - memset(&name, 0, sizeof name); - if (getsockname(fd, (struct sockaddr *)&name, &len) != 0) return 0; - return ntohs(name.sin_port); -} - -int main(int argc, char **argv) { - if (argc != 3) return 2; - struct sockaddr_in peer, from, all; - socklen_t fromlen = sizeof from; - char buf[16]; - int on = 1, held = 0; - socklen_t heldlen = sizeof held; - - memset(&peer, 0, sizeof peer); - peer.sin_family = AF_INET; - peer.sin_port = htons((uint16_t)atoi(argv[2])); - said("the peer's address is one", inet_pton(AF_INET, argv[1], &peer.sin_addr), 1); - - int s = socket(AF_INET, SOCK_DGRAM, 0); - said("SO_BROADCAST set on a socket never bound", setsockopt(s, SOL_SOCKET, SO_BROADCAST, &on, sizeof on), 0); - said("sendto from it", sendto(s, "ping", 4, 0, (struct sockaddr *)&peer, sizeof peer), 4); - said("the send bound it to a port", bound_port(s) != 0, 1); - - memset(&from, 0, sizeof from); - memset(buf, 0, sizeof buf); - said("the answer came back to that port", recvfrom(s, buf, sizeof buf, 0, (struct sockaddr *)&from, &fromlen), 4); - said("and is what was sent", memcmp(buf, "ping", 4) == 0, 1); - said("from the peer's address", from.sin_addr.s_addr == peer.sin_addr.s_addr, 1); - said("and the peer's port", from.sin_port == peer.sin_port, 1); - - said("the option is read back", getsockopt(s, SOL_SOCKET, SO_BROADCAST, &held, &heldlen), 0); - said("and is still set", held != 0, 1); - memset(&all, 0, sizeof all); - all.sin_family = AF_INET; - all.sin_port = htons(9); - all.sin_addr.s_addr = htonl(INADDR_BROADCAST); - said("sendto the limited broadcast address", sendto(s, "x", 1, 0, (struct sockaddr *)&all, sizeof all), 1); - - int c = socket(AF_INET, SOCK_DGRAM, 0); - said("connect on a second socket never bound", connect(c, (struct sockaddr *)&peer, sizeof peer), 0); - said("send from it", send(c, "pong", 4, 0), 4); - memset(buf, 0, sizeof buf); - said("its answer", recv(c, buf, sizeof buf, 0), 4); - said("is what it sent", memcmp(buf, "pong", 4) == 0, 1); - said("the two sockets hold two ports", bound_port(c) != 0 && bound_port(c) != bound_port(s), 1); - - if (wrong) { - printf("sendto_unbound: %d wrong\n", wrong); - return 1; - } - printf("sendto_unbound: ok\n"); - return 0; -} diff --git a/tests/guest/c/stream_ends.c b/tests/guest/c/stream_ends.c deleted file mode 100644 index cc818a1c0ff..00000000000 --- a/tests/guest/c/stream_ends.c +++ /dev/null @@ -1,91 +0,0 @@ -/* How libc tells a C client a stream ended: recv 0 at the peer's FIN after - its own SHUT_WR, ECONNRESET on a reset mid-stream and after SHUT_WR, EPIPE - for a send after SHUT_WR, and recv 0 after SHUT_RD. argv: the address and - the port of the harness's peer, whose first byte read names how it ends the - stream (`tests/guest/rust-tests/src/stream_ends.rs`). No stream is closed: - libc's close of a socket ends the program - (`issues/libc-close-of-a-socket-ends-the-process.md`), and the job's exit - lets each go. */ -#include -#include -#include -#include -#include -#include -#include - -/* What the peer sends before its reset of 'R'. */ -#define AHEAD 65536 - -static int wrong; -static struct sockaddr_in peer; - -static void said(const char *what, long got, long want) { - printf("%s: %ld%s\n", what, got, got == want ? "" : " <-- WRONG"); - if (got != want) wrong++; -} - -/* A call that answered -1: the errno it set, named. */ -static void refused(const char *what, long got, int err, int want) { - printf("%s: %ld, errno %d%s\n", what, got, got < 0 ? err : 0, got < 0 && err == want ? "" : " <-- WRONG"); - if (got >= 0 || err != want) wrong++; -} - -/* A stream to the peer that has been told how to end it. */ -static int dial(char how) { - int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0 || connect(fd, (struct sockaddr *)&peer, sizeof peer) != 0 || send(fd, &how, 1, 0) != 1) { - printf("stream_ends: the peer could not be dialled for '%c'\n", how); - exit(1); - } - return fd; -} - -/* Reads until `want` bytes or what ends them first: the bytes read. */ -static long take(int fd, long want) { - char buf[4096]; - long got = 0, r; - while (got < want && (r = recv(fd, buf, sizeof buf < (size_t)(want - got) ? sizeof buf : (size_t)(want - got), 0)) > 0) - got += r; - return got; -} - -int main(int argc, char **argv) { - if (argc != 3) return 2; - char c; - long r; - memset(&peer, 0, sizeof peer); - peer.sin_family = AF_INET; - peer.sin_port = htons((uint16_t)atoi(argv[2])); - said("the peer's address is one", inet_pton(AF_INET, argv[1], &peer.sin_addr), 1); - - int fd = dial('S'); - said("shut_first: the peer's answer", recv(fd, &c, 1, 0), 1); - said("shut_first: shutdown(SHUT_WR)", shutdown(fd, SHUT_WR), 0); - r = send(fd, "x", 1, 0); - refused("shut_first: a send after it", r, errno, EPIPE); - said("shut_first: the peer's four bytes after it", take(fd, 4), 4); - said("shut_first: then the peer's FIN", recv(fd, &c, 1, 0), 0); - - fd = dial('R'); - said("reset_mid_stream: the bytes ahead of it", take(fd, AHEAD), AHEAD); - said("reset_mid_stream: the answer", send(fd, "k", 1, 0), 1); - r = recv(fd, &c, 1, 0); - refused("reset_mid_stream: then", r, errno, ECONNRESET); - - fd = dial('D'); - said("reset_after_half_close: shutdown(SHUT_WR)", shutdown(fd, SHUT_WR), 0); - r = recv(fd, &c, 1, 0); - refused("reset_after_half_close: then", r, errno, ECONNRESET); - - fd = dial('B'); - said("shut_rd: shutdown(SHUT_RD) with the peer's three bytes on their way", shutdown(fd, SHUT_RD), 0); - said("shut_rd: then recv", recv(fd, &c, 1, 0), 0); - - if (wrong) { - printf("stream_ends: %d wrong\n", wrong); - return 1; - } - printf("stream_ends: ok\n"); - return 0; -} diff --git a/tests/guest/rust-tests/Cargo.lock b/tests/guest/rust-tests/Cargo.lock index 8144777142f..bd36d4c8f7a 100644 --- a/tests/guest/rust-tests/Cargo.lock +++ b/tests/guest/rust-tests/Cargo.lock @@ -34,12 +34,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bitflags" version = "2.13.1" @@ -144,17 +138,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" -[[package]] -name = "diskserver" -version = "0.0.0" -dependencies = [ - "toyos", - "toyos-abi", - "toyos-blockhold", - "toyos-blockring", - "toyos-gpt", -] - [[package]] name = "dispatch2" version = "0.3.1" @@ -218,22 +201,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "indexmap" version = "2.14.0" @@ -244,21 +211,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inspect" -version = "0.1.0" -dependencies = [ - "toyos", - "toyos-abi", - "toyos-inspect", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - [[package]] name = "jni" version = "0.22.4" @@ -545,12 +497,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -612,41 +558,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -716,12 +627,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[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" @@ -825,33 +730,10 @@ dependencies = [ name = "toyos-abi" version = "0.16.0" -[[package]] -name = "toyos-blockhold" -version = "0.1.0" - -[[package]] -name = "toyos-blockring" -version = "0.1.0" -dependencies = [ - "toyos-blockhold", - "toyos-transport", -] - -[[package]] -name = "toyos-desktop" -version = "0.1.0" -dependencies = [ - "toyos-abi", -] - [[package]] name = "toyos-font" version = "0.2.0" -[[package]] -name = "toyos-gpt" -version = "0.1.0" - [[package]] name = "toyos-inspect" version = "0.1.0" @@ -876,24 +758,17 @@ version = "0.1.0" dependencies = [ "acpiserver-api", "cpal", - "diskserver", - "inspect", "libloading", "logkeeper-api", "memmap2", "ring", - "rustls", "toyos", "toyos-abi", - "toyos-blockring", - "toyos-desktop", - "toyos-gpt", "toyos-inspect", "toyos-logstream", "toyos-tco", "toyos-trace", "toyos-window", - "ureq", ] [[package]] @@ -907,17 +782,6 @@ dependencies = [ "toyos-abi", ] -[[package]] -name = "toyos-transport" -version = "0.1.0" -dependencies = [ - "toyos-untrusted", -] - -[[package]] -name = "toyos-untrusted" -version = "0.1.0" - [[package]] name = "toyos-window" version = "0.20.0" @@ -940,40 +804,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" -dependencies = [ - "base64", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "walkdir" version = "2.5.0" @@ -1045,15 +875,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi-util" version = "0.1.11" @@ -1263,9 +1084,3 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/tests/guest/rust-tests/Cargo.toml b/tests/guest/rust-tests/Cargo.toml index ced2d9e744b..8e4439523d4 100644 --- a/tests/guest/rust-tests/Cargo.toml +++ b/tests/guest/rust-tests/Cargo.toml @@ -9,8 +9,6 @@ license = "MIT OR Apache-2.0" toyos-abi = { path = "../../../sdk/abi" } toyos = { path = "../../../sdk/toyos" } toyos-window = { path = "../../../sdk/window" } -# The desktop's furniture, for `consent`'s fullscreen window. -toyos-desktop = { path = "../../../system/compositor/desktop" } toyos-tco = { path = "../../../lib/tco" } toyos-inspect = { path = "../../../lib/inspect" } # The log as logkeeper serves it, and the asking for it, for `counters_metal`'s @@ -19,22 +17,12 @@ toyos-logstream = { path = "../../../lib/logstream" } logkeeper-api = { path = "../../../lib/logkeeper-api" } # The ACPI server's count line, for `acpi_hold`'s wait on it. acpiserver-api = { path = "../../../lib/acpiserver-api" } -# A block service's session, its grants and the partition types, for -# `partition_grant` and `update_idle_slot`. -diskserver = { path = "../../../system/diskserver" } -toyos-blockring = { path = "../../../lib/blockring" } -toyos-gpt = { path = "../../../lib/gpt" } # The diary's decoder, for `trace_read` and `trace_flood`. toyos-trace = { path = "../../../lib/trace" } -# The reader's asker, for `hda_client_stall`. -inspect = { path = "../../../system/inspect" } libloading = { git = "https://github.com/ToyOSOrg/rust_libloading", branch = "toyos-sdk-0.12" } cpal = { git = "https://github.com/ToyOSOrg/cpal", branch = "toyos-0.18.0" } memmap2 = "0.9" -# An HTTPS client as any Rust program writes one, for `https_get`, and the -# primitives under it, for `ring_kat`'s known answers. -ureq = { version = "3", default-features = false, features = ["rustls"] } -rustls = { version = "0.23", default-features = false } +# `ring`'s primitives, for `ring_kat`'s known answers. ring = "0.17" # Outside the workspace, so it needs its own redirects: the forks diff --git a/tests/guest/rust-tests/src/bin/acpi_mediated.rs b/tests/guest/rust-tests/src/bin/acpi_mediated.rs index d5181bdb03d..0b52ff77589 100644 --- a/tests/guest/rust-tests/src/bin/acpi_mediated.rs +++ b/tests/guest/rust-tests/src/bin/acpi_mediated.rs @@ -43,20 +43,22 @@ //! the parent, which supplied none, ask for the power-off after the child's //! claim and one more have been released. //! -//! **A press is served across the server's wait for the firmware's release** -//! ([`held`]), on a boot whose ROOT carries [`HELD`] and whose firmware lists -//! a table that takes the Global Lock at its load: the firmware's side of the -//! lock is staged owned before `/system/bin/acpiserver` is handed the claim. -//! The press, the wait's timeout and the power-off are the harness's to read. +//! **A press is one SCI, masked until it is served** ([`press`]), on a boot +//! whose ROOT carries [`PRESS`]: the fixed power button is enabled alone and +//! the SCI acknowledged, the harness presses it, and the press is read off +//! the claim as one interrupt with `PWRBTN_STS` latched, and no second while +//! the status stays set and the claim unacknowledged. use std::os::toyos::process::CommandExt; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use toyos::endow::{Endowments, DEV_PREFIX, SYSCAP_LABEL}; +use toyos::endow::{Endowments, SYSCAP_LABEL}; +use toyos::ioport::{in16, out16, out8}; +use toyos::poller::{Poller, READABLE}; use toyos::syscap::SysCap; use toyos::{AsHandle, Device}; -use toyos_abi::acpi::{pci_address, Access, AcpiInfo, Refused, Space, Width, UNLISTED}; +use toyos_abi::acpi::{pci_address, Access, AcpiInfo, Refused, Space, Width, ACK, FIXED_POWER_BUTTON, UNLISTED}; use toyos_abi::counters::{Counter, RawRecord, Record}; use toyos_abi::syscall::{self, debug_action, DeviceType, SyscallError}; use toyos_abi::RawHandle; @@ -71,8 +73,8 @@ const CLAIM_LABEL: &str = "acpi-claim"; /// binary's name and takes no argument. const OUTLIVED: &str = "/system/share/acpi_mediated_outlived"; -/// The file whose presence on ROOT makes this boot [`held`]'s. -const HELD: &str = "/system/share/acpi_mediated_held"; +/// The file whose presence on ROOT makes this boot [`press`]'s. +const PRESS: &str = "/system/share/acpi_mediated_press"; /// `EFI_MEMORY_TYPE`s: what the kernel hands out as RAM, and ACPI's two. const USABLE: [u8; 5] = [1, 2, 3, 4, 7]; @@ -121,7 +123,7 @@ impl Holder { fn main() { match std::env::args().nth(1).as_deref() { None if std::fs::exists(OUTLIVED).expect("acpi: ask ROOT for this boot's arm") => outlived(), - None if std::fs::exists(HELD).expect("acpi: ask ROOT for this boot's arm") => held(), + None if std::fs::exists(PRESS).expect("acpi: ask ROOT for this boot's arm") => press(), None => probe(), Some("keeper") => keeper(), Some("supplier") => supplier(), @@ -640,19 +642,60 @@ fn outlived() { panic!("acpi: the power-off was refused: {refused:?}"); } -/// What [`held`] says once the server has the claim, with the firmware's -/// side of the lock owned. -const STAGED: &str = "acpi: the firmware's side of the Global Lock staged owned, and the server handed the claim"; +/// `PWRBTN_STS` in the PM1 status register and `PWRBTN_EN` in the enable +/// register beside it (ACPI 6.5 Tables 4.13 and 4.15). +const PWRBTN: u16 = 1 << 8; -fn held() { +/// What [`press`] says once the button alone is enabled and the SCI +/// acknowledged, the harness's word to press. +const PRESS_ARMED: &str = "acpi: the fixed power button is enabled alone and the SCI acknowledged"; + +/// What it says once the press is served. +const PRESS_SERVED: &str = "acpi: the press came as one SCI with its status latched, and no second while it was unserved"; + +/// The claim's interrupts since its last read, or `None` for none. +fn sci(claim: &Device) -> Option { + let mut record = [0u8; toyos_abi::pci::DeviceIrqRecord::SIZE]; + match syscall::read_nonblock(claim.as_handle(), &mut record) { + Ok(n) => { + assert_eq!(n, record.len(), "acpi: an interrupt record of {n} bytes"); + Some(u64::from(u32::from_ne_bytes(record[..4].try_into().expect("a count's four bytes")))) + } + Err(SyscallError::WouldBlock) => None, + Err(other) => panic!("acpi: the claim's record answered {other:?}"), + } +} + +fn press() { let cap: SysCap = Endowments::get().take(SYSCAP_LABEL).expect("test-runner endows a device-minting capability"); let claim = claim(&cap); - assert_eq!(syscall::debug_with(debug_action::ACPI_FIRMWARE_LOCK, debug_action::FIRMWARE_OWNS), 0, "acpi: the lock was not free before the server ran"); - let mut server = Command::new("/system/bin/acpiserver") - .endow(&format!("{DEV_PREFIX}{}", DeviceType::Acpi.class_name()), claim.into_raw().0) - .spawn() - .expect("acpi: spawn the server"); - println!("{STAGED}"); - let ended = server.wait().expect("acpi: wait for the server"); - panic!("acpi: the server ended {ended:?}, where a press powers the machine off"); + let info = bind(&claim); + assert!(info.flags & FIXED_POWER_BUTTON != 0, "acpi: this guest's FADT has no fixed power button"); + let (status, enable) = (info.pm1_event.port, info.pm1_event.enable()); + // Every event off, a press latched before now cleared, then the button + // alone on. + out16(enable, 0); + out16(status, PWRBTN); + for i in 0..info.gpe0.len / 2 { + out8(info.gpe0.enable() + i, 0); + out8(info.gpe0.port + i, 0xFF); + } + out16(enable, PWRBTN); + syscall::write(claim.as_handle(), &ACK.to_ne_bytes()).expect("acpi: the claim's acknowledgement"); + assert_eq!(sci(&claim), None, "acpi: an SCI before the press, with every event but the button off"); + println!("{PRESS_ARMED}"); + + let poller = Poller::new(1); + poller.watch(&claim, READABLE, 0); + poller.wait(1, u64::MAX, |_| {}); + let first = sci(&claim).expect("acpi: the claim woke its poller with no interrupt on it"); + assert_eq!(first, 1, "acpi: the press came as {first} SCIs before it was served"); + assert!(in16(status) & PWRBTN != 0, "acpi: an SCI with PWRBTN_STS clear ({:#06x})", in16(status)); + // Still set and unacknowledged: a line the kernel had unmasked would be + // taken again for the same status. + assert_eq!(sci(&claim), None, "acpi: a second SCI while the press was unserved"); + out16(status, PWRBTN); + assert_eq!(in16(status) & PWRBTN, 0, "acpi: PWRBTN_STS stayed set after its clear"); + syscall::write(claim.as_handle(), &ACK.to_ne_bytes()).expect("acpi: the claim's acknowledgement"); + println!("{PRESS_SERVED}"); } diff --git a/tests/guest/rust-tests/src/bin/audio_idle_suspend.rs b/tests/guest/rust-tests/src/bin/audio_idle_suspend.rs deleted file mode 100644 index e74d6e40561..00000000000 --- a/tests/guest/rust-tests/src/bin/audio_idle_suspend.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Idle-suspend certification: on a boot where no audio client ever -//! connects, soundserver's CPU cost is exactly zero. Two sysinfo samples ~1s apart -//! must show no cpu_ns movement on any soundserver thread — a suspended soundserver -//! holds no timer and takes no wakes, so any nonzero delta is the mix or -//! control loop running without a reason. -//! -//! Another process's threads is the process roster, which costs -//! `Rights::ROSTER` on a `SysCap` — `tests/images/testcases.toml` names `roster` on the -//! test-runner row and every binary it spawns holds the duplicate. A capability -//! this binary does not hold is a config fact and says so, rather than reading -//! back zero soundserver threads and calling that a suspended daemon. - -use toyos::endow::{Endowments, SYSCAP_LABEL}; -use toyos::syscap::SysCap; -use toyos::system; - -const HEADER: usize = system::SYSINFO_HEADER_SIZE; -const ENTRY: usize = system::SYSINFO_ENTRY_SIZE; - -/// Live cpu_ns per soundserver thread. The mix thread reports under the process -/// name ("soundserver"), the control thread under its own ("soundserver-ctrl"); -/// matching the prefix covers both. -fn soundserver_threads(cap: &SysCap) -> Vec<(String, u64)> { - let mut buf = vec![0u8; HEADER + ENTRY * 128]; - let n = cap.roster(&mut buf); - assert!(n >= HEADER, "sysinfo failed"); - - let mut threads = Vec::new(); - let mut pos = HEADER; - while pos + ENTRY <= n { - let name_bytes = &buf[pos + 32..pos + 60]; - let len = name_bytes.iter().position(|&b| b == 0).unwrap_or(28); - if name_bytes[..len].starts_with(b"soundserver") { - let name = String::from_utf8_lossy(&name_bytes[..len]).into_owned(); - let cpu_ns = u64::from_le_bytes(buf[pos + 24..pos + 32].try_into().unwrap()); - threads.push((name, cpu_ns)); - } - pos += ENTRY; - } - threads -} - -fn main() { - let cap: SysCap = Endowments::get() - .take(SYSCAP_LABEL) - .expect("test-runner endows every binary it spawns a system capability"); - // soundserver's control thread is spawned after the mix thread, so a roster - // read can land between the two; the measurement starts once both exist. - let mut before = soundserver_threads(&cap); - let mut waited_ms = 0u32; - while before.len() < 2 && waited_ms < 5_000 { - std::thread::sleep(std::time::Duration::from_millis(20)); - waited_ms += 20; - before = soundserver_threads(&cap); - } - assert!( - before.len() >= 2, - "expected soundserver's mix and control threads in sysinfo, found {} after {waited_ms}ms", - before.len() - ); - std::thread::sleep(std::time::Duration::from_millis(1000)); - let after = soundserver_threads(&cap); - assert!( - after.len() >= 2, - "soundserver lost a thread mid-sample: found {} of the 2 the first sample saw \ - (before {before:?}, after {after:?})", - after.len() - ); - let total_before: u64 = before.iter().map(|(_, ns)| ns).sum(); - let total_after: u64 = after.iter().map(|(_, ns)| ns).sum(); - assert_eq!( - total_after, total_before, - "soundserver consumed {}ns of CPU across ~1s with no client — it is not suspended \ - (per thread, before {before:?}, after {after:?})", - total_after.saturating_sub(total_before) - ); - println!("soundserver idle cpu delta: 0ns over ~1s"); -} diff --git a/tests/guest/rust-tests/src/bin/consent.rs b/tests/guest/rust-tests/src/bin/consent.rs deleted file mode 100644 index 6a69b2fa9ee..00000000000 --- a/tests/guest/rust-tests/src/bin/consent.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! A package's launch from the desktop asks the person at the screen for a -//! folder, in the prompt layer, and what they answer is what it holds. -//! -//! Run by `consent_prompt` on `tests/images/consentcase.toml`, whose host side drives the -//! desktop through QEMU's keyboard and tablet: each step here says -//! `CONSENT ` on the console, the host clicks the package in the -//! launcher and answers the prompt with keys, and taps F12 once the package -//! has said what it saw (`CONSENTEE ...`). The tap reaches the job through -//! the one window it holds: -//! -//! - **a hostile client**, this binary as `hostile`: a fullscreen topmost -//! window that says every key and press the compositor gives it. It may be -//! given F12 and nothing else: every other key the host types, and the press -//! it makes outside the prompt, are the prompt's or nobody's. -//! -//! The steps, each held on the host to what the package saw and to the -//! store `/state/supervisor/grants`, which the job prints: -//! -//! - `always`: Games, Always. Stored; it lists the ROM and saves beside it. -//! - `again`: no question; the same folder. -//! - `deny`, after a revoke: Deny. Stored; no folder. -//! - `denied`: no question; no folder. -//! - `skip`, after a revoke: Escape. Nothing stored; no folder. -//! - `once`: asked again; Games, Allow once. Nothing stored; the folder. -//! - `ssh`: the package started from a login session no screen's row opened -//! asks nothing and holds no folder. -//! -//! First, std's half of the hash being of what runs: a prepared spawn starts -//! the bytes its prepare read, past a later working directory and a rewrite -//! of the file ([`prepared`]); and a grant of a file is refused and never -//! kept ([`not_a_folder`]). -//! -//! The package is this binary installed as `consentee`, which, started under -//! that name, does what `app_view`'s `game` does: list the ROMs in its -//! working directory and save beside the first. - -use std::fs; -use std::io::{BufRead, BufReader, Lines, Write}; -use std::process::{ChildStdout, Command, Stdio}; - -use toyos_desktop::Chrome; -use window::{Color, Event, ResolutionInfo, Window, MSG_GET_RESOLUTION, MSG_RESOLUTION_CHANGED}; - -const SELF: &str = "/system/bin/test_rs_consent"; -const PACKAGE: &str = "/apps/consentee"; -const PROGRAM: &str = "/apps/consentee/consentee"; -const MANIFEST: &[u8] = b"name = \"consentee\"\nversion = \"1\"\n\ - digest = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\ - program = \"/apps/consentee/consentee\"\n"; -const HOME: &str = "/home/toy"; -const GAMES: &str = "/home/toy/Games"; -const ROM: &str = "/home/toy/Games/test.gba"; -const SAVE: &str = "/home/toy/Games/test.sav"; -const STORE: &str = "/state/supervisor/grants"; -/// The hostile window's colour, which the host reads the panel for. -const HOSTILE: Color = Color { r: 0xff, g: 0x00, b: 0xff }; -/// The key the host taps once a step is done: HID usage F12. -const DONE: u8 = 0x45; -/// What this binary says run as `bytes`. -const RAN: &str = "BYTES ran"; - -fn main() { - let named = std::env::args().next().unwrap_or_default(); - if named.ends_with("/consentee") { - return game(); - } - match std::env::args().nth(1).as_deref() { - Some("hostile") => hostile(), - Some("bytes") => println!("{RAN}"), - _ => job(), - } -} - -fn job() { - let _ = fs::remove_dir_all(PACKAGE); - let _ = fs::remove_dir_all(GAMES); - fs::create_dir_all(PACKAGE).expect("make the package's directory"); - fs::copy(SELF, PROGRAM).expect("install this binary as the package's program"); - fs::write(format!("{PACKAGE}/manifest.toml"), MANIFEST).expect("write the package's manifest"); - fs::create_dir_all(GAMES).expect("make the folder the package is granted"); - fs::write(ROM, b"a ROM's bytes").expect("put a ROM in it"); - let _ = in_login("/system/bin/grants revoke consentee"); - - let mut red = Vec::new(); - prepared(&mut red); - not_a_folder(&mut red); - // Every step below asks of an empty store, and waits on the host. - if !red.is_empty() { - panic!("consent: {red:#?}"); - } - let mut hostile = Command::new(SELF) - .arg("hostile") - .stdout(Stdio::piped()) - .spawn() - .expect("start the hostile window"); - let mut said = BufReader::new(hostile.stdout.take().expect("its stdout")).lines(); - until(&mut said, &mut red, "HOSTILE up"); - - // Where Games is in the prompt's list: the home's folders a package may be - // granted, sorted as the prompt sorts them. - let mut folders: Vec = fs::read_dir(HOME) - .expect("list the home") - .flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|name| name != "Apps") - .collect(); - folders.sort_by_key(|name| name.to_lowercase()); - let games = folders.iter().position(|name| name == "Games").expect("Games is in the home"); - - step(&mut said, &mut red, &format!("always down={games}")); - holds("always"); - match fs::read(SAVE) { - Ok(_) => println!(" always: its save is beside the ROM"), - Err(e) => red.push(format!("always: {SAVE}: {e}")), - } - let _ = fs::remove_file(SAVE); - step(&mut said, &mut red, "again"); - let _ = fs::remove_file(SAVE); - - revoke(&mut red); - step(&mut said, &mut red, "deny"); - holds("deny"); - step(&mut said, &mut red, "denied"); - - revoke(&mut red); - step(&mut said, &mut red, "skip"); - holds("skip"); - step(&mut said, &mut red, &format!("once down={games}")); - holds("once"); - let _ = fs::remove_file(SAVE); - - println!("CONSENT ssh"); - let ran = in_login(PROGRAM); - let out = String::from_utf8_lossy(&ran.stdout); - let none = "CONSENTEE cwd=/ roms=[] saved=Err"; - match out.lines().find(|l| l.starts_with("CONSENTEE ")) { - Some(saw) if saw == none => println!(" ssh: it saw {}", &saw["CONSENTEE ".len()..]), - other => red.push(format!("from a login session off the screen it said {other:?}, not {none:?}: {ran:?}")), - } - println!("CONSENT ssh done"); - // Its own sentinel: the host taps once it has read the panel again. - until_done(&mut said, &mut red, "ssh"); - - let _ = hostile.kill(); - let _ = hostile.wait(); - let _ = in_login("/system/bin/grants revoke consentee"); - let _ = fs::remove_dir_all(PACKAGE); - let _ = fs::remove_dir_all(GAMES); - if !red.is_empty() { - panic!("consent: {red:#?}"); - } - println!("consent: every answer was the person's, and the package held what it was given"); -} - -/// **The bytes a prepared spawn starts are the ones its prepare read**, which -/// is what lets the supervisor hold a package to the hash of what it starts: -/// a working directory set after the prepare keeps them, and the file -/// rewritten after it, here into no program at all, is not read again. -fn prepared(red: &mut Vec) { - use std::os::toyos::process::CommandExt; - let copy = format!("{GAMES}/prepared"); - let ours = fs::read(SELF).expect("read this binary"); - fs::write(©, &ours).expect("copy this binary onto DATA"); - let mut command = Command::new(©); - command.arg("bytes"); - if let Err(e) = command.prepare() { - red.push(format!("{copy} would not prepare: {e}")); - return; - } - let read = command.prepared_image().map(<[u8]>::to_vec); - fs::write(©, b"no program").expect("rewrite the copy"); - if let Err(e) = command.current_dir(HOME).prepare() { - red.push(format!("{copy} would not prepare again in {HOME}: {e}")); - return; - } - let kept = command.prepared_image().map(<[u8]>::to_vec); - match (read.as_deref() == Some(&ours[..]), kept == read, command.output()) { - (true, true, Ok(ran)) if String::from_utf8_lossy(&ran.stdout).contains(RAN) => { - println!(" a prepared spawn started the bytes it read, past a rewrite") - } - (read, kept, ran) => red.push(format!( - "a prepared spawn: the bytes read were this binary: {read}; kept across the directory: {kept}; it ran {ran:?}" - )), - } - let _ = fs::remove_file(©); -} - -/// **A grant is kept only of a folder**: one of a file the folder rules -/// admit is refused by name and nothing is stored. The supervisor holds the -/// person's Always to the same check, in the one function that stores both. -fn not_a_folder(red: &mut Vec) { - let note = format!("{HOME}/Note"); - fs::write(¬e, b"a file, not a folder").expect("write a file in the home"); - let out = in_login(&format!("/system/bin/grants add consentee {note}")); - let said = String::from_utf8_lossy(&out.stdout); - let refused = format!("{note} is no directory"); - let kept = fs::read_to_string(STORE).unwrap_or_default().lines().any(|l| l.contains(" consentee ")); - match (said.contains(&refused), kept) { - (true, false) => println!(" a grant of a file was refused and nothing was kept"), - _ => red.push(format!("a grant of {note}: the supervisor said {said:?}, and the store holds a line for it: {kept}")), - } - let _ = fs::remove_file(¬e); -} - -/// Ask the host for `step`, and wait for its F12. -fn step(said: &mut Lines>, red: &mut Vec, step: &str) { - println!("CONSENT {step}"); - let _ = std::io::stdout().flush(); - until_done(said, red, step); -} - -/// Every line the hostile window says until the F12 the host taps, each held -/// to: F12 alone, and no press. -fn until_done(said: &mut Lines>, red: &mut Vec, step: &str) { - let done = format!("HOSTILE key {DONE:#x} down"); - loop { - let Some(Ok(line)) = said.next() else { - red.push(format!("{step}: the hostile window ended")); - return; - }; - if line == done { - return; - } - let allowed = line.starts_with(&format!("HOSTILE key {DONE:#x} ")) - || line.starts_with(&format!("HOSTILE mouse {} ", window::MOUSE_RELEASE)) - || line.starts_with(&format!("HOSTILE mouse {} ", window::MOUSE_MOVE)); - if !allowed { - red.push(format!("{step}: the hostile window was given `{line}`")); - } - } -} - -fn until(said: &mut Lines>, red: &mut Vec, want: &str) { - match said.next() { - Some(Ok(line)) if line == want => {} - other => red.push(format!("the hostile window said {other:?}, not {want:?}")), - } -} - -/// The store's line for the package after `step`, for the host to hold to -/// the SHA-256 of the binary it put on ROOT, which nothing in the guest -/// computed. -fn holds(step: &str) { - let store = fs::read_to_string(STORE).unwrap_or_default(); - let line = store.lines().find(|l| l.contains(" consentee ")).unwrap_or(""); - println!("STORE {step}: {line}"); -} - -fn revoke(red: &mut Vec) { - let out = in_login("/system/bin/grants revoke consentee"); - if !String::from_utf8_lossy(&out.stdout).contains("consentee's grant is revoked") { - red.push(format!("the revoke was answered {out:?}")); - } -} - -/// `line` run by a shell this job launches, whose `login` row opens a login -/// session that is not the screen's. -fn in_login(line: &str) -> std::process::Output { - Command::new("/system/bin/shell").args(["-c", line]).current_dir("/").output().expect("launch a shell") -} - -/// A fullscreen window that stays on top and says everything it is given. -fn hostile() { - let conn = toyos::endow::service("compositor").expect("a compositor"); - conn.signal(MSG_GET_RESOLUTION).expect("ask the compositor its resolution"); - let (kind, screen) = conn.recv::().expect("its resolution"); - assert_eq!(kind, MSG_RESOLUTION_CHANGED); - let chrome = Chrome::DEFAULT; - let (w, h) = ( - screen.width - chrome.chrome_w() as u32, - screen.height - chrome.taskbar as u32 - chrome.chrome_h() as u32, - ); - let mut window = Window::create_topmost(w, h, "hostile").expect("a topmost window"); - let fb = window.framebuffer(); - fb.clear(HOSTILE); - window.present(); - loop { - match window.recv_event() { - Event::Frame => break, - Event::Close => return, - _ => {} - } - } - println!("HOSTILE up"); - loop { - match window.recv_event() { - Event::KeyInput(key) => { - println!("HOSTILE key {:#x} {}", key.keycode, if key.pressed() { "down" } else { "up" }) - } - Event::MouseInput(mouse) => println!("HOSTILE mouse {} {} {}", mouse.event_type, mouse.x, mouse.y), - Event::Close => return, - _ => {} - } - } -} - -/// As gbae, started with no ROM: the ROMs in the working directory, and a -/// save beside the first, or where the granted folder's would be. -fn game() { - let cwd = std::env::current_dir().expect("a working directory"); - let mut roms: Vec = fs::read_dir(&cwd) - .into_iter() - .flatten() - .flatten() - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|name| name.ends_with(".gba")) - .collect(); - roms.sort(); - let save = match roms.first() { - Some(rom) => cwd.join(rom).with_extension("sav"), - None => SAVE.into(), - }; - let saved = if fs::write(save, b"a save").is_ok() { "Ok" } else { "Err" }; - println!("CONSENTEE cwd={} roms={roms:?} saved={saved}", cwd.display()); -} diff --git a/tests/guest/rust-tests/src/bin/disk_backtrace.rs b/tests/guest/rust-tests/src/bin/disk_backtrace.rs index b6dcea90fe1..758d8812248 100644 --- a/tests/guest/rust-tests/src/bin/disk_backtrace.rs +++ b/tests/guest/rust-tests/src/bin/disk_backtrace.rs @@ -1,5 +1,5 @@ -//! A program that dies running from a disk is recorded as its file and the -//! faulting offset, and `symbolize` names the function from them. +//! A program that dies running from a disk is recorded as its file, the +//! faulting offset and its build-id, from which a reader names the function. //! //! `/home` rather than `/tmp`: tmpfs has no device under it, so it would prove //! the mechanism without proving the claim. The claim is about a device. @@ -18,8 +18,6 @@ use toyos::syscap::SysCap; const DIR: &str = "/home/disk_backtrace"; const IN_ROOT: &str = "/system/bin/test_rs_disk_backtrace_child"; const ON_DISK: &str = "/home/disk_backtrace/child"; -const FRAME: &str = "/home/disk_backtrace/frame"; -const NAMER: &str = "/system/bin/symbolize"; /// The child's `#[inline(never)]` function the null read is in. const FAULTS_IN: &str = "null_deref_run_from_disk"; @@ -49,16 +47,6 @@ fn main() { assert!(frame.contains(" id=") && !frame.ends_with(" id=-"), "the frame carries no build-id: {frame}"); assert!(!frame.contains(FAULTS_IN), "the kernel named a program's frame: {frame}"); - fs::write(FRAME, format!("{frame}\n")).unwrap_or_else(|e| panic!("write {FRAME}: {e}")); - let named = Command::new(NAMER) - .arg(FRAME) - .output() - .unwrap_or_else(|e| panic!("run {NAMER}: {e}")); - assert!(named.status.success(), "{NAMER} {FRAME}: {:?}", named.status); - let named = String::from_utf8_lossy(&named.stdout).into_owned(); - println!(" named: {}", named.trim_end()); - assert!(named.contains(FAULTS_IN), "{NAMER} did not name {FAULTS_IN} from {frame}: {named}"); - println!("disk backtrace test passed"); } diff --git a/tests/guest/rust-tests/src/bin/empty_dir_stat.rs b/tests/guest/rust-tests/src/bin/empty_dir_stat.rs index 8a7c62ee4de..bc29f1c6cae 100644 --- a/tests/guest/rust-tests/src/bin/empty_dir_stat.rs +++ b/tests/guest/rust-tests/src/bin/empty_dir_stat.rs @@ -11,8 +11,7 @@ //! at each: the kernel tells the two cases apart and `std`'s `is_dir` reads //! anything the kernel accepted as a directory. A machine carrying one half and //! not the other passes the first block below and reds in the second, which is -//! how they are kept from drifting apart again. `toybox_file_tools` is where -//! the pair is judged by a program rather than by assertions. +//! how they are kept from drifting apart again. use std::fs; diff --git a/tests/guest/rust-tests/src/bin/endowment_denied.rs b/tests/guest/rust-tests/src/bin/endowment_denied.rs index 770650bc0c1..baeba0a5b19 100644 --- a/tests/guest/rust-tests/src/bin/endowment_denied.rs +++ b/tests/guest/rust-tests/src/bin/endowment_denied.rs @@ -36,12 +36,6 @@ //! is refused to the same handle. A kernel that demanded the bit for the header //! fails the first; a kernel that stopped demanding it fails the second. //! -//! `/system/bin/ps` is then run twice, endowed a duplicate with the bit and a -//! duplicate without it. The shipped applet reaching those same two answers -//! through the SDK is what says the manifest's name, the kernel's demand and -//! the program are one line rather than three that agree by luck — and it is -//! the only thing in this suite that runs `ps` at all. -//! //! **The shutdown arms are the ones with a machine behind them.** `SYS_SHUTDOWN` //! used to take no argument, so both of them made the call and the guest went //! away: a kernel that stops demanding `Rights::POWER` does not fail these @@ -67,7 +61,7 @@ use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Write}; use std::os::toyos::process::CommandExt; -use std::process::{Command, Output, Stdio}; +use std::process::{Command, Stdio}; use std::sync::OnceLock; use toyos::endow::{Endowments, SYSCAP_LABEL}; @@ -82,7 +76,6 @@ use toyos_abi::syscall::{ use toyos_abi::RawHandle; const SELF_PATH: &str = "/system/bin/test_rs_endowment_denied"; -const PS_PATH: &str = "/system/bin/ps"; const OPEN: &str = "echo"; const PRIVILEGED: &str = "privileged"; @@ -172,7 +165,6 @@ fn test() { the_base_plus_one_more_name(); a_right_the_capability_lacks_is_a_word(); the_roster_is_a_right_and_the_header_is_not(); - the_shipped_applet_reaches_both_answers(); every_applet_holds_only_what_its_policy_names(); for (role, what_would_be_wrong) in NOT_A_HANDLE { killed(role, what_would_be_wrong); @@ -452,54 +444,6 @@ fn the_roster_is_a_right_and_the_header_is_not() { /// `/system/bin/ps`, endowed a duplicate with the bit and a duplicate without it. /// /// **The shipped applet and not a raw call, because the plumbing is the risk.** -/// The manifest's `roster` name, `syscap_rights`' bit, the supervisor's narrowing, the -/// SDK's `SysCap::roster` and the kernel's demand are five places that have to -/// agree, and every arm above this one exercises the last of them through the -/// first only by hand. This runs the program a user runs. It is also the only -/// thing in this suite that runs `ps` at all. -fn the_shipped_applet_reaches_both_answers() { - let granted = ps_endowed( - cap().narrowed(Rights::TRANSFER.union(Rights::ROSTER)).expect("a cap carrying ROSTER"), - ); - let said = String::from_utf8_lossy(&granted.stdout); - assert_eq!(granted.status.code(), Some(0), "ps with ROSTER exited nonzero: {said}"); - let mut lines = said.lines(); - let header = lines.next().unwrap_or(""); - assert!( - header.contains("PID") && header.contains("NAME"), - "ps with ROSTER did not print its column header: {said:?}", - ); - let rows = lines.count(); - assert!(rows > 0, "ps with ROSTER printed a header and no process: {said:?}"); - - let refused = ps_endowed(cap().narrowed(Rights::TRANSFER).expect("a cap carrying less")); - let complaint = String::from_utf8_lossy(&refused.stderr); - assert_eq!( - refused.status.code(), - Some(1), - "ps without ROSTER did not refuse: {:?} / {complaint:?}", - String::from_utf8_lossy(&refused.stdout), - ); - assert!( - complaint.contains("ROSTER"), - "ps without ROSTER did not say which bit it lacked: {complaint:?}", - ); - assert!( - refused.stdout.is_empty(), - "ps without ROSTER printed a roster anyway: {:?}", - String::from_utf8_lossy(&refused.stdout), - ); - - // The rows do not sum to the machine, and `ps` says by how much rather than - // leaving the difference to be read as unattributed CPU. - assert!( - said.lines().last().is_some_and(|l| l.contains("have been reaped")), - "ps accounted for no reaped CPU at all: {said:?}", - ); - - println!(" ps: {rows} processes with the bit, and a named refusal without it"); -} - /// The authority records each `program` row grants, keyed by the binary path /// its own line names. **Its own parse, not `toyos_manifest`'s**: a checker that /// reads the manifest through the supervisor's parser and resolves links through the supervisor's @@ -590,21 +534,6 @@ fn every_applet_holds_only_what_its_policy_names() { ); } -/// Run `/system/bin/ps` holding exactly `cap` and nothing else of ours. -/// -/// A fresh duplicate per run: the endowment moves into the child, and a claim -/// this process kept would be a second holder of one handle. -fn ps_endowed(cap: SysCap) -> Output { - Command::new(PS_PATH) - .endow(SYSCAP_LABEL, cap.into_raw().0) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn /system/bin/ps") - .wait_with_output() - .expect("wait for /system/bin/ps") -} - /// Run `role` and require that the kernel ended it at its call. /// /// The marker is what gives the arm teeth: a child that died before reaching diff --git a/tests/guest/rust-tests/src/bin/file_mtime.rs b/tests/guest/rust-tests/src/bin/file_mtime.rs index 613f34d347e..bb7d6983c4f 100644 --- a/tests/guest/rust-tests/src/bin/file_mtime.rs +++ b/tests/guest/rust-tests/src/bin/file_mtime.rs @@ -5,9 +5,6 @@ //! between two `SYS_CLOCK_EPOCH` readings taken around what made it — a write, //! a create with truncation, a create of a missing file, a truncation — and a //! later write's stamp is later, which a clock of whole seconds cannot say. -//! Then the same on `/home`, fileserver's DATA, which keeps the nanosecond too. -//! Then `/log`: FAT keeps the whole seconds of its flush, read back after a -//! reopen. //! `write ` makes the first judgement on `path` and `read ` only //! prints what it finds there. `undated` runs on a machine whose RTC never //! answered and never asks the time: a file written there, on `/tmp` or on @@ -120,36 +117,7 @@ fn main() { match args.as_slice() { [_] => { let (first, second) = dated("/tmp"); - let (home_first, home_second) = dated("/home"); - // A whole second is one stamp in 10⁹; two of them are a clock of seconds. - assert!( - home_first % NANOS_PER_SEC != 0 || home_second % NANOS_PER_SEC != 0, - "/home stamps {home_first} ns and {home_second} ns: whole seconds, not the \ - nanosecond DATA keeps" - ); - for name in ["first", "second", "truncated", "missing"] { - let path = format!("/home/file-mtime-{name}"); - fs::remove_file(&path).unwrap_or_else(|e| panic!("remove {path}: {e}")); - } - - // The flush stamps FAT, which keeps whole seconds and drops an odd one. - const FAT: &str = "/log/file-mtime"; - let before = epoch(); - write(FAT, b"on FAT"); - let fat = mtime(FAT, "its write"); - let after = epoch(); - assert!( - (before - 1) * NANOS_PER_SEC <= fat - && fat <= after * NANOS_PER_SEC - && fat % NANOS_PER_SEC == 0, - "{FAT} reads back {fat} ns after a reopen, and the wall clock read {before} s \ - before its write and {after} s after", - ); - fs::remove_file(FAT).unwrap_or_else(|e| panic!("remove {FAT}: {e}")); - println!( - "file-mtime: /tmp stamps {first} then {second}, /home {home_first} then \ - {home_second}, /log {fat}" - ); + println!("file-mtime: /tmp stamps {first} then {second}"); } [_, mode] if mode == "undated" => { for undated in ["/tmp/file-mtime-undated", "/home/file-mtime-undated"] { diff --git a/tests/guest/rust-tests/src/bin/fs_cache_eviction.rs b/tests/guest/rust-tests/src/bin/fs_cache_eviction.rs deleted file mode 100644 index 0cb7ae2ff88..00000000000 --- a/tests/guest/rust-tests/src/bin/fs_cache_eviction.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! What DATA's server evicted from its cache it reads back off its disk, byte -//! for byte. -//! -//! The file is longer than the clean blocks the server keeps: once it is -//! durable every block of it is clean, the oldest are let go, and reading it -//! back from the start is a round trip through the disk for every block the -//! cache no longer holds, each checked against what was written. - -use std::fs::{self, File}; -use std::io::{Read, Write}; - -/// Mirrored from `system/fileserver/src/cache.rs`, in 4 KiB blocks. -const CLEAN_LIMIT: usize = 16 * 1024; - -const PATH: &str = "/home/fs_cache_eviction.bin"; -const CHUNK: usize = 256 * 1024; -/// 8 MiB past what the cache keeps. -const LEN: usize = (CLEAN_LIMIT + 2048) * 4096; - -/// The byte at `at`: each block opens with its own index, so a block read back -/// from anywhere else is seen, and the rest varies with the offset in it. -fn byte(at: usize) -> u8 { - let (block, within) = (at / 4096, at % 4096); - match within { - 0..8 => (block as u64).to_le_bytes()[within], - _ => (block ^ within.wrapping_mul(7)) as u8, - } -} - -fn main() { - let mut chunk = vec![0u8; CHUNK]; - { - let mut f = File::create(PATH).unwrap_or_else(|e| panic!("create {PATH}: {e}")); - for start in (0..LEN).step_by(CHUNK) { - for (i, b) in chunk.iter_mut().enumerate() { - *b = byte(start + i); - } - f.write_all(&chunk).unwrap_or_else(|e| panic!("write at {start}: {e}")); - } - f.sync_all().expect("the file is durable"); - } - println!("fs_cache_eviction: wrote {} MiB, past the {} MiB the cache keeps", LEN >> 20, (CLEAN_LIMIT * 4096) >> 20); - - let mut f = File::open(PATH).expect("open it again"); - let mut done = 0; - while done < LEN { - f.read_exact(&mut chunk).unwrap_or_else(|e| panic!("read at {done}: {e}")); - if let Some(i) = (0..CHUNK).find(|&i| chunk[i] != byte(done + i)) { - panic!("byte {} read back {:#04x}, written {:#04x}", done + i, chunk[i], byte(done + i)); - } - done += CHUNK; - } - drop(f); - fs::remove_file(PATH).expect("remove the file"); - println!("fs_cache_eviction: PASS"); -} diff --git a/tests/guest/rust-tests/src/bin/fs_escape.rs b/tests/guest/rust-tests/src/bin/fs_escape.rs deleted file mode 100644 index be542244299..00000000000 --- a/tests/guest/rust-tests/src/bin/fs_escape.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! A directory capability is a floor: nothing named through `/home` reaches a -//! file outside it, and a directory this program was not given is refused. -//! -//! - a relative symlink under `/home` whose `..` climbs above it is refused -//! `PermissionDenied` by the server — refused, not clamped to `/home`, which -//! would be a different file from the one the link named (the rule -//! `openat2(2)`'s `RESOLVE_BENEATH` states for Linux); -//! - one that climbs and comes back down inside `/home` is followed; -//! - a client that puts a `..` on the wire itself, past std, is refused -//! `InvalidArgument` before anything is looked up; -//! - a directory no capability names is nobody's, and nothing under it is -//! there. - -use std::fs; -use std::io::ErrorKind; -use std::os::toyos::fs::symlink; - -use toyos::fs::{hello, window_put, Reply, Request, OPEN, O_READ, REPLY, WINDOW_BYTES}; -use toyos::shm::SharedMemory; -use toyos::volatile::Window; -use toyos_abi::syscall::SyscallError; - -const SECRET: &[u8] = b"/state's own bytes, which nothing under /home may name"; -const PLAIN: &[u8] = b"a file under /home, named by a link that climbs and comes back"; - -fn refused(path: &str, want: ErrorKind) { - match fs::read(path) { - Err(e) if e.kind() == want => println!("fs_escape: {path} refused: {e}"), - Err(e) => panic!("{path}: refused with {e} ({:?}), not {want:?}", e.kind()), - Ok(bytes) if bytes == SECRET => panic!("{path} read /state's secret through /home"), - Ok(bytes) => panic!("{path} read {} bytes; it names nothing under /home", bytes.len()), - } -} - -fn fresh_link(target: &str, link: &str) { - let _ = fs::remove_file(link); - symlink(target, link).unwrap_or_else(|e| panic!("symlink {link} -> {target}: {e}")); -} - -/// A raw client of `fs:/home`, past std's own canonicalisation: `rel` on the -/// wire as it is, and the server's word for it. -fn wire_open(rel: &[u8]) -> Reply { - let names = toyos::endow::namespace().expect("this program was endowed a namespace"); - let conn = names.open("fs:/home").expect("this program holds fs:/home"); - let window = SharedMemory::create(WINDOW_BYTES).expect("a window"); - hello(&conn, &window).expect("fs:/home answers its hello"); - let answer = |conn: &toyos::ipc::Connection| -> Reply { - let header = conn.recv_header().expect("a reply"); - assert_eq!(header.msg_type, REPLY, "a reply frame"); - conn.recv_payload(&header).expect("a reply's words") - }; - // SAFETY: the region is `WINDOW_BYTES` long and outlives this use. - window_put(unsafe { Window::new(window.as_ptr(), WINDOW_BYTES) }, 0, rel); - conn.send(OPEN, &Request { len: rel.len() as u64, flags: O_READ, ..Request::new() }).expect("open"); - answer(&conn) -} - -fn main() { - fs::create_dir_all("/state/fs_escape").expect("make /state/fs_escape"); - fs::write("/state/fs_escape/secret", SECRET).expect("write the secret"); - fs::create_dir_all("/home/fs_escape/deeper").expect("make /home/fs_escape/deeper"); - fs::write("/home/fs_escape/plain", PLAIN).expect("write the plain file"); - - fresh_link("../state/fs_escape/secret", "/home/fs_escape_out"); - refused("/home/fs_escape_out", ErrorKind::PermissionDenied); - fresh_link("../../../state/fs_escape/secret", "/home/fs_escape/deeper/out"); - refused("/home/fs_escape/deeper/out", ErrorKind::PermissionDenied); - // A directory link climbing out, and a name under it. - fresh_link("../../..", "/home/fs_escape/deeper/up"); - refused("/home/fs_escape/deeper/up/state/fs_escape/secret", ErrorKind::PermissionDenied); - - fresh_link("../plain", "/home/fs_escape/deeper/back"); - let back = fs::read("/home/fs_escape/deeper/back").expect("a link that stays inside /home is followed"); - assert_eq!(back, PLAIN, "the link inside /home names the plain file"); - - for rel in [&b"../state/fs_escape/secret"[..], b"fs_escape/../../state/fs_escape/secret", b"/state"] { - let reply = wire_open(rel); - assert_eq!( - reply.status, - SyscallError::InvalidArgument.to_u64(), - "{:?} on the wire was answered status {}", - core::str::from_utf8(rel), - reply.status - ); - println!("fs_escape: {:?} on the wire refused", core::str::from_utf8(rel).unwrap_or("")); - } - - // A directory this program holds no capability for is not a file - // server's: the name is refused by the namespace, and the path goes to - // ROOT, which has no such directory. - assert!( - toyos::endow::namespace().expect("a namespace").open("fs:/fs_escape_nobodys").is_err(), - "a capability this program was never given answered" - ); - refused("/fs_escape_nobodys/secret", ErrorKind::NotFound); - - for link in ["/home/fs_escape_out", "/home/fs_escape/deeper/out", "/home/fs_escape/deeper/up", "/home/fs_escape/deeper/back"] { - let _ = fs::remove_file(link); - } - let _ = fs::remove_dir_all("/home/fs_escape"); - let _ = fs::remove_dir_all("/state/fs_escape"); - println!("fs_escape: PASS"); -} diff --git a/tests/guest/rust-tests/src/bin/fs_large_file.rs b/tests/guest/rust-tests/src/bin/fs_large_file.rs deleted file mode 100644 index 8d9d850990e..00000000000 --- a/tests/guest/rust-tests/src/bin/fs_large_file.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! A file bigger than the filesystem could describe, and a name bigger than it -//! will accept. Both used to panic the kernel from ordinary userland calls. -//! -//! bcachefs keeps a file's extent list inline in its btree value, and -//! `resolve_or_alloc_block` pushed one extent per page without merging -//! contiguous ones — so the value grew by 16 bytes per page against a -//! 4040-byte node payload, and at roughly 250 pages `Node::write_to` -//! underflowed `MAX_PAYLOAD - used`. `rename` reached the same line by a -//! shorter road: it was the one name-taking entry point with no length bound, -//! and `user_ptr::MAX_USER_STR` lets 64 KiB through. - -use std::fs; -use std::io::{Read, Seek, SeekFrom, Write}; - -const PAGE: usize = 4096; -/// Four times the old cap. Merging makes a sequentially written file one -/// extent whatever its length, so this is a claim about the *shape* of the -/// value and not about 4 MB in particular. -const PAGES: usize = 1024; -const PATH: &str = "/home/fs_large.bin"; - -fn page_bytes(page: usize) -> Vec { - (0..PAGE) - .map(|i| ((page.wrapping_mul(0x9E37_79B9) ^ i.wrapping_mul(0x85EB_CA6B)) >> 11) as u8) - .collect() -} - -fn main() { - { - let mut f = fs::File::create(PATH).expect("create on /home"); - for page in 0..PAGES { - f.write_all(&page_bytes(page)).expect("write a page"); - } - // The fsync is the trigger: flush walks every dirty page allocating a - // block each, then writes one metadata entry describing all of them. - f.sync_all().expect("fsync a 1024-page file"); - } - - let mut f = fs::File::open(PATH).expect("reopen"); - let meta = f.metadata().expect("stat"); - assert_eq!(meta.len(), (PAGES * PAGE) as u64, "length changed across the round trip"); - - for page in [0, 1, 249, 250, 251, PAGES / 2, PAGES - 1] { - f.seek(SeekFrom::Start((page * PAGE) as u64)).expect("seek"); - let mut got = vec![0u8; PAGE]; - f.read_exact(&mut got).expect("read"); - let want = page_bytes(page); - let bad = got.iter().zip(&want).position(|(a, b)| a != b); - assert!(bad.is_none(), "page {page} byte {} differs", bad.unwrap()); - } - drop(f); - - // A name no btree value can hold is untrusted input, so it owes an error. - let huge = format!("/home/{}", "n".repeat(4096)); - let renamed = fs::rename(PATH, &huge); - assert!(renamed.is_err(), "rename accepted a 4096-byte name"); - assert!( - fs::File::open(PATH).is_ok(), - "the rejected rename took the source file with it", - ); - - let created = fs::File::create(&huge); - assert!(created.is_err(), "create accepted a 4096-byte name"); - - fs::remove_file(PATH).expect("cleanup"); - println!("large file ok: {PAGES} pages round-tripped, oversized names refused"); -} diff --git a/tests/guest/rust-tests/src/bin/fs_share.rs b/tests/guest/rust-tests/src/bin/fs_share.rs deleted file mode 100644 index cf74f947b03..00000000000 --- a/tests/guest/rust-tests/src/bin/fs_share.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! A file server's bounds are shared out by share: one holding all it may -//! leaves the server answering another, and no launch made in a login session -//! gives that session a second. -//! -//! This job holds test-runner's grants and launcher: test-runner's share, in -//! the machine's session. On DATA's server: -//! -//! - each of DATA's directories is its grant's root: a file made under -//! `/home` is under none of the others; -//! - it takes every stream the server will give its share, until one is -//! refused; a shell it launches, which opens no session, then has its -//! redirect's stream refused; -//! - it takes every connection the server will serve its share, until one is -//! refused; a shell it launches then has its redirect's connection refused; -//! - holding all that, a shell it launches launches another, which that -//! shell's `login` row opens a login session for. Down a chain of shells -//! each launching the next, every one in that session, each streams a -//! child's output into a file and holds one more stream, its next shell's -//! output. The first's file shows another session's first connection and -//! first stream answered; every one's up to the session's share of streams -//! is written, and the next shell's is refused: a `login` row's launches in -//! a login session opened none; -//! - last, it opens more connections than one share may have waiting on -//! their hello: the ones past it are answered `ResourceExhausted` -//! and let go as the server takes them, so the first to end is not the -//! first opened, which the server would otherwise let go first, at its -//! handshake timeout; a hello on it, which finds the server gone, reads -//! why, and every later one keeps none of the windows it could not lend. -//! Last, because the server reaps the ones this job drops only when it -//! next reads them, and until then they are this share's. - -use std::fs; -use std::process::Command; - -use toyos::endow; -use toyos::fs::{hello, Dir, O_CREATE, O_WRITE, WINDOW_BYTES}; -use toyos::ipc::Connection; -use toyos::poller::{Poller, READABLE}; -use toyos::shm::SharedMemory; -use toyos_abi::syscall::SyscallError; -use toyos_abi::RawHandle; - -const DIR: &str = "/home/fs_share"; -const SAME_STREAM: &str = "/home/fs_share/same_stream"; -const SAME_CONNECTION: &str = "/home/fs_share/same_connection"; -const FIRST: &str = "/home/fs_share/first"; -const PAST: &str = "/home/fs_share/past"; -const MARK: &str = "/home/fs_share/mark"; -const SAID: &str = "answered"; - -/// More connections than one share of a server's handshakes: the server's own -/// machine-wide bound on them. -const UNANSWERED: usize = 32; - -/// Far past any bound a server keeps, so a server that refuses nothing ends the -/// loop rather than the machine. -const CEILING: usize = 1024; - -/// How long the first unanswered connection may take to end: a hang ceiling, -/// never a measure. -const HANG_NS: u64 = 60_000_000_000; - -fn main() { - let names = endow::namespace().expect("this job was endowed a namespace"); - // std's own connection to /home, made before anything is held, is what - // the shell's file is read back through. - fs::create_dir_all(DIR).expect("make the test's directory"); - for file in [SAME_STREAM, SAME_CONNECTION, FIRST, PAST] { - let _ = fs::remove_file(file); - } - // Every arm runs, so one run says each one that is red. - let mut red = Vec::new(); - - fs::write(MARK, b"home's").expect("write a file under /home"); - for elsewhere in ["/apps", "/config", "/state"] { - let path = format!("{elsewhere}/fs_share/mark"); - match fs::metadata(&path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - other => red.push(format!("{MARK} is also {path}: {other:?}")), - } - } - - // Every stream the server gives this share. - let mut dir = Dir::connect(names, "fs:/home").expect("a client of /home"); - let file = dir.open("fs_share/streams", O_WRITE | O_CREATE).expect("open a file to stream into"); - let mut streams = Vec::new(); - let refused = loop { - if streams.len() == CEILING { - break None; - } - match dir.stream(file.fid, file.generation, 0) { - Ok(pipe) => streams.push(pipe), - Err(e) => break Some(e), - } - }; - match refused { - Some(SyscallError::ResourceExhausted) => println!(" this share holds {} streams", streams.len()), - other => red.push(format!("after {} streams, the next was answered {other:?}", streams.len())), - } - // A launch that opens no session: its redirect connects and opens the - // file, and the stream into it is refused, so it stays empty. - let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_STREAM}")).output().expect("launch a shell"); - match fs::read_to_string(SAME_STREAM) { - Ok(text) if text.is_empty() => println!(" a shell launched under this share was refused a stream"), - other => red.push(format!("a shell launched under this share streamed {other:?}: {shell:?}")), - } - - // Every connection the server serves this share, all lent one window. - let window = SharedMemory::create(WINDOW_BYTES).expect("a window"); - let mut served = Vec::new(); - let refused = loop { - if served.len() == CEILING { - break None; - } - let conn = names.open("fs:/home").expect("connect to /home"); - match hello(&conn, &window) { - Ok(_) => served.push(conn), - Err(e) => break Some(e), - } - }; - match refused { - Some(SyscallError::ResourceExhausted) => { - println!(" this share is served {} more connections", served.len()) - } - other => red.push(format!("after {} connections, the next hello was answered {other:?}", served.len())), - } - // A launch that opens no session: its redirect's connection is refused, - // so the file is never made. - let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_CONNECTION}")).output().expect("launch a shell"); - match fs::metadata(SAME_CONNECTION) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - println!(" a shell launched under this share was refused a connection") - } - other => red.push(format!("a shell launched under this share made {SAME_CONNECTION}: {other:?}, {shell:?}")), - } - - // A login session, while this share holds all that: the shell this job - // launches opens none, and the one it launches is in one its row opens. - // The first shell in it streams, and every shell holds its next one's - // output, one stream more, until the session holds as many as this share - // does; the last asks for one past that. Each link's line is a variable - // the next shell expands, so no line is quoted inside another. - let links = streams.len() + 1; - let mut chain = run_shell("/system/bin/shell -c $FS_SHARE_1"); - for link in 1..=links { - let next = format!("/system/bin/shell -c $FS_SHARE_{} > {DIR}/next_{link}", link + 1); - let line = match link { - 1 => format!("/system/bin/toybox echo {SAID} > {FIRST} ; {next}"), - _ if link == links => format!("/system/bin/toybox echo {SAID} > {PAST}"), - _ => next, - }; - chain.env(format!("FS_SHARE_{link}"), line); - } - let chain = chain.output(); - match fs::read_to_string(FIRST) { - Ok(text) if text.trim_end() == SAID => println!(" a login session connected and streamed"), - other => red.push(format!("a login session's first stream wrote {other:?}: {chain:?}")), - } - match fs::read_to_string(PAST) { - Ok(text) if text.is_empty() => { - println!(" a login session's launches held {} streams and the next was refused", links - 1) - } - other => red.push(format!("past {} streams, a login session's launch streamed {other:?}: {chain:?}", links - 1)), - } - - // Connections that never say hello. - let opened: Vec = - (0..UNANSWERED).map(|_| names.open("fs:/home").expect("connect to /home")).collect(); - let poller = Poller::new(UNANSWERED as u32); - for (i, conn) in opened.iter().enumerate() { - poller.watch(conn, READABLE, i as u64); - } - let mut ended = Vec::new(); - // Two, so the server had let the first of them go whole before the second - // was answered: it takes one connection at a time. - poller.wait(2, HANG_NS, |token| ended.push(token as usize)); - ended.sort_unstable(); - match ended.first() { - None => red.push(format!("none of {UNANSWERED} unanswered connections ended")), - Some(0) => red.push(format!("the first unanswered connection ended first, with {ended:?}")), - Some(_) => println!(" unanswered connections past the share ended first: {ended:?}"), - } - // So this hello finds the server gone, and reads what it was answered. - if let Some(&first) = ended.first() { - match hello(&opened[first], &window) { - Err(SyscallError::ResourceExhausted) => println!(" a hello on a connection let go is told why"), - other => red.push(format!("a hello on connection {first}, let go, was answered {:?}", other.map(|_| ()))), - } - // As many hellos as this process has handle slots: one that kept the - // window it could not lend would fill the table before the last. - let refused = (0..RawHandle::MAX_SLOTS) - .map(|_| hello(&opened[first], &window).map(|_| ())) - .enumerate() - .find(|(_, answer)| *answer != Err(SyscallError::Gone)); - match refused { - None => println!(" {} hellos on a connection let go kept no window", RawHandle::MAX_SLOTS), - Some((i, answer)) => red.push(format!("hello {i} on connection {first}, let go, was answered {answer:?}")), - } - } - - // A table the last arm filled has no room left to report it in. - drop((poller, opened, served, streams)); - assert!(red.is_empty(), "fs_share:\n {}", red.join("\n ")); - println!("fs_share: PASS"); -} - -/// A shell launched through test-runner's launcher, under this job's share, -/// running `line`, from `/`: no file server's directory, so a redirect is the -/// only file it opens. -fn run_shell(line: &str) -> Command { - let mut shell = Command::new("/system/bin/shell"); - shell.args(["-c", line]).current_dir("/"); - shell -} diff --git a/tests/guest/rust-tests/src/bin/fs_stream_offset.rs b/tests/guest/rust-tests/src/bin/fs_stream_offset.rs deleted file mode 100644 index 6f6bfb5737c..00000000000 --- a/tests/guest/rust-tests/src/bin/fs_stream_offset.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! A client's number is bounded where the server keeps it: a stream asked to -//! start past the largest offset a file has is refused, and the server that -//! refused it goes on answering. -//! -//! A raw client of `fs:/home`, past std: std never asks for such a stream, and -//! any program holding `/home` can. Accepted, the offset would grow by what -//! the pipe carries until it overflowed, which panics the server that every -//! program's `/apps`, `/config`, `/home` and `/state` are on. - -use toyos::fs::{ - hello, window_put, Reply, Request, MAX_FILE_BYTES, OPEN, O_CREATE, O_WRITE, REPLY, STREAM, WINDOW_BYTES, -}; -use toyos::ipc::Connection; -use toyos::shm::SharedMemory; -use toyos::volatile::Window; -use toyos_abi::syscall::SyscallError; - -/// Under `/home`, relative to it. -const NAME: &[u8] = b"fs_stream_offset"; - -fn answer(conn: &Connection) -> Reply { - let header = conn.recv_header().expect("a reply: the server is still there"); - assert_eq!(header.msg_type, REPLY, "a reply frame"); - conn.recv_payload(&header).expect("a reply's words") -} - -fn open(conn: &Connection, window: &SharedMemory) -> Reply { - // SAFETY: the region is `WINDOW_BYTES` long and outlives this use. - window_put(unsafe { Window::new(window.as_ptr(), WINDOW_BYTES) }, 0, NAME); - let request = Request { len: NAME.len() as u64, flags: O_WRITE | O_CREATE, ..Request::new() }; - conn.send(OPEN, &request).expect("open"); - answer(conn) -} - -fn main() { - let names = toyos::endow::namespace().expect("this program was endowed a namespace"); - let conn = names.open("fs:/home").expect("this program holds fs:/home"); - let window = SharedMemory::create(WINDOW_BYTES).expect("a window"); - hello(&conn, &window).expect("fs:/home answers its hello"); - - let opened = open(&conn, &window); - assert_eq!(opened.status, 0, "{} opened to write", core::str::from_utf8(NAME).unwrap_or("")); - let fid = opened.value; - - for offset in [u64::MAX - 1, MAX_FILE_BYTES + 1] { - conn.send(STREAM, &Request { fid, offset, ..Request::new() }).expect("stream"); - let reply = answer(&conn); - if reply.status == 0 { - // What the server's end would have been: the pipe's bytes drained - // at an offset that overflows. - if let Some([end]) = conn.recv_handles_exact::<1>() { - let _ = toyos_abi::syscall::write(end, &[0x5A; 16]); - toyos_abi::syscall::close(end); - } - panic!("a stream at offset {offset:#x} was accepted"); - } - assert_eq!( - reply.status, - SyscallError::InvalidArgument.to_u64(), - "a stream at offset {offset:#x} was answered status {}", - reply.status - ); - println!("fs_stream_offset: a stream at {offset:#x} refused InvalidArgument"); - } - - // The same connection, so an end of the server between the two would be - // this read failing and not a reconnect hiding it. - assert_eq!(open(&conn, &window).status, 0, "the server answers after the refusals"); - drop(conn); - let _ = std::fs::remove_file("/home/fs_stream_offset"); - println!("fs_stream_offset: PASS"); -} diff --git a/tests/guest/rust-tests/src/bin/fs_truncate_persist.rs b/tests/guest/rust-tests/src/bin/fs_truncate_persist.rs index 45cb9a4c346..5c9784795ac 100644 --- a/tests/guest/rust-tests/src/bin/fs_truncate_persist.rs +++ b/tests/guest/rust-tests/src/bin/fs_truncate_persist.rs @@ -1,13 +1,5 @@ -//! `set_len` on a persistent filesystem, and running a binary out of tmpfs. -//! -//! Both are the same shape of gap: something that works in memory and stops at -//! the boundary where a mount has to describe itself to someone else. A -//! truncate dirties no page, so the flush had nothing to write and returned -//! before it reached the metadata; and tmpfs had no `open_backing`, so the -//! loader could not read a file whose pages are the file. -//! -//! /tmp is the control in both directions: it passed the truncate case before -//! the fix because its authority is the file cache itself. +//! `set_len` on the kernel's own mount, across a close, and running a binary +//! out of tmpfs: the loader reads a file whose pages are the file. use std::fs; use std::io::{Read, Seek, SeekFrom, Write}; @@ -102,7 +94,6 @@ fn main() { return; } truncate_round_trip("/tmp/trunc_control.bin"); - truncate_round_trip("/home/trunc_persist.bin"); spawn_from_tmpfs(); - println!("truncate persists on /home and /tmp; tmpfs binaries are loadable"); + println!("truncate holds on /tmp across a close; tmpfs binaries are loadable"); } diff --git a/tests/guest/rust-tests/src/bin/fs_turns.rs b/tests/guest/rust-tests/src/bin/fs_turns.rs deleted file mode 100644 index f1be3aa3e0e..00000000000 --- a/tests/guest/rust-tests/src/bin/fs_turns.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Threads of one process sharing one directory's connection are served in turn. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Barrier}; - -const THREADS: usize = 6; -/// The passes the first thread to finish makes. -const PASSES: u32 = 32; - -fn main() { - std::fs::create_dir_all("/home/fs_turns").expect("make /home/fs_turns"); - let start = Arc::new(Barrier::new(THREADS)); - let done = Arc::new(AtomicBool::new(false)); - let workers: Vec<_> = (0..THREADS) - .map(|n| { - let (start, done) = (Arc::clone(&start), Arc::clone(&done)); - std::thread::spawn(move || { - let path = format!("/home/fs_turns/{n}"); - start.wait(); - let mut passes: u32 = 0; - while !done.load(Ordering::Acquire) { - std::fs::write(&path, passes.to_le_bytes()).unwrap_or_else(|e| panic!("thread {n}: write {path}: {e}")); - passes += 1; - if passes == PASSES { - done.store(true, Ordering::Release); - } - } - passes - }) - }) - .collect(); - let passes: Vec = workers.into_iter().map(|w| w.join().expect("a writer panicked")).collect(); - println!("fs_turns: passes per thread {passes:?}"); - let _ = std::fs::remove_dir_all("/home/fs_turns"); - let least = *passes.iter().min().expect("threads"); - // One pass is a lock that lets its releaser take it straight back: every - // other thread gets in once, at the start. A floor any higher also - // measures how long a thread is kept off-CPU between its reply and its - // next request, when it holds no ticket. - assert!(least > 1, "a thread made {least} passes while another made {PASSES}: {passes:?}"); - println!("fs_turns: PASS"); -} diff --git a/tests/guest/rust-tests/src/bin/hda_client_stall.rs b/tests/guest/rust-tests/src/bin/hda_client_stall.rs deleted file mode 100644 index d25e3579dc7..00000000000 --- a/tests/guest/rust-tests/src/bin/hda_client_stall.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! A client that stops producing mid-stream, on a machine whose audio device -//! is a cyclic DMA ring. -//! -//! The stall is the whole actuator. soundserver's mix loop may leave a freed period -//! unfilled while a streaming client is still producing it, and on -//! virtio-sound that costs nothing: a period soundserver has not submitted is a -//! period the device does not have. HDA's engine owns every period for as long -//! as it runs and replays the ones nobody refilled, so a period held across a -//! lap is completed a second time — which is what killed soundserver on the T14. -//! -//! Nothing in the ordinary tone clients reaches that state: they keep their -//! ring full, so soundserver never defers at all (`deferred=0` on every `hda_tone` -//! run measured). This one empties it on purpose, for longer than the ring -//! takes to come round. - -use std::sync::mpsc; -use std::time::{Duration, Instant}; - -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use toyos_inspect::{Value, SOUND}; - -const FREQ_HZ: f64 = 440.0; -const AMPLITUDE: f64 = 16000.0; - -/// Periods of tone between stalls, and how long each stall lasts. -/// -/// The stall has to outlast one lap of the device ring — 8 periods, 23.2 ms — -/// or the engine never reaches a period soundserver is still holding. 60 ms is two -/// and a half laps, and the run stages eight of them so the test does not rest -/// on catching one window. -const STALL: Duration = Duration::from_millis(60); -const STALLS: u64 = 8; -const CALLBACKS_BETWEEN_STALLS: u64 = 60; - -/// How long each wait here has before it panics by name. A hang ceiling: what -/// each waits on is a quarter of a second or a lap of the ring away. -const WITHIN: Duration = Duration::from_secs(5); - -fn main() { - play(STALLS); - // A second stream over the same device, after soundserver has drained and - // suspended: on a ring the drain gives the periods up rather than holding - // them, so what the resume primes and where in the ring it starts are both - // state the first stream left behind. - await_suspended(); - play(2); - println!("stalled {STALLS} then 2 times, soundserver survived"); -} - -/// One stream of `stalls + 1` stretches of tone with a stall after each but -/// the last, closed when the last has played: the laps of the ring that follow -/// the last stall are played to a client that is still there, as every other -/// stall's are. -fn play(stalls: u64) { - let host = cpal::default_host(); - let device = host.default_output_device().expect("no audio output device"); - let config = device.default_output_config().expect("no audio config"); - let sample_rate = config.sample_rate() as f64; - let channels = config.channels() as usize; - - let (ended, stretch_ended) = mpsc::channel(); - let mut n: u64 = 0; - let mut callbacks: u64 = 0; - - let stream = device - .build_output_stream( - config.into(), - move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { - for frame in data.chunks_exact_mut(channels) { - let phase = 2.0 * std::f64::consts::PI * FREQ_HZ * n as f64 / sample_rate; - frame.fill((AMPLITUDE * phase.sin()) as i16); - n += 1; - } - callbacks += 1; - let stretch = callbacks / CALLBACKS_BETWEEN_STALLS; - if callbacks % CALLBACKS_BETWEEN_STALLS != 0 || stretch > stalls + 1 { - return; - } - if stretch <= stalls { - std::thread::sleep(STALL); - } - ended.send(()).expect("`play` holds the receiver until the last stretch ends"); - }, - |err| eprintln!("audio error: {err}"), - None, - ) - .expect("failed to build audio stream"); - - stream.play().expect("failed to play"); - let stretches = stalls + 1; - for stretch in 1..=stretches { - if let Err(why) = stretch_ended.recv_timeout(WITHIN) { - panic!("stretch {stretch} of {stretches} did not end within {WITHIN:?}: {why}"); - } - } - drop(stream); -} - -/// Wait until soundserver says its device stream is stopped. -/// -/// soundserver tells no client that it suspended: its `inspect` answer is the one -/// place a client reads it. The mix loop publishes that once a wake, and the -/// device playing its tail out wakes it once a period, so a period is how -/// often it is asked. -fn await_suspended() { - let deadline = Instant::now() + WITHIN; - loop { - let sound = - inspect::ask(SOUND).unwrap_or_else(|why| panic!("soundserver's inspect answer: {why}")); - let (Some(Value::Text(state)), Some(&Value::U64(frames)), Some(&Value::U64(rate))) = ( - sound.get("sound.stream.state"), - sound.get("sound.period_frames"), - sound.get("sound.rate_hz"), - ) else { - panic!("soundserver's snapshot names no stream state and period: {sound:?}"); - }; - if state == "suspended" { - return; - } - assert!( - Instant::now() < deadline, - "soundserver's stream still reads `{state}` {WITHIN:?} after its only client closed, so \ - the second stream has no suspended daemon to resume" - ); - std::thread::sleep(Duration::from_nanos(1_000_000_000 * frames / rate)); - } -} diff --git a/tests/guest/rust-tests/src/bin/hierarchy_paths.rs b/tests/guest/rust-tests/src/bin/hierarchy_paths.rs index 5ec9ef7b14b..a03a3990be6 100644 --- a/tests/guest/rust-tests/src/bin/hierarchy_paths.rs +++ b/tests/guest/rust-tests/src/bin/hierarchy_paths.rs @@ -1,5 +1,4 @@ -//! `/` is a synthesized read-only directory, and `/apps` and `/home` are two -//! paths into one filesystem. +//! `/` is a synthesized read-only directory. //! //! The listing of `/` is a closed set, so a stray top-level name cannot appear //! because some volume happens to carry a directory by that name; `/` is no @@ -7,7 +6,6 @@ //! refused the way a read-only mount refuses one, and the machine survives it. use std::fs; -use std::io::Write; use toyos_abi::syscall::{self, OpenFlags, SyscallError}; @@ -16,14 +14,6 @@ use toyos_abi::syscall::{self, OpenFlags, SyscallError}; const ROOT_ENTRIES: [&str; 9] = ["apps", "boot", "config", "home", "log", "media", "state", "system", "tmp"]; -const IN_HOME: &str = "/home/hierarchy-home.bin"; -const IN_APPS: &str = "/apps/hierarchy-apps.bin"; -const LEN: usize = 2 * 4096 + 61; - -fn payload(seed: u8) -> Vec { - (0..LEN).map(|i| (i.wrapping_mul(53) ^ seed as usize) as u8).collect() -} - fn names(dir: &str) -> Vec { let mut out: Vec = fs::read_dir(dir) .unwrap_or_else(|e| panic!("read_dir {dir}: {e}")) @@ -40,7 +30,6 @@ fn main() { root_refuses_every_way_of_changing_it(); media_is_an_empty_directory(); - apps_and_home_take_writes(); println!("all hierarchy tests passed"); } @@ -97,21 +86,3 @@ fn media_is_an_empty_directory() { assert!(fs::read("/media/anything").is_err(), "/media served a file"); println!(" PASS /media is an empty directory"); } - -/// One file under each name DATA answers to, fsynced, so the host's reader finds -/// them on the device rather than in a cache. -fn apps_and_home_take_writes() { - for (path, seed) in [(IN_HOME, 0xA5u8), (IN_APPS, 0x5A)] { - let data = payload(seed); - let mut f = fs::File::create(path).unwrap_or_else(|e| panic!("create {path}: {e}")); - f.write_all(&data).unwrap_or_else(|e| panic!("write {path}: {e}")); - f.sync_all().unwrap_or_else(|e| panic!("fsync {path}: {e}")); - drop(f); - let back = fs::read(path).unwrap_or_else(|e| panic!("read back {path}: {e}")); - assert_eq!(back, data, "{path} did not read back as it was written"); - } - // Two paths, not two names for one file: a symlink between them would break this. - assert!(fs::read("/apps/hierarchy-home.bin").is_err(), "/apps answered for a /home file"); - assert!(fs::read("/home/hierarchy-apps.bin").is_err(), "/home answered for an /apps file"); - println!(" PASS {LEN} bytes under each of /home and /apps, and neither is the other"); -} diff --git a/tests/guest/rust-tests/src/bin/home_backing_revoked.rs b/tests/guest/rust-tests/src/bin/home_backing_revoked.rs deleted file mode 100644 index b6a1b1fb974..00000000000 --- a/tests/guest/rust-tests/src/bin/home_backing_revoked.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! A file backing must not outlive the file it reads. -//! -//! Staged here rather than reasoned about: the victim's blocks are freed and -//! then deliberately handed to a file whose bytes are nothing like the -//! victim's, and the still-open descriptor is read afterwards. - -use std::fs; -use std::io::{Read, Write}; - -const VICTIM: &str = "/home/revoke_victim.bin"; -const ATTACKER: &str = "/home/revoke_attacker.bin"; -const CONTROL: &str = "/home/revoke_control.bin"; - -/// Eight pages. More than one so the read crosses pages, and small enough that -/// the harness's `/home` has room for two of them at once. -const LEN: usize = 8 * 4096; - -const VICTIM_BYTE: u8 = 0xA7; -const ATTACKER_BYTE: u8 = 0x5C; - -fn write_file(path: &str, byte: u8) { - { - let mut f = fs::File::create(path).unwrap_or_else(|e| panic!("create {path}: {e}")); - f.write_all(&vec![byte; LEN]).unwrap_or_else(|e| panic!("write {path}: {e}")); - f.sync_all().unwrap_or_else(|e| panic!("fsync {path}: {e}")); - } // close: the last handle drops here. -} - -fn read_all(f: &mut fs::File) -> std::io::Result> { - let mut got = Vec::new(); - f.read_to_end(&mut got)?; - Ok(got) -} - -fn main() { - // The control. - write_file(CONTROL, VICTIM_BYTE); - let control = read_all(&mut fs::File::open(CONTROL).expect("open the control")) - .expect("read the control"); - assert_eq!(control.len(), LEN, "the control read short"); - assert!( - control.iter().all(|&b| b == VICTIM_BYTE), - "the backing did not serve the control file's own bytes", - ); - - write_file(VICTIM, VICTIM_BYTE); - - // Held open, and deliberately not read. - let mut held = fs::File::open(VICTIM).expect("open the victim"); - - fs::remove_file(VICTIM).expect("unlink the victim"); - - // The victim's blocks are the lowest free ones now, so this takes them. - write_file(ATTACKER, ATTACKER_BYTE); - - // **Refused, not zeroed.** The read used to come back as `LEN` bytes of - // zeros and a success, because the revocation reached a `read_page` that - // had no way to say so — and a hole is not distinguishable from data a - // caller may act on. It reaches the process now, so this asks for the - // refusal and keeps the byte checks for the case where it does not come: - // a backing that still resolves serves either {ATTACKER_BYTE:#04x} or - // {VICTIM_BYTE:#04x}, and neither is zero. - let refused = match read_all(&mut held) { - Err(e) => e, - Ok(got) => { - if let Some(at) = got.iter().position(|&b| b == ATTACKER_BYTE) { - panic!( - "byte {at} read through the deleted file's descriptor is \ - {ATTACKER_BYTE:#04x} — the backing served another file's data", - ); - } - if let Some(at) = got.iter().position(|&b| b != 0) { - panic!( - "byte {at} read through the deleted file's descriptor is {:#04x}, not \ - zero — the backing still resolves blocks the allocator has taken back", - got[at], - ); - } - panic!( - "the read through the deleted file's descriptor returned {} bytes and \ - succeeded; a revoked backing has no bytes to serve and has to say so", - got.len(), - ); - } - }; - - let _ = fs::remove_file(ATTACKER); - let _ = fs::remove_file(CONTROL); - println!( - "a read through a backing whose file was deleted was refused ({refused}) rather than \ - serving any of the next file's {LEN} bytes" - ); -} diff --git a/tests/guest/rust-tests/src/bin/https_get.rs b/tests/guest/rust-tests/src/bin/https_get.rs deleted file mode 100644 index e72b78dde80..00000000000 --- a/tests/guest/rust-tests/src/bin/https_get.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! An HTTPS client as any Rust program writes one: `ureq` on `rustls` on -//! `ring`, as published, trusting the authorities of one roots file and no -//! others, and naming itself with the project's `User-Agent`. -//! -//! argv: the URL, then the roots file. It says one line and ends: -//! `https_get: ok bytes= sha256=` and 0 for a body read to its end, -//! hashed by `ring` over every byte; `https_get: refused ` and 2 for a -//! certificate the client refused; anything else panics. - -use std::io::Read; -use std::process::ExitCode; - -use ureq::tls::{PemItem, RootCerts, TlsConfig}; - -/// Root `CLAUDE.md`'s: the only `User-Agent` ToyOS sends. -const USER_AGENT: &str = "toyos-build (https://github.com/ToyOSOrg/ToyOS)"; - -fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); - let [url, roots] = args.as_slice() else { - panic!("usage: https_get , not {args:?}"); - }; - let pem = std::fs::read(roots).unwrap_or_else(|e| panic!("read {roots}: {e}")); - let certs: Vec<_> = ureq::tls::parse_pem(&pem) - .map(|item| match item.unwrap_or_else(|e| panic!("{roots}: {e}")) { - PemItem::Certificate(cert) => cert, - _ => panic!("{roots} holds a PEM block that is not a certificate"), - }) - .collect(); - let tls = TlsConfig::builder().root_certs(RootCerts::new_with_certs(&certs)).build(); - let agent = ureq::Agent::config_builder().user_agent(USER_AGENT).tls_config(tls).build().new_agent(); - - let response = match agent.get(url).call() { - Ok(response) => response, - Err(e) => { - let Some(why) = refused(&e) else { panic!("GET {url}: {e}") }; - let why = match why { - rustls::CertificateError::NotValidForName | rustls::CertificateError::NotValidForNameContext { .. } => { - "not-valid-for-name".to_string() - } - rustls::CertificateError::UnknownIssuer => "unknown-issuer".to_string(), - other => format!("{other:?}"), - }; - println!("https_get: refused {why}"); - return ExitCode::from(2); - } - }; - let mut body = response.into_body().into_reader(); - let mut digest = ring::digest::Context::new(&ring::digest::SHA256); - let mut chunk = vec![0u8; 16 * 1024]; - let mut bytes = 0usize; - loop { - let n = body.read(&mut chunk).unwrap_or_else(|e| panic!("the body of {url}: {e}")); - if n == 0 { - break; - } - digest.update(&chunk[..n]); - bytes += n; - } - let hex: String = digest.finish().as_ref().iter().map(|b| format!("{b:02x}")).collect(); - println!("https_get: ok bytes={bytes} sha256={hex}"); - ExitCode::SUCCESS -} - -/// The certificate `rustls` refused, where that is what `e` is: ureq hands a -/// handshake's error back inside the I/O error of the read that met it. -fn refused(e: &ureq::Error) -> Option<&rustls::CertificateError> { - let tls = match e { - ureq::Error::Rustls(tls) => Some(tls), - ureq::Error::Io(io) => io.get_ref().and_then(|inner| inner.downcast_ref::()), - _ => None, - }; - match tls { - Some(rustls::Error::InvalidCertificate(why)) => Some(why), - _ => None, - } -} diff --git a/tests/guest/rust-tests/src/bin/lent_device_memory_refused.rs b/tests/guest/rust-tests/src/bin/lent_device_memory_refused.rs index 715da260a94..52e5bf68293 100644 --- a/tests/guest/rust-tests/src/bin/lent_device_memory_refused.rs +++ b/tests/guest/rust-tests/src/bin/lent_device_memory_refused.rs @@ -1,35 +1,26 @@ -//! A file server lets go a hello whose window is a device's registers, which -//! the kernel says are not memory it allocated, and answers the next client as -//! the same process. +//! What the kernel says of a device's registers lent as a window, asked as a +//! file server asks it (`SharedMemory::lent`): not memory it allocated to a +//! region, so they are never mapped as one. //! //! The device is QEMU's virtio NIC, which no program of //! `tests/images/testcases.toml` holds. use toyos::endow::Endowments; +use toyos::fs::WINDOW_BYTES; +use toyos::shm::{Lent, SharedMemory}; use toyos::syscap::SysCap; -use toyos::{AsHandle, OwnedHandle, PciDev}; -use toyos_abi::syscall::{self, PciId, SyscallError, SYSCAP_LABEL}; - -#[path = "../lent_hello.rs"] -mod lent_hello; -use lent_hello::{hello, window}; +use toyos::{AsHandle, PciDev}; +use toyos_abi::syscall::{self, PciId, SYSCAP_LABEL}; const NIC: PciId = PciId { vendor: 0x1af4, device: 0x1041 }; fn main() { - let served = hello(window()).expect("lent_device_memory_refused: a window of WINDOW_BYTES is served"); - let cap: SysCap = Endowments::get().take(SYSCAP_LABEL).expect("lent_device_memory_refused: a device-minting capability"); let nic: PciDev = cap.claim_pci(NIC).expect("lent_device_memory_refused: the virtio NIC's claim"); let info = nic.describe().expect("lent_device_memory_refused: the claim's description"); let bar = info.bar_bytes.iter().position(|&bytes| bytes != 0).expect("lent_device_memory_refused: a memory BAR"); let bar = syscall::device_bar_map(nic.as_handle(), bar as u32).expect("lent_device_memory_refused: the BAR's region"); - // SAFETY: the kernel installed the handle for this call, and nothing else answers for it. - let answer = hello(unsafe { OwnedHandle::from_raw(bar) }); - let again = hello(window()).expect("lent_device_memory_refused: a window after the refusal is served"); - - println!("lent_device_memory_refused: a hello lending a device's BAR answered {answer:?}"); - assert_eq!(answer, Err(SyscallError::Gone), "lent_device_memory_refused: the BAR was not let go unanswered"); - assert_eq!(again, served, "lent_device_memory_refused: the server that let go is not the one that answers"); - println!("lent_device_memory_refused: the server that let go answers the next client, volume {served}"); + let answer = SharedMemory::lent(bar, WINDOW_BYTES).err(); + assert_eq!(answer, Some(Lent::NotRam), "lent_device_memory_refused: a device's BAR lent as a window"); + println!("lent_device_memory_refused: a device's BAR lent as a window is not memory the kernel allocated"); } diff --git a/tests/guest/rust-tests/src/bin/lent_window_refused.rs b/tests/guest/rust-tests/src/bin/lent_window_refused.rs index 387e80e96cd..274882df93e 100644 --- a/tests/guest/rust-tests/src/bin/lent_window_refused.rs +++ b/tests/guest/rust-tests/src/bin/lent_window_refused.rs @@ -1,32 +1,28 @@ -//! A file server lets go a hello whose window the kernel says is not a region -//! `WINDOW_BYTES` long, whatever the client says: a pipe end, or a region -//! twice that; and the server that let it go answers the next client as the -//! same process. +//! What the kernel says of a handle lent as a window, asked as a file server +//! asks it (`SharedMemory::lent`): a pipe end is no memory it allocated to a +//! region, a region twice `WINDOW_BYTES` long is refused by its length, and a +//! region of `WINDOW_BYTES` maps. use toyos::fs::WINDOW_BYTES; -use toyos_abi::syscall::SyscallError; +use toyos::shm::{Lent, SharedMemory}; +use toyos_abi::syscall; +use toyos_abi::RawHandle; -#[path = "../lent_hello.rs"] -mod lent_hello; -use lent_hello::{hello, region, window}; +/// A region of `bytes`, as a peer lends it. +fn region(bytes: usize) -> RawHandle { + syscall::shm_create(bytes).expect("lent_window_refused: a region") +} fn main() { - let served = hello(window()).expect("lent_window_refused: a window of WINDOW_BYTES is served"); + assert!(SharedMemory::lent(region(WINDOW_BYTES), WINDOW_BYTES).is_ok(), "lent_window_refused: a region of WINDOW_BYTES was refused"); let (read, _write) = toyos::pipe_pair().expect("lent_window_refused: a pipe"); - - // Every hostile hello is made before any is judged, so a server that serves one is seen serving each. - let answers = [ - ("a pipe end", hello(read.into())), - ("a region twice WINDOW_BYTES long", hello(region(2 * WINDOW_BYTES))), - ]; - let again = hello(window()).expect("lent_window_refused: a window after the refusals is served"); - - for (what, answer) in &answers { - println!("lent_window_refused: a hello lending {what} answered {answer:?}"); - } - assert!( - answers.iter().all(|(_, answer)| *answer == Err(SyscallError::Gone)), - "lent_window_refused: a hostile hello was not let go unanswered" + let pipe = SharedMemory::lent(read.into_raw(), WINDOW_BYTES).err(); + assert_eq!(pipe, Some(Lent::NotRam), "lent_window_refused: a pipe end lent as a window"); + let twice = SharedMemory::lent(region(2 * WINDOW_BYTES), WINDOW_BYTES).err(); + assert_eq!( + twice, + Some(Lent::Length { is: 2 * WINDOW_BYTES, bytes: WINDOW_BYTES }), + "lent_window_refused: a region twice WINDOW_BYTES long lent as a window" ); - assert_eq!(again, served, "lent_window_refused: the server that let go is not the one that answers"); + println!("lent_window_refused: a pipe end is no region, and a region twice the window's length is refused by it"); } diff --git a/tests/guest/rust-tests/src/bin/lseek_past_eof.rs b/tests/guest/rust-tests/src/bin/lseek_past_eof.rs index 2293b0e7ac9..40011896a4b 100644 --- a/tests/guest/rust-tests/src/bin/lseek_past_eof.rs +++ b/tests/guest/rust-tests/src/bin/lseek_past_eof.rs @@ -3,14 +3,12 @@ //! POSIX judges the offsets, the hole and the seek pointer; the `MAX_FILE_SIZE` //! ceiling is this kernel's own, judged against the page index it protects. -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; -use std::process::Command; const PAGE: u64 = 4096; /// `file_cache::MAX_FILE_SIZE`: the page index is a `u32`, so `(u32::MAX + 1) * 4096`. const MAX_FILE_SIZE: u64 = (u32::MAX as u64 + 1) * PAGE; -const DRAINS_THE_QUEUE: &str = "/system/bin/echo"; fn pattern(len: usize) -> Vec { (0..len).map(|i| (i * 37 + 11) as u8 | 1).collect() @@ -125,52 +123,10 @@ fn the_ceiling_refuses(dir: &str) { fs::remove_file(&path).expect("cleanup"); } -/// The same hole off the device, not out of the cache, and nothing here is -/// timed: `sync_all` reports a failure to reach it, and the spawn's -/// `Vfs::open_backing` drains the queue and releases the file before the re-open. -fn the_hole_reaches_the_device(dir: &str) { - let path = format!("{dir}/lseek_device_hole.bin"); - let head = pattern(100); - { - let mut f = OpenOptions::new() - .read(true).write(true).create(true).truncate(true) - .open(&path) - .unwrap_or_else(|e| panic!("create {path}: {e}")); - f.write_all(&head).expect("write the head"); - f.seek(SeekFrom::Start(200)).expect("seek past EOF"); - f.write_all(&[0xAB]).expect("write one byte at the seeked offset"); - f.sync_all().unwrap_or_else(|e| panic!("{dir}: the hole did not reach the device: {e}")); - } - let status = Command::new(DRAINS_THE_QUEUE) - .arg("lseek_past_eof") - .status() - .unwrap_or_else(|e| panic!("spawn {DRAINS_THE_QUEUE}: {e}")); - assert!(status.success(), "{dir}: the draining spawn exited {:?}", status.code()); - - let mut back = Vec::new(); - File::open(&path) - .expect("re-open after the write-back drained") - .read_to_end(&mut back) - .expect("read the file back off the device"); - assert_eq!(back.len(), 201, "{dir}: the device holds {} bytes, not 201", back.len()); - assert_eq!(&back[..100], &head[..], "{dir}: the head is not what reached the device"); - for (i, &b) in back[100..200].iter().enumerate() { - assert_eq!(b, 0, "{dir}: hole byte {} reads {b:#04x} off the device, not zero", i + 100); - } - assert_eq!(back[200], 0xAB, "{dir}: the poked byte is not at the offset asked on the device"); - - fs::remove_file(&path).expect("cleanup"); -} - fn main() { - // /tmp is the in-memory control; /home carries the hole through a device-backed mount. - for dir in ["/tmp", "/home"] { - seek_past_eof_lands_where_asked(dir); - truncate_leaves_the_pointer(dir); - } + seek_past_eof_lands_where_asked("/tmp"); + truncate_leaves_the_pointer("/tmp"); the_ceiling_refuses("/tmp"); - the_ceiling_refuses("/home"); - the_hole_reaches_the_device("/home"); - println!("lseek past eof: the offset asked is the offset answered, the gap reads as zeros on the device, and the ceiling refuses"); + println!("lseek past eof: the offset asked is the offset answered, the gap reads as zeros, and the ceiling refuses"); } diff --git a/tests/guest/rust-tests/src/bin/netstack_streams.rs b/tests/guest/rust-tests/src/bin/netstack_streams.rs deleted file mode 100644 index cba943fbabe..00000000000 --- a/tests/guest/rust-tests/src/bin/netstack_streams.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Streams through netstack against a TCP nobody here wrote: the host -//! kernel's, behind QEMU's user network. -//! -//! argv[1] is the port of the harness's host server, which sends back every -//! byte it reads. The job writes [`BULK`] bytes of a sequence to it and shuts -//! its sending half down at once, while it reads them back, and compares each: -//! more than a pipe and both of the stack's buffers hold, so a byte lost, -//! repeated or moved where either side made the other wait shows as the first -//! that differs. After the last byte it reads the stream's end: the server's -//! FIN, which std reads as an end and not a reset behind the shutdown. -//! -//! argv[2] is a port the job listens on. It says so and takes no connection -//! until its listener's pipe has given up two wakes: the harness dials twice, -//! so the second connection arrives while the first waits to be accepted, and -//! both are then accepted, answered with the byte each sent and closed, which -//! each peer reads as its stream's end. - -use std::io::{Read, Write}; -use std::net::{Ipv4Addr, Shutdown, TcpStream}; -use std::time::{Duration, Instant}; - -use toyos::poller::{Poller, READABLE}; -use toyos_abi::syscall::SyscallError; - -/// The host, as QEMU's user network names it to a guest. -const HOST: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 2); - -/// Twice a kernel pipe, so the client's pipe fills behind the stack's window. -const BULK: usize = 4 * 1024 * 1024; - -/// A hang ceiling on the two peers' wakes: the harness dials both as it reads -/// the line that says the listener waits. -const WOKEN: Duration = Duration::from_secs(60); - -/// The sequence's byte at `at`: no period a buffer's or a segment's size -/// divides. -fn byte(at: usize) -> u8 { - (at % 251) as u8 ^ (at / 251) as u8 -} - -fn bulk(port: u16) { - let mut reading = TcpStream::connect((HOST, port)).unwrap_or_else(|e| panic!("the echoing server: {e}")); - let mut writing = reading.try_clone().expect("a second handle on the stream"); - let writer = std::thread::spawn(move || { - let mut sent = 0; - let mut chunk = [0u8; 8192]; - while sent < BULK { - let len = chunk.len().min(BULK - sent); - for (i, b) in chunk[..len].iter_mut().enumerate() { - *b = byte(sent + i); - } - writing.write_all(&chunk[..len]).unwrap_or_else(|e| panic!("writing byte {sent} of the bulk: {e}")); - sent += len; - } - // At once: what the pipe still holds is the stack's to send first. - writing.shutdown(Shutdown::Write).expect("shutting the sending half"); - }); - let mut read = 0; - let mut chunk = [0u8; 8192]; - while read < BULK { - let len = reading.read(&mut chunk).unwrap_or_else(|e| panic!("reading byte {read} of the bulk: {e}")); - assert_ne!(len, 0, "the stream ended at byte {read} of the bulk"); - for (i, b) in chunk[..len].iter().enumerate() { - assert_eq!(*b, byte(read + i), "byte {} came back as another", read + i); - } - read += len; - } - writer.join().expect("the writer"); - match reading.read(&mut chunk) { - Ok(0) => {} - ended => panic!("the read after the bulk's last byte answered {ended:?} and not the stream's end"), - } - println!("netstack_streams: {BULK} bytes went out and came back as sent, then the stream's end"); -} - -fn two_peers(port: u16) { - let bound = toyos::net::tcp_bind([0, 0, 0, 0], port).unwrap_or_else(|e| panic!("listening on {port}: {e:?}")); - println!("netstack_streams: the listener waits for two peers"); - let poller = Poller::new(1); - let asked = Instant::now(); - let mut wakes = [0u8; 2]; - let mut woken = 0; - while woken < wakes.len() { - let Some(left) = WOKEN.checked_sub(asked.elapsed()) else { - // Which half is missing: a connection that never finished its - // handshake, or a wake for one that did. - let waiting: Vec<_> = (0..wakes.len()) - .map(|_| toyos::net::tcp_accept(bound.socket_id).map(|accepted| accepted.remote_port)) - .collect(); - panic!("the listener was woken for {woken} of two peers in {WOKEN:?}, and two accepts then answered {waiting:?}"); - }; - poller.watch(&bound.notify, READABLE, 0); - poller.wait(1, left.as_nanos() as u64, |_| {}); - match bound.notify.read_nonblock(&mut wakes[woken..]) { - Ok(0) => panic!("netstack ended the listener after {woken} wake(s)"), - Ok(n) => woken += n, - Err(SyscallError::WouldBlock) => {} - Err(e) => panic!("reading the listener's wakes: {e:?}"), - } - } - let mut answered = Vec::new(); - for _ in 0..wakes.len() { - let accepted = toyos::net::tcp_accept(bound.socket_id).unwrap_or_else(|e| panic!("an accept after its wake: {e:?}")); - let mut said = [0u8; 1]; - assert_eq!(accepted.rx.read(&mut said), Ok(1), "a peer's one byte"); - assert_eq!(accepted.tx.write(&said), Ok(1), "its answer"); - answered.push(said[0]); - toyos::net::tcp_close(accepted.socket_id).expect("closing an accepted stream"); - } - toyos::net::tcp_close(bound.socket_id).expect("closing the listener"); - answered.sort_unstable(); - assert_eq!(answered, *b"12", "each peer was accepted once"); - println!("netstack_streams: two peers that arrived before any accept were both accepted"); -} - -fn main() { - let port = |at: usize| -> u16 { - std::env::args() - .nth(at) - .and_then(|p| p.parse().ok()) - .expect("usage: netstack_streams ") - }; - bulk(port(1)); - two_peers(port(2)); - println!("netstack_streams: ok"); -} diff --git a/tests/guest/rust-tests/src/bin/nodelay_accepted.rs b/tests/guest/rust-tests/src/bin/nodelay_accepted.rs deleted file mode 100644 index ffe92985b23..00000000000 --- a/tests/guest/rust-tests/src/bin/nodelay_accepted.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! `TCP_NODELAY` as std answers it and as netstack's pipe ABI carries a -//! listener's. -//! -//! The first half is std's alone: a stream and its duplicate are one socket, -//! so a set on either is read on both, and a stream accepted from a listener -//! that holds no option has none. -//! -//! The second is ToyOS's: a listener made by `toyos::net`, whose option std -//! has no call for. A connection that waited when the option was set has not -//! got it, and the next one dialled has; the wake netstack writes the owner -//! is what says the first one waits. -//! -//! argv: the address and port of a host that accepts and holds each -//! connection, then the port std listens on and the port the pipe ABI's -//! listener does. That host dials each once at WAITING, and the second again -//! at AGAIN. - -use std::net::{TcpListener, TcpStream}; - -const WAITING: &str = "nodelay_accepted: both listeners wait for a peer"; -const AGAIN: &str = "nodelay_accepted: the listener waits for a second peer"; - -struct Said(u32); - -impl Said { - fn said(&mut self, what: &str, got: T, want: T) { - let wrong = got != want; - println!("{what}: {got:?}{}", if wrong { " <-- WRONG" } else { "" }); - self.0 += u32::from(wrong); - } -} - -/// The listener `toyos::net` binds on `port`: one connection that waits before -/// the option is set, and one dialled after. -fn the_pipe_abis_listener(port: u16, said: &mut Said) { - use toyos::net::{self, NetError, OPT_NODELAY}; - - let listener = net::tcp_bind([0; 4], port).unwrap_or_else(|e| panic!("a listener on {port}: {e:?}")); - println!("{WAITING}"); - let mut wake = [0u8; 1]; - assert_eq!(listener.notify.read(&mut wake), Ok(1), "the wake for the first connection"); - said.said("set on a listener a connection waits at", net::tcp_listener_set_option(listener.socket_id, OPT_NODELAY, 1), Ok(())); - let before = net::tcp_accept(listener.socket_id).unwrap_or_else(|e| panic!("the first accept: {e:?}")); - said.said("the connection that waited before the set", before.options.nodelay(), false); - said.said( - "a listener's request that names a stream", - net::tcp_listener_set_option(before.socket_id, OPT_NODELAY, 1), - Err(NetError::NotConnected), - ); - said.said( - "a stream's request that names a listener", - net::tcp_set_option(listener.socket_id, OPT_NODELAY, 1), - Err(NetError::NotConnected), - ); - said.said( - "an option a listener has none of", - net::tcp_listener_set_option(listener.socket_id, net::OPT_BROADCAST, 1), - Err(NetError::InvalidInput), - ); - println!("{AGAIN}"); - assert_eq!(listener.notify.read(&mut wake), Ok(1), "the wake for the second connection"); - let after = net::tcp_accept(listener.socket_id).unwrap_or_else(|e| panic!("the second accept: {e:?}")); - said.said("the connection dialled after the set", after.options.nodelay(), true); - for id in [before.socket_id, after.socket_id, listener.socket_id] { - net::tcp_close(id).unwrap_or_else(|e| panic!("closing {id:?}: {e:?}")); - } -} - -fn main() { - let args: Vec = std::env::args().collect(); - let [_, host, holding, std_port, pipe_port] = &args[..] else { - panic!("usage: nodelay_accepted ") - }; - let port = |text: &String| -> u16 { text.parse().unwrap_or_else(|e| panic!("the port {text}: {e}")) }; - let mut said = Said(0); - - let stream = TcpStream::connect((host.as_str(), port(holding))).expect("the holding server"); - let duplicate = stream.try_clone().expect("a duplicate"); - said.said("a fresh stream's nodelay", stream.nodelay().ok(), Some(false)); - said.said("set on the stream", stream.set_nodelay(true).is_ok(), true); - said.said("read from its duplicate", duplicate.nodelay().ok(), Some(true)); - said.said("cleared on the duplicate", duplicate.set_nodelay(false).is_ok(), true); - said.said("read from the stream", stream.nodelay().ok(), Some(false)); - - let listener = TcpListener::bind(("0.0.0.0", port(std_port))).expect("std's listener"); - the_pipe_abis_listener(port(pipe_port), &mut said); - let (accepted, _) = listener.accept().expect("std's accept"); - said.said("a stream accepted from a listener that holds none", accepted.nodelay().ok(), Some(false)); - - if said.0 != 0 { - println!("nodelay_accepted: {} wrong", said.0); - std::process::exit(1); - } - println!("nodelay_accepted: ok"); -} diff --git a/tests/guest/rust-tests/src/bin/null_sink_client_exits.rs b/tests/guest/rust-tests/src/bin/null_sink_client_exits.rs deleted file mode 100644 index 8eaf0a9dd0f..00000000000 --- a/tests/guest/rust-tests/src/bin/null_sink_client_exits.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A client that plays through the null sink must finish and exit. -//! -//! `/system/bin/tone` and not this crate's own tone: the T14 hangs on the shipped -//! binary, which reaches soundserver through `cpal`. Whatever the defect is, only the -//! path a user actually takes shows it — which is why this spawns the program the -//! shell spawns rather than linking the SDK directly. -//! -//! Two of them in series, because one hung client is what the T14 log shows -//! blocking the *next* connect: if the first exits and the second hangs, the -//! sink stops draining after a run rather than never starting. - -use std::process::Command; -use std::time::{Duration, Instant}; - -/// One second of tone. Long enough that a sink discarding instantly is visible -/// as an implausibly fast exit, short enough that two fit in a test. -const SECONDS: &str = "1"; - -/// A tone that has not finished in this long is not slow, it is stuck: the -/// sink drains one period per 2.902 ms and the whole run is 1 s of audio. -const PATIENCE: Duration = Duration::from_secs(15); - -fn play(round: u32) { - let start = Instant::now(); - let mut child = Command::new("/system/bin/tone") - .arg("440") - .arg(SECONDS) - .spawn() - .unwrap_or_else(|e| panic!("round {round}: /system/bin/tone did not spawn: {e}")); - - // `try_wait` in a loop rather than `wait`, so the failure is this test's - // own message and a wall-clock number rather than the harness's timeout — - // a hang and a slow guest look identical in a timeout and different here. - loop { - match child.try_wait() { - Ok(Some(status)) => { - let took = start.elapsed(); - assert!( - status.success(), - "round {round}: /system/bin/tone exited {status:?} after {took:?}" - ); - println!("round {round}: tone exited after {:.2?}", took); - return; - } - Ok(None) => {} - Err(e) => panic!("round {round}: try_wait failed: {e}"), - } - assert!( - start.elapsed() < PATIENCE, - "round {round}: /system/bin/tone has not exited after {:?} — the null sink is not \ - draining its client", - start.elapsed() - ); - std::thread::sleep(Duration::from_millis(50)); - } -} - -fn main() { - play(1); - play(2); - println!("null sink drained two clients in series"); -} diff --git a/tests/guest/rust-tests/src/bin/nvme_home_roundtrip.rs b/tests/guest/rust-tests/src/bin/nvme_home_roundtrip.rs deleted file mode 100644 index ffe6057cfa7..00000000000 --- a/tests/guest/rust-tests/src/bin/nvme_home_roundtrip.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! A file round trip on `/home`, which is the only mount backed by the NVMe -//! device. Every other filesystem test reads `/system/bin` out of ROOT or -//! writes `/tmp` into tmpfs, so nothing else in the suite makes the block -//! layer allocate, cache and write back a block. - -use std::fs; -use std::io::Write; - -const PATH: &str = "/home/nvme_roundtrip.bin"; - -/// Three whole blocks and a partial fourth: enough that the file needs an -/// extent rather than a single block, and that the tail is the case an -/// off-by-one in the block mapping gets wrong. -const LEN: usize = 3 * 4096 + 17; - -fn main() { - let data: Vec = (0..LEN).map(|i| (i.wrapping_mul(31) ^ 0xA5) as u8).collect(); - - { - let mut f = fs::File::create(PATH).expect("create on /home"); - f.write_all(&data).expect("write to /home"); - f.sync_all().expect("fsync /home file"); - } - - let back = fs::read(PATH).expect("read back from /home"); - assert_eq!(back.len(), data.len(), "length changed across the round trip"); - let mismatch = back.iter().zip(&data).position(|(a, b)| a != b); - assert!(mismatch.is_none(), "byte {} differs after the round trip", mismatch.unwrap()); - - println!("nvme round trip ok: {LEN} bytes on /home"); -} diff --git a/tests/guest/rust-tests/src/bin/partition_grant.rs b/tests/guest/rust-tests/src/bin/partition_grant.rs deleted file mode 100644 index 7dcee56b5a1..00000000000 --- a/tests/guest/rust-tests/src/bin/partition_grant.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! A block service's grants, asked of diskserver serving the disk this -//! machine booted from: this job holds the controller's claim and the block -//! port's acceptor, starts diskserver with both as the supervisor starts it, -//! and mints every grant on the acceptor as the supervisor mints a file -//! server's. -//! -//! - One partition's grant lists that partition alone, opens it, and is -//! refused another partition of the same disk by name — one nothing holds, -//! so nothing but the grant stands in the way. -//! - A type's grant lists and opens its type's partition and is refused one -//! of another type. -//! - A session whose grant does not write is refused a write, and reads. -//! - The port's own connector, which carries no grant, lists and opens -//! nothing. -//! - The partition the machine runs from is held, whatever reaches it. - -use std::os::toyos::process::CommandExt; -use std::process::Command; - -use diskserver::{Error, Outcome, Session}; -use toyos::endow::{Endowments, SYSCAP_LABEL}; -use toyos::namespace::{self, Namespace}; -use toyos::port::{self, Connector}; -use toyos::syscap::SysCap; -use toyos::AsHandle; -use toyos_abi::inventory::{RawRecord, Record, Role}; -use toyos_abi::syscall::{DeviceRequest, DEV_PREFIX, SERVE_PREFIX}; -use toyos_blockring::wire::{Grant, Refusal, Scope}; -use toyos_blockring::PORT; - -/// The controller diskserver's row names, which this boot starts no row for. -const CONTROLLER: &str = "pci:1b36:0010"; - -fn names(connector: &Connector) -> Namespace { - namespace::build().add(PORT, connector).finish().expect("partition_grant: a namespace of one connector") -} - -fn listed(connector: &Connector) -> Result, Error> { - diskserver::list(&names(connector), PORT).map(|all| all.into_iter().map(|l| l.unique).collect()) -} - -fn open(connector: &Connector, guid: [u8; 16]) -> Result { - Session::open(names(connector), PORT, guid) -} - -fn main() { - let cap: SysCap = Endowments::get().take(SYSCAP_LABEL).expect("test-runner endows a device-minting capability"); - let records: Vec = cap.records(|n| vec![RawRecord::EMPTY; n]).expect("partition_grant: the inventory"); - let loaded = |role: Role| { - records - .iter() - .find_map(|r| match r { - Record::Loaded(l) if l.role == role => Some(l.unique_guid), - _ => None, - }) - .unwrap_or_else(|| panic!("partition_grant: the loader named no {role:?}")) - }; - let (log, boot, root) = (loaded(Role::Log), loaded(Role::Boot), loaded(Role::Root)); - - let Some(DeviceRequest::Pci(id)) = DeviceRequest::parse(CONTROLLER) else { unreachable!("a PCI request") }; - let claim: toyos::Device = cap.claim_pci(id).expect("partition_grant: the NVMe controller nothing else claims"); - let (acceptor, bare) = port::create().expect("partition_grant: the block port"); - let served = toyos_abi::syscall::dup(acceptor.as_handle()).expect("partition_grant: the acceptor for diskserver"); - let mut text = [0u8; toyos_abi::part::GUID_TEXT_LEN]; - let running = toyos_abi::part::PartGuid(root).write_text(&mut text).to_string(); - let mut server = Command::new("/system/bin/diskserver") - .args(["--running", &running]) - .endow(&format!("{DEV_PREFIX}{CONTROLLER}"), claim.into_raw().0) - .endow(&format!("{SERVE_PREFIX}{PORT}"), served.0) - .spawn() - .expect("partition_grant: diskserver"); - let mint = |scope: Scope, writes: bool| { - acceptor.mint(&Grant { scope, writes }.encode()).expect("partition_grant: a grant minted") - }; - - let logs = mint(Scope::Unique(log), true); - assert_eq!(listed(&logs), Ok(vec![log]), "the log's grant listed more than the log"); - let held = open(&logs, log).expect("partition_grant: the log's grant opens the log"); - assert_eq!( - open(&logs, boot).err(), - Some(Error::Refused(Refusal::NotGranted)), - "the log's grant opened the boot volume, which nothing holds" - ); - drop(held); - println!("partition_grant: the log's grant opened the log and was refused the boot volume NotGranted"); - - let data = mint(Scope::Kind(toyos_gpt::Guid::TOYOS_DATA.0), true); - let [data_part] = listed(&data).expect("partition_grant: DATA's grant lists")[..] else { - panic!("partition_grant: DATA's grant lists other than one partition") - }; - let held = open(&data, data_part).expect("partition_grant: DATA's grant opens DATA"); - assert_eq!(open(&data, log).err(), Some(Error::Refused(Refusal::NotGranted)), "DATA's grant opened the log"); - drop(held); - println!("partition_grant: DATA's grant opened DATA and was refused the log NotGranted"); - - let boots = mint(Scope::Unique(boot), false); - let mut volume = open(&boots, boot).expect("partition_grant: the boot volume's grant opens it"); - let (read, first) = volume.read(0, 1).expect("partition_grant: a read of the boot volume"); - let first = first.filter(|_| read == Outcome::Done).expect("partition_grant: the boot volume's first block"); - // The bytes it holds: a write let through changes nothing. - assert_eq!( - volume.write(0, &first), - Ok(Outcome::ReadOnly), - "a session whose grant does not write wrote the boot volume" - ); - drop(volume); - println!("partition_grant: the boot volume's read-only grant read it and was refused a write ReadOnly"); - - assert_eq!(listed(&bare), Err(Error::Refused(Refusal::NotGranted)), "the port's own connector listed"); - assert_eq!(open(&bare, log).err(), Some(Error::Refused(Refusal::NotGranted)), "the port's own connector opened"); - println!("partition_grant: the port's own connector was refused a listing and an open NotGranted"); - - let roots = mint(Scope::Unique(root), true); - assert_eq!(open(&roots, root).err(), Some(Error::Refused(Refusal::Held)), "a grant opened the running ROOT"); - println!("partition_grant: the running ROOT was refused Held to the grant naming it"); - - server.kill().expect("partition_grant: diskserver ends"); - server.wait().expect("partition_grant: diskserver reaped"); -} diff --git a/tests/guest/rust-tests/src/bin/process_tree.rs b/tests/guest/rust-tests/src/bin/process_tree.rs index d2ab4685c15..a5cdea12377 100644 --- a/tests/guest/rust-tests/src/bin/process_tree.rs +++ b/tests/guest/rust-tests/src/bin/process_tree.rs @@ -14,19 +14,10 @@ //! answers. //! //! **The other arms.** A `MANAGE`-only handle is no place, and the supervisor refuses a -//! launch whose place is a pipe and answers the launches after it. A launch -//! carries a copy of its place, so std refuses one under a place this process -//! cannot duplicate, rather than spawning it directly. The supervisor starts a -//! child only by a launch, so std refuses `under_supervisor` for a program no row -//! declares and for a command carrying an extra slot; and a launch runs its row's -//! own program, so std refuses a command naming its image under the supervisor or -//! with a connector provided, and spawns one naming its image directly, under -//! its program's name, launcher or not. A chain — each link starts a shell, -//! and the shell spawns the next link — stops where the kernel refuses a +//! launch whose place is a pipe and answers the launches after it. A chain — +//! each link spawns the next directly — stops where the kernel refuses a //! process more than `MAX_DEPTH` below the supervisor, and dies whole with its -//! first link: that link is endowed a launcher and launches its shell, and -//! every later one is a direct spawn, holds none, and spawns its own. And a -//! `cat` a shell `detach`es outlives the shell. +//! first link. And a `cat` a shell `detach`es outlives the shell. //! //! This process holds the launcher test-runner endows every job, and hands a //! duplicate to each child that launches: a direct spawn inherits none. @@ -35,7 +26,6 @@ use std::io::{BufRead, BufReader}; use std::os::toyos::process::{ChildExt, CommandExt}; -use std::path::Path; use std::process::{Child, ChildStdin, Command, Stdio}; use toyos::endow::{self, Endowments, SVC_LABEL, SYSCAP_LABEL}; @@ -63,11 +53,6 @@ const MSG_GROWN: u32 = 1; /// A to B: fault. const MSG_FAULT: u32 = 2; -/// The role of this binary run as [`HELD`] by naming its image, and the exit -/// that says this binary's bytes ran rather than `cat`'s. -const NAMED: &str = "named"; -const NAMED_EXIT: i32 = 42; - /// `process::KILLED_EXIT_CODE`. const KILLED: i32 = 137; /// What `syscall::kill_process(-1)` publishes for a Ring 3 CPU fault. @@ -77,8 +62,8 @@ const CPU_FAULT: i32 = -1; const MAX_DEPTH: u32 = 64; /// The links a chain may grow before a refusal must have stopped it: each -/// adds two levels, so from any start below the supervisor the refusal comes sooner. -const LINK_BOUND: u32 = MAX_DEPTH / 2 + 1; +/// adds a level, so from any start below the supervisor the refusal comes sooner. +const LINK_BOUND: u32 = MAX_DEPTH + 1; /// `sched::payload`'s state for a thread whose entry is a zombie, which /// `sys_sysinfo` also answers for an exited one. @@ -97,10 +82,6 @@ fn main() { Some("b") => b(), Some("c") => park(), Some("link") => link(args[2].parse().expect("a link's index")), - Some(NAMED) => { - assert_eq!(args[0], HELD, "a command naming its image did not keep its program as argv[0]"); - std::process::exit(NAMED_EXIT) - } Some(other) => panic!("unknown role {other:?}"), None => test(), } @@ -112,9 +93,6 @@ fn test() { } a_manage_only_handle_is_no_place(); a_pipe_is_no_place(); - a_place_without_dup_is_refused(); - the_supervisor_is_asked_only_by_a_launch(); - a_named_image_is_spawned_directly(); a_chain_stops_at_max_depth_and_dies_whole(); a_detached_program_outlives_its_shell(); println!("process_tree: PASS"); @@ -312,76 +290,12 @@ fn a_pipe_is_no_place() { println!(" a pipe is no place: the supervisor refused the launch"); } -/// This process's `self` narrowed to `WRITE`: a place the kernel takes and a -/// launch cannot carry. The duplicate's refusal is the spawn's, and nothing -/// starts in place of the launch. -fn a_place_without_dup_is_refused() { - let place = syscall::dup_narrowed(endow::this_process().as_handle(), Rights::WRITE) - .expect("a WRITE-only copy of this process's self"); - match Command::new(HELD).under(place.0).stdin(Stdio::null()).spawn() { - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {} - Err(e) => panic!("a launch under a place without DUP was refused as {e:?}, not PermissionDenied"), - Ok(mut child) => { - let _ = child.kill(); - panic!("a launch under a place without DUP started its child directly"); - } - } - syscall::close(place); - println!(" a place without DUP: the launch is refused, and nothing starts"); -} - -/// The supervisor is reached only by a launch of its row's own program: std refuses -/// `under_supervisor` or `provide` for what the launcher would not start, and starts nothing. -fn the_supervisor_is_asked_only_by_a_launch() { - let undeclared = Command::new(SELF_PATH).arg("c").under_supervisor().spawn(); - refused(undeclared, "under_supervisor for a program no row declares"); - - let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); - let extra = Command::new(HELD).inherit_handle(5, write.as_handle().0).under_supervisor().spawn(); - refused(extra, "under_supervisor for a command carrying an extra slot"); - - let named = Command::new(HELD).image_from(Path::new(HELD)).under_supervisor().spawn(); - refused(named, "under_supervisor for a command naming its image"); - let provided = Command::new(HELD).image_from(Path::new(HELD)).provide("x", write.as_handle().0).spawn(); - refused(provided, "provide for a command naming its image"); - println!(" the supervisor is asked only by a launch of its row's own program: the others are refused"); -} - -/// This process holds a `launcher` and `cat` is declared, so only the named -/// image keeps this command from routing as a launch of `cat`'s row. -fn a_named_image_is_spawned_directly() { - let status = Command::new(HELD) - .image_from(Path::new(SELF_PATH)) - .arg(NAMED) - .stdin(Stdio::null()) - .status() - .expect("spawn a command naming its image"); - assert_eq!( - status.code(), - Some(NAMED_EXIT), - "a command naming its image ran something else ({status:?}): it was launched as its row's program" - ); - println!(" a command naming its image runs that image directly, under its program's name"); -} - -fn refused(spawned: std::io::Result, what: &str) { - match spawned { - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {} - Err(e) => panic!("{what} was refused as {e:?}, not PermissionDenied"), - Ok(mut child) => { - let _ = child.kill(); - panic!("{what} started it"); - } - } -} - -/// Each link starts a shell, and the shell spawns the next link: until a -/// start is refused for its depth, which the link or the shell says on the -/// chain's one output. Killing the first link then ends every one. +/// Each link spawns the next: until a spawn is refused for its depth, which +/// the link says on the chain's one output. Killing the first link then ends +/// every one. fn a_chain_stops_at_max_depth_and_dies_whole() { let mut first = Command::new(SELF_PATH) .args(["link", "1"]) - .endow(LAUNCHER, launcher_copy().0) .stdout(Stdio::piped()) .spawn() .expect("spawn the chain's first link"); @@ -395,7 +309,7 @@ fn a_chain_stops_at_max_depth_and_dies_whole() { started = k.parse().expect("a link's index"); } else if line.starts_with("unbounded") { panic!("the chain passed link {LINK_BOUND} with no depth refusal: {line}"); - } else if line.contains("refused") || line.ends_with(": not found") { + } else if line.contains("refused") { break line; } }; @@ -403,7 +317,7 @@ fn a_chain_stops_at_max_depth_and_dies_whole() { first.kill().expect("kill the first link"); assert_eq!(first.wait().expect("wait the first link").code(), Some(KILLED)); - let live = live_named(&["test_rs_process_tree", "shell"]); + let live = live_named(&["test_rs_process_tree"]); let me = syscall::getpid().raw(); let others: Vec<_> = live.iter().filter(|(pid, _)| *pid != me).collect(); assert!(others.is_empty(), "the chain's first link ended and these of it run on: {others:?}"); @@ -416,9 +330,9 @@ fn link(k: u32) -> ! { println!("unbounded at link {k}"); park(); } - let next = Command::new(SHELL).arg("-c").arg(format!("{SELF_PATH} link {}", k + 1)).spawn(); + let next = Command::new(SELF_PATH).args(["link", &(k + 1).to_string()]).spawn(); if let Err(e) = next { - println!("link {k}: its shell was refused: {e}"); + println!("link {k}: its next link was refused: {e}"); } park(); } diff --git a/tests/guest/rust-tests/src/bin/soundserver_log_stall.rs b/tests/guest/rust-tests/src/bin/soundserver_log_stall.rs deleted file mode 100644 index 701b8822f6b..00000000000 --- a/tests/guest/rust-tests/src/bin/soundserver_log_stall.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! soundserver plays on while the log reads nothing of what it says. -//! -//! Booted on `tests/images/logstallcase.toml`, where `logkeeper` leaves soundserver's log ring unread -//! until this program says [`RELEASE`]. The ring is filled first, by soundserver's -//! own words: this program takes every control connection soundserver will hold and -//! then connects [`FLOOD`] more times, and soundserver refuses each with a line. Then -//! it plays the tone with the ring full, so every line soundserver says while it -//! plays — the mix thread's into its lane, the control thread's into the ring — -//! is said where nobody reads. A mix thread that waits on its output stops -//! there, and the tone with it. -//! -//! The verdict is `/log`'s: once `logkeeper` reads again soundserver's lines account for -//! every connection — each refusal said, or counted by `logkeeper` among the records -//! that found the ring full. - -#[path = "../tone.rs"] -mod tone; - -use std::time::{Duration, Instant}; - -use toyos::audio::{MSG_STREAM_CLOSE, MSG_STREAM_ERROR}; -use toyos::endow::{self, EndowError}; -use toyos::poller::{Poller, READABLE}; -use toyos::ipc::{IpcError, IpcHeader}; -use toyos::Connection; -use toyos_abi::syscall::SyscallError; - -/// What ends `logkeeper`'s stall. `tests/images/logstallcase.toml` names the same words. -const RELEASE: &str = "soundserver_log_stall: the tone has played, and logkeeper may read soundserver again"; - -/// soundserver's `MAX_CONTROL_CLIENTS`: past it, a connection is refused with a line. -const HELD: usize = 63; - -/// Connections refused with a line each, closed as they are made; one more is -/// kept open to hear its refusal. Each refusal is one record, and the flood is -/// several times the records one ring holds, so the ring fills and the rest -/// are counted. -const FLOOD: usize = 8_192; - -/// A flood that has not been refused in this long has met a soundserver that stopped -/// accepting: its control thread is waiting on something. -const PATIENCE: Duration = Duration::from_secs(180); - -fn main() { - let start = Instant::now(); - // Queued before any connection of the flood, and a port accepts in the - // order it was connected to: these are the connections soundserver holds. - let held: Vec = (0..HELD).map(|_| connect(start)).collect(); - - for _ in 0..FLOOD { - // Closed at once: soundserver's refusal then finds no reader and costs no - // ring page, and the only thing the connection leaves is the line. - drop(connect(start)); - } - // The one refusal this program waits to hear. soundserver says its line before - // it answers, and accepts in order, so every connection before this one has - // been refused with a line by then — none is still queued to be accepted - // once the held ones are gone, as a client that says nothing. - let last = connect(start); - match answer(&last, start) { - Ok(header) if header.msg_type == MSG_STREAM_ERROR => {} - other => panic!("soundserver answered the flood's last connection with {:?}", other.map(|h| h.msg_type)), - } - println!("flooded soundserver with {} refusals in {:.2?}", FLOOD + 1, start.elapsed()); - drop(last); - drop(held); - - // The tone's connection has to find a free slot, and soundserver frees one when - // it reads a held connection's end, which it may do after it accepts the - // next. A probe that breaks the protocol tells the two apart: soundserver - // refuses it with an error while it is full, and drops it unanswered once - // it has room. Each refusal is one more line soundserver says. - let mut refused = 0; - loop { - let probe = connect(start); - probe.signal(MSG_STREAM_CLOSE).expect("a probe's frame to soundserver"); - match answer(&probe, start) { - Ok(header) if header.msg_type == MSG_STREAM_ERROR => { - refused += 1; - std::thread::yield_now(); - } - Err(IpcError::Disconnected) => break, - other => panic!("soundserver answered a probe with {:?}", other.map(|h| h.msg_type)), - } - } - - println!("soundserver refused {refused} probe(s) before it had room"); - tone::play_tone(); - println!("{RELEASE}"); -} - -/// soundserver's first frame on `conn`, which it sends within [`PATIENCE`] of `start` -/// or never. -fn answer(conn: &Connection, start: Instant) -> Result { - let poller = Poller::new(1); - poller.watch(conn, READABLE, 0); - let mut answered = false; - let left = PATIENCE.saturating_sub(start.elapsed()); - poller.wait(1, left.as_nanos().max(1) as u64, |_| answered = true); - assert!(answered, "soundserver did not answer a connection within {PATIENCE:?} of the start"); - conn.recv_header() -} - -/// One connection to soundserver, waiting out a full queue of connections soundserver has -/// not accepted yet. -fn connect(start: Instant) -> Connection { - loop { - match endow::service("soundserver") { - Ok(conn) => return conn, - Err(EndowError::Refused(SyscallError::ResourceExhausted)) => { - assert!( - start.elapsed() < PATIENCE, - "soundserver stopped accepting connections: its control thread is waiting on \ - something" - ); - std::thread::yield_now(); - } - Err(e) => panic!("soundserver refused a connection: {e:?}"), - } - } -} diff --git a/tests/guest/rust-tests/src/bin/stat_identity.rs b/tests/guest/rust-tests/src/bin/stat_identity.rs index 072c7e66623..a7e1ef03e01 100644 --- a/tests/guest/rust-tests/src/bin/stat_identity.rs +++ b/tests/guest/rust-tests/src/bin/stat_identity.rs @@ -1,11 +1,10 @@ -//! A file's identity through std (`std::os::toyos::fs::MetadataExt`): on a -//! kernel mount (`/tmp`), on DATA (`/home`) and on FAT (`/log`), one file is -//! one `(dev, ino)` through every path, a symlink, its open handle, its -//! directory's listing and a rename; two files are two, as are two -//! directories; a symlink is one of its own, across a rename; a kernel -//! directory that ends gives its number up, and one that stands keeps it; and -//! the three are three volumes, each served one with -//! `toyos::fs::SERVED_VOLUME` set. +//! A file's identity through std (`std::os::toyos::fs::MetadataExt`) on the +//! kernel's own mount (`/tmp`): one file is one `(dev, ino)` through every +//! path, a symlink, its open handle, its directory's listing and a rename; +//! two files are two, as are two directories; a symlink is one of its own, +//! across a rename; a directory that ends gives its number up, and one that +//! stands keeps it; and the volume is no served one, `toyos::fs::SERVED_VOLUME` +//! clear. use std::fs::{self, File, Metadata}; use std::os::toyos::fs::{symlink, MetadataExt}; @@ -25,7 +24,7 @@ fn write(path: &str) { } /// Judges `dir`; `spelling` is another name for `dir/a`. Returns its volume. -fn judge(dir: &str, spelling: &str, links: bool) -> u64 { +fn judge(dir: &str, spelling: &str) -> u64 { let (a, b) = (format!("{dir}/stat-identity-a"), format!("{dir}/stat-identity-b")); write(&a); write(&b); @@ -43,17 +42,15 @@ fn judge(dir: &str, spelling: &str, links: bool) -> u64 { .unwrap_or_else(|| panic!("{a} is not in its listing")); assert_eq!(id(&listed.metadata().unwrap()), ida, "on {dir} the listing's entry is another file"); - if links { - let link = format!("{dir}/stat-identity-link"); - symlink(&a, &link).unwrap_or_else(|e| panic!("symlink {link}: {e}")); - assert_eq!(of(&link), ida, "on {dir} a symlink to {a} is another file"); - let own = id(&fs::symlink_metadata(&link).unwrap()); - assert!(own.0 == ida.0 && own.1 != 0 && own.1 != ida.1, "on {dir} the link itself is {own:?}"); - let renamed = format!("{dir}/stat-identity-link2"); - fs::rename(&link, &renamed).unwrap_or_else(|e| panic!("rename {link}: {e}")); - assert_eq!(id(&fs::symlink_metadata(&renamed).unwrap()), own, "on {dir} a rename made another link"); - fs::remove_file(&renamed).unwrap(); - } + let link = format!("{dir}/stat-identity-link"); + symlink(&a, &link).unwrap_or_else(|e| panic!("symlink {link}: {e}")); + assert_eq!(of(&link), ida, "on {dir} a symlink to {a} is another file"); + let own = id(&fs::symlink_metadata(&link).unwrap()); + assert!(own.0 == ida.0 && own.1 != 0 && own.1 != ida.1, "on {dir} the link itself is {own:?}"); + let renamed = format!("{dir}/stat-identity-link2"); + fs::rename(&link, &renamed).unwrap_or_else(|e| panic!("rename {link}: {e}")); + assert_eq!(id(&fs::symlink_metadata(&renamed).unwrap()), own, "on {dir} a rename made another link"); + fs::remove_file(&renamed).unwrap(); let moved = format!("{dir}/stat-identity-moved"); fs::rename(&a, &moved).unwrap_or_else(|e| panic!("rename {a}: {e}")); @@ -134,15 +131,7 @@ fn a_standing_directory_keeps_its_number() { fn main() { an_ended_directory_is_forgotten(); a_standing_directory_keeps_its_number(); - let tmp = judge("/tmp", "/tmp/../tmp/./stat-identity-a", true); - let home = judge("/home", "/home/./stat-identity-a", true); - // FAT names one entry by either case, and has no symlinks. - let log = judge("/log", "/log/STAT-IDENTITY-A", false); - + let tmp = judge("/tmp", "/tmp/../tmp/./stat-identity-a"); assert_eq!(tmp & toyos::fs::SERVED_VOLUME, 0, "a kernel mount answered a served volume"); - for (dir, volume) in [("/home", home), ("/log", log)] { - assert_ne!(volume & toyos::fs::SERVED_VOLUME, 0, "{dir}'s volume {volume:#x} lacks the served bit"); - } - assert!(tmp != home && home != log && tmp != log, "two mounts are one volume"); println!("stat_identity: ok"); } diff --git a/tests/guest/rust-tests/src/bin/stream_ends_std.rs b/tests/guest/rust-tests/src/bin/stream_ends_std.rs deleted file mode 100644 index 3de8b6df58a..00000000000 --- a/tests/guest/rust-tests/src/bin/stream_ends_std.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! `stream_ends.rs`'s report, in a guest: one line each, then `ok` where it -//! is the one a host's TCP gives, and an exit of 1 where it is not. -//! -//! argv: the address and port of the harness's peer. - -#[path = "../stream_ends.rs"] -mod stream_ends; - -fn main() { - let args: Vec = std::env::args().collect(); - let [_, host, port] = &args[..] else { panic!("usage: stream_ends_std ") }; - let port = port.parse().unwrap_or_else(|e| panic!("the port {port}: {e}")); - let mut report = Vec::new(); - stream_ends::run(host, port, |line| { - println!("stream_ends_std: {line}"); - report.push(line); - }); - if report != stream_ends::EXPECTED { - println!("stream_ends_std: a host's TCP reports\n{}", stream_ends::EXPECTED.join("\n")); - std::process::exit(1); - } - println!("stream_ends_std: ok"); -} diff --git a/tests/guest/rust-tests/src/bin/toybox_file_tools.rs b/tests/guest/rust-tests/src/bin/toybox_file_tools.rs deleted file mode 100644 index 11eb5195494..00000000000 --- a/tests/guest/rust-tests/src/bin/toybox_file_tools.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! `cp`, `mv` and `hexdump` as a user reaches them: spawned from `/system/bin`, judged -//! on their exit code and on their bytes. -//! -//! The three claims that are not "it works": -//! -//! - **`cp` streams.** The source is larger than `cp`'s own flush interval -//! twice over and is not a page multiple, so the periodic-flush path and the -//! partial tail both run. Nothing in a process can prove the *absence* of a -//! file-sized allocation, but this is the workload that reaches the constants -//! that rule one out. -//! - **`cp` never leaves a short file under the destination's name.** Every -//! refusal below is checked for the destination it must not have touched and -//! for the `.part` sibling it must not have left. -//! - **`mv` does not copy behind the caller's back.** A move between mounts is -//! refused and both ends survive. If a copy-and-delete fallback is ever -//! added, this goes red — which is the point: `sys_rename` reports one error -//! for every cause, so such a fallback would fire on a broken rename too and -//! hide it. -//! - **A rename that reports success moved the file.** Both `cp` and `mv` run -//! on `/home` here, where a rename used to insert the entry under the new -//! name and then delete that same entry, freeing the file's blocks on the -//! way out. Every claim below is about bytes on the other side of the move, -//! because that failure returned `Ok(())` and left both names absent. -//! -//! `hexdump`'s expected output is not computed here. It is the byte-for-byte -//! output of the host's own `xxd` over the same 25 bytes, pasted in, so the -//! format is judged by something that is not this project. -//! -//! Every directory this test makes is left empty, which is the harder case and -//! until recently the broken one: `cp x emptydir/` wrote a *file* named -//! `emptydir`, because both halves of `is_dir` read "no entries" as "no such -//! path". Both halves are fixed and this is where the pair is judged together — -//! `/system/bin/cp` asks `fs::metadata`, which asks `readdir`, which asks the kernel. - -use std::fs; -use std::path::Path; -use std::process::{Command, Stdio}; - -/// Larger than `cp`'s `FLUSH_BYTES` twice over, and not a page multiple. -const BIG: usize = 2 * 1024 * 1024 + 137; -/// `/home`, the bcachefs mount, because `cp` lands its bytes on a sibling and -/// renames that onto the destination — and a rename here used to insert the -/// entry under the new name and then delete exactly that entry, blocks and -/// all, reporting success. A copy that arrives byte-for-byte is that path as a -/// user reaches it. `/log` is the mount this test's host-verified half uses. -const BIG_SRC: &str = "/home/toybox_cp_src.bin"; -const BIG_DST: &str = "/home/toybox_cp_dst.bin"; - -/// The three names the rename round trip walks a file through, on the same -/// mount so that every step is one `SYS_RENAME`. -const MV_A: &str = "/home/toybox_mv_home_a.bin"; -const MV_B: &str = "/home/toybox_mv_home_b.bin"; -const MV_ONTO: &str = "/home/toybox_mv_home_onto.bin"; - -/// The 25 bytes the host's `xxd` was run against. -const FIXTURE: &[u8] = b"Hello, world! ABCDEFGH\x00\x01\xff"; -const FIXTURE_PATH: &str = "/tmp/toybox_fixture.bin"; - -/// `xxd fixture`, verbatim. -const XXD_ALL: &str = "\ -00000000: 4865 6c6c 6f2c 2077 6f72 6c64 2120 4142 Hello, world! AB -00000010: 4344 4546 4748 0001 ff CDEFGH... -"; - -/// `xxd -s 4 -l 8 fixture`, verbatim. -const XXD_WINDOW: &str = "\ -00000004: 6f2c 2077 6f72 6c64 o, world -"; - -fn big() -> Vec { - (0..BIG).map(|i| (i.wrapping_mul(31).wrapping_add(i >> 9) ^ 0xA5) as u8).collect() -} - -/// Spawn `/system/bin/` with one of its two output streams on a pipe. -/// -/// One stream at a time rather than `Command::output()`: the stream that is -/// not piped is inherited, so a command that says something unexpected says it -/// on the console instead of into a buffer no assertion reads. -fn spawn(cmd: &str, args: &[&str], errors: bool) -> std::process::Output { - let mut command = Command::new(format!("/system/bin/{cmd}")); - command.args(args); - if errors { - command.stderr(Stdio::piped()); - } else { - command.stdout(Stdio::piped()); - } - command - .spawn() - .unwrap_or_else(|e| panic!("spawn /system/bin/{cmd}: {e}")) - .wait_with_output() - .unwrap_or_else(|e| panic!("wait for /system/bin/{cmd}: {e}")) -} - -fn must_pass(cmd: &str, args: &[&str]) -> String { - let out = spawn(cmd, args, false); - assert!(out.status.success(), "{cmd} {args:?} exited {:?}", out.status.code()); - String::from_utf8_lossy(&out.stdout).into_owned() -} - -/// A refusal is an exit code *and* a line naming what was refused. A command -/// that dies silently with a non-zero status satisfies the first half of that -/// and tells the caller nothing. -fn must_refuse(cmd: &str, args: &[&str], needle: &str) -> String { - let out = spawn(cmd, args, true); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!(!out.status.success(), "{cmd} {args:?} succeeded, expected a refusal"); - assert!( - stderr.contains(cmd) && stderr.contains(needle), - "{cmd} {args:?} refused without naming {needle:?}: {stderr:?}" - ); - stderr -} - -/// An empty directory, which is the case `cp x d/` used to get wrong. -fn make_dir(path: &str) { - fs::create_dir(path).unwrap_or_else(|e| panic!("mkdir {path}: {e}")); -} - -/// Every sibling of `path` whose name marks it as a copy in progress. -fn leftovers(path: &str) -> Vec { - let dir = Path::new(path).parent().expect("a parent directory"); - fs::read_dir(dir) - .unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())) - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|n| n.contains(".part")) - .collect() -} - -fn main() { - fs::write(FIXTURE_PATH, FIXTURE).expect("stage the fixture"); - cp_round_trip(); - cp_refusals(); - mv_within_a_mount(); - mv_on_home(); - mv_across_mounts(); - hexdump_format(); - hexdump_refusals(); - fs::remove_file(FIXTURE_PATH).expect("cleanup the fixture"); - println!("toybox file tools ok"); -} - -fn cp_round_trip() { - let data = big(); - fs::write(BIG_SRC, &data).expect("stage the source"); - - must_pass("cp", &[BIG_SRC, BIG_DST]); - let back = fs::read(BIG_DST).expect("read the copy back"); - assert_eq!(back.len(), data.len(), "the copy is {} bytes, the source is {BIG}", back.len()); - let bad = back.iter().zip(&data).position(|(a, b)| a != b); - assert!(bad.is_none(), "the copy differs at byte {}", bad.unwrap_or(0)); - assert!(leftovers(BIG_DST).is_empty(), "a successful copy left {:?}", leftovers(BIG_DST)); - println!(" PASS cp streamed {BIG} bytes byte-for-byte and left no partial"); - - // A destination that is a directory takes the source's own name, which is - // the only reason `cp x somedir/` means anything. - make_dir("/tmp/toybox_cp_dir"); - must_pass("cp", &[FIXTURE_PATH, "/tmp/toybox_cp_dir"]); - let landed = "/tmp/toybox_cp_dir/toybox_fixture.bin"; - assert_eq!(fs::read(landed).expect("cp into a directory"), FIXTURE); - println!(" PASS cp into a directory keeps the source's name"); - - fs::remove_file(BIG_SRC).expect("cleanup"); - fs::remove_file(BIG_DST).expect("cleanup"); - fs::remove_file(landed).expect("cleanup"); -} - -fn cp_refusals() { - fs::write("/tmp/toybox_keepme.bin", b"the destination, untouched\n").expect("stage a victim"); - - // A missing source must not open, truncate or otherwise disturb the - // destination — which is the whole reason the bytes go to a sibling first. - must_refuse("cp", &["/tmp/toybox_absent.bin", "/tmp/toybox_keepme.bin"], "toybox_absent.bin"); - let kept = fs::read_to_string("/tmp/toybox_keepme.bin").expect("the destination survived"); - assert_eq!(kept, "the destination, untouched\n", "a refused cp changed the destination"); - assert!( - leftovers("/tmp/toybox_keepme.bin").is_empty(), - "a refused cp left {:?}", - leftovers("/tmp/toybox_keepme.bin") - ); - println!(" PASS cp refuses a missing source, and the destination is unchanged"); - - // `/system/bin` rather than a directory made here: it is the one that is populated - // without this test having to populate it. - must_refuse("cp", &["/system/bin", "/tmp/toybox_fromdir.bin"], "is a directory"); - assert!(fs::read("/tmp/toybox_fromdir.bin").is_err(), "cp of a directory created a file"); - println!(" PASS cp refuses a directory by name"); - - must_refuse("cp", &["/tmp/toybox_keepme.bin"], "Usage"); - println!(" PASS cp refuses a one-argument invocation"); - - fs::remove_file("/tmp/toybox_keepme.bin").expect("cleanup"); -} - -fn mv_within_a_mount() { - let body = b"moved, not copied\n"; - fs::write("/tmp/toybox_mv_a.bin", body).expect("stage"); - must_pass("mv", &["/tmp/toybox_mv_a.bin", "/tmp/toybox_mv_b.bin"]); - assert!(fs::read("/tmp/toybox_mv_a.bin").is_err(), "mv left the source behind"); - assert_eq!(&fs::read("/tmp/toybox_mv_b.bin").expect("the moved file")[..], body); - println!(" PASS mv within a mount renames, and the old name is gone"); - - // The same directory rule as cp, and literally the same code. - make_dir("/tmp/toybox_mv_dir"); - must_pass("mv", &["/tmp/toybox_mv_b.bin", "/tmp/toybox_mv_dir"]); - assert_eq!( - &fs::read("/tmp/toybox_mv_dir/toybox_mv_b.bin").expect("moved into the directory")[..], - body - ); - println!(" PASS mv into a directory keeps the source's name"); - - must_refuse("mv", &["/tmp/toybox_absent.bin", "/tmp/toybox_x.bin"], "toybox_absent.bin"); - assert!(fs::read("/tmp/toybox_x.bin").is_err(), "a refused mv created the destination"); - println!(" PASS mv refuses a missing source before it renames anything"); - - fs::remove_file("/tmp/toybox_mv_dir/toybox_mv_b.bin").expect("cleanup"); -} - -/// Whether `/home`'s listing holds a name. Other tests write there in the same -/// boot, so this asks about one name and not about the whole directory. -fn home_holds(name: &str) -> bool { - fs::read_dir("/home") - .expect("read_dir /home") - .filter_map(|e| e.ok()) - .any(|e| e.file_name().to_string_lossy() == name) -} - -/// The rename `/tmp` exercises is not the one that was broken. `/home` keys an -/// entry by the hash of its name and holds the file's extent list inline in -/// the value, so a rename re-encodes that value under a new key and has to -/// remove the old entry *without* freeing what it points at. Byte-exactness -/// after the move is what carries that claim: a name that resolves proves only -/// that some entry exists under it. -fn mv_on_home() { - let body: Vec = - (0..3 * 4096 + 29).map(|i: usize| (i.wrapping_mul(37) ^ 0x3C) as u8).collect(); - fs::write(MV_A, &body).expect("stage a file on /home"); - - must_pass("mv", &[MV_A, MV_B]); - assert!(fs::read(MV_A).is_err(), "mv left the source behind on /home"); - assert!(!home_holds("toybox_mv_home_a.bin"), "the old name is still in /home's listing"); - let moved = fs::read(MV_B).expect("read the moved file"); - assert_eq!(moved.len(), body.len(), "the moved file is {} bytes", moved.len()); - let bad = moved.iter().zip(&body).position(|(a, b)| a != b); - assert!(bad.is_none(), "the moved file differs from the source at byte {}", bad.unwrap_or(0)); - println!(" PASS mv on /home moves {} bytes and the old name is gone", body.len()); - - // Onto a name that already exists. The entry the rename overwrites is the - // one whose blocks are freed; freeing the other one is the same mistake - // wearing the destination's name. - fs::write(MV_ONTO, b"the file that gets replaced\n").expect("stage the destination"); - must_pass("mv", &[MV_B, MV_ONTO]); - assert!(fs::read(MV_B).is_err(), "mv onto an existing name left the source behind"); - let over = fs::read(MV_ONTO).expect("read the overwritten destination"); - let bad = over.iter().zip(&body).position(|(a, b)| a != b); - assert!( - over.len() == body.len() && bad.is_none(), - "the destination holds {} bytes differing at {:?}, not the {} that were moved onto it", - over.len(), - bad, - body.len(), - ); - println!(" PASS mv onto an existing name on /home replaces it byte-for-byte"); - - fs::remove_file(MV_ONTO).expect("cleanup"); -} - -fn mv_across_mounts() { - let body = b"this file stays on /tmp\n"; - fs::write("/tmp/toybox_mv_cross.bin", body).expect("stage"); - - let stderr = must_refuse( - "mv", - &["/tmp/toybox_mv_cross.bin", "/home/toybox_mv_cross.bin"], - "different mounts", - ); - assert!(stderr.contains("cp then rm"), "the refusal does not say what to do: {stderr:?}"); - assert_eq!( - &fs::read("/tmp/toybox_mv_cross.bin").expect("the source survived")[..], - body, - "a refused mv damaged the source" - ); - assert!( - fs::read("/home/toybox_mv_cross.bin").is_err(), - "a refused mv put the file at the destination anyway — mv is copying behind the \ - caller's back, which is exactly what it must not do while a rename failure has \ - only one error code" - ); - println!(" PASS mv refuses a move between mounts and leaves both ends alone"); - - fs::remove_file("/tmp/toybox_mv_cross.bin").expect("cleanup"); -} - -fn hexdump_format() { - let got = must_pass("hexdump", &[FIXTURE_PATH]); - assert_eq!(got, XXD_ALL, "hexdump does not agree with xxd\ngot:\n{got}want:\n{XXD_ALL}"); - - let got = must_pass("hexdump", &["-s", "4", "-l", "8", FIXTURE_PATH]); - assert_eq!(got, XXD_WINDOW, "hexdump -s -l does not agree with xxd\ngot:\n{got}"); - - // The same window asked for in hex, and a length past the end folded into - // the file rather than read off it. - let got = must_pass("hexdump", &["-s", "0x4", "-l", "0x8", FIXTURE_PATH]); - assert_eq!(got, XXD_WINDOW, "0x offsets differ from decimal"); - let got = must_pass("hexdump", &["-l", "4096", FIXTURE_PATH]); - assert_eq!(got, XXD_ALL, "-l past the end read past the end"); - println!(" PASS hexdump matches xxd byte-for-byte, whole file and window"); -} - -fn hexdump_refusals() { - let past = (FIXTURE.len() + 1).to_string(); - must_refuse("hexdump", &["-s", &past, FIXTURE_PATH], "past the end"); - must_refuse("hexdump", &["-s", "twelve", FIXTURE_PATH], "not a number"); - must_refuse("hexdump", &["-s", FIXTURE_PATH], "not a number"); - must_refuse("hexdump", &["-l"], "needs a number"); - must_refuse("hexdump", &["-q", FIXTURE_PATH], "unknown option"); - must_refuse("hexdump", &[FIXTURE_PATH, FIXTURE_PATH], "one file at a time"); - must_refuse("hexdump", &["/tmp/toybox_absent.bin"], "toybox_absent.bin"); - println!(" PASS hexdump refuses seven bad requests, each by name"); -} diff --git a/tests/guest/rust-tests/src/bin/update_idle_slot.rs b/tests/guest/rust-tests/src/bin/update_idle_slot.rs deleted file mode 100644 index 957754c526b..00000000000 --- a/tests/guest/rust-tests/src/bin/update_idle_slot.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! `update` run as `ssh update < image` runs it, on the signed -//! image the harness put on ROOT: launched through this job's launcher, so -//! the supervisor grants it the idle slot, with the image as its standard -//! input. What it says is this job's; whether the slot holds the image is -//! the harness's to read off the disk. - -use std::process::{Command, Stdio}; - -/// Where the harness puts the image (`tests/images/slotscase.toml`). -const IMAGE: &str = "/system/share/update-test.img"; - -fn main() { - let image = std::fs::File::open(IMAGE).expect("update_idle_slot: the image the harness staged"); - let out = Command::new("/system/bin/update") - .stdin(Stdio::from(image)) - .output() - .expect("update_idle_slot: update launched"); - for line in String::from_utf8_lossy(&out.stdout).lines() { - println!("update_idle_slot: update said: {line}"); - } - assert!(out.status.success(), "update_idle_slot: update ended {:?}", out.status); -} diff --git a/tests/guest/rust-tests/src/bin/usbd_spare.rs b/tests/guest/rust-tests/src/bin/usbd_spare.rs index 970865b8dcd..d43b2739468 100644 --- a/tests/guest/rust-tests/src/bin/usbd_spare.rs +++ b/tests/guest/rust-tests/src/bin/usbd_spare.rs @@ -3,10 +3,10 @@ //! itself so it can end it: this job holds the claim, the port usbd answers //! `inspect` on, and usbd's process. //! -//! - no argument: usbd started, asked what it named once it says it has -//! settled, asked again across a span in which nothing may wake it, killed, -//! and started again on a fresh claim, which must name the same devices. -//! Every answer is printed for the harness to judge. +//! - no argument: usbd started, asked once it says it has settled whether its +//! No-Op was answered by an interrupt through the claim, asked again across +//! a span in which nothing may wake it, and killed. Every answer is printed +//! for the harness to judge. //! - `fault`: the claim's function aimed, with no usbd, at an address outside //! every grant: the kernel's `DMA FAULT` record is the harness's to read, //! and the claim refusing its interrupt read from then on is this job's. @@ -39,12 +39,6 @@ const SPARES: [PciId; 2] = [PciId { vendor: 0x1b36, device: 0x000d }, PciId { ve /// is a few control transfers per device, and the guest may be slow. const SETTLE_BOUND: Duration = Duration::from_secs(30); -/// How long a killed usbd's claim may take to come back: the kernel publishes -/// a process's end before its deferred releases have run -/// (`issues/deferred-release-outlives-its-syscall.md`), the supervisor's -/// `CLAIM_RETURN` for the same reason. A liveness guard. -const CLAIM_RETURN: Duration = Duration::from_secs(2); - /// The span across which a parked usbd may wake for nothing but an event: /// the absence is what is measured, so the span is the instrument. const PARKED_SPAN: Duration = Duration::from_millis(500); @@ -159,25 +153,12 @@ fn number(snapshot: &BTreeMap, path: &str) -> u64 { } } -/// The devices an answer names, as `port id speed class` lines. -fn devices(snapshot: &BTreeMap) -> Vec { - let mut named = Vec::new(); - for (path, value) in snapshot { - let Some(port) = path.strip_prefix("usb.device.").and_then(|p| p.strip_suffix(".id")) else { continue }; - let field = |f: &str| snapshot.get(&format!("usb.device.{port}.{f}")).map(|v| v.to_string()).unwrap_or_default(); - named.push(format!("port {port} {value} {} {}", field("speed"), field("class"))); - } - named -} - fn drive(cap: &SysCap) { let (id, claimed) = claim(cap); let dev = claimed.unwrap_or_else(|why| panic!("usbd_spare: the claim on {} was refused: {why:?}", spelled(id))); let usbd = Usbd::start(id, dev); usbd.until("usbd: settled"); let first = usbd.ask("settled"); - let named = devices(&first); - assert_eq!(number(&first, "usb.devices") as usize, named.len()); assert_eq!(number(&first, "usb.events.unannounced"), 0, "usbd_spare: an event no interrupt announced"); assert_eq!(number(&first, "usb.operations.silent"), 0, "usbd_spare: an operation nothing answered"); assert!(number(&first, "usb.interrupts.records") > 0, "usbd_spare: usbd read no interrupt record"); @@ -194,21 +175,6 @@ fn drive(cap: &SysCap) { assert_eq!(after.get("usb.deadline_ns").map(Value::to_string).as_deref(), Some("none")); usbd.kill(); println!("usbd_spare: usbd killed"); - - let asked = Instant::now(); - let dev = loop { - match cap.claim_pci::(id) { - Err(SyscallError::AlreadyExists) if asked.elapsed() < CLAIM_RETURN => std::thread::sleep(Duration::from_millis(1)), - answered => break answered.unwrap_or_else(|why| panic!("usbd_spare: the claim did not come back: {why:?}")), - } - }; - println!("usbd_spare: the claim came back after {} ms", asked.elapsed().as_millis()); - let again = Usbd::start(id, dev); - again.until("usbd: settled"); - let second = again.ask("restarted"); - assert_eq!(devices(&second), named, "usbd_spare: a restarted usbd named other devices"); - again.kill(); - println!("usbd_spare: {} device(s) named, the same after a restart: {named:?}", named.len()); } fn fault(cap: &SysCap) { diff --git a/tests/guest/rust-tests/src/lent_hello.rs b/tests/guest/rust-tests/src/lent_hello.rs deleted file mode 100644 index 14e8abb36fe..00000000000 --- a/tests/guest/rust-tests/src/lent_hello.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! A raw client's hello to `fs:/home`, lending a window of the caller's -//! choosing, as the lent-window tests make it. - -use toyos::fs::{Reply, Request, HELLO, REPLY, WINDOW_BYTES}; -use toyos::ipc::IpcError; -use toyos::shm::SharedMemory; -use toyos::OwnedHandle; -use toyos_abi::syscall::SyscallError; - -/// What `fs:/home` answers a hello lending `window`: the volume, which is the -/// server's pid, or `Gone` for a server that let the client go unanswered. -pub fn hello(window: OwnedHandle) -> Result { - let names = toyos::endow::namespace().expect("a namespace"); - let conn = names.open("fs:/home").expect("fs:/home"); - conn.send_handles([window]).expect("the window into the connection"); - conn.send(HELLO, &Request::new()).expect("the hello"); - let header = match conn.recv_header() { - Ok(header) => header, - Err(IpcError::Disconnected | IpcError::Syscall(SyscallError::Gone)) => return Err(SyscallError::Gone), - Err(e) => panic!("the hello's answer: {e:?}"), - }; - assert_eq!(header.msg_type, REPLY, "a hello answered by a frame that is no reply"); - let reply: Reply = conn.recv_payload(&header).expect("the reply's words"); - assert_eq!(reply.status, 0, "a hello refused with a word, not let go"); - Ok(reply.value2) -} - -/// A region of `bytes`, for lending. -pub fn region(bytes: usize) -> OwnedHandle { - SharedMemory::create(bytes).expect("a region").share().expect("its handle") -} - -/// A window the server serves. -pub fn window() -> OwnedHandle { - region(WINDOW_BYTES) -} diff --git a/tests/guest/rust-tests/src/stream_ends.rs b/tests/guest/rust-tests/src/stream_ends.rs deleted file mode 100644 index 61b4dbbc94d..00000000000 --- a/tests/guest/rust-tests/src/stream_ends.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Each way a stream ends, as std reports it to a client: the same code runs -//! in a guest against netstack and on the harness's host against the host -//! kernel's TCP, and the two reports are compared line for line. -//! -//! The peer is the harness's: a connection's first byte names what it does, -//! and `tests/guest/c/stream_ends.c` dials it with the same bytes. Every end it -//! makes waits on something the client did, so no line depends on how long -//! anything took. -//! -//! Std alone: the harness compiles this file too. - -use std::io::{ErrorKind, Read, Write}; -use std::net::{Shutdown, TcpStream}; - -/// What the peer sends before the reset of `reset_mid_stream`. -pub const AHEAD: usize = 1 << 16; - -/// The peer sends its FIN before the client writes. -pub const PEER_FIN_FIRST: u8 = b'F'; -/// The peer sends [`AHEAD`] bytes, reads the client's one, then resets. -pub const RESET_MID_STREAM: u8 = b'R'; -/// The peer reads to the client's end, then resets. -pub const RESET_AFTER_HALF_CLOSE: u8 = b'D'; -/// The peer sends three bytes and its FIN. -pub const BOTH_CLOSE: u8 = b'B'; -/// The peer answers one byte; the client then shuts its sending half with -/// nothing pending, and the peer, at that FIN, sends four bytes and its own. -pub const SHUT_FIRST: u8 = b'S'; - -/// The report a host's TCP gives, and so the one netstack owes. -pub const EXPECTED: [&str; 6] = [ - "peer_fin_first: the read Ok(0), then a write Ok(5)", - "reset_mid_stream: 65536 bytes came as sent, the answer Ok(1), then Err(ConnectionReset)", - "reset_after_half_close: shut the sending half: Ok, then Err(ConnectionReset)", - "both_close: 3 bytes came as sent, then Ok(0)", - "both_close: shut the sending half: Ok, then Ok(0)", - "shut_first: the answer Ok(1), shut the sending half: Ok, 4 bytes came as sent, then Ok(0)", -]; - -/// Byte `i` of what the peer sends ahead of a reset: a pattern no shift or -/// cut reproduces. -pub fn pattern(i: usize) -> u8 { - (i % 251) as u8 -} - -fn kind(result: std::io::Result) -> String { - match result { - Ok(_) => "Ok".to_string(), - Err(e) => format!("Err({:?})", e.kind()), - } -} - -fn count(result: std::io::Result) -> String { - match result { - Ok(n) => format!("Ok({n})"), - Err(e) => format!("Err({:?})", e.kind()), - } -} - -/// What one more read answers: the end, or a byte that is not one. -fn the_end(stream: &mut TcpStream) -> String { - count(stream.read(&mut [0u8; 1])) -} - -/// Reads until `want` bytes, the end or a refusal: how many matched -/// `expect`, and what stopped it, if anything did before `want`. -fn take(stream: &mut TcpStream, want: usize, expect: impl Fn(usize) -> u8) -> (String, Option) { - let mut buf = vec![0u8; 65536]; - let mut got = 0; - while got < want { - let room = buf.len().min(want - got); - match stream.read(&mut buf[..room]) { - Ok(0) => return (format!("{got} bytes came"), Some("Ok(0)".to_string())), - Ok(n) => { - if let Some(at) = (0..n).find(|&i| buf[i] != expect(got + i)) { - return (format!("byte {} differs after {got} bytes", got + at), None); - } - got += n; - } - Err(e) if e.kind() == ErrorKind::Interrupted => {} - Err(e) => return (format!("{got} bytes came"), Some(format!("Err({:?})", e.kind()))), - } - } - (format!("{got} bytes came"), None) -} - -fn dial(host: &str, port: u16, what: u8) -> TcpStream { - let mut stream = TcpStream::connect((host, port)).unwrap_or_else(|e| panic!("the peer at {host}:{port}: {e}")); - stream.write_all(&[what]).unwrap_or_else(|e| panic!("naming the end to the peer: {e}")); - stream -} - -/// Every end, in order: one line each as [`EXPECTED`] spells them. -pub fn run(host: &str, port: u16, mut say: impl FnMut(String)) { - let mut stream = dial(host, port, PEER_FIN_FIRST); - let read = the_end(&mut stream); - say(format!("peer_fin_first: the read {read}, then a write {}", count(stream.write(b"after")))); - - let mut stream = dial(host, port, RESET_MID_STREAM); - let line = match take(&mut stream, AHEAD, pattern) { - (came, Some(end)) => format!("{came}, then {end}"), - (came, None) => { - let answer = count(stream.write(b"k")); - format!("{came} as sent, the answer {answer}, then {}", the_end(&mut stream)) - } - }; - say(format!("reset_mid_stream: {line}")); - - let mut stream = dial(host, port, RESET_AFTER_HALF_CLOSE); - let shut = kind(stream.shutdown(Shutdown::Write)); - say(format!("reset_after_half_close: shut the sending half: {shut}, then {}", the_end(&mut stream))); - - let mut stream = dial(host, port, BOTH_CLOSE); - let (came, end) = take(&mut stream, 3, |i| b"bye"[i]); - let end = end.unwrap_or_else(|| the_end(&mut stream)); - say(format!("both_close: {came} as sent, then {end}")); - let shut = kind(stream.shutdown(Shutdown::Write)); - say(format!("both_close: shut the sending half: {shut}, then {}", the_end(&mut stream))); - - let mut stream = dial(host, port, SHUT_FIRST); - let answer = the_end(&mut stream); - let shut = kind(stream.shutdown(Shutdown::Write)); - let (came, end) = take(&mut stream, 4, |i| b"late"[i]); - let end = end.unwrap_or_else(|| the_end(&mut stream)); - say(format!("shut_first: the answer {answer}, shut the sending half: {shut}, {came} as sent, then {end}")); -} diff --git a/tests/images/blockgrantcase.toml b/tests/images/blockgrantcase.toml deleted file mode 100644 index 3711b5819a2..00000000000 --- a/tests/images/blockgrantcase.toml +++ /dev/null @@ -1,20 +0,0 @@ -# A block service's grants, judged by `block_grants_reach_their_partitions`: -# diskserver is built into the image and started by nothing but the one job, -# `partition_grant`, which holds the claim it hands it and the acceptor every -# grant is minted on. No file server runs, so no partition of the disk the -# machine booted from is held but the one the job opens. -[boot] -start = ["logkeeper", "test-runner"] - -[programs.logkeeper] -service = true -syscap = ["logread"] - -# `device` because the job mints the controller's claim it hands diskserver, -# `dup` because test-runner hands a job its capability as a duplicate and -# hands none without it, and `inventory` because the job reads which -# partitions the loader named. -[programs.test-runner] -syscap = ["device", "dup", "inventory"] - -[programs.diskserver] diff --git a/tests/images/consentcase.toml b/tests/images/consentcase.toml deleted file mode 100644 index 66b293aa1e5..00000000000 --- a/tests/images/consentcase.toml +++ /dev/null @@ -1,61 +0,0 @@ -# The boot `consent_prompt` drives through the compositor's own keyboard and -# tablet on `Profile::Desktop`'s machine: a package launched from the -# launcher asks the person at the screen for a folder in the prompt layer, -# which `filepicker` alone reaches, over a hostile fullscreen topmost window -# the job itself opens. -# -# test-runner receives `compositor` so a job's own window is a client like any, -# and lists `shell`, whose `login` row opens a login session that is not the -# screen's: the session `grants` is answered in, and a launch that never asks. - -assets = ["assets"] - -[boot] -start = ["logkeeper", "diskserver", "fileserver", "compositor", "filepicker", "test-runner"] - -[apps] -receives = ["compositor"] -folder = "read-write" - -# `every_boot_config_runs_logkeeper` is what refuses a boot config without it. -[programs.logkeeper] -service = true -syscap = ["logread"] - -[programs.compositor] -service = true -serves = ["compositor", "prompt"] -devices = ["framebuffer", "keyboard", "mouse"] -starts = ["/apps"] -login = true - -[programs.filepicker] -service = true -serves = ["filepicker", "consent"] -receives = ["compositor", "prompt"] - -# `dup` because test-runner hands each binary a duplicate of its capability. -[programs.test-runner] -syscap = ["dup"] -receives = ["compositor"] -starts = ["shell", "grants", "/apps"] - -[programs.shell] -starts = ["grants", "/apps"] -login = true - -[programs.grants] -receives = ["grants"] - -# The block service and the file servers, as `tests/images/proctreecase.toml` has them: -# the package, the home and the store are DATA's. -[programs.diskserver] -service = true -restart = true -serves = ["block"] -devices = ["pci:1b36:0010"] - -[programs.fileserver] -restart = true -roles = ["data", "log", "boot"] -receives = ["block"] diff --git a/tests/images/logstallcase.toml b/tests/images/logstallcase.toml deleted file mode 100644 index 6f790e6e673..00000000000 --- a/tests/images/logstallcase.toml +++ /dev/null @@ -1,45 +0,0 @@ -# The shared boot's shape with `/system/bin/logkeeper` leaving soundserver's log ring -# unread until `test_rs_soundserver_log_stall` says the tone has played: the ring -# fills and stays full while it plays, which is the state its mix thread must -# never wait in. The `soundserver_log_stall` metal row runs it, and reads the verdict -# off `/log`. - -[boot] -start = ["logkeeper", "diskserver", "fileserver", "soundserver", "test-runner"] - -[programs.logkeeper] -service = true -args = [ - "--stall=soundserver", - "--stall-until=soundserver_log_stall: the tone has played, and logkeeper may read soundserver again", -] -syscap = ["logread"] - -[programs.soundserver] -service = true -serves = ["soundserver"] -devices = ["hda-audio", "pci:1af4:1059"] -syscap = ["rt"] - -[programs.test-runner] -receives = ["soundserver"] - -[programs.toybox] -receives = ["soundserver"] - -# The block service: the NVMe controller this machine's DATA is on, driven -# from userland through its claim. Started again when it ends; the claim goes -# back with the process and is minted again for the next. -[programs.diskserver] -service = true -restart = true -serves = ["block"] -devices = ["pci:1b36:0010"] - -# The file servers: DATA, the log and the running slot's volume, one process -# each, serving every program the directories of its role. Started again when -# one ends, on the same ports. -[programs.fileserver] -restart = true -roles = ["data", "log", "boot"] -receives = ["block"] diff --git a/tests/images/netcase.toml b/tests/images/netcase.toml index a6c56e547cf..6fc679f695d 100644 --- a/tests/images/netcase.toml +++ b/tests/images/netcase.toml @@ -25,7 +25,7 @@ syscap = ["logread"] [programs.netstack] service = true serves = ["netstack"] -devices = ["pci:1af4:1041", "pci:8086:10d3"] +devices = ["pci:1af4:1041"] [programs.soundserver] service = true @@ -34,7 +34,6 @@ devices = ["pci:1af4:1059"] syscap = ["rt"] [programs.test-runner] -receives = ["netstack"] # The block service: the NVMe controller this machine's DATA is on, driven # from userland through its claim. Started again when it ends; the claim goes diff --git a/tests/images/proctreecase.toml b/tests/images/proctreecase.toml index 92fb92a96a1..0703d94bbbd 100644 --- a/tests/images/proctreecase.toml +++ b/tests/images/proctreecase.toml @@ -2,16 +2,10 @@ # # A launcher for test-runner, which it endows every job, because the subtree # under test has children the supervisor launches; a declared `cat`, which runs -# until its input ends; and a declared shell, which a depth chain launches and -# which detaches a `cat`. +# until its input ends; and a declared shell, which detaches a `cat`. # # `launch_toctou` rides it too: a launch of `toybox` through a link it re-points. # -# And `fs_share`: a shell test-runner launches opens no session and spends -# test-runner's share of DATA's server; a shell that shell launches is in a -# login session its row opens, which has a share of its own, and so is every -# shell launched down from it, whose `login` row opens none there. -# # And `app_view`: test-runner's row lists `/apps`, so a job launches the # package it installed under the image's `[apps]` row and the view an # installed package is minted, and is refused one named after the `shell` row. @@ -44,7 +38,7 @@ starts = ["toybox", "shell", "grants", "/apps"] syscap = ["roster"] [programs.shell] -starts = ["toybox", "shell", "grants"] +starts = ["shell", "grants"] login = true [programs.grants] diff --git a/tests/images/slotscase.toml b/tests/images/slotscase.toml deleted file mode 100644 index 0e85e895f0c..00000000000 --- a/tests/images/slotscase.toml +++ /dev/null @@ -1,28 +0,0 @@ -# The update, judged by `update_writes_the_idle_slot_through_the_block_service`: -# a machine booted off its NVMe disk, whose slots are diskserver's partitions, -# runs `update` as a login runs it. test-runner is a `login` row listing it, -# so the job it runs launches `update` in a login session of its own. -[boot] -start = ["logkeeper", "diskserver", "fileserver", "test-runner"] - -[programs.logkeeper] -service = true -syscap = ["logread"] - -[programs.test-runner] -login = true -starts = ["update"] - -[programs.update] -slots = true - -[programs.diskserver] -service = true -restart = true -serves = ["block"] -devices = ["pci:1b36:0010"] - -[programs.fileserver] -restart = true -roles = ["data", "log", "boot"] -receives = ["block"] diff --git a/tests/images/testcases.toml b/tests/images/testcases.toml index 61711c79956..3cb3a82b1b7 100644 --- a/tests/images/testcases.toml +++ b/tests/images/testcases.toml @@ -16,8 +16,8 @@ syscap = ["logread"] serves = ["log"] # The machine's ACPI fixed hardware, as `images/system.toml` serves it: on the T14 -# every row of this image runs in ACPI mode, and on q35 `acpi_power_button` -# presses the button it serves. +# every row of this image runs in ACPI mode, and on q35 the stop tests power off +# on the sleep type it hands the kernel. [programs.acpiserver] service = true devices = ["acpi"] @@ -41,9 +41,9 @@ syscap = ["rt"] # carried it would make vacuous. `run shutdown` does not use it: the applet asks # the supervisor through the `power` connector, and the supervisor has `logkeeper` make the log whole # before it stops the machine. -# `roster` because guest binaries read `SYS_SYSINFO`'s per-thread entries — -# soundserver's, for the idle-suspend certification, and their own, which arrive in -# the same machine-wide answer and have no narrower question in the ABI. It is +# `roster` because guest binaries read their own of `SYS_SYSINFO`'s per-thread +# entries, which arrive in the same machine-wide answer as every other process's +# and have no narrower question in the ABI. It is # also what `endowment_denied` narrows *away* to prove the refusal, so an estate # without it would make that arm vacuous rather than red. # `counters` and `trace` because `counters_read` reads every counter there is, @@ -57,12 +57,6 @@ syscap = ["device", "dup", "logread", "power", "roster", "counters", "trace"] [programs.toybox] receives = ["soundserver"] -# `disk_backtrace` names its child's crash record with it. -[programs.symbolize] - -# `toyfetch_reports` runs it, holding nothing, as the image's row does. -[programs.toyfetch] - # One xHCI controller driven from userland: `usbd_spare` claims the controller # the kernel was told to leave alone (`xhci-leave=`, `pci:1b36:000d` on the # guest and the Type-C controller on the T14), and starts and ends this itself, @@ -84,9 +78,8 @@ receives = ["soundserver"] "bin/reboot" = "/system/bin/toybox" "bin/rm" = "/system/bin/toybox" "bin/shutdown" = "/system/bin/toybox" -# The shipped audio client, and the one the T14 hangs on. The raw-API tone in -# `toyos-rust-tests` drains the same sink perfectly, so a suite that ran only -# that one certified a path no user takes. +# The one applet that needs the `soundserver` this image's toybox receives: +# `endowment_denied` holds every other applet's grant of it as declared. "bin/tone" = "/system/bin/toybox" # The block service: the NVMe controller this machine's DATA is on, driven diff --git a/tests/toyos.rs b/tests/toyos.rs index f95ce314318..a174e90387f 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -2,8 +2,6 @@ extern crate toyos_build; mod common; -#[path = "guest/rust-tests/src/stream_ends.rs"] -mod stream_ends; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -130,43 +128,15 @@ const RUST_SKIP: &[&str] = &[ // Needs a launcher whose row lists `/apps`, which `tests/images/testcases.toml` does // not give: the `app_view` metal row runs it on tests/images/proctreecase.toml. "app_view", - // Needs a desktop driven by a person's keys and pointer: `consent_prompt` - // runs it on tests/images/consentcase.toml. - "consent", - // Needs a launcher whose row lists a shell, and a shell whose row opens a - // login session and lists a shell, so that a login session and its - // launches ask DATA's server while this job's share holds all it may: the - // `fs_share` metal row runs it on tests/images/proctreecase.toml. - "fs_share", - // It needs a NIC in front of netstack, a host server behind it and a host - // that dials its listener: `netstack_streams` runs it on `tests/images/netcase.toml`. - "netstack_streams", - // It needs a host that dials its listeners when it says they wait: - // `libc_sockets` runs it on `tests/images/netcase.toml`. - "nodelay_accepted", - // It needs a NIC in front of netstack and HTTPS servers behind it: - // `https_fetch` runs it on `tests/images/netcase.toml`. - "https_get", - // `https_fetch` runs it on the boot its fetches run on, so that test - // carries its own oracle; a shared run would be the same QEMU CPU twice. - "ring_kat", - // It needs a host peer that ends each stream as the stream asks: - // `libc_sockets` runs it on `tests/images/netcase.toml`. - "stream_ends_std", // Fills /tmp to the VFS listing limit, so it needs a boot nothing else // shares — every later `read_dir("/tmp")` in it would be refused. // `readdir_bound` gives it one. "readdir_bound", // Fills the VFS `created_dirs` cap and leaves it there. `mkdir_cap` runs it. "mkdir_cap", - // Audio is judged on the T14 and nowhere else: the `hda_client_stall`, - // `hda_tone`, `audio_idle_suspend`, `shipped_client_departures` and - // `soundserver_log_stall` metal rows run these. - "hda_client_stall", + // Audio is judged on the T14 and nowhere else: the `hda_tone` metal row + // runs it. "audio_tone", - "audio_idle_suspend", - "null_sink_client_exits", - "soundserver_log_stall", // It hands the ACPI claim to a server of its own and kills it, which needs // a boot that starts none: the `acpi_server_death` metal row runs it on // tests/images/acpicase.toml. @@ -178,7 +148,7 @@ const RUST_SKIP: &[&str] = &[ // It claims the fixed hardware itself, which needs a boot that starts no // server, and stages the firmware's side of the Global Lock and finds the // i8042's row another claim's, which need the test kernel and its - // `i8042-withheld`: `acpi_mediated_access` and `acpi_press_across_a_lock_wait` + // `i8042-withheld`: `acpi_mediated_access` and `acpi_press_is_one_sci` // run it on tests/images/acpicase.toml. "acpi_mediated", // It powers the machine off: `machine_shutdown_short_stop` runs it. @@ -186,20 +156,12 @@ const RUST_SKIP: &[&str] = &[ // It claims QEMU's virtio NIC, which the T14 has none of: `bar_map_again` // runs it. "bar_map_again", - // It lends fileserver the virtio NIC's BAR, which the T14 has none of: - // `lent_device_memory_refused` runs it. + // It claims the virtio NIC and asks how its BAR is lent, and the T14 has + // no virtio NIC: `lent_device_memory_refused` runs it. "lent_device_memory_refused", // It needs ToyOS's own clang and LLD on ROOT, which only its boot stages: // `hosted_clang_hello` runs it on tests/images/hostedclangcase.toml. "hosted_clang_hello", - // It claims the NVMe controller a boot off that disk starts no server - // for, and reads the inventory: `block_grants_reach_their_partitions` - // runs it on tests/images/blockgrantcase.toml. - "partition_grant", - // It installs the image its machine test staged into a second slot on - // the disk diskserver drives: `update_writes_the_idle_slot_through_the_block_service` - // runs it on tests/images/slotscase.toml. - "update_idle_slot", // It claims the xHCI controller `xhci-leave=` leaves alone, which no other // boot's kernel does: `usbd_drives_the_spare` and the // `usbd_drives_the_type_c_controller` metal row run it. @@ -311,13 +273,6 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("virt_random_differs", qemu::Profile::Virt), ("virt_smmu", qemu::Profile::VirtSmmu), ("virt_no_seed_refused", qemu::Profile::VirtNoRng), - // A package's launch from the desktop asks the person at the screen, in - // the prompt layer, over a hostile fullscreen window: the supervisor - // parking a launch, filepicker drawing through `prompt` and the compositor - // giving it every key, run together. Driven through the desktop's own - // keyboard and tablet and read off the panel, neither of which the T14's - // loop can do; each rule alone is a host test. - ("consent_prompt", qemu::Profile::Desktop), ("virt_wall_clock_utc", qemu::Profile::Virt), ("virt_low_ecam", qemu::Profile::VirtLowEcam), ]; @@ -334,35 +289,14 @@ const MACHINE_TESTS: &[&str] = &[ // in reach, and the register sequence and the DMA it frees are no host // test's. "amdvi_firmware_left", - // The stack against a TCP its writers did not write, the host kernel's - // behind QEMU's user network, through the kernel's own pipes and each of - // netstack's two drivers: netstack has no host build, the stack's host - // tests answer it from the tests' own script, and the T14's bench may - // open no peer that echoes megabytes or dials in. - "netstack_streams", - "netstack_streams_e1000e", - // What libc's and std's socket calls ask of netstack, read back from a - // peer that answers, and each way a stream ends as libc and std read it: - // the calls are requests on netstack's port, an end is what netstack, the - // kernel's pipes and the library read together, netstack has no host - // build, and the T14's bench has no peer that answers or resets on cue. - "libc_sockets", - // An HTTPS client's fetch from a peer on the host, and its refusals: - // `ring`'s ToyOS build, the random source and wall clock it trusts a - // certificate by and the sockets under it exist in a ToyOS guest alone, - // and the T14's row is the next stage's, against a server on the bench. - "https_fetch", // The nested-NMI report is a raw write to the 16550, which the T14 does not // have. "nested_nmi_is_loud", - // The power-off itself: the metal loop reaches the T14 over `ssh` and has - // no way to turn it back on, so only a machine QEMU reports stopping can - // be asked. `acpi_tables_loaded` reads the sleep type the T14's server - // handed its kernel. - "machine_shutdown", - // The press itself: QEMU raises the fixed power-button event on demand, - // and nothing presses the T14's button but a hand. - "acpi_power_button", + // A press of the fixed power button reaches the `acpi` claim's holder as + // one SCI, its line masked until the holder serves it: the kernel's SCI + // and its mask have no host build, QEMU raises the event on demand, and + // nothing presses the T14's button but a hand. + "acpi_press_is_one_sci", // What the kernel reads and writes for the `acpi` claim's holder and what // it refuses. A red here is a write the kernel made — to RAM, to the // firmware's tables, to COM1, to `PM1a_CNT`, to a function's configuration @@ -379,12 +313,6 @@ const MACHINE_TESTS: &[&str] = &[ // by stopping. No host test reaches either, and the T14 is never asked to // power off. "acpi_supply_outlives_holder", - // A press while acpiserver waits for the firmware's release of the Global - // Lock: the firmware's hold is the test kernel's actuator on the FACS's - // word, the table that takes the lock is one QEMU adds, and the press is - // QEMU's on demand. The T14's firmware holds the lock when it will, and - // nothing presses its button but a hand. - "acpi_press_across_a_lock_wait", // The power-off after a stop that left a thread running, in ACPI mode: it // ends the machine, so only one QEMU reports stopping can be asked, and // the T14 hands over in legacy mode, where no holder means no quieting. @@ -404,9 +332,9 @@ const MACHINE_TESTS: &[&str] = &[ // function is its I219, and a kernel that dies on this takes the bench's // machine down with it. "bar_map_again", - // A region that is not memory the kernel allocated, lent to a file server: - // the BAR of a function no program of the boot holds, which on the T14 is - // its I219, as `bar_map_again` says. + // A region that is not memory the kernel allocated, lent as a window: the + // kernel describes the BAR of a function no program of the boot holds, + // which on the T14 is its I219, as `bar_map_again` says. "lent_device_memory_refused", // `images/console.toml`'s image, which runs no job and hands no machine // back: a metal boot of it ends with a hand on the power button. @@ -416,48 +344,26 @@ const MACHINE_TESTS: &[&str] = &[ // which no host build has and the T14, whose CPUs offer x2APIC, never // reaches; the AMD laptop that needs it is not on the bench. "desktop_without_x2apic", - // A machine whose one NVMe disk is all it has: its firmware boots the - // image off it, and `/log` and `/home` are `diskserver`'s partitions of - // it across a reset. The claim on the controller and the firmware that - // reads it have no host build, and the T14 boots from a stick beside an - // NVMe disk that is another system's. - "nvme_disk_keeps_log_and_home", + // A machine whose one NVMe disk is all it has boots the image off it: the + // firmware that reads it and the loader and kernel it hands the image to + // have no host build, and the T14 boots from a stick beside an NVMe disk + // that is another system's. + "nvme_disk_boots", // clang and LLD built for ToyOS, compiling and linking inside it: the // claim is that they run on ToyOS, which no host test can make, and the // T14 would read nothing a guest does not. On demand: [`ON_DEMAND`]. "hosted_clang_hello", - // A badge the kernel stamps on a connection to diskserver, judged by - // diskserver on the disk it drives: the grant's decisions are host-tested - // in diskserver and toyos-blockring, but whether the badge diskserver - // reads is the one a connector was minted with, and whether a refused - // open stays refused against a partition nothing else holds, is the - // kernel's port and a claimed controller, which have no host build; and - // the T14 boots from a stick, so no disk diskserver drives holds a - // partition the loader named. - "block_grants_reach_their_partitions", - // The supervisor reading the slot table through a block session and - // minting `update` the idle slot's partitions, which `update` writes and - // marks through diskserver: the supervisor, the launcher and diskserver - // have no host build, and the T14's slots are on its stick, which only - // the kernel drives until usbd. - "update_writes_the_idle_slot_through_the_block_service", - // usbd on a controller the kernel leaves alone, with two devices on it, - // and a transfer aimed past its claim's grants: usbd is one binary that - // owns its controller, with no host build; `toyos-xhci`'s host tests and - // simulator hold every decision it takes, and none of them holds the - // effects — the rings, the interrupt through the claim, the unit. The - // T14's spare controller has no device on it and its unit is not - // asked to fault. + // A controller the kernel leaves alone, handed over to a claim whose + // holder's No-Op is answered by an interrupt through it, and a transfer + // aimed past the claim's grants: the hand-over, the interrupt through the + // claim and the unit have no host build, and the T14's unit is not asked + // to fault. What usbd names on the bus is `toyos-xhci`'s and its + // simulator's to hold. "usbd_drives_the_spare", // A boot keyboard reporting more keys than its six slots name: the // kernel's driver reading a report a device delivered over xHCI has no host // build, and the T14 binds no USB keyboard. "usb_keyboard_rollover", - // What `toyfetch` reads through the `sysinfo` fork's ToyOS backend: the - // backend calls `SYS_SYSINFO` and reads ROOT, which no host has, and the - // CPU count is held to the `-smp` this boot gave QEMU, a count the - // kernel did not make. - "toyfetch_reports", // Every vector register a thread holds, across switches to threads holding // others, against a scheduler, which no host build has. The shared boot's // run is the benches'; this is TCG's, whose XSAVE is QEMU's own reading of @@ -533,29 +439,6 @@ const METAL: &[(&str, metal::Metal)] = &[ judge: |b| b[0].job_passed("test_rs_wake_storm_cost"), }, ), - ( - // The shipped tone client, twice in series, plays to completion and - // exits 0, and soundserver names how each left. - "shipped_client_departures", - metal::Metal { - arms: TESTCASES, - judge: |b| { - b[0].job_passed("test_rs_null_sink_client_exits")?; - audio::departures_on_metal(&b[0].log()) - }, - }, - ), - ( - // soundserver with no client costs no CPU, before any client has connected. - "audio_idle_suspend", - metal::Metal { - arms: TESTCASES, - judge: |b| { - b[0].job_passed("test_rs_audio_idle_suspend")?; - audio::idle_suspend_on_metal(&b[0].log()) - }, - }, - ), ( "hda_tone", metal::Metal { @@ -566,27 +449,6 @@ const METAL: &[(&str, metal::Metal)] = &[ }, }, ), - ( - "hda_client_stall", - metal::Metal { - arms: TESTCASES, - judge: |b| { - b[0].job_passed("test_rs_hda_client_stall")?; - audio::client_stall_on_metal(&b[0].log()) - }, - }, - ), - // ---- the audio boot of its own ---- - ( - "soundserver_log_stall", - metal::Metal { - arms: LOGSTALLCASE, - judge: |b| { - b[0].job_passed("test_rs_soundserver_log_stall")?; - audio::log_stall_on_metal(&b[0].log()) - }, - }, - ), // ---- one image: tests/images/testcases.toml armed with the chipset watchdog ---- ( // Two boots, and they cannot merge: the control is the same image @@ -878,12 +740,6 @@ const METAL: &[(&str, metal::Metal)] = &[ "port_badge", metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_port_badge") }, ), - ( - // One share holding all a file server lets it hold leaves the server - // answering a login session, whose launches all spend its one share. - "fs_share", - metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_fs_share") }, - ), ( // An installed package sees its own directory read-only and its own // folder as `HOME`, and nothing else of `/apps` or `/home`. @@ -1026,8 +882,8 @@ const METAL: &[(&str, metal::Metal)] = &[ ( // The Type-C controller left to a claim, and usbd driving it: handed // over, MSI armed through remapping, and the No-Op answered by an - // interrupt; usbd is killed and started again, so the release says - // which reset the function advertises. + // interrupt; usbd is killed, so the release says which reset the + // function advertises. "usbd_drives_the_type_c_controller", metal::Metal { arms: &[metal::once("testcases-xhci-leave", "tests/images/testcases.toml", &["xhci-leave=8086:9a13"], &["test_rs_usbd_spare"])], @@ -1092,13 +948,9 @@ const TESTCASES: &[metal::Arm] = &[metal::once( &[], &[ "test_rs_wake_storm_cost", - // Before any client connects, which is `audio_idle_suspend`'s premise. - "test_rs_audio_idle_suspend", "test_rs_audio_tone", - "test_rs_hda_client_stall", "test_rs_syscall_cost", "test_rs_spawn_cost", - "test_rs_null_sink_client_exits", claims::RECLAIM, ], )]; @@ -1144,10 +996,6 @@ fn windows_load_exited() -> String { format!("{}{} pid=", toyos_build::bootlog::EXIT, toyos_build::bootlog::recorded_name(WINDOWS_LOAD)) } -/// A `logkeeper` that leaves soundserver's ring unread until the job says the tone played. -const LOGSTALLCASE: &[metal::Arm] = - &[metal::once("logstallcase", "tests/images/logstallcase.toml", &[], &["test_rs_soundserver_log_stall"])]; - /// The two boots the reset ruling is judged on: the device boot for a reset /// with megabytes behind it, and `metalcase` for one with no job behind it. const USB_RESET_BOOTS: &[metal::Arm] = &[ @@ -1163,20 +1011,13 @@ const USB_RESET_BOOTS: &[metal::Arm] = &[ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/images/metalcase.toml", &[], &[])]; /// A launcher and a declared `cat` and shell, which `process_tree`'s subtree -/// launches, a `toybox` row holding `roster`, which `launch_toctou` races, the -/// shells `fs_share` asks DATA's server through, under its share and in a -/// login session, and the `/apps` `app_view` launches its package from. +/// launches, a `toybox` row holding `roster`, which `launch_toctou` races, and +/// the `/apps` `app_view` launches its package from. const PROCTREECASE: &[metal::Arm] = &[metal::once( "proctreecase", "tests/images/proctreecase.toml", &[], - &[ - "test_rs_process_tree", - "test_rs_launch_toctou", - "test_rs_port_badge", - "test_rs_fs_share", - "test_rs_app_view", - ], + &["test_rs_process_tree", "test_rs_launch_toctou", "test_rs_port_badge", "test_rs_app_view"], )]; /// Every in-kernel self-test that logs its verdict at init and does nothing @@ -2047,6 +1888,48 @@ fn acpi_supply_outlives_holder() -> Result<(), String> { Ok(()) } +/// What `acpi_mediated`'s `press` arm says once the fixed power button alone +/// is enabled and the SCI acknowledged, which is when the button is pressed. +const PRESS_ARMED: &str = "acpi: the fixed power button is enabled alone and the SCI acknowledged"; + +/// What it says once the press was served. +const PRESS_SERVED: &str = "acpi: the press came as one SCI with its status latched, and no second while it was unserved"; + +/// Boot `tests/images/acpicase.toml` on the shipping kernel with `acpi_mediated`'s +/// `press` arm staged: the probe holds the `acpi` claim, and QEMU presses the +/// power button once the probe says it is armed. The press reaches the probe +/// as one SCI, and the kernel holds the line masked until the probe has +/// served it; the probe's verdict is its own exit and its last line. +fn acpi_press_is_one_sci() -> Result<(), String> { + const JOB: &str = "test_rs_acpi_mediated"; + let case = compile::repo_root().join("tests/images/acpicase.toml"); + let mut qemu = QemuInstance::boot_with_options( + &case, + &[], + &[], + BootOptions { + ready_marker: "acpi: the ACPI row: ", + extra_root_files: vec![ + suite_bin(toyos_build::arch::Arch::X86_64, "acpi_mediated"), + // The arm's name is the file's: `acpi_mediated` asks whether it is there. + ("share/acpi_mediated_press".to_string(), b"press\n".to_vec()), + ], + qmp: true, + ..Default::default() + }, + ); + let mut console = format!("{}\n", qemu.boot_log()); + await_marker(&mut qemu, &mut console, PRESS_ARMED, "the probe to arm the power button")?; + qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET)).power_button(); + let ended = format!("===TEST_END {JOB} "); + await_guest(&mut qemu, &mut console, "the probe to serve the press", |said| said.contains(&ended))?; + let said = serial::Serial::named("the pressed boot", console); + said.must_be_clean()?; + said.must_say(&format!("{ended}exit=0"))?; + eprintln!(" [acpi] {}", said.must_say_after(PRESS_ARMED, PRESS_SERVED)?.trim()); + Ok(()) +} + /// Boot the image `--console-boot` builds, on the machine shape with a panel /// for `/system/bin/console` to claim, and wait for what that image owes at /// boot: the console up on its panel, having read the log and the keyboard. @@ -3172,7 +3055,6 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "virt_reboot_wire_kept" => virt_reboot(profile, power::wire_staged(true)), "virt_off_names_the_cpus_left_on" => virt_off_names_the_cpus_left_on(profile), "virt_reboot_refused_without_psci" => virt_reboot_refused_without_psci(profile), - "consent_prompt" => consent_prompt(profile), "screen_fatal_behind_a_painter" => { // The fatal halt with a painter holding the panel's latch and // never giving it back — which is what a painter is when the halt @@ -3440,440 +3322,17 @@ fn scanout_wc(console: &str) -> Result<(), String> { Ok(()) } -/// What one job of netstack's said, as its verdict: it ended by itself, with -/// 0, and with its last line. -fn job_ok(job: &str, result: &qemu::TestResult) -> Result<(), String> { - if let Some(why) = &result.error { - return Err(format!("{why}\nthe job said:\n{}", result.stdout)); - } - if result.exit_code != Some(0) { - return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); - } - if !result.stdout.lines().any(|l| l.trim_end().ends_with(&format!("{job}: ok"))) { - return Err(format!("the guest never said it was done:\n{}", result.stdout)); - } - Ok(()) -} - -/// Megabytes out to the host kernel's TCP and back unchanged, and two host -/// peers that dial the guest's listener before it accepts either, both -/// accepted, answered and read their stream's end: through netstack on -/// `profile`'s card. The host server here sends back what it reads; the -/// guest's comparisons and each peer's answer are the verdict. -fn netstack_streams(profile: qemu::Profile) -> Result<(), String> { - use std::io::Read; - const JOB: &str = "netstack_streams"; - /// The port the job listens on in the guest, which nothing else on its - /// boot binds. - const LISTENER: u16 = 7010; - const WAITS: &str = "netstack_streams: the listener waits for two peers"; - /// A hang ceiling on a peer's answer, which the job wrote before it - /// ended. - const ANSWERED: Duration = Duration::from_secs(30); - let echo = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("the echoing server: {e}"))?; - let port = echo.local_addr().map_err(|e| format!("the echoing server's port: {e}"))?.port(); - // Ends with the process: a guest that never dials leaves it in `accept`. - thread::spawn(move || { - for stream in echo.incoming().flatten() { - thread::spawn(move || { - let mut back = stream.try_clone().expect("a second handle on an accepted stream"); - // A guest that resets the stream ends its echo, and reads the - // loss itself. - let _ = std::io::copy(&mut &stream, &mut back); - }); - } - }); - - let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/guest/rust-tests"), JOB); - let options = BootOptions { qmp: true, profile, ..Default::default() }; - let mut qemu = boot_netcase(&[], &[(JOB.to_string(), bin)], options)?; - let [to_listener] = forwards_into(&qemu, [LISTENER])?; - let mut monitor = qemu::QmpMonitor::open(qemu.qmp_socket()); - // The connections QEMU's user network has taken off the forwarded port - // and is carrying to the guest's listener: every row of its table that - // names the guest's port, but the forward's own. - let mut carried = move || { - let table = monitor.human("info usernet"); - table - .lines() - .map(|row| row.split_whitespace().collect::>()) - .filter(|row| row.first().is_some_and(|kind| kind.starts_with("TCP[") && *kind != "TCP[HOST_FORWARD]")) - .filter(|row| [3, 5].iter().any(|&at| row.get(at) == Some(&LISTENER.to_string().as_str()))) - .count() - }; - // Each peer says its own byte as soon as it has dialled, and the next - // dials once QEMU carries this one: its forward queues one connection, - // and resets a dial that arrives while one is queued. - let mut dial = |said: u8| -> Result { - let before = carried(); - let mut peer = std::net::TcpStream::connect(("127.0.0.1", to_listener)).map_err(|e| e.to_string())?; - peer.write_all(&[said]).map_err(|e| e.to_string())?; - peer.set_read_timeout(Some(ANSWERED)).map_err(|e| e.to_string())?; - let dialled = Instant::now(); - // QEMU says nothing when it carries a connection, so its table is - // asked again after a wait that doubles to 64 ms. - let mut pause = Duration::from_millis(1); - while carried() == before { - if dialled.elapsed() > ANSWERED { - return Err(format!("QEMU's user network took no connection off its forward in {ANSWERED:?}")); - } - thread::sleep(pause); - pause = (pause * 2).min(Duration::from_millis(64)); - } - Ok(peer) - }; - let mut peers = Vec::new(); - let result = - qemu.run_test_paced(&format!("test_rs_{JOB} {port} {LISTENER}"), Duration::from_secs(240), |_, line| { - if line.trim_end().ends_with(WAITS) { - peers.extend(b"12".iter().map(|&said| dial(said).map(|peer| (said, peer)))); - } - }); - // A dial that failed first: it is why the job's wakes did not come. - let peers: Vec<_> = peers.into_iter().collect::>().map_err(|e| { - format!("the host could not dial the guest's listener: {e}\nthe job said:\n{}", result.stdout) - })?; - job_ok(JOB, &result)?; - if peers.len() != 2 { - return Err(format!("the job never said its listener waits:\n{}", result.stdout)); - } - for (said, mut peer) in peers { - let mut answer = [0u8; 1]; - peer.read_exact(&mut answer).map_err(|e| format!("the peer that said {:?} was not answered: {e}", said as char))?; - if answer[0] != said { - return Err(format!("the peer that said {:?} was answered {:?}", said as char, answer[0] as char)); - } - // The guest closed the stream behind its answer. - match peer.read(&mut answer) { - Ok(0) => {} - ended => return Err(format!("the peer that said {:?} read {ended:?} where its stream ends", said as char)), - } - } - Ok(()) -} - -/// A host server that holds each connection it accepts and reads none of it, -/// for as long as the process lives: its port. A guest that never dials -/// leaves it in `accept`. -fn holding_server() -> Result { - let holder = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("the holding server: {e}"))?; - let port = holder.local_addr().map_err(|e| format!("the holding server's port: {e}"))?.port(); - thread::spawn(move || holder.incoming().collect::>()); - Ok(port) -} - -/// The peer `stream_ends` dials, for as long as the process lives: its port. -/// Each connection's first byte names how it ends, and each end waits on what -/// the client did before it. -fn ending_server() -> Result { - use std::io::Read; - use std::os::fd::AsRawFd; - /// A close that sends a reset (RFC 9293 §3.10.4's ABORT): a zero linger. - fn reset(stream: std::net::TcpStream) { - let linger = libc::linger { l_onoff: 1, l_linger: 0 }; - // SAFETY: a live socket, and an option of the size its type says. - let set = unsafe { - libc::setsockopt( - stream.as_raw_fd(), - libc::SOL_SOCKET, - libc::SO_LINGER, - (&raw const linger).cast(), - size_of::() as libc::socklen_t, - ) - }; - assert_eq!(set, 0, "a zero linger: {}", std::io::Error::last_os_error()); - } - fn serve(mut stream: std::net::TcpStream) { - let mut what = [0u8; 1]; - if stream.read_exact(&mut what).is_err() { - return; - } - match what[0] { - stream_ends::PEER_FIN_FIRST => {} - stream_ends::RESET_MID_STREAM => { - let ahead: Vec = (0..stream_ends::AHEAD).map(stream_ends::pattern).collect(); - if stream.write_all(&ahead).is_ok() && stream.read_exact(&mut what).is_ok() { - reset(stream); - } - } - stream_ends::RESET_AFTER_HALF_CLOSE => { - if stream.read_to_end(&mut Vec::new()).is_ok() { - reset(stream); - } - } - stream_ends::BOTH_CLOSE => { - let _ = stream.write_all(b"bye"); - } - stream_ends::SHUT_FIRST => { - let _ = stream - .write_all(b".") - .and_then(|()| stream.read_to_end(&mut Vec::new())) - .and_then(|_| stream.write_all(b"late")); - } - _ => {} - } - } - let peer = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("the ending peer: {e}"))?; - let port = peer.local_addr().map_err(|e| format!("the ending peer's port: {e}"))?.port(); - thread::spawn(move || { - for stream in peer.incoming().flatten() { - thread::spawn(move || serve(stream)); - } - }); - Ok(port) -} - -/// A host server that answers each datagram with itself, for as long as the -/// process lives: its port. -fn answering_server() -> Result { - let echo = std::net::UdpSocket::bind(("127.0.0.1", 0)).map_err(|e| format!("the answering server: {e}"))?; - let port = echo.local_addr().map_err(|e| format!("the answering server's port: {e}"))?.port(); - thread::spawn(move || { - let mut datagram = [0u8; 64]; - while let Ok((len, from)) = echo.recv_from(&mut datagram) { - echo.send_to(&datagram[..len], from).expect("answer a datagram"); - } - }); - Ok(port) -} - -/// Boot `tests/images/netcase.toml` with these binaries staged, to netstack's lease, since -/// its jobs name their peer by an address, and then to the claim of its name: -/// a responder whose probing never ends leaves the machine nameless, and -/// nothing else on a boot reads the name. -fn boot_netcase( - c_bins: &[(String, Vec)], - rust_bins: &[(String, Vec)], - options: BootOptions, -) -> Result { - const LEASED: &str = "netstack: DHCP: lease "; - /// `system/netstack/src/serve.rs`'s line for a probing no host answered. - const CLAIMED: &str = "netstack: mDNS: no host answered for "; - let case = compile::repo_root().join("tests/images/netcase.toml"); - let mut qemu = QemuInstance::boot_with_options(&case, c_bins, rust_bins, options); - let mut console = qemu.boot_log().to_string(); - await_marker(&mut qemu, &mut console, LEASED, "netstack's lease").map_err(|e| format!("{e}\n{console}"))?; - await_marker(&mut qemu, &mut console, CLAIMED, "netstack to claim its name").map_err(|e| format!("{e}\n{console}"))?; - Ok(qemu) -} - -/// Have the guest's network carry what dials a loopback port of QEMU's -/// choosing to each of `guest_ports`: those host ports, in that order. -fn forwards_into(qemu: &QemuInstance, guest_ports: [u16; N]) -> Result<[u16; N], String> { - const FORWARD: &str = "TCP[HOST_FORWARD]"; - let mut monitor = qemu::QmpMonitor::open(qemu.qmp_socket()); - for port in guest_ports { - // `net0` is the NIC's backend in `common::qemu`'s argv. - let said = monitor.human(&format!("hostfwd_add net0 tcp:127.0.0.1:0-:{port}")); - if !said.trim().is_empty() { - return Err(format!("QEMU forwards nothing to the guest's port {port}: {said}")); - } - } - // A row is the protocol, a descriptor, the host's address and port, then - // the guest's. - let table = monitor.human("info usernet"); - let mut host_ports = [0u16; N]; - for (host, guest) in host_ports.iter_mut().zip(guest_ports) { - *host = table - .lines() - .map(|row| row.split_whitespace().collect::>()) - .find(|row| row.first() == Some(&FORWARD) && row.get(5) == Some(&guest.to_string().as_str())) - .and_then(|row| row[3].parse().ok()) - .ok_or_else(|| format!("QEMU names no host port forwarded to the guest's {guest}:\n{table}"))?; - } - Ok(host_ports) -} - -/// libc's sockets as a C program uses them and std's as a Rust one does, on -/// one boot of `tests/images/netcase.toml`: each job dials a host server that holds what -/// it accepts, or sends to one that answers each datagram with itself, at the -/// address the guest's network gives the host; and each time a job says its -/// listeners wait, the host dials them through the ports QEMU forwards; and -/// `stream_ends` in C and in std dial a host peer that ends each stream as -/// its first byte names, std's report first read on this host's TCP. A job's -/// own comparisons are its verdict. -fn libc_sockets() -> Result<(), String> { - const HOST: &str = "10.0.2.2"; - /// The ports `nodelay_kept` and then `nodelay_accepted` listen on in the - /// guest, which nothing else on its boot binds. - const LISTENERS: [u16; 4] = [7001, 7002, 7003, 7004]; - const RUST_JOBS: [&str; 2] = ["nodelay_accepted", "stream_ends_std"]; - let (holding, answering, ending) = (holding_server()?, answering_server()?, ending_server()?); - // The oracle: the report std's job owes is this host's TCP's. - let mut host = Vec::new(); - stream_ends::run("127.0.0.1", ending, |line| host.push(line)); - if host != stream_ends::EXPECTED { - return Err(format!("this host's TCP reports\n{}\nand not\n{}", host.join("\n"), stream_ends::EXPECTED.join("\n"))); - } - - let sources = compile::repo_root().join("tests/guest/c"); - let c_bins: Vec<(String, Vec)> = ["addr_order", "nodelay_kept", "sendto_unbound", "stream_ends"] - .map(|name| (name.to_string(), compile::link_toyos(&compile::compile_own_c(&sources, name), name))) - .into(); - let rust_bins: Vec<(String, Vec)> = RUST_JOBS - .map(|job| (job.to_string(), qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/guest/rust-tests"), job))) - .into(); - let mut qemu = boot_netcase(&c_bins, &rust_bins, BootOptions { qmp: true, ..Default::default() })?; - let [held, clear, std_port, pipe_port] = LISTENERS; - let [to_held, to_clear, to_std, to_pipe] = forwards_into(&qemu, LISTENERS)?; - /// Each line a job says its listeners wait in, the constants of its - /// source, and the forwarded ports the host dials when it does. - type Waits<'a> = &'a [(&'a str, &'a [u16])]; - // The address first: every job names its peer by one. - let jobs: [(&str, String, Waits); 6] = [ - ("test_c_addr_order", holding.to_string(), &[]), - ( - "test_c_nodelay_kept", - format!("{holding} {held} {clear}"), - &[ - ("nodelay_kept: both listeners wait for a peer", &[to_held, to_clear]), - ("nodelay_kept: both listeners wait for a second peer", &[to_held, to_clear]), - ], - ), - ("test_c_sendto_unbound", answering.to_string(), &[]), - ( - "test_rs_nodelay_accepted", - format!("{holding} {std_port} {pipe_port}"), - &[ - ("nodelay_accepted: both listeners wait for a peer", &[to_std, to_pipe]), - ("nodelay_accepted: the listener waits for a second peer", &[to_pipe]), - ], - ), - ("test_c_stream_ends", ending.to_string(), &[]), - ("test_rs_stream_ends_std", ending.to_string(), &[]), - ]; - // Held to the test's end: a peer gone before a job's `accept` is a - // connection its listener no longer holds. - let mut dialled = Vec::new(); - for (job, ports, waits) in jobs { - let name = job.trim_start_matches("test_c_").trim_start_matches("test_rs_"); - let result = qemu.run_test_paced(&format!("{job} {HOST} {ports}"), Duration::from_secs(120), |_, line| { - for (_, dial) in waits.iter().filter(|(waiting, _)| line.trim_end().ends_with(waiting)) { - dialled.extend(dial.iter().map(|&port| std::net::TcpStream::connect(("127.0.0.1", port)))); - } - }); - if let Some(Err(e)) = dialled.iter().find(|dial| dial.is_err()) { - return Err(format!("{name}: the host could not dial a port QEMU forwards to the guest: {e}")); - } - if qemu::VERBOSE.load(std::sync::atomic::Ordering::Relaxed) { - eprintln!(" [libc] {name} ended {:?} and said:\n{}", result.exit_code, result.stdout); - } - if let Some(why) = &result.error { - return Err(format!("{name}: {why}\nthe job said:\n{}", result.stdout)); - } - if result.exit_code != Some(0) { - return Err(format!("{name} ended {:?}:\n{}", result.exit_code, result.stdout)); - } - if !result.stdout.lines().any(|l| l.trim_end().ends_with(&format!("{name}: ok"))) { - return Err(format!("{name} never said it was done:\n{}", result.stdout)); - } - } - Ok(()) -} - -/// An unchanged HTTPS client — `ureq` on `rustls` on `ring`, as published but -/// for `ring`'s ToyOS arms — trusting `build::trust_roots` and this run's -/// authority, and sending the project's `User-Agent`, on one boot of -/// `tests/images/netcase.toml` against servers this run starts on the host: it fetches a body over TLS 1.3 whole, by a hash over -/// every byte the host computes with another SHA-256 than the guest's; and it -/// refuses a certificate for another address and one from an authority the -/// roots file does not hold, each by its own name, at the handshake. First, -/// on the same CPU, `ring_kat` holds `ring` to its specifications' answers: -/// the server is `rustls` on `ring` too, so the handshake alone would pass a -/// `ring` wrong at both ends. -fn https_fetch() -> Result<(), String> { - use common::https::{self, Authority, Seen, Server}; - const JOB: &str = "https_get"; - const KAT: &str = "ring_kat"; - /// Root `CLAUDE.md`'s, spelled again here so the guest's copy is checked - /// against the rule and not against itself. - const USER_AGENT: &str = "toyos-build (https://github.com/ToyOSOrg/ToyOS)"; - const WAIT: Duration = Duration::from_secs(30); - let host: std::net::IpAddr = https::HOST.parse().expect("an address"); - let trusted = Authority::new("ToyOS harness test authority"); - let stranger = Authority::new("ToyOS harness authority nothing trusts"); - let body = std::sync::Arc::new(https::body()); - let hex: String = toyos_sha2_hw::sha256_digest(body.as_slice()).iter().map(|b| format!("{b:02x}")).collect(); - let want = format!("{JOB}: ok bytes={} sha256={hex}", body.len()); - let fetched = Server::start(trusted.leaf(host), body.clone())?; - let wrong_name = Server::start(trusted.leaf([192, 0, 2, 1].into()), body.clone())?; - let untrusted = Server::start(stranger.leaf(host), body)?; - - // The shipped roots and the harness's authority, under a name of the - // test's own: the client reads the file its argument names. - const ROOTS: &str = "etc/ssl/harness-cert.pem"; - let mut roots = toyos_build::build::trust_roots(); - roots.extend_from_slice(trusted.pem().as_bytes()); - let crate_path = compile::repo_root().join("tests/guest/rust-tests"); - let bins = [JOB, KAT].map(|name| (name.to_string(), qemu::build_toyos_bin(qemu::SUITE_ARCH, &crate_path, name))); - let options = BootOptions { extra_root_files: vec![(ROOTS.to_string(), roots)], ..Default::default() }; - let mut qemu = boot_netcase(&[], &bins, options)?; - let kat = qemu.run_test("test_rs_ring_kat", Duration::from_secs(120)); - if kat.error.is_some() || kat.exit_code != Some(0) || !kat.stdout.lines().any(|l| l.trim_end() == "ring_kat: ok") { - return Err(format!("{KAT} ended {:?} ({:?}):\n{}", kat.exit_code, kat.error, kat.stdout)); - } - let roots = format!("/system/{ROOTS}"); - let fetches: [(&str, &Server, String, i32); 3] = [ - ("the trusted server", &fetched, want, 0), - ("the server named for another address", &wrong_name, format!("{JOB}: refused not-valid-for-name"), 2), - ("the server no root vouches for", &untrusted, format!("{JOB}: refused unknown-issuer"), 2), - ]; - for (what, server, line, code) in &fetches { - let url = format!("https://{}:{}/body", https::HOST, server.port); - let result = qemu.run_test(&format!("test_rs_{JOB} {url} {roots}"), Duration::from_secs(120)); - if let Some(why) = &result.error { - return Err(format!("{what}: {why}\nthe job said:\n{}", result.stdout)); - } - if result.exit_code != Some(*code) || !result.stdout.lines().any(|l| l.trim_end() == line) { - return Err(format!( - "{what}: the client ended {:?} and was to end {code} saying {line:?}:\n{}", - result.exit_code, result.stdout - )); - } - } - - // Of a refused handshake the server sees the connection arrive and no - // more: this client sends it no alert, on the host as here, and the close - // after it need not arrive - // (`issues/a-guest-close-after-a-refused-handshake-can-leave-its-peer-waiting.md`). - for (what, server, _, code) in &fetches { - if !matches!(server.next(WAIT).map_err(|e| format!("{what}: {e}"))?, Seen::Accepted) { - return Err(format!("{what} saw something before a connection")); - } - if *code != 0 { - continue; - } - match server.next(WAIT).map_err(|e| format!("{what}: {e}"))? { - Seen::Served { version: Some(rustls::ProtocolVersion::TLSv1_3), path, user_agents } - if path == "/body" && user_agents == [USER_AGENT] => {} - other => return Err(format!("{what} saw {other:?}, not a TLS 1.3 GET of /body as {USER_AGENT:?}")), - } - } - for (what, server, _, _) in &fetches { - if let Some(seen) = server.more().into_iter().find(|seen| !matches!(seen, Seen::Failed)) { - return Err(format!("{what} saw more than the one connection it was asked for: {seen:?}")); - } - } - Ok(()) -} - /// Run the machine-shape test, which owns its QEMU: the machine shape *is* the /// test. fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { match name { "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config), "amdvi_firmware_left" => common::iommu::amdvi_firmware_left(test_config), - "netstack_streams" => netstack_streams(qemu::Profile::Headless), - "netstack_streams_e1000e" => netstack_streams(qemu::Profile::HeadlessE1000e), - "libc_sockets" => libc_sockets(), - "https_fetch" => https_fetch(), "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config), - "machine_shutdown" => power::machine_shutdown(test_config), - "acpi_power_button" => power::acpi_power_button(test_config), + "acpi_press_is_one_sci" => acpi_press_is_one_sci(), "acpi_mediated_access" => acpi_mediated_access(2), "acpi_lock_given_back_on_one_cpu" => acpi_mediated_access(1), "acpi_supply_outlives_holder" => acpi_supply_outlives_holder(), - "acpi_press_across_a_lock_wait" => power::acpi_press_across_a_lock_wait(suite_bin(toyos_build::arch::Arch::X86_64, "acpi_mediated")), "machine_shutdown_short_stop" => power::machine_shutdown_short_stop(test_config), "machine_shutdown_wire_held" => power::machine_shutdown_wire_held(test_config, false), "machine_shutdown_wire_kept" => power::machine_shutdown_wire_held(test_config, true), @@ -3882,638 +3341,25 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "lent_device_memory_refused" => lent_device_memory_refused(test_config), "console_image_boots" => console_image_boots(), "desktop_without_x2apic" => desktop_without_x2apic(), - "nvme_disk_keeps_log_and_home" => nvme_disk_keeps_log_and_home(test_config), + "nvme_disk_boots" => nvme_disk_boots(test_config), "hosted_clang_hello" => common::hostedclang::hosted_clang_hello(), - "block_grants_reach_their_partitions" => block_grants_reach_their_partitions(), - "update_writes_the_idle_slot_through_the_block_service" => update_writes_the_idle_slot_through_the_block_service(), "usbd_drives_the_spare" => usbd_drives_the_spare(test_config), "usb_keyboard_rollover" => usb_keyboard_rollover(test_config), - "toyfetch_reports" => toyfetch_reports(test_config), "vector_state_survives_a_switch" => vector_state_survives_a_switch(test_config), other => Err(format!("unknown machine test {other}")), } } -/// What each role's file server says once it serves a volume it mounted, up to -/// the volume's own counts. -const SERVED_FROM_DISK: [&str; 3] = [ - "fileserver: Data serving /apps, /config, /home, /state — bcachefs, ", - "fileserver: Log serving /log — FAT32, ", - "fileserver: Boot serving /boot — FAT32 read-only, ", -]; - -/// Wait for the three file servers of a machine with no disk the kernel -/// drives to say what they serve: each a volume it mounted, on a session of -/// its own that `diskserver` opened, and ROOT's partition refused to all. -fn served_by_diskserver(qemu: &mut QemuInstance, console: &mut String) -> Result<(), String> { - await_guest(qemu, console, "the three file servers to say what they serve", |c| { - ["Data", "Log", "Boot"].iter().all(|role| c.contains(&format!("fileserver: {role} serving "))) - }) - .map_err(|e| format!("{e}\n{console}"))?; - let said = serial::Serial::named("the boot", console.clone()); - for served in SERVED_FROM_DISK { - said.must_say(served)?; - } - said.must_say("is the ROOT this machine runs from; refusing every session to it")?; - let sessions: BTreeSet<&str> = said - .text() - .lines() - .filter(|line| line.contains("diskserver: session ")) - .filter_map(|line| line.split_once(" opened ")?.1.split_whitespace().next()) - .collect(); - if sessions.len() != SERVED_FROM_DISK.len() { - return Err(format!( - "diskserver opened sessions on {sessions:?}, and DATA, the log and the slot's volume are three partitions\n{console}" - )); - } - // The boot volume's grant does not write, and only that one. - let read_only = said.text().lines().filter(|line| line.contains("diskserver: session ") && line.contains(", read-only)")).count(); - if read_only != 1 { - return Err(format!("diskserver opened {read_only} read-only sessions, and only the slot's volume is granted one\n{console}")); - } - Ok(()) -} - -/// `consent`'s steps, driven through the desktop's own keyboard and tablet -/// (`tests/guest/rust-tests/src/bin/consent.rs`), and each held to what the -/// panel, the supervisor and the package said. -/// -/// **Every step is the guest's line and the host's answer to it**: the job -/// says `CONSENT `, and the host clicks the package in the launcher; -/// the compositor says its prompt is on the panel, and the host reads the -/// panel and answers with keys; the package says what it saw, and the host -/// taps F12, which the job waits on through its hostile window. Nothing here -/// waits on a clock but the run's own ceiling. -fn consent_prompt(profile: qemu::Profile) -> Result<(), String> { - let case = compile::repo_root().join("tests/images/consentcase.toml"); - let (_, job) = suite_bin(profile.arch(), "consent"); - let digest: String = toyos_sha2_hw::sha256_digest(&job).iter().map(|b| format!("{b:02x}")).collect(); - let options = BootOptions { profile, qmp: true, ..Default::default() }; - let mut qemu = QemuInstance::boot_with_options(&case, &[], &[("consent".to_string(), job)], options); - let mut drive = Consent { digest, out: qemu.screendump_file(), ..Consent::default() }; - let result = qemu.run_test_paced("test_rs_consent", Duration::from_secs(300), |qmp, line| { - drive.line(qmp.expect("consent_prompt boots with QMP"), line) - }); - if let Some(why) = &result.error { - drive.red.push(format!("the job did not finish: {why}")); - } else if result.exit_code != Some(0) { - drive.red.push(format!("the job ended {:?}", result.exit_code)); - } - let all = ["always", "again", "deny", "denied", "skip", "once", "ssh"]; - if drive.done != all { - drive.red.push(format!("the steps finished were {:?}, not {all:?}", drive.done)); - } - match drive.red.is_empty() { - true => Ok(()), - false => Err(format!("{:#?}\nthe job said:\n{}", drive.red, result.stdout)), - } -} - -/// [`consent_prompt`]'s side of the conversation. -#[derive(Default)] -struct Consent { - /// The SHA-256 of the binary the job installs as the package, computed - /// here, which an answer the supervisor keeps is keyed on. - digest: String, - out: std::path::PathBuf, - /// The panel's size, from the first dump. - screen: Option<(usize, usize)>, - step: Option, - done: Vec, - /// The panel above the taskbar before the launch off the screen. - before: Option>, - red: Vec, -} - -#[derive(Default)] -struct ConsentStep { - name: String, - /// Where Games is in the prompt's list, from the job. - games: usize, - /// The supervisor's word on the answer. - answer: Option, - /// What the package saw. - saw: Option, - /// The prompt came up, and went. - up: bool, - down: bool, -} - -/// What the hostile window paints. -const HOSTILE_RGB: [u8; 3] = [0xff, 0x00, 0xff]; -const ROM_SEEN: &str = "cwd=/home/toy/Games roms=[\"test.gba\"] saved=Ok"; -const NOTHING_SEEN: &str = "cwd=/ roms=[] saved=Err"; - -impl ConsentStep { - /// Whether this step asks, the keys that answer it, the supervisor's word - /// on the answer, and what the package sees. - fn expected(&self) -> (bool, Vec<&'static str>, Option<&'static str>, &'static str) { - let down = || std::iter::repeat_n("down", self.games); - let tabs = |n| std::iter::repeat_n("tab", n); - match self.name.as_str() { - "always" => ( - true, - down().chain(tabs(3)).chain(["ret"]).collect(), - Some("always, /home/toy/Games read-write"), - ROM_SEEN, - ), - "again" => (false, Vec::new(), None, ROM_SEEN), - "deny" => (true, tabs(4).chain(["ret"]).collect(), Some("denied, until `grants revoke`"), NOTHING_SEEN), - "denied" => (false, Vec::new(), None, NOTHING_SEEN), - "skip" => (true, vec!["esc"], Some("skipped, so its next launch asks again"), NOTHING_SEEN), - "once" => ( - true, - down().chain(tabs(2)).chain(["ret"]).collect(), - Some("once, /home/toy/Games read-write"), - ROM_SEEN, - ), - other => panic!("consent_prompt: the job asked for a step `{other}` this side has no answer for"), - } - } -} - -impl Consent { - fn line(&mut self, qmp: &Path, line: &str) { - let after = |marker: &str| line.find(marker).map(|at| line[at + marker.len()..].trim().to_string()); - if let Some(step) = after("CONSENT ") { - self.begin(qmp, &step); - } else if line.contains("compositor: the prompt is on the panel") { - self.prompt_up(qmp); - } else if line.contains("compositor: the prompt is down") { - match &mut self.step { - Some(step) => step.down = true, - None => self.red.push("the prompt went down outside any step".to_string()), - } - } else if let Some(answer) = after("supervisor: consent: consentee: ") { - match &mut self.step { - Some(step) => step.answer = Some(answer), - None => self.red.push(format!("the supervisor took an answer outside any step: {answer}")), - } - } else if let Some(saw) = after("CONSENTEE ") { - match &mut self.step { - Some(step) => step.saw = Some(saw), - None => self.red.push(format!("the package ran outside any step and saw {saw}")), - } - } else if let Some(stored) = after("STORE ") { - self.stored(&stored); - } - self.finish(qmp); - } - - fn dump(&mut self, qmp: &Path) -> screen::Ppm { - let dump = qemu::QmpInput::open(qmp).screendump(&self.out); - self.screen.get_or_insert((dump.width, dump.height)); - dump - } - - fn begin(&mut self, qmp: &Path, said: &str) { - let mut words = said.split_whitespace(); - let name = words.next().unwrap_or_default().to_string(); - let games = words.find_map(|w| w.strip_prefix("down=")).map_or(0, |n| n.parse().unwrap_or(0)); - match name.as_str() { - // Off the screen: the panel above the taskbar is unchanged across - // it, and no prompt comes up (`prompt_up` reds one outside a step). - "ssh" if said == "ssh" => self.before = Some(self.above_taskbar(qmp)), - "ssh" => { - let after = self.above_taskbar(qmp); - if self.before.take().is_none_or(|before| before != after) { - self.red.push("the panel changed across the launch off the screen".to_string()); - } - self.done.push(name); - Self::tap_done(qmp); - } - _ => { - if self.screen.is_none() { - self.dump(qmp); - } - let (w, h) = self.screen.expect("the first dump measured the panel"); - // The `+`, then the launcher's last row, which is the one - // package installed: built-in rows come first. - let desk = toyos_desktop::Desk { - chrome: toyos_desktop::Chrome::DEFAULT, - screen: toyos_desktop::Rect::new(0, 0, w as i32, h as i32), - font_w: 8, - apps: 1, - }; - let bar = desk.taskbar(1); - let centre = |r: toyos_desktop::Rect| (i32::midpoint(r.x0, r.x1), i32::midpoint(r.y0, r.y1)); - // **The row is clicked once the launcher is on the panel**: the - // compositor folds two clicks it reads at once into one at - // the second's place (issues/two-clicks-the-compositor-reads-at-once-are-one-click.md). - let row = bar.launcher_item(0); - let shown = |dump: &screen::Ppm| -> Vec<[u8; 3]> { - (row.y0 as usize..row.y1 as usize) - .flat_map(|y| dump.pixels[y * dump.width + row.x0 as usize..y * dump.width + row.x1 as usize].to_vec()) - .collect() - }; - let mut input = qemu::QmpInput::open(qmp); - let closed = shown(&input.screendump(&self.out)); - input.click(centre(bar.new_button()), (w, h)); - let deadline = Instant::now() + Duration::from_secs(20); - while shown(&input.screendump(&self.out)) == closed { - if Instant::now() >= deadline { - self.red.push(format!("{name}: the launcher did not open in 20 s")); - return; - } - thread::sleep(Duration::from_millis(50)); - } - input.click(centre(row), (w, h)); - self.step = Some(ConsentStep { name, games, ..ConsentStep::default() }); - } - } - } - - fn above_taskbar(&mut self, qmp: &Path) -> Vec<[u8; 3]> { - let dump = self.dump(qmp); - let rows = dump.height - toyos_desktop::Chrome::DEFAULT.taskbar as usize; - dump.pixels[..rows * dump.width].to_vec() - } - - /// The prompt is on the panel: it is drawn over the hostile window, and a - /// press outside it and the keys that answer it go in. - fn prompt_up(&mut self, qmp: &Path) { - let asks = match &self.step { - Some(step) => step.expected().0, - None => false, - }; - if !asks { - let name = self.step.as_ref().map_or("none", |s| s.name.as_str()).to_string(); - self.red.push(format!("a prompt came up in step {name}, which asks nothing")); - return; - } - let dump = self.dump(qmp); - let framed: Vec<(usize, usize)> = (0..dump.height) - .flat_map(|y| (0..dump.width).map(move |x| (x, y))) - .filter(|&(x, y)| dump.pixels[y * dump.width + x] == toyos_desktop::PROMPT_FRAME) - .collect(); - let at = |x: usize, y: usize| dump.pixels[y * dump.width + x]; - let (x0, x1) = match (framed.iter().map(|p| p.0).min(), framed.iter().map(|p| p.0).max()) { - (Some(x0), Some(x1)) if x1 - x0 >= 600 => (x0, x1), - other => { - self.red.push(format!("no prompt's frame on the panel: {} pixels, across {other:?}", framed.len())); - return; - } - }; - let y0 = framed.iter().map(|p| p.1).min().unwrap_or(0); - let y1 = framed.iter().map(|p| p.1).max().unwrap_or(0); - if at(usize::midpoint(x0, x1), usize::midpoint(y0, y1)) == HOSTILE_RGB || at(x0, y0 + 2) != toyos_desktop::PROMPT_FRAME { - self.red.push(format!("the prompt at ({x0}, {y0})-({x1}, {y1}) is under the hostile window")); - } - if at(8, 40) != HOSTILE_RGB { - self.red.push(format!("the hostile window is not on the panel beside the prompt: {:?}", at(8, 40))); - } - let screen = (dump.width, dump.height); - let prompt = |dump: &screen::Ppm| -> Vec<[u8; 3]> { - (y0..=y1).flat_map(|y| dump.pixels[y * dump.width + x0..=y * dump.width + x1].to_vec()).collect() - }; - let mut shown = prompt(&dump); - let mut input = qemu::QmpInput::open(qmp); - // A press on the hostile window around the prompt, which nothing takes. - input.click((8, 40), screen); - // **Each key is paced against the prompt it changes.** QEMU's keyboard - // loses a press or a release sent behind another before the guest has - // read it, so the next key goes only once the panel shows the last. - let keys = self.step.as_ref().map(|s| s.expected().1).unwrap_or_default(); - for key in keys { - input.keys(&[(key, true), (key, false)]); - let deadline = Instant::now() + Duration::from_secs(20); - loop { - let now = prompt(&input.screendump(&self.out)); - if now != shown { - shown = now; - break; - } - if Instant::now() >= deadline { - self.red.push(format!("`{key}` changed nothing on the prompt in 20 s")); - return; - } - thread::sleep(Duration::from_millis(50)); - } - } - if let Some(step) = &mut self.step { - step.up = true; - } - } - - /// A step that has said all it will: held to what it should have said, - /// and its F12 tapped. - fn finish(&mut self, qmp: &Path) { - let Some(step) = &self.step else { return }; - let (asks, _, answer, seen) = step.expected(); - if step.saw.is_none() || (asks && !(step.up && step.down)) { - return; - } - let step = self.step.take().expect("a step is under way"); - if step.answer.as_deref() != answer { - self.red.push(format!("{}: the supervisor said {:?}, not {answer:?}", step.name, step.answer)); - } - if step.saw.as_deref() != Some(seen) { - self.red.push(format!("{}: the package saw {:?}, not {seen:?}", step.name, step.saw)); - } - self.done.push(step.name); - Self::tap_done(qmp); - } - - /// The store's line after a step, held to the binary's own digest. - fn stored(&mut self, said: &str) { - let (step, line) = said.split_once(':').unwrap_or((said, "")); - let want = match step { - "always" => format!("grant toy consentee {} read-write /home/toy/Games", self.digest), - "deny" => format!("deny toy consentee {}", self.digest), - _ => String::new(), - }; - if line.trim() != want { - self.red.push(format!("{step}: the store holds {:?}, not {want:?}", line.trim())); - } - } - - fn tap_done(qmp: &Path) { - qemu::QmpInput::open(qmp).keys(&[("f12", true), ("f12", false)]); - } -} - -/// Run `command` as a job of the boot, to exit 0: what it said. -fn job_said(qemu: &mut QemuInstance, command: &str) -> Result { - let result = qemu.run_test(command, Duration::from_secs(60)); - if let Some(why) = &result.error { - return Err(format!("`{command}`: {why}\nit said:\n{}", result.stdout)); - } - if result.exit_code != Some(0) { - return Err(format!("`{command}` ended {:?}:\n{}", result.exit_code, result.stdout)); - } - Ok(result.stdout) -} - -/// `toyfetch` names the build the image's os-release does, the CPUs this boot -/// gave QEMU, and the memory total `free` reads off the same ambient header. -fn toyfetch_reports(test_config: &Path) -> Result<(), String> { - const CPUS: u32 = 3; - let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], BootOptions { smp: CPUS, ..Default::default() }); - let (_, bytes) = toyos_build::image::root_file_on(qemu.boot_image(), toyos_osrelease::PATH)?; - let release = toyos_osrelease::parse(&bytes).map_err(|why| format!("the image's {}: {why:?}", toyos_osrelease::PATH))?; - // `free`'s second line is the total, used and free mebibytes. - let free = job_said(&mut qemu, "free")?; - let total: u64 = free - .lines() - .find_map(|line| line.trim().strip_suffix('M')?.split_whitespace().next()?.strip_suffix('M')?.parse().ok()) - .ok_or_else(|| format!("`free` said no total:\n{free}"))?; - - // The console carries an escape as the four characters `\x1b`. - let said = job_said(&mut qemu, "toyfetch")?.replace('\x1b', "\\x1b"); - // The text a terminal shows: every SGR sequence taken out. - let mut shown = String::new(); - let mut rest = said.as_str(); - while let Some(at) = rest.find("\\x1b[") { - shown.push_str(&rest[..at]); - let end = rest[at..].find('m').ok_or_else(|| format!("an SGR sequence that never ends:\n{said}"))?; - rest = &rest[at + end + 1..]; - } - shown.push_str(rest); - let field = |label: &str| -> Result { - let at = format!("{label}: "); - let mut values = shown.lines().filter_map(|line| Some(line[line.find(&at)? + at.len()..].to_string())); - match (values.next(), values.next()) { - (Some(value), None) => Ok(value), - _ => Err(format!("toyfetch said no single {label} line:\n{shown}")), - } - }; - - let wanted = [ - ("OS", release.pretty_name().to_string()), - ("Kernel", format!("{} {}", toyos_osrelease::NAME, release.uname_release())), - ("Arch", release.arch.machine().to_string()), - ]; - for (label, value) in wanted { - if field(label)? != value { - return Err(format!("toyfetch's {label} is not {value:?}:\n{shown}")); - } - } - // x86-64 reads its model off CPUID; QEMU names one. - let cpu = field("CPU")?; - if !cpu.ends_with(&format!(" ({CPUS})")) || cpu.starts_with("model not reported") { - return Err(format!("toyfetch's CPU is {cpu:?}, on a boot QEMU gave {CPUS} CPUs:\n{shown}")); - } - let memory = field("Memory")?; - if !memory.ends_with(&format!(" / {total} MiB")) { - return Err(format!("toyfetch's Memory is {memory:?}, and `free` says {total}M in all:\n{shown}")); - } - field("Uptime")?; - eprintln!(" [toyfetch] OS {}, CPU {cpu}, Memory {memory}", field("OS")?); - Ok(()) -} - /// A machine with one NVMe disk and no USB controller boots the image off -/// that disk, and what one boot wrote to `/home` and said into `/log` the next -/// boot reads back: a file under a name of this run's, line for line, and a -/// line of this run's in a log file the first boot listed. -fn nvme_disk_keeps_log_and_home(test_config: &Path) -> Result<(), String> { - let options = BootOptions { profile: qemu::Profile::HeadlessNoUsb, qmp: true, ..Default::default() }; +/// that disk to ready, and says nothing a clean boot does not. +fn nvme_disk_boots(test_config: &Path) -> Result<(), String> { + let options = BootOptions { profile: qemu::Profile::HeadlessNoUsb, ..Default::default() }; if let Some(usb) = qemu::profile_argv(&options).iter().find(|arg| arg.contains("usb") || arg.contains("xhci")) { return Err(format!("this machine was to have no USB controller, and QEMU is given {usb}")); } - let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).expect("this host's clock is past 1970"); - let nonce = format!("kept-{}-{}", std::process::id(), since.as_nanos()); - let kept = format!("/home/{nonce}/release"); - let source = toyos_osrelease::GUEST_PATH; - - let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); - let mut console = qemu.boot_log().to_string(); - served_by_diskserver(&mut qemu, &mut console)?; - job_said(&mut qemu, &format!("mkdir /home/{nonce}"))?; - job_said(&mut qemu, &format!("cp {source} {kept}"))?; - job_said(&mut qemu, &format!("echo {nonce}"))?; - // `ls` says a file as two spaces, its name, and its size in brackets. - let logs: Vec = job_said(&mut qemu, "ls /log")? - .lines() - .filter_map(|line| Some(line.strip_prefix(" ")?.split_once(" (")?.0.to_string())) - .collect(); - if logs.is_empty() { - return Err(format!("the first boot's /log lists no file\n{console}")); - } - - // The reset the guest asks for, and the one its loader makes of the pass - // that reads how that boot ended: the boot after both is the one waited for. - qemu.reset_on_reboot(); - writeln!(qemu.stdin_mut(), "run reboot").expect("write to QEMU stdin"); - qemu.flush_stdin(); - qemu.await_boot(); - let mut console = qemu.boot_log().to_string(); - if !console.contains(bootlog::REBOOTING) { - return Err(format!("the machine reached {} again without saying {}\n{console}", qemu::DEFAULT_READY, bootlog::REBOOTING)); - } - // What the second boot said, and none of the first's servers' lines. - console = console.split_once(bootlog::REBOOTING).expect("checked above").1.to_string(); - served_by_diskserver(&mut qemu, &mut console)?; - // A job's capture is every program's lines of its window, a server's - // late ones among them: the file's are the ones of its own form, - // `KEY=value`, which no program says. - let assignments = |said: &str| -> Vec { - let key = |line: &str| line.split_once('=').is_some_and(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')); - said.lines().filter(|line| key(line)).map(str::to_string).collect() - }; - let shipped = assignments(&job_said(&mut qemu, &format!("cat {source}"))?); - let read_back = assignments(&job_said(&mut qemu, &format!("cat {kept}"))?); - if shipped.is_empty() || read_back != shipped { - return Err(format!("{kept} reads back after the reboot as\n{read_back:#?}\nand {source} is\n{shipped:#?}")); - } - // The line as the first boot's `logkeeper` filed it: `echo`'s own, whose - // whole text is the nonce, which nothing the second boot says is. - let mut found = None; - for log in &logs { - let lines = job_said(&mut qemu, &format!("grep {nonce} /log/{log}"))?; - found = found.or(lines.lines().find_map(|line| { - toyos_logstream::program_line(line).filter(|said| said.text == nonce).map(|said| format!("{log}: {}", said.tag)) - })); - } - let Some(found) = found else { - return Err(format!("none of {logs:?}, the first boot's log files, holds its line {nonce} after the reboot")); - }; - eprintln!(" [disk] after the reboot {kept} is the {} lines of {source}, and /log/{found} said {nonce}", read_back.len()); - Ok(()) -} - -/// What `partition_grant` says, a line per grant it was refused through. -const GRANTS_SAID: [&str; 5] = [ - "partition_grant: the log's grant opened the log and was refused the boot volume NotGranted", - "partition_grant: DATA's grant opened DATA and was refused the log NotGranted", - "partition_grant: the boot volume's read-only grant read it and was refused a write ReadOnly", - "partition_grant: the port's own connector was refused a listing and an open NotGranted", - "partition_grant: the running ROOT was refused Held to the grant naming it", -]; - -/// A connection to diskserver opens only what the badge on its connector -/// grants, on the NVMe disk the machine booted from: `partition_grant` starts -/// diskserver itself and mints each grant, and diskserver names each refusal. -fn block_grants_reach_their_partitions() -> Result<(), String> { - const JOB: &str = "partition_grant"; - let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/guest/rust-tests"), JOB); - let case = compile::repo_root().join("tests/images/blockgrantcase.toml"); - let options = BootOptions { profile: qemu::Profile::HeadlessNoUsb, ..Default::default() }; - let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], options); - let result = qemu.run_test("test_rs_partition_grant", Duration::from_secs(60)); - if let Some(why) = &result.error { - return Err(format!("{why}\nthe job said:\n{}", result.stdout)); - } - if result.exit_code != Some(0) { - return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); - } - let said = serial::Serial::named("the job", result.stdout); - for line in GRANTS_SAID { - eprintln!(" [grant] {}", said.must_say(line)?.trim()); - } - // diskserver's own word for each refusal, beside the job's. - for refused in ["refused: NotGranted", "refused: Held"] { - if !said.text().lines().any(|l| l.contains("diskserver: an open of ") && l.trim_end().ends_with(refused)) { - return Err(format!("diskserver never said an open was {refused}:\n{}", said.text())); - } - } - Ok(()) -} - -/// The update image `update_writes_the_idle_slot_through_the_block_service` -/// stages: a ROOT of one file, signed with this run's key at a version past -/// any a build signs; the ROOT's length, which the second slot is made to -/// hold exactly, so the host mounts the partition whole; its file; and the -/// three files the slot's FAT volume is to carry, each with its bytes. -struct StagedUpdate { - image: Vec, - version: u64, - root_bytes: u64, - marker: Vec, - volume: [(&'static str, Vec); 3], -} - -/// Where the staged ROOT's one file is, and the path the guest reads the -/// update image at. -const UPDATE_MARKER: &str = "etc/update-test"; -const UPDATE_IMAGE: &str = "share/update-test.img"; - -fn staged_update() -> StagedUpdate { - let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).expect("this host's clock is past 1970"); - let marker = format!("update {} {}\n", std::process::id(), since.as_nanos()).into_bytes(); - let root = toyos_build::image::create_root_image(&[(UPDATE_MARKER.to_string(), marker.clone())], &[], true); - // Past every version a build signs, which is its Unix time. - let version = u64::from(u32::MAX) << 8; - let signing = toyos_build::image::Signing { key: toyos_build::signing::key(), version }; - let kernel = b"update test kernel"; - let image = toyos_build::image::update_image(kernel, &root, "", signing); - // The image is the signed header, the kernel, the boot parameter and - // ROOT, end to end (`toyos_update::image`). - let signed = toyos_update::image::SIGNED_BYTES; - let volume = [ - (toyos_update::slots::SIGNED_FILE, image[..signed].to_vec()), - (toyos_update::slots::KERNEL_FILE, kernel.to_vec()), - (toyos_update::slots::CMDLINE_FILE, image[signed + kernel.len()..image.len() - root.len()].to_vec()), - ]; - StagedUpdate { image, version, root_bytes: root.len() as u64, marker, volume } -} - -/// `update` on a machine booted off its NVMe disk: the supervisor reads the -/// slot table through a session on diskserver and grants `update` the idle -/// slot's partitions as connectors minted for each, and `update` writes the -/// signed image there and marks it — read back off the disk by the host, and -/// its FAT volume judged by toyos-fat32-check, which shares no code with it. -fn update_writes_the_idle_slot_through_the_block_service() -> Result<(), String> { - const JOB: &str = "update_idle_slot"; - let staged = staged_update(); - let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/guest/rust-tests"), JOB); - let case = compile::repo_root().join("tests/images/slotscase.toml"); - let options = BootOptions { - profile: qemu::Profile::HeadlessNoUsb, - extra_root_files: vec![(UPDATE_IMAGE.to_string(), staged.image.clone())], - second_slot: Some(staged.root_bytes), - ..Default::default() - }; - let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], options); - let result = qemu.run_test("test_rs_update_idle_slot", Duration::from_secs(120)); - if let Some(why) = &result.error { - return Err(format!("{why}\nthe job said:\n{}", result.stdout)); - } - if result.exit_code != Some(0) { - return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); - } - let said = serial::Serial::named("the job", result.stdout); - eprintln!( - " [update] {}", - said.must_say("supervisor: the idle slot is B, granted with the slot table read through the block service")?.trim() - ); - eprintln!(" [update] {}", said.must_say(&format!("update: installed version {} in slot B", staged.version))?.trim()); - - let mut disk = fs::File::open(qemu.boot_image()).map_err(|e| format!("{}: {e}", qemu.boot_image().display()))?; - let table = toyos_build::image::slot_table_of(&mut disk)?; - let marked = table.slot(table.marked).ok_or("the slot table marks a slot it does not carry")?; - if table.marked.letter() != 'B' || marked.version != staged.version { - return Err(format!("after the update the slot table marks {} at version {}, not B at {}", table.marked.letter(), marked.version, staged.version)); - } - let (_, read_back) = toyos_build::image::root_file_on(qemu.boot_image(), UPDATE_MARKER)?; - if read_back != staged.marker { - return Err(format!("slot B's ROOT carries {:?} at {UPDATE_MARKER}, and the update carried {:?}", String::from_utf8_lossy(&read_back), String::from_utf8_lossy(&staged.marker))); - } - eprintln!(" [update] the disk's slot table marks B at version {}, and B's ROOT mounts carrying the update's {UPDATE_MARKER}", staged.version); - - use std::io::{Read as _, Seek as _}; - // B's FAT volume: judged whole by the checker that is no part of the - // driver `update` wrote it with, then each file read back byte for byte. - let (at, len) = toyos_build::image::partition_extent(&mut disk, marked.boot)?; - let mut volume = vec![0u8; usize::try_from(len).map_err(|_| format!("a {len}-byte volume"))?]; - disk.seek(std::io::SeekFrom::Start(at)) - .and_then(|_| disk.read_exact(&mut volume)) - .map_err(|e| format!("slot B's volume at byte {at}: {e}"))?; - let complaints = toyos_fat32_check::check(&volume); - if !complaints.is_empty() { - return Err(format!("toyos-fat32-check refuses slot B's volume:\n{}", toyos_fat32_check::describe(&complaints))); - } - for (name, staged) in &staged.volume { - let read = toyos_build::image::read_file_on(&mut disk, marked.boot, name) - .map_err(|why| format!("slot B's volume: {why}"))?; - if read != *staged { - return Err(format!("slot B's {name} is {} bytes that are not the {} the update carried", read.len(), staged.len())); - } - } - eprintln!(" [update] slot B's volume checks out, carrying the update's {} byte for byte", staged.volume.iter().map(|(name, _)| *name).collect::>().join(", ")); - Ok(()) + let qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); + serial::Serial::boot(&qemu).must_be_clean() } - /// Run `command` as a job of the boot, to exit 0: everything said in its /// window, the kernel's records with the program lines. fn job_window(qemu: &mut QemuInstance, command: &str) -> Result { @@ -4527,26 +3373,16 @@ fn job_window(qemu: &mut QemuInstance, command: &str) -> Result Ok(result.serial) } -/// The devices `qemu-xhci` carries on [`qemu::Profile::HeadlessUsbSpare`], -/// as `usbd_spare` prints usbd's answer: QEMU 11.1's `usb-storage`, which -/// trains on a USB3 port, and `usb-kbd`, each by its id, its speed and its -/// class triple. -const SPARE_DEVICES: [(&str, &str, &str); 2] = [ - ("46f4:0001", "super", "08:06:50"), - ("0627:0001", "high", "03:01:01"), -]; - /// What usbd says once the bring-up's No-Op has completed: its ring and the /// interrupt through the claim both work. const USBD_NOOP: &str = "usbd: a No-Op command completed, announced by an interrupt"; -/// The controller `xhci-leave=` names, claimed by `usbd_spare` and driven by -/// usbd: each device named by id, speed and class on a fresh claim and again -/// after usbd is killed and started on the next; every event announced by an -/// interrupt; nothing waking usbd while it has nothing to do. Then the two -/// controls: the function aimed past every grant is the kernel's `DMA FAULT` -/// record against its slot and the claim's refusal, and on a kernel without -/// the switch the claim is refused because the kernel drives the function. +/// The controller `xhci-leave=` names, handed over to `usbd_spare`'s claim and +/// driven by usbd as far as its No-Op, answered by an interrupt through the +/// claim, with every event announced by one. Then the two controls: the +/// function aimed past every grant is the kernel's `DMA FAULT` record against +/// its slot and the claim's refusal, and on a kernel without the switch the +/// claim is refused because the kernel drives the function. fn usbd_drives_the_spare(test_config: &Path) -> Result<(), String> { const JOB: &str = "usbd_spare"; const SPARE: &str = "1b36:000d"; @@ -4569,25 +3405,11 @@ fn usbd_drives_the_spare(test_config: &Path) -> Result<(), String> { let said = job_window(&mut qemu, "test_rs_usbd_spare")?; let run = serial::Serial::named("usbd_spare", said.clone()); run.must_be_clean()?; - for when in ["settled", "restarted"] { - let named: Vec<&str> = said.lines().filter(|l| l.contains(&format!("usbd_spare: {when}: usb.device."))).collect(); - for (id, speed, class) in SPARE_DEVICES { - let port = named - .iter() - .find_map(|l| l.split_once("usb.device.")?.1.split_once(&format!(".id = {id}")).map(|(p, _)| p.to_string())) - .ok_or_else(|| format!("usbd named no {id} once {when}:\n{said}"))?; - for (field, want) in [("speed", speed), ("class", class)] { - run.must_say(&format!("usbd_spare: {when}: usb.device.{port}.{field} = {want}"))?; - } - } - run.must_say(&format!("usbd_spare: {when}: usb.devices = {}", SPARE_DEVICES.len()))?; - } run.must_say(USBD_NOOP)?; run.must_say("usbd_spare: settled: usb.events.unannounced = 0")?; - run.must_say("usbd_spare: 2 device(s) named, the same after a restart")?; let handed = format!("pcidev: PCI {at} [{SPARE}] handed over on slot "); - if said.matches(&handed).count() != 2 { - return Err(format!("{at} was not handed over once for each usbd:\n{said}")); + if said.matches(&handed).count() != 1 { + return Err(format!("{at} was not handed over once, to the one usbd:\n{said}")); } run.must_say(&format!("PCI {at}: msi address="))?; @@ -4606,7 +3428,7 @@ fn usbd_drives_the_spare(test_config: &Path) -> Result<(), String> { if u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok() != u64::from_str_radix(aimed.trim_start_matches("0x"), 16).ok() { return Err(format!("the record faults {addr}, and the job aimed at {aimed}: {record}")); } - eprintln!(" [usbd] {at}: two devices named twice; aimed at {aimed}: {}", record.trim()); + eprintln!(" [usbd] {at}: handed over, its No-Op answered; aimed at {aimed}: {}", record.trim()); drop(qemu); // The control: the same machine and no switch, so the kernel drives it. @@ -4739,11 +3561,11 @@ fn bar_map_again(test_config: &Path) -> Result<(), String> { Ok(()) } -/// fileserver refuses a hello whose window is the virtio NIC's BAR, by the -/// kernel's answer and by name, and answers the next client as the process -/// that refused it. +/// The kernel answers a lent window that is the virtio NIC's BAR as no memory +/// it allocated, by `fstat`, so no reader maps it as a region. fn lent_device_memory_refused(test_config: &Path) -> Result<(), String> { const JOB: &str = "lent_device_memory_refused"; + const SAID: &str = "lent_device_memory_refused: a device's BAR lent as a window is not memory the kernel allocated"; let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/guest/rust-tests"), JOB); let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[(JOB.to_string(), bin)], BootOptions::default()); @@ -4755,15 +3577,10 @@ fn lent_device_memory_refused(test_config: &Path) -> Result<(), String> { if result.exit_code != Some(0) { return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); } - for said in [ - ": its window is not memory the kernel allocated", - "lent_device_memory_refused: the server that let go answers the next client", - ] { - let Some(line) = result.stdout.lines().find(|l| l.contains(said)) else { - return Err(format!("nobody said `{said}`:\n{}", result.stdout)); - }; - eprintln!(" [claims] {}", line.trim()); - } + let Some(line) = result.stdout.lines().find(|l| l.contains(SAID)) else { + return Err(format!("the job never said `{SAID}`:\n{}", result.stdout)); + }; + eprintln!(" [claims] {}", line.trim()); Ok(()) } From b1d430bea4db26e63db54dd383859e7c34d0a700 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 02:21:06 +0200 Subject: [PATCH 2/5] process_tree reads a chain's refusal before a link's index The chain's links now spawn each other directly, so the depth refusal is a link's own line, "link : its next link was refused: ...", which starts with the prefix of a link's index line. The reader took the index prefix first and parsed ": its next ..." as a number, which panicked on the T14 at ff1a11dbd (process_tree.rs:309:33, ParseIntError InvalidDigit). Before the narrowing the refusal came from the shell a link started, so the collision never arose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- tests/guest/rust-tests/src/bin/process_tree.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/guest/rust-tests/src/bin/process_tree.rs b/tests/guest/rust-tests/src/bin/process_tree.rs index a5cdea12377..7ca6e86a178 100644 --- a/tests/guest/rust-tests/src/bin/process_tree.rs +++ b/tests/guest/rust-tests/src/bin/process_tree.rs @@ -305,12 +305,13 @@ fn a_chain_stops_at_max_depth_and_dies_whole() { let mut line = String::new(); assert!(said.read_line(&mut line).expect("read the chain") > 0, "the chain ended without a refusal"); let line = line.trim().to_string(); - if let Some(k) = line.strip_prefix("link ") { - started = k.parse().expect("a link's index"); + // The refusal is a link's too, so it is read before a link's index. + if line.contains("refused") { + break line; } else if line.starts_with("unbounded") { panic!("the chain passed link {LINK_BOUND} with no depth refusal: {line}"); - } else if line.contains("refused") { - break line; + } else if let Some(k) = line.strip_prefix("link ") { + started = k.parse().expect("a link's index"); } }; assert!(started >= 2, "the chain stopped at link {started}: {stop}"); From aa6816c1083174337e5271c55567b3672920e33d Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 02:44:01 +0200 Subject: [PATCH 3/5] proctreecase's shell row lists toybox, which its detach of cat launches The sweep cut `toybox` from the shell row's `starts` with the depth chain that launched shells, but `a_detached_program_outlives_its_shell` still has a shell detach `/system/bin/cat`, a launch of the `toybox` row. On the T14 the supervisor refused it ("shell in the machine's session may not start toybox: its caller's row does not list it"), the shell exited 126, and process_tree panicked at its detach assertion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- tests/images/proctreecase.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/images/proctreecase.toml b/tests/images/proctreecase.toml index 0703d94bbbd..e878af7d576 100644 --- a/tests/images/proctreecase.toml +++ b/tests/images/proctreecase.toml @@ -2,7 +2,8 @@ # # A launcher for test-runner, which it endows every job, because the subtree # under test has children the supervisor launches; a declared `cat`, which runs -# until its input ends; and a declared shell, which detaches a `cat`. +# until its input ends; and a declared shell, whose row lists `toybox` because +# it detaches a `cat`. # # `launch_toctou` rides it too: a launch of `toybox` through a link it re-points. # @@ -38,7 +39,7 @@ starts = ["toybox", "shell", "grants", "/apps"] syscap = ["roster"] [programs.shell] -starts = ["shell", "grants"] +starts = ["toybox", "shell", "grants"] login = true [programs.grants] From 0dba06f1f4691e1736f9b155614b1808c2989817 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 03:01:22 +0200 Subject: [PATCH 4/5] Round 5: the roster's clocks are asserted, std's copies build at std's edition, and the ACPI exit is narrowed to extracts - toybox-copies' granted-roster test writes two distinct non-zero clocks at the header's ABI offsets and asserts those values; the all-zero header it decoded before held nothing a swap of the two would break. - toyos-std-copies compiles launch_route.rs and turns.rs at edition 2024, std's own, as toybox-copies and update-copies match their programs'. - The ACPI track's exit asks of committed extracts only what an extract can hold, each query method and the battery's and adapter's, and names the metal rows that keep each block's load and \_S5; the loader dump is dropped, as the orchestrator ruled on #893. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...ine-in-acpi-mode-and-interprets-its-aml.md | 23 +++++++++++-------- sdk/std/copies/Cargo.toml | 2 +- system/toybox/copies/src/roster_reading.rs | 7 ++++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md b/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md index ef5af199aab..9d119ea06a7 100644 --- a/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md +++ b/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md @@ -164,17 +164,20 @@ refuses** (owner, #890: integration between modules is tested only for the kernel): `acpi_server_events` reads each embedded-controller query number once with its count, `acpi_tables_loaded` the 14 definition blocks loaded and `\_S5`'s `SLP_TYPa` of 7, and `battery_read` the battery and adapter as -the interpreter evaluated them over the controller. They stay until host -tests hold what each reads, because nothing else reads the T14's firmware. +the interpreter evaluated them over the controller. `acpi_server_events` +and `battery_read` stay until host tests hold what each reads, because +nothing else reads the T14's firmware. The oracle is the firmware's own table bytes, not another OS's reading of -them, and a dump the loader takes is preferred to one taken under Ubuntu -(the orchestrator, ruling on #893); what the tree keeps of it is extracts -only, as "Extracts only" says. Owner: this stage. **Exit**: the loader -dumps the T14's DSDT and SSDTs; extracts of that dump, masked, are -committed with its provenance; host tests load them into the interpreter -over a scripted host and controller and hold each block's load and -`\_S5`, each query method the controller raises, and the battery's and -adapter's methods; and the three rows leave `tests/toyos.rs` in that diff. +them (the orchestrator, ruling on #893); what the tree keeps of them is +extracts only, as "Extracts only" says. A definition block's load takes +the whole block, which no extract is, so each block's load and `\_S5` +stay held by the metal rows that load a machine's own tables whole: +`acpi_tables_loaded` on the T14, and the AMD bench's. Owner: this stage. +**Exit**: extracts of the T14's tables, masked, are committed with their +provenance; host tests evaluate each query method the controller raises, +and the battery's and adapter's methods, over a scripted host and +controller; and `acpi_server_events` and `battery_read` leave +`tests/toyos.rs` in that diff. What that check found, which whoever builds on the interpreter would otherwise pay to find again: diff --git a/sdk/std/copies/Cargo.toml b/sdk/std/copies/Cargo.toml index d01f5082746..d5858cc6eef 100644 --- a/sdk/std/copies/Cargo.toml +++ b/sdk/std/copies/Cargo.toml @@ -2,7 +2,7 @@ name = "toyos-std-copies" description = "A host test of std's ToyOS platform: its modules that name nothing but core and std's own sync, compiled as they are." version = "0.1.0" -edition = "2021" +edition = "2024" license = "MIT OR Apache-2.0" publish = false diff --git a/system/toybox/copies/src/roster_reading.rs b/system/toybox/copies/src/roster_reading.rs index 38ada99eed6..be7927a4bd8 100644 --- a/system/toybox/copies/src/roster_reading.rs +++ b/system/toybox/copies/src/roster_reading.rs @@ -17,13 +17,16 @@ fn a_refused_roster_is_no_row() { #[test] fn a_granted_roster_is_its_header_and_each_whole_entry() { let mut answer = vec![0u8; HEADER + ENTRY + ENTRY / 2]; - let header = toyos_abi::syscall::SysinfoHeader::decode(answer[..HEADER].try_into().unwrap()); + // `uptime_ns` and `total_cpu_ns` at their ABI offsets, distinct so a swap is seen. + let (uptime_ns, total_cpu_ns) = (0x0102_0304_0506_0708u64, 0x1112_1314_1516_1718u64); + answer[24..32].copy_from_slice(&uptime_ns.to_le_bytes()); + answer[32..40].copy_from_slice(&total_cpu_ns.to_le_bytes()); let entry = &mut answer[HEADER..HEADER + ENTRY]; entry[..4].copy_from_slice(&7u32.to_le_bytes()); entry[4..8].copy_from_slice(&1u32.to_le_bytes()); entry[32..37].copy_from_slice(b"shell"); let (uptime, cpu, rows) = rows(&answer).expect("a granted roster"); - assert_eq!((uptime, cpu), (header.uptime_ns, header.total_cpu_ns)); + assert_eq!((uptime, cpu), (uptime_ns, total_cpu_ns)); assert_eq!(rows.len(), 1); assert_eq!((rows[0].pid, rows[0].tid, rows[0].name.as_str()), (7, 1, "shell")); } From 59697d07833b1e80e532a6dda4e3f26dde01b570 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 03:09:37 +0200 Subject: [PATCH 5/5] The ACPI exit names the T14's acpi_tables_loaded alone as holding each block's load The AMD bench loads none of its tables and hands over no \_S5: its DSDT is refused (acpiserver-refuses-the-amd-benchs-dsdt-for-a-read-in-memory-mapped-io), so it holds neither. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...os-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md b/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md index 9d119ea06a7..8ad5768f1a0 100644 --- a/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md +++ b/issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md @@ -171,8 +171,8 @@ The oracle is the firmware's own table bytes, not another OS's reading of them (the orchestrator, ruling on #893); what the tree keeps of them is extracts only, as "Extracts only" says. A definition block's load takes the whole block, which no extract is, so each block's load and `\_S5` -stay held by the metal rows that load a machine's own tables whole: -`acpi_tables_loaded` on the T14, and the AMD bench's. Owner: this stage. +stay held by the T14's metal row that loads its own tables whole, +`acpi_tables_loaded`. Owner: this stage. **Exit**: extracts of the T14's tables, masked, are committed with their provenance; host tests evaluate each query method the controller raises, and the battery's and adapter's methods, over a scripted host and