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..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 @@ -159,6 +159,26 @@ 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. `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 (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 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 +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/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..d5858cc6eef --- /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 = "2024" +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..be7927a4bd8 --- /dev/null +++ b/system/toybox/copies/src/roster_reading.rs @@ -0,0 +1,32 @@ +//! 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]; + // `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), (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")); +} 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 be005f45cab..598bd35a766 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -1154,7 +1154,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 17cfe23c655..7f2ebb78af1 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(), @@ -649,19 +651,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..7ca6e86a178 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"); @@ -391,19 +305,20 @@ 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") || line.ends_with(": not found") { - 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}"); 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 +331,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..e878af7d576 100644 --- a/tests/images/proctreecase.toml +++ b/tests/images/proctreecase.toml @@ -2,16 +2,11 @@ # # 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, whose row lists `toybox` because +# it 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. 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 939152b9937..6cd2b717dcb 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; @@ -141,43 +139,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. @@ -189,7 +159,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. @@ -197,20 +167,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. @@ -322,13 +284,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), ]; @@ -345,35 +300,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 @@ -390,12 +324,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. @@ -415,9 +343,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. @@ -427,48 +355,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 @@ -544,29 +450,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 { @@ -577,27 +460,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 @@ -928,12 +790,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`. @@ -1076,8 +932,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"])], @@ -1143,13 +999,9 @@ const TESTCASES: &[metal::Arm] = &[metal::once( &[ "test_rs_wake_storm_cost", "test_rs_wake_onto_busy", - // 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, ], )]; @@ -1195,10 +1047,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] = &[ @@ -1214,20 +1062,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 @@ -2098,6 +1939,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. @@ -3223,7 +3106,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 @@ -3491,440 +3373,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), @@ -3933,638 +3392,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 { @@ -4578,26 +3424,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"; @@ -4620,25 +3456,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="))?; @@ -4657,7 +3479,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. @@ -4790,11 +3612,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()); @@ -4806,15 +3628,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(()) }