diff --git a/Cargo.toml b/Cargo.toml index 119a669c1c9..a06e5672708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,10 +134,6 @@ exclude = [ "tests/toyos-rust-tests/tls-lib", "tests/toyos-rust-tests/tls-multi-crate", "tests/toyos-rust-tests/tls-multi-crate/dep", - # A host judge: a second implementation, whose dependency graph is not the - # harness's to resolve (`src/build.rs`, `build_host_judges`). It takes - # upstream tokio, where the `[patch]` below names the fork. - "tests/ssh-client-host", ] [package] @@ -154,8 +150,6 @@ default-run = "toyos-build" fatfs = "0.3.6" fontdue = "0.9" gpt = "3.1.0" -# The unprivileged ICMP datagram socket `icmp::echo` asks a metal boot over, -# which is the platform call that replaces a `ping` binary. libc = "0.2" toml = "0.8" uuid = { version = "1", features = ["v4"] } @@ -183,8 +177,7 @@ toyos-osrelease = { path = "toyos-osrelease" } # off a stick, so one declaration is what both sides spell it with. toyos-quiesce = { path = "toyos-quiesce" } # The swap port's name and the one program the image may give it to, so the -# gate on every manifest and the host that asks for a swap read what the supervisor and -# sshserver read. +# gate on every manifest reads what the supervisor reads. toyos-swap = { path = "toyos-swap" } # The watchdog's parameter name, so the gate over `kernel/src/params.rs` reads # the same constant the kernel and the bootloader do. diff --git a/issues/a-boot-paint-at-subsystems-ready-stalled-4-ms-on-the-t14.md b/issues/a-boot-paint-at-subsystems-ready-stalled-4-ms-on-the-t14.md index 15b0cdfe59f..5675a5f69c2 100644 --- a/issues/a-boot-paint-at-subsystems-ready-stalled-4-ms-on-the-t14.md +++ b/issues/a-boot-paint-at-subsystems-ready-stalled-4-ms-on-the-t14.md @@ -8,7 +8,7 @@ opened: 2026-10-04 One T14 boot of `lantalkcase`, at `43cbe73d4` (pull request #710's head, run on 2026-10-04), wrote `panel: paints=10 px=8513536 us=25527 max_us=8194`. The -machine's record for that boot is `boot.lantalkcase.panel_max_us = 3919` +machine's record for that boot was `boot.lantalkcase.panel_max_us = 3919` (`tests/metal/lenovo-20w0003amz.toml`), and the judge's ceiling 7838, so the judge exited 1. The same kernel's four other boots of that run read `max_us` 3921, 3802, 3878 and 3870, and the negative control's four 3837, 3883, 3927 @@ -31,7 +31,9 @@ the claim. What stalled a paint for 4.3 ms is not known — a single boot, with no instrument inside the paint. Owner: the orchestrator, who holds the T14 record. Exit: the stall's cause is -named from a measurement — the review's alternating `lantalkcase` boots of -`e81d26db8` and `43cbe73d4`, three each, comparing `max_us` and the gap from -`subsystems ready` to the next record, is the first — and removed, and three -`lantalkcase` boots then read `panel_max_us` inside the record's ceiling. +named from a measurement — the review's alternating boots of `e81d26db8` and +`43cbe73d4`, three each, comparing `max_us` and the gap from `subsystems +ready` to the next record, is the first — and removed, and three boots of one +image then read `panel_max_us` inside the record's ceiling. `lantalkcase` is +deleted (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`), so +the boots are of an image the profile still runs. diff --git a/issues/a-cable-refusal-hides-the-stick-only-row-of-the-talking-boot.md b/issues/a-cable-refusal-hides-the-stick-only-row-of-the-talking-boot.md deleted file mode 100644 index 36ef9a402c6..00000000000 --- a/issues/a-cable-refusal-hides-the-stick-only-row-of-the-talking-boot.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-10-08 ---- - -# A cable refusal hides the stick-only row of the talking boot - -`toyos-metal` refuses a talking boot whose host heard nothing over the cable, -and the harness takes a refused boot's readback as the refusal -(`read_readback`, `tests/common/metal.rs`): every row riding the boot is red -with the refusal's text, whatever it reads. `lantalkcase` carries three rows. -`lan_talk` reads the conversation and `lan_dhcp_lease` holds the lease to the -address the host reached, so both need the cable. `lan_message_delivery` reads -only the kernel's records on the stick, and is red with them. - -## Measured - -The T14, `lantalkcase`, with the bench's wired path down -(`issues/lan-talk-is-unread-on-the-t14-since-the-claims-binding-landed.md`), at -`main`'s `6f87cdb9c` and at `c84f5fafe` the same day. Both `verdict.txt`: - -``` -refused -the boot did not say over its own cable what a talking boot owes: - the stream never opened -``` - -At `c84f5fafe` the judge printed the same line for all three rows: - -``` -FAIL lan_message_delivery: toyos-metal refused lantalkcase: the boot did not say over its own cable what a talking boot owes: -``` - -and the stick it did not read carries what that row asks for: - -``` -pcidev: PCI 00:1f.6 [8086:15fc] handed over on slot 0, vector 0x28 -pcidev: slot 0 took its first message on vector 0x28 -``` - -## Owner - -The harness's author. - -## Exit condition - -On a `lantalkcase` readback refused only for what the host heard, -`lan_message_delivery` is judged on the stick's records, shown by a -`toyos-checks` fixture of such a readback. diff --git a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index 2d0a22e8b50..b86c3e49ab2 100644 --- a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -44,8 +44,9 @@ directory is `issues/every-program-sees-only-the-files-it-was-given.md`'s. all it does kept: a `restart` row that ends is started again on the ports it kept; a row without one has its acceptors closed, so a client's next connect is `Gone`; a swap's expected end leaves them open for the binary - after it. `src/metalswap.rs`'s `judge` reads the third, on the T14 through - `toyos-metal --swap`; no test reads the first two. + after it. No test reads any of the three: `src/metalswap.rs`'s `judge` + read the third, on the T14 through `toyos-metal --swap`, and is deleted + (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). 2. **An end says how**. An end reads as an exit, a kill or a fault kind alike on every architecture, and a bare code reads the last two as failures. No end reads as a quit's reason. *Exit*: exited 137, killed, and each fault diff --git a/issues/a-connect-between-two-accepts-is-reset.md b/issues/a-connect-between-two-accepts-is-reset.md index 6104a9eae85..e71d0d5c06c 100644 --- a/issues/a-connect-between-two-accepts-is-reset.md +++ b/issues/a-connect-between-two-accepts-is-reset.md @@ -28,6 +28,9 @@ the client hides this and does not fix it. QEMU and T14 rows both, with `lan_swap_hold`, the T14 row's judge and the metal harness's swapping boots, which that row was the one user of, and `29733dba3` then deleted the swap file and `--hand-back` only they used. +`toyos-metal --swap`, the host's stream and its ssh client went after them; +`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md` records the commit +that restores those. **Exit**: a listener that queues a connect arriving between two accepts, and `lan_swap` restored and green. diff --git a/issues/a-first-dial-turned-away-before-a-line-is-waited-on-to-its-callers-bound.md b/issues/a-first-dial-turned-away-before-a-line-is-waited-on-to-its-callers-bound.md deleted file mode 100644 index b3652fc5b99..00000000000 --- a/issues/a-first-dial-turned-away-before-a-line-is-waited-on-to-its-callers-bound.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-28 ---- - -# A first dial turned away before a line is waited on to its caller's bound - -`src/metaltalk.rs`'s `serve` never asks a first dial again when its -connection closes before a line (`again` is false), and it records no -`unopened` for it. So `Stream::wait_for_connection` still reads the stream as -dialling (`state.unopened.is_none()`) and waits out its whole `by`. Its doc says -it returns "once the dial asked for it ended with none". Through QEMU's -forward that close is the only sign the guest refused, and each caller then -names the wrong thing, late: - -- `metalswap::swap` says "no boot opened the record stream within N s" once - the whole window has passed. -- `tests/common/logstream.rs`'s `reader` says "the stream never opened" once - `CEILING` has passed. -- `converse` waits `FOREVER` and returns only at the metal loop's `give_up`. - -The close itself is recorded in `end`. - -Measured: a mutation of -`a_refused_first_dial_is_asked_again_up_to_its_ceiling` hands its first dial -to a listener that accepts it and closes it before a line. That test's -`wait_connected(5 s)` returned `None` after 5.02 s, and `unopened()` was still -`None`. - -## Exit condition - -A first dial whose connection closes before a line ends `wait_for_connection` -at once, and `unopened` names the close. A host test stages that close -through a listener it owns and accepts on, and fails on the base. diff --git a/issues/a-netstack-that-dies-while-serving-leaves-the-hosts-stream-silent.md b/issues/a-netstack-that-dies-while-serving-leaves-the-hosts-stream-silent.md deleted file mode 100644 index 714b0b7eaab..00000000000 --- a/issues/a-netstack-that-dies-while-serving-leaves-the-hosts-stream-silent.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-25 ---- - -# A netd that dies while serving leaves the host's log stream silent - -A connection on `logd`'s port lives in netd's TCP state, and a netd that ends -takes it with no FIN and no reset: the host's side waits for a byte that no -process on the machine can send. The one death this side is told of first is a -swap init has accepted — `logd` turns readers away from init's `accepted` until -the old netd is gone, and `metalswap::swap` holds the swap's go until `logd` -has said so (`toyos_logstream::CARRIER_LEAVING`), then redials. - -Every other death is unannounced: a replacement that passes its first moments -and then ends inside probation (init's `failed` and `restored` are then said -into a connection already gone), and a netd that crashes outside any swap. In -both the host's `Stream` reads nothing more, and `metalswap::swap` reaches its -window with init's final word missing — red, and loud, but about the stream -rather than the swap. - -No test stages either: `swap_crash_rolls_back`'s replacement panics before it -serves anything, so the host's redial is admitted by the restored netd. - -Exit condition: a host reader that learns of an unannounced netd death through -an event — the replacement netd ending the connections it cannot know, or a -stop that ends them before netd goes — with a test whose replacement serves and -then ends inside probation. diff --git a/issues/a-reader-that-never-reads-holds-its-slot-until-its-buffers-fill.md b/issues/a-reader-that-never-reads-holds-its-slot-until-its-buffers-fill.md deleted file mode 100644 index f5f8fcb1617..00000000000 --- a/issues/a-reader-that-never-reads-holds-its-slot-until-its-buffers-fill.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-25 ---- - -# A log reader that never reads holds its slot until its buffers fill - -`logd` lets a network reader go once its sink has taken no byte for `STALLED` -while bytes are owed (`userland/logd/src/serve.rs`). A connection that never -reads is only seen to take no bytes once everything between `logd` and the -peer is full: the pipe to netd (one 2 MiB page), netd's send buffer and the -peer's window. Until the log has grown by that much, such a connection holds -one of `MAX_NETWORK_READERS` slots, so enough idle connections refuse a later -network reader for as long as the log grows slowly. This machine's own readers -are counted apart and are not affected. - -## Exit condition - -A connection that has not acknowledged what it was sent is let go on a bound -that does not depend on how fast the log grows — netd telling `logd` its peer's -window is closed, or a bound on unacknowledged bytes — and a test that opens -`MAX_NETWORK_READERS` connections that never read, with no flood, finds a later -reader admitted. diff --git a/issues/a-renamed-services-state-stays-under-its-old-name-unread.md b/issues/a-renamed-services-state-stays-under-its-old-name-unread.md index e9e0e28eeeb..5244abbef40 100644 --- a/issues/a-renamed-services-state-stays-under-its-old-name-unread.md +++ b/issues/a-renamed-services-state-stays-under-its-old-name-unread.md @@ -18,7 +18,7 @@ five services, and a DATA volume that outlives that change: `/state/sshserver`, which a client that pinned the old key refuses; - no longer authorizes a key that lived only in `/state/sshd/authorized_keys`: `userland/sshserver/src/main.rs`'s `authorized_keys` reads - `/state/sshserver/authorized_keys` and `/system/etc/ssh_authorized_keys`, so + `/state/sshserver/authorized_keys`, so a machine updated over ssh whose owner's key was only there refuses its owner afterwards. diff --git a/issues/a-swap-on-the-t14-lives-inside-a-metal-boots-bound.md b/issues/a-swap-on-the-t14-lives-inside-a-metal-boots-bound.md deleted file mode 100644 index c824415913b..00000000000 --- a/issues/a-swap-on-the-t14-lives-inside-a-metal-boots-bound.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-23 ---- - -# A service swap on the T14 lives inside a metal boot's bound - -`toyos-metal --swap ` replaces a service's binary on a running T14 -with no flash and no reboot. What there is to swap *on* is a metal-staged boot, -and every metal image carries `boot-deadline=` (`toyos_tco::WEDGE_BOUND_MS`, -120 s) and a runner whose list ends in `reboot` (`toyos_tco::JOB_BOUND_MS`, -60 s). - -So the loop the owner asked for — netd rebuilt on the Mac and swapped in, in -seconds, over and over, for weeks of LAN work — has on the T14 today the span -from netd's first lease (20.9 s of boot time on run 122) to the runner's -60 s: long enough for one swap and a few, and -then a flash. Under QEMU the loop has no such bound. - -Nothing here is wrong: the bounds are what keep an unattended machine from -needing a hand. What is owed is a decision: a boot staged for the swap loop -whose hold is not the runner's — longer, or held until the host lets it go -over the cable — with the kernel's deadline widened to match, and a ruling on -what bounds such a boot instead. - -`lanswapcase` and `lan_swap_hold` are deleted with `lan_swap`, and `29733dba3` -deleted `--swap`'s `--hand-back`, which ended that hold; -`issues/a-connect-between-two-accepts-is-reset.md` records the commits -that restore them. diff --git a/issues/a-swaps-redial-asks-again-with-no-event-to-wait-on.md b/issues/a-swaps-redial-asks-again-with-no-event-to-wait-on.md deleted file mode 100644 index f8bc2558c93..00000000000 --- a/issues/a-swaps-redial-asks-again-with-no-event-to-wait-on.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -status: assigned -kind: defect -opened: 2026-09-25 ---- - -# A swap's redial asks again with no event to wait on - -Held by the orchestrator. - -A swap of netd ends the host's log stream with no FIN and no reset, and -`src/metaltalk.rs`'s `Stream::redial` dials `logd` again. Nothing the machine -sends says when `logd` listens again: the stream and the ssh channel both die -with the old netd, so init's words are unreachable until a dial succeeds. So -every dial turned away — refused with a reset by the old netd whose `logd` -listener is closed, or by the new netd before `logd` binds; or, through QEMU's -forward, accepted and closed before a line — is asked again at once. On a LAN -that is a question for the name on the link, which the old netd answers at -once, and a `connect` per round trip for the whole gap. - -The T14 is unmeasured, and a refusal there costs a LAN round trip rather than -QEMU's forward's. Its redial asks the name on the link again after every dial -turned away, as soon as the old netd answers the last ask, so its questions -may go out faster than RFC 6762 §5.2's floor between two queries -(`ASK_WAIT`); that rate is unmeasured on metal. - -The forward's cost is measured: a hold-green run turned away 8125 dials in -7019 ms, and the guest logged 16231 of its 19034 interrupts over that boot, all -on cpu0. - -## Exit condition - -The host waits on a guest-side event instead of asking again: something the -machine sends when `logd` can admit a reader again after a swap of netd — -for example `logd` keeping its listener across the swap and holding the -connections it accepts until the new netd serves, or netd announcing its -exit on a channel that outlives it — and `Stream::redial` dials once per -such event, with the count deleted, so no redial asks the link faster than -§5.2's floor. diff --git a/issues/a-test-asserts-a-daemons-line-off-a-boot-log-that-ends-before-it.md b/issues/a-test-asserts-a-daemons-line-off-a-boot-log-that-ends-before-it.md index d9d3493341f..8313fc6142f 100644 --- a/issues/a-test-asserts-a-daemons-line-off-a-boot-log-that-ends-before-it.md +++ b/issues/a-test-asserts-a-daemons-line-off-a-boot-log-that-ends-before-it.md @@ -27,8 +27,8 @@ test-runner, so a daemon's line can come after the marker. Two tests read one fr ``` #639 has each `iommu_virtio_platform` arm wait on the guest for the daemon's lines it reads -(`d773a4306`). `lan_dhcp_lease` has left the guest suite: its T14 row -judges the whole readback, which ends at the boot's last word. +(`d773a4306`). `lan_dhcp_lease` has left the guest suite, and its T14 row is +deleted (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). Owner: the orchestrator. diff --git a/issues/connects-are-turned-away-for-6-s-after-logkeeper-listens-again.md b/issues/connects-are-turned-away-for-6-s-after-logkeeper-listens-again.md deleted file mode 100644 index d6a4e3a0039..00000000000 --- a/issues/connects-are-turned-away-for-6-s-after-logkeeper-listens-again.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-28 ---- - -# Connects are turned away for 6 s after `logd` listens again - -In a swap of netd, connects to `logd`'s port keep being turned away for about -6 s after `logd` itself has already re-bound the port and init has said the -new netd is in service. In a hold-red run at PR #566's `7e06a657`, netd -re-bound `41337` at 1.758 s and init said `in service` at 6.739 s -(`566r2-hold-red.log:330`, `:332`); the matching hold-green run (guest- -identical, since the branch's fix is host-only) only had its redial admitted -at 7.721 s (`566r2` hold-green oracle line). That is what makes the redial's -window long after a swap — not `logd`'s closed listener during the swap -itself, which is announced and handled. - -## Exit condition - -Whatever holds a bound listener from accepting for those ~6 s is named, and -either removed or bounded by a measured floor — shown by a run whose -admission time tracks `logd`'s own re-bind and init's `in service`, not -trailing it by seconds. diff --git a/issues/four-shares-take-a-file-server.md b/issues/four-shares-take-a-file-server.md index 4ef7ec523b4..2be35c8b822 100644 --- a/issues/four-shares-take-a-file-server.md +++ b/issues/four-shares-take-a-file-server.md @@ -47,7 +47,7 @@ detached from a desktop shell and runs in that shell's login session. Its one share with each other and with the terminal that started the server: one login at its parts has every other's next connection or stream on that server refused, and that terminal's. Only where a boot config starts sshserver as a -service (`tests/lantalkcase`, `tests/metalcase`) does each launch it makes +service (`tests/metalcase`) does each launch it makes open a session of its own. **Ruled** (owner, 2026-10-07): diff --git a/issues/lan-talk-host-closes-judges-a-fixed-ten-second-window.md b/issues/lan-talk-host-closes-judges-a-fixed-ten-second-window.md deleted file mode 100644 index a21a5a92ae5..00000000000 --- a/issues/lan-talk-host-closes-judges-a-fixed-ten-second-window.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-25 ---- - -# `lan_talk_host_closes` judges a fixed ten-second window - -`tests/common/lan.rs`'s `lan_talk_host_closes` boots the e1000e talk image, -drains the serial console for a flat 10 s (`drain_serial(Duration::from_secs(10))`), -and then counts the host listener's accepts. If the boot has not brought the -82574 up, leased, and opened its log stream inside those 10 s of host wall -clock, the count is 0 and the test fails with "the boot never connected, so -nothing here was closed on it". - -Seen at `c22e326f` (`wt/toyos-inspect`) in a fast-tier run beside another -worktree's suite, with all 12 guest slots held by two suites: -`FAIL lan_talk_host_closes (15s)`, then `ALONE lan_talk_host_closes: GREEN`. - -The window paces the verdict and does not wait for an event. It should wait on -the listener's first accept, bounded by a ceiling that fails loudly, and only -then pace the offers into the closed connection. - -**Exit condition**: the test waits for the accept event instead of for a fixed -span, and stays green under a loaded suite. diff --git a/issues/lan-talk-is-unread-on-the-t14-since-the-claims-binding-landed.md b/issues/lan-talk-is-unread-on-the-t14-since-the-claims-binding-landed.md deleted file mode 100644 index f87fcca74ed..00000000000 --- a/issues/lan-talk-is-unread-on-the-t14-since-the-claims-binding-landed.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-10-08 ---- - -# `lan_talk` has not been read on the T14 since a device call began to borrow its claim's binding - -#763 makes every `pcidev` call act on a `&Binding` its claim lends, and a poll -register through the claim. The I219 under traffic is the one holder that -drives those calls on real hardware at rate: its interrupt records -(`take_record`, `has_irq`), its polls and its DMA grants. The `lan_talk` metal -row is what reads it, and it was staged from #763's head and not read: the -bench's wired path between the T14 and the development machine is down. - -The row cannot tell #763 from `main` while the path is down: staged from -`main` at `6f87cdb9c` and booted the same day, it is red the same way, `the -stream never opened` (`src/metaltalk.rs`), which is the host failing to reach -the machine and not the machine's answer. - -What stands in for it meanwhile: under QEMU, `iommu_virtio_platform` and the -suite's network tests drive the same `pcidev` functions through virtio-net's -claim; on the T14, `claim_reuses_its_remapping_entry`, `isa_` and `acpi_`. None -is the I219. - -Owner: the orchestrator, which holds the T14 and the bench. - -**Exit**: `lan_talk` green from the T14 on a head that contains #763. diff --git a/issues/logkeeper-hears-a-reader-leave-only-at-its-next-write.md b/issues/logkeeper-hears-a-reader-leave-only-at-its-next-write.md index 713c554c6af..6ffc1d4375c 100644 --- a/issues/logkeeper-hears-a-reader-leave-only-at-its-next-write.md +++ b/issues/logkeeper-hears-a-reader-leave-only-at-its-next-write.md @@ -9,8 +9,7 @@ opened: 2026-10-08 A log reader's thread (`userland/logkeeper/src/serve.rs`, `feed`) waits on `shared.grew` once it has caught up, and learns its reader is gone only when a write to the sink fails. A reader that leaves while the log is quiet keeps its -thread and its place in `MAX_NETWORK_READERS` or `MAX_LOCAL_READERS` until the -next record lands. +thread and its place in `MAX_READERS` until the next record lands. Read from the code, not measured. diff --git a/issues/nothing-reaches-the-msi-refusal-arms-of-a-claimed-function.md b/issues/nothing-reaches-the-msi-refusal-arms-of-a-claimed-function.md index aa9144d2175..74b31eb4903 100644 --- a/issues/nothing-reaches-the-msi-refusal-arms-of-a-claimed-function.md +++ b/issues/nothing-reaches-the-msi-refusal-arms-of-a-claimed-function.md @@ -8,8 +8,9 @@ opened: 2026-09-08 `pcidev::bring_up` arms a claimed function on MSI where it publishes no MSI-X. The T14's I219 is one: `claim_reuses_its_remapping_entry` arms it through -`arm_claimed_msi` and releases it through `tear_down`'s MSI arm, and -`lan_message_delivery` counts its messages. Nothing reaches: +`arm_claimed_msi` and releases it through `tear_down`'s MSI arm. No T14 row +reads a message it raised +(`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). Nothing reaches: - `bring_up`'s refusal after the MSI is armed — `place_bars` refusing a function armed on MSI — where `PciDevice::disable_msi` runs and the slot's diff --git a/issues/sshserver-holds-one-channel-and-does-not-say-so.md b/issues/sshserver-holds-one-channel-and-does-not-say-so.md index dd02d64532e..cfe2f68c6a6 100644 --- a/issues/sshserver-holds-one-channel-and-does-not-say-so.md +++ b/issues/sshserver-holds-one-channel-and-does-not-say-so.md @@ -19,9 +19,8 @@ client sends on channel A reaches the program running on channel B** — and output direction is unaffected: each program's forwarder task owns its own channel's write half. -Nothing in the tree does this today: the harness's client -(`tests/ssh-client-host`) opens one channel per connection, and OpenSSH's `ssh` -and `sftp` each open one. A multiplexed OpenSSH client (`ControlMaster`) is the +Nothing in the tree does this today: the tree has no ssh client, and OpenSSH's +`ssh` and `sftp` each open one. A multiplexed OpenSSH client (`ControlMaster`) is the shape that would reach it, and so is any client that runs a second command on one connection. diff --git a/issues/swap_refused_device_fails-reds-unanswered-in-60s.md b/issues/swap_refused_device_fails-reds-unanswered-in-60s.md deleted file mode 100644 index be727915376..00000000000 --- a/issues/swap_refused_device_fails-reds-unanswered-in-60s.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -status: open -kind: finding -opened: 2026-09-25 ---- - -# `swap_refused_device_fails` reds "unanswered nothing in 60s" before it reaches `accepted` - -Seen once, in PR #492's round-3 CI (`abi-split` pass, `host` skipped by -design, run 36151956317), as a red beside this PR's own metaltalk/metalswap -work. `swap_once_sshd_answers`'s ssh client -(`tests/ssh-client-host/src/main.rs:408`) printed `unanswered nothing in 60s -after ""`: `SWAP_ANSWER` (60 s) passed with no byte on the channel and -`said` still empty, so `init_accepted` failed on `the swap was answered -Ok("unanswered nothing in 60s after \"\""), where init's `accepted` is owed` -— the swap never reached `accepted` at all. - -This is not this round's redial or ceiling: `Stream`/`metaltalk` play no part -in `refused_device_fails` before `accepted`, and this red carries none of -`lan_swap`/`swap_refusals`' 2,080,768-byte upload signature. It is neither -reproduced nor attributed to a cause here — it may be sshd, the ssh client, -or the harness's own timing, and it may not recur. - -## Exit condition - -Reproduced (or shown gone) and attributed to an owning module, at which point -this is promoted to a `defect` there or closed as noise with the run that -showed it gone. diff --git a/issues/the-build-runs-host-tools-outside-rust-and-qemu.md b/issues/the-build-runs-host-tools-outside-rust-and-qemu.md index a0701d04800..01955f943b2 100644 --- a/issues/the-build-runs-host-tools-outside-rust-and-qemu.md +++ b/issues/the-build-runs-host-tools-outside-rust-and-qemu.md @@ -27,7 +27,7 @@ arrives and is not one. M4 and M5 are stages of `issues/toyos-builds-itself.md`. | `sh` running `rust/x`, and Python running `x.py` and `bootstrap.py` | every toolchain build (`src/toolchain.rs`) | refused: a Rust tool does it, upstream's bootstrap binary, which builds with stable cargo, fetches its own stage0 (`rust/src/bootstrap/src/core/download.rs`) and needs no Python | `src/toolchain.rs` runs the bootstrap binary | | `curl` in rustc's bootstrap | fetches the stage0 `rust/src/stage0` pins, for a compiler or LLVM build whose build directory lacks it, whichever bootstrap runs | refused: a Rust tool does it, rustup installs the dated beta the pin names, and bootstrap takes a stage0 through `build.rustc` and `build.cargo`, as `src/sysroot.rs` hands it one | no toolchain build fetches with `curl` | | `tar` and `zstd` | `actions/cache` packs and unpacks every cache entry with them, on CI runners; `guest.yml`'s `deps` installs `zstd`, without which the guest's restore names no entry the toolchain job saved | admitted: GitHub's cache action runs them, and CI keeps no store between runs without it | CI keeps no store between runs | -| `ssh` | `src/metal.rs` reaches the T14's Ubuntu with it, only in the metal loop | refused: a Rust tool does it, the repository's own russh client `crate::build::ssh_client_host`, which `src/metaltalk.rs` already drives | `src/metal.rs` drives that client, or Ubuntu leaves the loop | +| `ssh` | `src/metal.rs` reaches the T14's Ubuntu with it, only in the metal loop | refused: a Rust tool does it, russh, which sshserver is built on; the harness's own russh client was built and is deleted (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`) | `src/metal.rs` drives a russh client, or Ubuntu leaves the loop | | `cc`, `c++`, `ar` and `xcrun` on a macOS host, Apple's Command Line Tools | what the Linux row's tools do, and rustc asks `xcrun` for the SDK on every host link that names no `SDKROOT` (`rust/compiler/rustc_codegen_ssa/src/back/apple.rs`), as `src/llvm.rs` does for the LLVM's key | refused: one host OS alone | M5: no host in the loop | | `diag/flash.sh` | the owner's flash of a stick by hand: `bash`, and the `stat`, `seq`, `tr`, `grep`, `cut` and `sync` it strings together | refused: shell of our own | `issues/the-owners-flash-script-runs-diskutil.md` | | `diskutil` and `plutil` | `diag/flash.sh`, and `diskutil` in the README's flashing steps | refused: one host OS alone | `issues/the-owners-flash-script-runs-diskutil.md` | diff --git a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index 473cf52c2ab..5e941e8bfc8 100644 --- a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -225,12 +225,13 @@ Host: `toyos-net-tcp`, `toyos-dns`, `toyos-mdns`, `toyos-swap`, `toyos-inspect`, program are refused a swap. - host `sshd_key_auth`: sshd refuses a key not authorized. - host `lan_lease_report`: a link that goes down and comes back after a lease neither gives the - lease up nor starts the client over. The T14 cannot flap its cable, and `lan_dhcp_lease` reads only a - lease from the bench's router. Exit: netd's link-up decision (its main loop and + lease up nor starts the client over. The T14 cannot flap its cable, and no T14 row reads a lease + (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). Exit: netd's link-up decision (its main loop and `dhcp::restart`) lifted into a function a netd `#[test]` drives with a lease held, red when the `!dhcp.leased()` guard goes. -- metal `sshd_exec`, the arms `lan_talk`'s one command does not reach, each a step of that row's - exchange (`src/metaltalk.rs`) red when its arm in `userland/sshd/src/main.rs` is reverted: +- metal `sshd_exec`, the arms `lan_talk`'s one command did not reach, each a step of that row's + exchange red when its arm in `userland/sshd/src/main.rs` is reverted; the row and its exchange + are deleted, and this waits on `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`: - a program that is not there ends 127, `cannot run /system/bin/` on stderr and nothing on stdout; - an unquotable line ends 127 before anything runs; `command::split`'s own tests already hold diff --git a/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md b/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md new file mode 100644 index 00000000000..d2d15cf0b12 --- /dev/null +++ b/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md @@ -0,0 +1,150 @@ +--- +status: open +kind: tooling +opened: 2026-10-08 +--- + +# The host cannot reach the T14 while it runs ToyOS, and no T14 row reads its wired card + +The development host reaches the T14 only while it runs Ubuntu. Under ToyOS no +connection the host opens to it arrives, so every row whose boot the host had +to talk to was red, and they are deleted as a red test is. + +## Measured + +The T14, boot `lantalkcase`, at `main`'s `6f87cdb9c` and at `c84f5fafe` on +2026-10-08. Both `verdict.txt`: + +``` +refused +the boot did not say over its own cable what a talking boot owes: + the stream never opened +``` + +`toyos-metal` refused the whole boot, so its three rows were red with that +text: `lan_talk` and `lan_dhcp_lease`, which read what the host heard, and +`lan_message_delivery`, which read only the stick. + +## Deleted + +`809c33c0c` holds all of it, and is the commit that restores it: + +- the rows `lan_talk`, `lan_dhcp_lease` and `lan_message_delivery`, their + judges (`tests/common/lan.rs`, `tests/common/logstream.rs`) and + `lan_dhcp_lease`'s fixture (`tests/checks/lan.rs`); +- the boot `lantalkcase`: `tests/lantalkcase`, its job `lan_talk_hold`, and its + rows in `tests/metal/lenovo-20w0003amz.toml`; +- the harness's talking boots: `Arm::talk`, `Arm::nic`, the key minted beside + an image (`tests/common/ssh.rs`), `Readback::talk` and `Readback::wire_mac`; +- `toyos-metal`'s half: `--talk` and `--nic`, `Refusal::Talk`, `Refusal::Wire` + and `Refusal::Cable`, the host's reader of the log a machine serves and its + conversation (`src/metaltalk.rs`), its ping (`src/icmp.rs`), and the lease, + link and message readers (`src/lan.rs`); +- the swap of a running service from the host, by the owner's ruling "Delete + the whole chain": `toyos-metal --swap` and `--binary`, `Refusal::Swap`, + `src/metalswap.rs`, the harness's ssh client `tests/ssh-client-host` with + `build::build_host_judges`, `build::ssh_client_host` and + `build::copy_guest_program`, and `toyos-swap`'s readers of the supervisor's + words (`heard`, `outcome`, `Word::is_final`); +- the machine's end of the stream, which no image could reach once + `lantalkcase` went: `logkeeper`'s network half (`serve_network`, `bind_port`, + `Carrier`, `Hub::carrier`, the network readers' count, the `stream` word of + its `inspect` snapshot), `toyos_logstream::PORT`, `CARRIER`, + `CARRIER_LEAVING` and the `SWAP` frames the supervisor sent `logkeeper` to + close that listener across a swap of netstack, and the build's + `no_shipped_image_serves_the_log_on_the_network`, which then guarded nothing; +- sshserver's second key file, `/system/etc/ssh_authorized_keys`, which only a + talking boot's staging wrote. It reads `authorized_keys` under its own state + directory and nothing else. + +`lan_message_delivery` could not move to a boot that does not talk without a +new job: the card's first message arrived 8.499 s into the last boot read, +with its link, so the boot has to be held open until then, and the one hold +the tree has is the flat sleep +`issues/lan-hold-holds-a-boot-open-for-a-flat-twenty-seconds.md` records. + +## What stands in the meantime + +No T14 row reads netstack on the I219, and none reads a message the card +raised. `claim_reuses_its_remapping_entry` claims the function and gives it +back twice and takes no traffic. So the calls #763 moved onto a claim's +binding — the I219's interrupt records, its polls and its DMA grants, driven +at rate — have not been read on real hardware since they landed; under QEMU +`iommu_virtio_platform` and the suite's network tests drive the same `pcidev` +functions through virtio-net's claim. + +`/system/bin/swap` and the supervisor's swap path stay, because a shipped +machine can ask for one: `system.toml` makes the desktop a login session whose +shell starts `swap`, and gives a login over sshserver the same. No test +performs a swap: `launch_authority` reads the rows that refuse to start it and +nothing reads one accepted, stopped, started or restored. + +Two things about that swap and that login are unread at this head: + +- Whether sshserver's port is turned away after a swap of netstack as + `logkeeper`'s was. `logkeeper` bound its port again and connects were still + refused for about 6 s (the line under "What the restored chain still + owes"); what held the bound listener was never named, and sshserver binds + again through a swapped netstack the same way. +- The file that authorizes a login. No test at any tier logs in over + sshserver or reaches `authorized_keys`: its host tests call + `authorizes(text, key)` and none calls `is_authorized`, + `authorized_key_count` or `authorized_keys()`; no guest starts the daemon; + and the T14's `metalcase` boot, read at `25766b613`, leaves at `no network on + this machine` before the read. The reader that lost its second file is + carried by the diff alone. + +What reads the card next is not these rows restored. The owner ruled that the +rows judged from the stick alone — the T14 reaching its router and the +internet — are built on ToyOS's own network stack ("no smoltcp."), so they +arrive with `issues/toyos-has-its-own-network-stack.md`, which owns them. + +## What the restored chain still owes + +Each was an issue of its own and is true of `809c33c0c`'s code, so it comes +back with it: + +- A swap on the T14 lives inside a metal boot's bound: every metal image + carries `boot-deadline=` (`toyos_tco::WEDGE_BOUND_MS`) and a runner whose + list ends in `reboot` (`toyos_tco::JOB_BOUND_MS`), so the loop the owner + asked for — netstack rebuilt on the host and swapped in, over and over — + has the span from the first lease to the runner's bound, and then a flash. + Owed: a boot staged for the swap loop whose hold is not the runner's, and a + ruling on what bounds it. +- `Stream::redial` asks again with no event to wait on: a swap of netstack + ends the host's stream with no FIN and no reset, nothing the machine sends + says when `logkeeper` admits a reader again, and every dial turned away is + asked again at once (through QEMU's forward, 8125 dials in 7019 ms). Exit: + the host waits on something the machine sends, and dials once per event. +- `open` sets `on_the_link` on the first failed dial and never clears it, so + every refusal is followed by a multicast question, faster than RFC 6762 + §5.2's floor. Exit: only a dial that finds nobody home asks the link again. +- `serve` records no `unopened` for a first dial closed before a line, so + `Stream::wait_for_connection` waits out its whole bound. Exit: that close + ends the wait at once, shown by a host test that fails on the base. +- `Stream::wait_until` is woken only by a line, so `metalswap::swap` waits out + its window on a stream that can carry no more (124 s at PR #566's + `7e06a657`). Exit: it returns once the stream's `dialing` goes false. +- A netstack that dies while serving leaves the host's stream silent: its + connections go with no FIN and no reset, and only a swap the supervisor + accepted was announced. Exit: a host reader learns of an unannounced death + through an event, with a test whose replacement serves and then ends inside + probation. +- Connects to `logkeeper`'s port were turned away for about 6 s after it had + bound the port again and the supervisor had said `in service` (PR #566's + `7e06a657`: bound at 1.758 s, in service at 6.739 s, admitted at 7.721 s). + Exit: what holds a bound listener from accepting is named, and removed or + bounded. +- A network reader that never reads holds one of `MAX_NETWORK_READERS` slots + until the pipe, netstack's send buffer and the peer's window are full. Exit: + it is let go on a bound that does not depend on how fast the log grows. +- The ssh client is no second implementation: its lock pins the `russh` commit + `Cargo.lock` pins for sshserver, so a defect in the fork's protocol code is + on both ends of every exchange. + +Owner: the orchestrator, which holds the T14. + +**Exit**: a connection the development host opens to the T14 while it runs +ToyOS is answered, read as `lan_talk`, restored from `809c33c0c`, green from +the T14, and each line of "What the restored chain still owes" is fixed or +filed again as its own issue in that restore. diff --git a/issues/the-lan-is-not-yet-production-grade.md b/issues/the-lan-is-not-yet-production-grade.md index 4ca9818a05b..9d9217c48cb 100644 --- a/issues/the-lan-is-not-yet-production-grade.md +++ b/issues/the-lan-is-not-yet-production-grade.md @@ -83,6 +83,7 @@ of real traffic with no error, leak or stall. ## Standing The bench tests that prove each exit run in the T14 suite on every change to -the path, not once. The bench itself stays honest: the boot stick's first +the path, not once. Today none does: no T14 row reads the wired card +(`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). The bench itself stays honest: the boot stick's first command still sometimes goes unanswered on the USB2 half of its receptacle (runs 84, 103, 104, 105), and everything above rests on the record it carries. diff --git a/issues/the-loader-does-only-what-must-precede-the-handover.md b/issues/the-loader-does-only-what-must-precede-the-handover.md index 3584ded9586..9c05b2a802f 100644 --- a/issues/the-loader-does-only-what-must-precede-the-handover.md +++ b/issues/the-loader-does-only-what-must-precede-the-handover.md @@ -32,7 +32,8 @@ PR #539 does not land. Its pieces: `tests/bench*case`, `--bench-image` with `build::bench_image`, `src/image.rs`' `update_of`, `tests/common/metal.rs`' `Reach`, `stage` and bench `invocation`, `--metal-via-ubuntu`, toybox `date`, `Ssh::probe` and - `Ssh::fetch` with `ssh-client-host`'s `probe` and `fetch`, + `Ssh::fetch` with the ssh client's `probe` and `fetch` (the client is + deleted, `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`), `build::AUTHORIZED_ON_ROOT`, and `src/bootlog.rs`' `LOADER_PREVIOUS_LOG`, `MOUNTED_FROM_MEMORY` and `BOOT_PARAMETER`; - deleted: `update --boot-next ` with `slots::Next::Esp`, `Guid::parse`, @@ -333,12 +334,10 @@ Each stage lands on its own, in this order. `issues/a-reset-stops-xhci-and-leaves-every-claimed-pci-function-armed.md`, `issues/the-t14-hung-after-rebooting-with-its-i219-faulted.md`, `issues/a-fatal-event-stands-down-both-bounds-and-may-leave-nothing-to-end-the-machine.md`, - `issues/most-t14-leases-land-one-dhcp-retry-late.md`, - `issues/a-swaps-redial-asks-again-with-no-event-to-wait-on.md`, - `issues/the-t14-redial-re-asks-mdns-after-every-refusal.md`, - `issues/a-first-dial-turned-away-before-a-line-is-waited-on-to-its-callers-bound.md` - and - `issues/a-netstack-that-dies-while-serving-leaves-the-hosts-stream-silent.md`. + `issues/most-t14-leases-land-one-dhcp-retry-late.md` and + `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`, + which deleted the host's reader, its ssh client and the machine's end of + the stream, and records what they still owe. - #539's issues `a-loader-change-reaches-a-machine-only-by-writing-its-stick`, `the-bench-reads-no-quiescent-log-volume`, `the-bench-runs-with-no-bound-on-its-own-boot`, diff --git a/issues/the-metal-loop-speaks-on-stdout-and-none-of-its-lines-says-when.md b/issues/the-metal-loop-speaks-on-stdout-and-none-of-its-lines-says-when.md index bd953bd6a11..36fcf481355 100644 --- a/issues/the-metal-loop-speaks-on-stdout-and-none-of-its-lines-says-when.md +++ b/issues/the-metal-loop-speaks-on-stdout-and-none-of-its-lines-says-when.md @@ -12,20 +12,18 @@ at. `toyos-metal` narrates a run with `println!` and `print!` instead, and stdout passes through no printer: ``` -rg -n '(^|[^e])print(ln)?!' src/metal.rs src/metalswap.rs src/metaltalk.rs src/bin/toyos-metal.rs +rg -n '(^|[^e])print(ln)?!' src/metal.rs src/bin/toyos-metal.rs ``` Its progress is among them — ` run: `, `machine , BIOS `, `the machine answered ssh again after N s`, `the boot stick -enumerated N s after the machine answered`, ` swap: …`, ` talk: …`, -` stream: …`, `readback written to ` — so a flash, a boot and a readback +enumerated N s after the machine answered`, `readback written to ` — so a flash, a boot and a readback are the one stretch of a run whose lines cannot be laid beside the suite's or beside `/log` by time. The harness's own `[metal]` lines around them are stamped. -Not all of them are speech. `print!("{loader}{log}")`, ` | ` -and ` stream| ` relay what the machine wrote, which carries the -machine's own stamps, and `PASS: the machine booted ToyOS in N ms` is the +Not all of them are speech. `print!("{loader}{log}")` relays what the machine +wrote, which carries the machine's own stamps, and `PASS: the machine booted ToyOS in N ms` is the loop's verdict. Owner: the orchestrator, which alone runs the loop against the machine and so diff --git a/issues/the-ssh-judge-runs-the-servers-own-russh.md b/issues/the-ssh-judge-runs-the-servers-own-russh.md deleted file mode 100644 index 75fde08f336..00000000000 --- a/issues/the-ssh-judge-runs-the-servers-own-russh.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-04 ---- - -# The SSH judge runs the server's own russh - -`tests/ssh-client-host` is the harness's SSH client, and the root manifest -keeps it out of the workspace as "a second implementation". It is not one: -it takes `russh` from `https://github.com/ToyOSOrg/russh`, branch `toyos`, -and its lock pins commit `389804d3b9908250282746c934c58c059b88fb7e`, the same -commit `Cargo.lock` pins for `sshserver` through the root -`[patch]`. A defect in the fork's protocol code is on both ends of every SSH -test, and the two can agree on it. - -**Exit:** the judge's lock names no `ToyOSOrg` source, or the root -manifest's exclusion comment stops calling it a second implementation and -this file names what judges the server's protocol instead. diff --git a/issues/the-t14-answers-only-through-a-usb-stick.md b/issues/the-t14-answers-only-through-a-usb-stick.md index 66ff0670266..9246f00e854 100644 --- a/issues/the-t14-answers-only-through-a-usb-stick.md +++ b/issues/the-t14-answers-only-through-a-usb-stick.md @@ -9,10 +9,10 @@ opened: 2026-09-07 Every boot of the bench is flashed to a stick under Ubuntu and judged by what is read off it after a reboot into Ubuntu. The laptop is on a cable on the same LAN as the development Mac and its NIC is the onboard Intel I219 at `00:1f.6`, -`8086:15fc`, which netd claims on the LAN boot (`tests/lantalkcase`), where -the Mac reads the boot's log, runs a command and hands the machine back over -the cable. The track is to make that -cable the answer path. +`8086:15fc`. The track is to make that cable the answer path. The boot on +which the Mac read the log, ran a command and handed the machine back over it +is deleted: the host reaches no T14 that runs ToyOS +(`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). What is left is the harness running userland tests over ssh through a russh client, which `issues/the-t14-reboots-through-ubuntu-for-every-test.md` @@ -31,7 +31,9 @@ Constraints a reader would otherwise pay to re-derive: has VT-d, so this is not a bound on the bench — but a `pcidev` refusal there is the first thing to check before suspecting the driver. - **ssh is the bench's transport and a real feature**: sshd is built on russh - and the harness's client is russh too. No host ssh binary, no fork. + and the harness's client was russh too, until it was deleted + (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). No host + ssh binary, no fork. - **Addressing is DHCP with a hostname**, resolved through the router's DNS. The T14's MAC is the same under ToyOS and Ubuntu. Wi-Fi is out — the AX210 needs a firmware image. @@ -42,10 +44,12 @@ Constraints a reader would otherwise pay to re-derive: hand-over, so a function still holding its last holder's queue addresses can act on none of them. `release` asks for a reset where the function advertises one; the I219 does, so on the T14 both hold. -- **The machine serves its own log, and the Mac finds it by name.** `logd` - serves the boot from its first line on TCP `41337` (`toyos-logstream`'s - `PORT`), and netd answers multicast DNS for `toyos-t14.local` once it holds a - lease (`toyos-mdns`), so nothing in the image names the Mac and nothing on the - Mac listens. A boot that dies before netd leases still needs the stick. +- **The machine served its own log, and the Mac found it by name.** `logd` + served the boot from its first line on a TCP port, and netd answers multicast + DNS for `toyos-t14.local` once it holds a lease (`toyos-mdns`), so nothing in + the image named the Mac and nothing on the Mac listened. The serving half and + the Mac's reader are deleted + (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`); a boot + that dies before netd leases needed the stick either way. - The metal loop is `toyos-metal` (`src/metal.rs`), and the T14 is run by the orchestrator alone. diff --git a/issues/the-t14-reboots-through-ubuntu-for-every-test.md b/issues/the-t14-reboots-through-ubuntu-for-every-test.md index 82db42ca634..5cd11a383dc 100644 --- a/issues/the-t14-reboots-through-ubuntu-for-every-test.md +++ b/issues/the-t14-reboots-through-ubuntu-for-every-test.md @@ -19,8 +19,10 @@ metal rows. after a reboot). Its registration says which. - **One exec channel**, to `test-runner`'s stdin loop, since `sshd` aliases a second channel's input (`issues/sshserver-holds-one-channel-and-does-not-say-so.md`). - `tests/ssh-client-host`'s `pipe` relays it once it writes output as it - arrives (`tests/ssh-client-host/src/main.rs:251-263`). + The harness's ssh client relayed it through `pipe`, once that wrote output + as it arrived; the client was built and is deleted, and + `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md` records the + commit that restores it. - **A test's window** is what crosses between its markers: the job's output, which never reaches `/log` (`userland/sshd/src/main.rs:234-239`), and the kernel's records, which a runner the exec starts reads on its own @@ -39,11 +41,15 @@ metal rows. runs. QEMU's harness names no bound and sends no `list`; its own ceiling ends the guest. +Every stage below has the host drive a T14 that runs ToyOS, so the track is +blocked on `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`. + **Next: two sessions in place of six boots.** `shared`, `shared-2`, `ccorpus`, `testcases`, `testcases-mkdir` and `testcases-readdir` share a config, a parameter line and the shipping kernel. The session image is `tests/testcases` -with `tests/lantalkcase`'s netd, sshd and streaming `logd`, under the same -120 s `boot-deadline=`. A session holds 74.8 s of members, priced at +with the netd, sshd and streaming `logd` of `tests/lantalkcase`, which is +deleted (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`), +under the same 120 s `boot-deadline=`. A session holds 74.8 s of members, priced at `toyos_tco::RUST_MEMBER_MS` a Rust member, `toyos_tco::C_MEMBER_MS` a C case and a list at twice its slowest on the T14: the bound less a tenth, less a lease as late as 19.1 s @@ -71,11 +77,10 @@ a session image comes by `update`, a row that boots on its own by `issues/a-frozen-toyos-waits-for-a-hand-on-the-power-button.md` has armed the hard-lockup detector on every boot and shipped `watchdogd`. An image names no `boot-deadline=`, `metal::judge_arms` takes `watchdogd`'s row as its -bound, and `pipe` bounds each window rather than its whole run -(`tests/ssh-client-host/src/main.rs:64`). A ToyOS the host cannot reach keeps +bound, and `pipe` bounds each window rather than its whole run. A ToyOS the host cannot reach keeps its watchdog fed, so the run says it waits for a hand once nothing has answered within the watchdog's bound and a POST allowance. A swap then leaves a boot's -bound (`issues/a-swap-on-the-t14-lives-inside-a-metal-boots-bound.md`). +bound (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). **Exit**: the host's plan names every reset in a run by what forces it, and a host test whose transport goes quiet under a session finds the run stopped, diff --git a/issues/the-t14-redial-re-asks-mdns-after-every-refusal.md b/issues/the-t14-redial-re-asks-mdns-after-every-refusal.md deleted file mode 100644 index 87b3bcd3842..00000000000 --- a/issues/the-t14-redial-re-asks-mdns-after-every-refusal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-28 ---- - -# The T14 redial re-asks mDNS after every refusal - -`open` (`src/metaltalk.rs:383`, `:419`, `:452`) sets `on_the_link` on the -first failed dial and never clears it, so once the old or new netd starts -sending refusals or resets, every dial that follows first asks the link for -the name again (`ask_the_link`) before it redials. Without a dial ceiling to -stop that loop, a gap where the machine keeps answering with a refusal sends -multicast queries at LAN round-trip rate for as long as `wait_secs` runs — -below the RFC 6762 §5.2 floor between two queries (`ASK_WAIT`) that the code -itself cites. - -## Exit condition - -A timer-free fix: set `on_the_link` only on a host-absent failure -(`not_yet_reachable`'s `EHOSTDOWN`, `EHOSTUNREACH`, `ENETUNREACH`, or a -failure seen after `WAITED`). A refusal or reset is the machine answering at -that address, so the next dial goes there again with no new ask, and only a -dial that finds nobody home asks the link once more. diff --git a/issues/there-is-no-network-gate.md b/issues/there-is-no-network-gate.md index 50231c703b1..2093a34e899 100644 --- a/issues/there-is-no-network-gate.md +++ b/issues/there-is-no-network-gate.md @@ -45,7 +45,7 @@ Open at the implementation pass: hand-rolled pcap parsing versus a host dev-dependency; what counter surface smoltcp already exposes against what netd must count. -The ssh half is built: `tests/ssh-client-host` drives a guest over -`BootOptions::ssh_port`'s `hostfwd`, which a frame-level analyser reuses rather -than building again. What is left is the pcap analyser, the two tiers, +The ssh half was built and is deleted: `tests/ssh-client-host` drove a guest +over a `hostfwd`, and `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md` +records the commit that restores it. What is left beside it is the pcap analyser, the two tiers, `-netdev socket`, and the idle→packet ceiling. diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 2a8ee1a0227..23af27bfd49 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -15,6 +15,7 @@ netd runs smoltcp. Its replacement is ToyOS's own stack, built clean-room: reade - Stage 4: `toyos-net-shard` and `toyos-net-testnet`. - Stage 5: netd on one shard, pipe ABI unchanged; smoltcp leaves netd, `Cargo.toml` and `Cargo.lock` in the same PR. - Then multi-core, netring (blocked on the owner's ABI ruling), TCP and IP hardening, IPv6, offloads, soak. +- This track owns the T14's outbound rows, the machine reaching its router and the internet on the I219, judged from the stick: they arrive on this stack and not on smoltcp (owner: "no smoltcp."), and until they do no T14 row reads the wired card (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). The listener defects are this track's: `issues/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md` and `issues/a-connect-between-two-accepts-is-reset.md`, on smoltcp until stage 5, and `issues/an-accept-that-never-reaches-netstack-strands-its-listener.md`, in std's accept. diff --git a/issues/toyos-records-carry-the-sticks-serial-the-cards-mac-and-the-resolvers-in-full.md b/issues/toyos-records-carry-the-sticks-serial-the-cards-mac-and-the-resolvers-in-full.md index 2cd65e695d9..1042d69106d 100644 --- a/issues/toyos-records-carry-the-sticks-serial-the-cards-mac-and-the-resolvers-in-full.md +++ b/issues/toyos-records-carry-the-sticks-serial-the-cards-mac-and-the-resolvers-in-full.md @@ -13,19 +13,13 @@ public carries them unless whoever pastes it masks them by hand: - the USB stick's serial number: `kernel/src/drivers/xhci/wait/msc.rs:1407`, on every bind, and `:1415`, when a disk comes back; -- the network card's MAC: `userland/netstack/src/main.rs:1605`, and - `src/metal.rs:2281`, where the host loop writes the one it read before the - flash into the readback's `boot.txt`; +- the network card's MAC: `userland/netstack/src/main.rs:1605`; - the resolvers the lease named, which on the bench are the provider's public ones: `userland/netstack/src/dhcp.rs:149`. `src/sourcegate.rs` reads tracked files only. A pull request's body, a comment and a commit message are read by nothing before they are public. -The harness's judge prints neither: `tests/common/lan.rs:99` holds the MAC -record against the readback's without quoting either, and `:116` counts the -resolvers. The records it reads still carry all three. - ## The question Should ToyOS print these values at all, or do they stay in its records and @@ -33,6 +27,5 @@ whoever pastes a log masks them? ## Exit condition -The owner's answer. If the system is not to print them, the three records and -the readback's key change, and the judges that read them with them. If the -paster masks, this file is deleted. +The owner's answer. If the system is not to print them, the three records +change. If the paster masks, this file is deleted. diff --git a/issues/wait-until-for-the-supervisors-word-never-wakes-once-the-stream-is-dead.md b/issues/wait-until-for-the-supervisors-word-never-wakes-once-the-stream-is-dead.md deleted file mode 100644 index 513ecc305e4..00000000000 --- a/issues/wait-until-for-the-supervisors-word-never-wakes-once-the-stream-is-dead.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-28 ---- - -# `wait_until` for init's final word never wakes once the stream is dead - -`Stream::wait_until` (`src/metaltalk.rs:260`) is woken by each line as it -lands, and by nothing else. `wait_for_connection` (`src/metaltalk.rs:289`) -computes its own `dialing` — `redial.is_some() || current.is_some() || -unopened.is_none()` — and returns `None` at once once a redial has ended with -no connection: the stream can never carry another line. `metalswap::swap`'s -call to `wait_until` for init's final word (`src/metalswap.rs:214`) has no -such exit, so it waits out its whole `window` even after the stream has -reached that same dead state. - -Predates this branch: a redial that ends without naming a cause already left -`wait_until` waiting before `src/metaltalk.rs:383` started ending a forward's -redial at its first refusal. - -Evidence: hold-red took 124 s this way, run at PR #566's `7e06a657` -(`566r2-hold-red.log:507`, orchestrator's round-2 review job). - -## Exit condition - -Give `wait_until` the same exit `wait_for_connection` already has: return -`None` once the stream's own `dialing` state goes false, instead of waiting -out `by`. diff --git a/src/build.rs b/src/build.rs index 9b8b73c365b..f3791dbae73 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2010,63 +2010,6 @@ pub fn build_test_parts( Parts { kernel: kernel_bytes, bootloader: bl_bytes, root: root_bytes } } -/// The host binaries the network judges drive, built here rather than inside a -/// test: a judge's price is its exchange and not a compile. -/// -/// Each of these keeps its own `Cargo.lock` and is excluded from the -/// workspace. That is what makes them possible: they exist to be -/// a *second* implementation, and a second implementation's dependency graph is -/// not the harness's to resolve. -pub fn build_host_judges(root: &Path, quiet: bool) { - for (dir, _) in HOST_JUDGES { - let _building = Building::start(format!("the host's {dir}")); - let at = root.join(dir); - let mut cmd = Command::new("cargo"); - cmd.args(["build", "--release"]); - if quiet { - cmd.arg("--quiet"); - } - let status = cmd - .current_dir(&at) - .env_remove("RUSTUP_TOOLCHAIN") - .env_remove("RUSTC") - .env_remove("RUSTFLAGS") - .status() - .unwrap_or_else(|e| panic!("cargo failed to launch in {}: {e}", at.display())); - assert!(status.success(), "{dir} did not build"); - } -} - -/// One host judge: where its crate is, and the binary that crate builds. Named -/// rather than indexed, because a row inserted anywhere but the end would -/// silently repoint every accessor below. -type Judge = (&'static str, &'static str); - -const SSH_CLIENT: Judge = ("tests/ssh-client-host", "toyos_ssh"); - -const HOST_JUDGES: [Judge; 1] = [SSH_CLIENT]; - -/// Copy to `to` the binary the build leaves for the program -/// `name`: the bytes a swap sends a running machine in place of the ones its -/// image carries. Read under the artifact lock, as every image build reads it. -pub fn copy_guest_program(root: &Path, arch: Arch, name: &str, to: &Path) -> Result<(), String> { - let from = root.join(format!("target/{}/{PROFILE}/{name}", arch.userland())); - let _artifact = buildlock::artifact(root); - fs::copy(&from, to) - .map(|_| ()) - .map_err(|e| format!("{} to {}: {e}", from.display(), to.display())) -} - -/// The harness's SSH client — the only thing in this tree that speaks the -/// protocol from the other side of `userland/sshserver`. -pub fn ssh_client_host(root: &Path) -> PathBuf { - host_judge(root, SSH_CLIENT) -} - -fn host_judge(root: &Path, (dir, bin): Judge) -> PathBuf { - root.join(dir).join("target/release").join(bin) -} - /// Build all binaries in a multi-binary crate. Returns vec of (binary_name, bytes). /// Also builds any cdylib subcrates and includes their .so files. /// @@ -3001,25 +2944,6 @@ mod tests { } } - /// **An image a user boots serves no log on the network.** `logkeeper` answers - /// `toyos_logstream::PORT` to whoever connects, with nothing to authenticate - /// them, once it holds a `netstack` connector: the test configs that read the - /// stream give it one, and these do not. - #[test] - fn no_shipped_image_serves_the_log_on_the_network() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - for config in ALL_CONFIGS.iter().filter(|config| !config.starts_with("tests/")) { - let parsed = parse_config(&root.join(config)); - let logkeeper = parsed.programs.get("logkeeper").expect("every config runs logkeeper"); - assert!( - logkeeper.receives.is_empty(), - "{config}: `logkeeper` receives {:?}, and a `netstack` connector is what serves this \ - machine's log to anyone on its network", - logkeeper.receives, - ); - } - } - /// Every config renders, so a row the manifest refuses — one that serves a /// port and is not marked `service` — reds here rather than at a build. #[test] @@ -3097,7 +3021,6 @@ mod tests { "console/system.toml", "tests/acpicase/system.toml", "tests/jobcase/system.toml", - "tests/lantalkcase/system.toml", "tests/latencycase/system.toml", "tests/logstallcase/system.toml", "tests/metalcase/system.toml", diff --git a/src/ci.rs b/src/ci.rs index 1fb5d1d481a..53b4052b877 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -1208,12 +1208,12 @@ mod tests { #[test] fn the_summary_keeps_the_count_and_the_verdicts() { let log = "test result: ok. 3 passed\n\ - 12:00:00 FAIL rs::lan_talk: no exit code\nnoise\n\ - 12:00:07 STALL rs::lan_talk (7s)\n\ + 12:00:00 FAIL rs::std_alloc: no exit code\nnoise\n\ + 12:00:07 STALL rs::std_alloc (7s)\n\ 12:00:09 test result: FAILED. 40 passed; 1 failed, 41 total (300 s)\n"; let said = verdicts(log); assert!(said.starts_with("test result: FAILED. 40 passed; 1 failed, 41 total"), "{said}"); - assert!(said.contains("\nFAIL rs::lan_talk: no exit code\n STALL rs::lan_talk (7s)\n"), "{said}"); + assert!(said.contains("\nFAIL rs::std_alloc: no exit code\n STALL rs::std_alloc (7s)\n"), "{said}"); assert!(!said.contains("noise"), "{said}"); assert_eq!(verdicts(""), "no suite result line"); } diff --git a/src/cicache.rs b/src/cicache.rs index e1261f18088..c7cad1dec0d 100644 --- a/src/cicache.rs +++ b/src/cicache.rs @@ -666,7 +666,7 @@ pub fn word(_: proc_macro::TokenStream) -> proc_macro::TokenStream { fs::create_dir_all(tmp.join(DRIVER)).unwrap(); open(&tmp, RUNNER, SystemTime::now()).expect("the driver's own target"); start(&tmp, RUNNER).expect("the driver's own target"); - for target in ["tests/ssh-client-host/target", "target/debug"] { + for target in ["tests/toyos-rust-tests/target", "target/debug"] { fs::create_dir_all(tmp.join(target)).unwrap(); let refusals = [open(&tmp, RUNNER, SystemTime::now()).err(), start(&tmp, RUNNER).err()]; assert!(refusals.iter().all(|r| r.as_ref().is_some_and(|r| r.contains(target))), "{refusals:?}"); @@ -685,9 +685,9 @@ pub fn word(_: proc_macro::TokenStream) -> proc_macro::TokenStream { let tmp = TempDir::new("cicache-seal"); let cold = cold_repo(&tmp, RUNNER); write(&tmp, "target/debug/deps/x", "x"); - write(&tmp, "tests/ssh-client-host/target/y", "y"); + write(&tmp, "tests/toyos-rust-tests/target/y", "y"); seal_at(&tmp, &tmp.join("home"), &cold).unwrap(); - for file in ["target/debug/deps/x", "target/debug", "tests/ssh-client-host/target/y"] { + for file in ["target/debug/deps/x", "target/debug", "tests/toyos-rust-tests/target/y"] { assert_eq!(modified(&tmp.join(file)).unwrap(), built(), "{file}"); } fs::remove_file(tmp.join(MANIFEST)).unwrap(); @@ -718,12 +718,12 @@ pub fn word(_: proc_macro::TokenStream) -> proc_macro::TokenStream { let tmp = TempDir::new("cicache-image"); let cold = cold_repo(&tmp, &runner_in(&image).unwrap()); write(&tmp, "target/debug/x", "x"); - write(&tmp, "tests/ssh-client-host/target/y", "y"); + write(&tmp, "tests/toyos-rust-tests/target/y", "y"); seal_at(&tmp, &tmp.join("home"), &cold).unwrap(); let mut other = image.clone(); other.insert(name, "another"); let said = open(&tmp, &runner_in(&other).unwrap(), SystemTime::now()).unwrap(); - assert!(!tmp.join("target/debug").exists() && !tmp.join("tests/ssh-client-host/target").exists(), "{name}: {said}"); + assert!(!tmp.join("target/debug").exists() && !tmp.join("tests/toyos-rust-tests/target").exists(), "{name}: {said}"); other.remove(name); let refusal = runner_in(&other).expect_err("a runner without a variable"); assert!(refusal.contains(name), "{refusal}"); diff --git a/src/icmp.rs b/src/icmp.rs deleted file mode 100644 index e5c95976032..00000000000 --- a/src/icmp.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! One ICMP echo, asked over the platform's own unprivileged datagram socket. -//! -//! **A host that cannot ask and an address that did not answer are different -//! answers**, and the caller gets them as `Err` and `Ok(false)`: a socket the -//! host refuses would otherwise red a boot for this machine's configuration. - -use std::io; -use std::net::{IpAddr, Ipv4Addr, UdpSocket}; -use std::os::fd::FromRawFd; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -/// Type, code, checksum, identifier, sequence (RFC 792). -const HEADER: usize = 8; -const ECHO_REQUEST: u8 = 8; -const ECHO_REPLY: u8 = 0; - -/// The payload that says a reply is this probe's. -const TOKEN: usize = 16; - -/// Enough for any echo reply, its headers included. -const MOST: usize = 1024; - -/// One per probe this process makes, so a reply to the probe before this one is -/// another probe's and not an answer here. -static ASKED: AtomicU64 = AtomicU64::new(0); - -/// Whether `addr` answered an echo request inside `wait`. -/// -/// `Err` is this host's failing and never the address's. -pub fn echo(addr: Ipv4Addr, wait: Duration) -> Result { - let socket = open()?; - let token = token(); - let request = request(&token); - let deadline = Instant::now() + wait; - socket - .send_to(&request, (addr, 0)) - .map_err(|e| format!("this host could not send an ICMP echo request to {addr}: {e}"))?; - let mut buf = [0u8; MOST]; - loop { - let Some(left) = deadline.checked_duration_since(Instant::now()) else { - return Ok(false); - }; - // A window of zero is no wait at all to `SO_RCVTIMEO`, which is what a - // caller of `set_read_timeout` may not pass either. - if left.is_zero() { - return Ok(false); - } - socket - .set_read_timeout(Some(left)) - .map_err(|e| format!("this host would not bound an ICMP read by {left:?}: {e}"))?; - match socket.recv_from(&mut buf) { - Ok((got, from)) if is_reply(from.ip(), addr, &buf[..got], &token) => return Ok(true), - // Somebody else's echo on the same socket: keep listening for this - // probe's own until the window is spent. - Ok(_) => {} - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - Err(e) - if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => - { - return Ok(false) - } - Err(e) => return Err(format!("this host could not read an ICMP reply: {e}")), - } - } -} - -/// The socket, or why this host would not open one. -fn open() -> Result { - // SAFETY: `socket` answers a fresh descriptor or -1, and the descriptor it - // answers is an `AF_INET` datagram socket — which is what `UdpSocket` - // sends, receives and closes on. - let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, libc::IPPROTO_ICMP) }; - if fd < 0 { - let why = io::Error::last_os_error(); - return Err(format!( - "this host would not open an unprivileged ICMP datagram socket: {why}. On Linux \ - that socket is `net.ipv4.ping_group_range`'s to permit" - )); - } - // SAFETY: `fd` is the descriptor just answered and nothing else owns it, so - // the socket this makes is the only thing that will close it. - Ok(unsafe { UdpSocket::from_raw_fd(fd) }) -} - -/// A payload no other probe of this host writes: the process, and which probe -/// of it this is. -fn token() -> [u8; TOKEN] { - let mut token = [0u8; TOKEN]; - token[..8].copy_from_slice(&u64::from(std::process::id()).to_be_bytes()); - token[8..].copy_from_slice(&ASKED.fetch_add(1, Ordering::Relaxed).to_be_bytes()); - token -} - -/// One echo request carrying `token`. -/// -/// **The checksum is this code's and not the kernel's**: one of the two hosts -/// computes it for a datagram ICMP socket and the other sends what it was given. -fn request(token: &[u8; TOKEN]) -> [u8; HEADER + TOKEN] { - let mut message = [0u8; HEADER + TOKEN]; - message[0] = ECHO_REQUEST; - message[HEADER..].copy_from_slice(token); - let sum = checksum(&message).to_be_bytes(); - message[2..4].copy_from_slice(&sum); - message -} - -/// Whether `message`, which came `from`, is `addr`'s reply to the request -/// carrying `token`. -/// -/// **Whose reply it was is half the question**: the socket is handed every ICMP -/// datagram this host receives, so another host returning this probe's payload -/// is not `addr` answering. -/// -/// **Found by the payload rather than at an offset**: one host hands the -/// datagram over with the IPv4 header in front of the ICMP message and the -/// other without it, and the type byte is [`HEADER`] bytes before the payload -/// either way. The identifier is not read at all — Linux's ping socket -/// overwrites it with the socket's own port. -fn is_reply(from: IpAddr, addr: Ipv4Addr, message: &[u8], token: &[u8; TOKEN]) -> bool { - from == addr - && message - .windows(TOKEN) - .position(|window| window == token) - .is_some_and(|at| at >= HEADER && message[at - HEADER] == ECHO_REPLY) -} - -/// The internet checksum (RFC 1071): the one's-complement sum of the message as -/// 16-bit words, complemented. -pub fn checksum(message: &[u8]) -> u16 { - let mut sum = 0u32; - let (words, rest) = message.as_chunks::<2>(); - for word in words { - sum += u32::from(u16::from_be_bytes(*word)); - } - if let [odd] = rest { - sum += u32::from(u16::from_be_bytes([*odd, 0])); - } - while sum >> 16 != 0 { - sum = (sum & 0xffff) + (sum >> 16); - } - !(sum as u16) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// An IPv4 header of no options, as one of the two hosts leaves in front of - /// the message. - const IPV4: [u8; 20] = [0x45, 0, 0, 36, 0, 0, 0, 0, 64, 1, 0, 0, 10, 0, 0, 1, 10, 0, 0, 2]; - - const HOST: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); - - fn reply_to(request: &[u8]) -> Vec { - let mut reply = request.to_vec(); - reply[0] = ECHO_REPLY; - reply[2..4].copy_from_slice(&[0, 0]); - let sum = checksum(&reply).to_be_bytes(); - reply[2..4].copy_from_slice(&sum); - reply - } - - #[test] - fn a_message_carrying_its_own_checksum_sums_to_zero() { - let message = request(&token()); - assert_eq!(checksum(&message), 0, "{message:02x?}"); - // The worked example in RFC 1071 §3: the sum of those eight bytes is - // `0xddf2`, and its complement is what the field carries. - assert_eq!(checksum(&[0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7]), 0x220d); - // An odd tail is the last byte padded, not dropped. - assert_ne!(checksum(&[0x01, 0x02, 0x03]), checksum(&[0x01, 0x02])); - } - - #[test] - fn a_request_is_an_echo_request_carrying_its_token() { - let token = token(); - let message = request(&token); - assert_eq!(message[0], ECHO_REQUEST); - assert_eq!(message[1], 0, "an echo carries code 0"); - assert_eq!(&message[HEADER..], &token[..]); - // Two probes of one process are two tokens, so a late reply to the - // first is not an answer to the second. - assert_ne!(token, super::token()); - } - - #[test] - fn the_checksum_is_the_field_rfc_792_gives_it() { - let token = token(); - let message = request(&token); - // The same request with the checksum field unwritten, built here rather - // than taken from `request`, so where the sum went is what is compared. - let mut unsummed = [0u8; HEADER + TOKEN]; - unsummed[0] = ECHO_REQUEST; - unsummed[HEADER..].copy_from_slice(&token); - assert_eq!(u16::from_be_bytes([message[2], message[3]]), checksum(&unsummed)); - assert_eq!(message[4..HEADER], unsummed[4..HEADER], "{message:02x?}"); - } - - #[test] - fn a_reply_is_this_probes_however_the_host_hands_it_over() { - let token = token(); - let reply = reply_to(&request(&token)); - assert!(is_reply(HOST.into(), HOST, &reply, &token)); - let headed: Vec = IPV4.iter().chain(reply.iter()).copied().collect(); - assert!(is_reply(HOST.into(), HOST, &headed, &token)); - } - - /// Everything that carries the bytes and is not this probe's answer. - #[test] - fn nothing_but_the_reply_to_this_probe_counts() { - let token = token(); - let request = request(&token); - // The request itself, which a host that loops its own traffic back - // would otherwise read as an answer. - assert!(!is_reply(HOST.into(), HOST, &request, &token)); - assert!(!is_reply(Ipv4Addr::new(10, 0, 0, 9).into(), HOST, &reply_to(&request), &token)); - // Another probe's reply, and a reply to nobody. - assert!(!is_reply(HOST.into(), HOST, &reply_to(&super::request(&super::token())), &token)); - assert!(!is_reply(HOST.into(), HOST, &[], &token)); - // The token with nothing in front of it: a message that cannot carry a - // type byte is not one. - assert!(!is_reply(HOST.into(), HOST, &token, &token)); - // An unreachable message quoting this probe's request in its own body - // is the address refusing, not answering: the type byte before the - // payload is the quoted request's `8`. - let mut unreachable = vec![3u8, 0, 0, 0, 0, 0, 0, 0]; - unreachable.extend_from_slice(&IPV4); - unreachable.extend_from_slice(&request); - assert!(!is_reply(HOST.into(), HOST, &unreachable, &token)); - } -} diff --git a/src/lan.rs b/src/lan.rs deleted file mode 100644 index 765a6a81854..00000000000 --- a/src/lan.rs +++ /dev/null @@ -1,445 +0,0 @@ -//! What a boot's log and its wire say about the address this machine took from -//! its network. -//! -//! **Text and frames in, verdicts out.** Nothing here touches a machine: the -//! QEMU arm and the T14 arm in `tests/common/lan.rs` read their answers through -//! this, so a guest and a laptop cannot be judged by different grammars. - -#![forbid(unsafe_code)] - -use std::net::Ipv4Addr; - -use crate::bootlog::message; - -/// The records both arms are written against, spelled once. -pub const MAC: &str = "netstack: MAC "; -const LEASE: &str = "netstack: DHCP: lease "; -pub const LINK_UP: &str = "netstack: I219: link up at "; -pub const READY: &str = "netstack: ready, at most "; - -/// The name this machine asks its network to record for it, and answers for as -/// `.local`; held to netstack's own `dhcp::HOSTNAME` by -/// [`tests::netstack_declares_the_name_this_module_spells`]. -pub const HOSTNAME: &str = "toyos-t14"; - -/// One lease, as the record carries it. -#[derive(Debug, PartialEq, Eq)] -pub struct Lease { - pub address: Ipv4Addr, - pub prefix: u8, - pub server: Ipv4Addr, - pub gateway: Ipv4Addr, - pub dns: Vec, - /// Milliseconds between netstack starting and the lease landing. - pub ms: u64, -} - -/// The lease record, read out of a boot's log. -/// -/// Anchored on the record's own words rather than on positions, so a line that -/// grows a field still reads and one that loses a field is refused by name. -pub fn lease_in(text: &str) -> Result { - let line = text - .lines() - .find(|l| l.contains(LEASE)) - .ok_or_else(|| format!("no {LEASE:?} record: this boot took no address from its network"))?; - let unreadable = |what: &str| format!("{line:?} carries no {what}"); - let after = |head: &str, tail: &str| -> Result { - let (_, rest) = line.split_once(head).ok_or_else(|| unreadable(head))?; - let (got, _) = rest.split_once(tail).ok_or_else(|| unreadable(tail))?; - Ok(got.to_string()) - }; - let address = |what: &'static str, got: String| -> Result { - got.parse().map_err(|_| format!("{line:?} reads {got:?} where {what} belongs")) - }; - let cidr = after(LEASE, " from ")?; - let (host, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; - let mut dns = Vec::new(); - for server in after(", dns [", "]")?.split_whitespace() { - dns.push(address("a resolver", server.to_string())?); - } - Ok(Lease { - address: address("this machine's address", host.to_string())?, - prefix: prefix.parse().map_err(|_| unreadable("a prefix length"))?, - server: address("the server's address", after(" from ", ",")?)?, - gateway: address("the gateway's address", after(", gateway ", ",")?)?, - dns, - ms: after("], ", " ms after netstack came up")? - .parse() - .map_err(|_| unreadable("a millisecond count"))?, - }) -} - -/// How long after the driver came up the link did, out of the driver's own -/// record. -pub fn link_up_ms(text: &str) -> Result { - let line = text.lines().find(|l| l.contains(LINK_UP)).ok_or_else(|| { - format!("no {LINK_UP:?} record: this boot's card never reported a link") - })?; - let (_, rest) = line.split_once(", ").ok_or_else(|| { - format!("{line:?} says nothing about when the link came up, so the card was already up") - })?; - rest.split_once(" ms after the driver came up") - .ok_or_else(|| format!("{line:?} carries no link-up time"))? - .0 - .parse() - .map_err(|_| format!("{line:?} carries no readable link-up time")) -} - -/// What netstack wrote in a boot's `/log`: the text of each of its lines. -pub fn netstack_records(text: &str) -> String { - crate::bootlog::lines_of(text, "netstack") -} - -/// The T14's I219, as the kernel's hand-over record spells its id. -pub const I219: &str = "8086:15fc"; - -/// The kernel's two records that say a message the I219 raised reached a CPU. -#[derive(Debug, PartialEq, Eq)] -pub struct Delivery { - pub slot: usize, - pub vector: u8, - pub handed: String, - pub took: String, -} - -/// Whether a message the I219 raised reached a CPU, out of the kernel's own -/// records: the hand-over that names the function's slot and vector, and that -/// slot's first-message record on that vector, after it and before the slot's -/// next hand-over. -/// -/// **Whole messages in the kernel's spelling, never a substring**, so another -/// writer's line, another slot's record, and a record with no hand-over of this -/// function before it are each refused by what they lack. -pub fn delivered(text: &str) -> Result { - let handed = format!("[{I219}] handed over on slot "); - let Some((at, line)) = text.lines().enumerate().find(|(_, l)| { - message(l).is_some_and(|m| m.starts_with("pcidev: PCI ") && m.contains(&handed)) - }) else { - return Err(match text.lines().find(|l| l.contains("NOT HANDED OVER")) { - Some(refused) => format!( - "no `{handed}` record, and the kernel refused a function: {}", - refused.trim() - ), - None => format!( - "no `{handed}` record and no refusal either: nothing on this boot claimed {I219}" - ), - }); - }; - let (slot, vector) = message(line) - .and_then(|m| m.split_once(&handed)) - .and_then(|(_, tail)| tail.split_once(", vector 0x")) - .and_then(|(slot, vector)| { - Some((slot.parse::().ok()?, u8::from_str_radix(vector, 16).ok()?)) - }) - .ok_or_else(|| format!("{line:?} carries no readable slot and vector"))?; - - // A hand-over clears the slot's record, so the next one of this slot ends - // the span the record can answer this claim in. - let again = format!("handed over on slot {slot},"); - let end = text - .lines() - .enumerate() - .skip(at + 1) - .find(|(_, l)| { - message(l).is_some_and(|m| m.starts_with("pcidev: PCI ") && m.contains(&again)) - }) - .map(|(i, l)| (i, l.trim())); - let span = end.map_or(usize::MAX, |(i, _)| i); - let want = format!("pcidev: slot {slot} took its first message on vector {vector:#x}"); - if let Some(took) = - text.lines().take(span).skip(at + 1).find(|l| message(l) == Some(want.as_str())) - { - return Ok(Delivery { slot, vector, handed: line.to_string(), took: took.to_string() }); - } - - let mut why = format!( - "{I219} was handed over on slot {slot}, vector {vector:#x}, and no kernel record \ - `{want}` follows it: the kernel took no message on this function's vector" - ); - if let Some((_, handed_again)) = end { - why.push_str(&format!("\n the slot, handed over again: {handed_again}")); - } - for (i, other) in text.lines().enumerate().filter(|(_, l)| l.contains("took its first message")) { - let said = message(other); - why.push_str(if said == Some(want.as_str()) && i < at { - "\n the record, before the hand-over it would answer: " - } else if said == Some(want.as_str()) && i > span { - "\n the record, after the slot was handed over again: " - } else if said.is_some_and(|m| { - m.starts_with("pcidev: slot ") && m.contains(" took its first message on vector 0x") - }) { - "\n another claim's record, not this function's: " - } else { - "\n a line not in the kernel's spelling of the record: " - }); - why.push_str(other.trim()); - } - Err(why) -} - -#[cfg(test)] -mod tests { - use super::*; - - const LEASED: &str = "[2026-09-08 16:08:23 2.100 cpu0 kernel] netstack: DHCP: lease 10.0.2.15/24 from \ - 10.0.2.2, gateway 10.0.2.2, dns [10.0.2.3 10.0.2.4], 412 ms after netstack \ - came up"; - - /// Nothing links the two crates: netstack is a `no_std`-shaped userland binary - /// and this is the build system, so the name both ends spell is held to - /// netstack's own declaration by reading its source. - #[test] - fn netstack_declares_the_name_this_module_spells() { - let at = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("userland/netstack/src/dhcp.rs"); - let source = std::fs::read_to_string(&at).expect("netstack's dhcp module"); - assert!( - crate::bootlog::declares(&source, &format!("\"{HOSTNAME}\"")), - "{} declares no constant equal to \"{HOSTNAME}\"", - at.display() - ); - } - - /// **Every field the lease decided, typed**, so a record that grew a field - /// still reads and one that lost a field is refused by the field's name. - #[test] - fn a_lease_record_is_read_field_by_field() { - assert_eq!( - lease_in(LEASED), - Ok(Lease { - address: Ipv4Addr::new(10, 0, 2, 15), - prefix: 24, - server: Ipv4Addr::new(10, 0, 2, 2), - gateway: Ipv4Addr::new(10, 0, 2, 2), - dns: vec![Ipv4Addr::new(10, 0, 2, 3), Ipv4Addr::new(10, 0, 2, 4)], - ms: 412, - }) - ); - // A lease with no resolvers at all is a lease, and an empty list is not - // a missing field. - let none = LEASED.replace("10.0.2.3 10.0.2.4", ""); - assert!(lease_in(&none).expect("a lease").dns.is_empty()); - } - - #[test] - fn a_record_missing_a_field_is_refused_by_that_fields_name() { - assert!(lease_in("nothing here\n").unwrap_err().contains("took no address")); - for (cut, says) in [ - (", gateway 10.0.2.2", "gateway"), - (", dns [10.0.2.3 10.0.2.4]", "dns ["), - ("/24", "an address/prefix"), - ] { - let why = lease_in(&LEASED.replace(cut, "")).expect_err(cut); - assert!(why.contains(says), "{cut}: {why}"); - } - // A field that is there and is not what it claims to be. - let why = lease_in(&LEASED.replace("gateway 10.0.2.2", "gateway enp0s31f6")) - .expect_err("an interface name is not a gateway"); - assert!(why.contains("the gateway's address"), "{why}"); - let why = lease_in(&LEASED.replace("dns [10.0.2.3", "dns [fe80::1")) - .expect_err("an IPv6 resolver is not one this record can carry"); - assert!(why.contains("a resolver"), "{why}"); - let why = lease_in(&LEASED.replace("412 ms", "later ms")).expect_err("no milliseconds"); - assert!(why.contains("a millisecond count"), "{why}"); - } - - /// netstack's lines and nothing else: a kernel record spelled like netstack's, - /// another program's line quoting netstack's head, and a console line with no - /// head at all are each left out. - #[test] - fn only_netstacks_own_lines_are_netstacks() { - let lease = LEASED.split_once("] ").expect("a record").1; - let log = format!( - "[2026-09-08 16:08:23 2.100 netstack] {lease}\n\ - [2026-09-08 16:08:23 2.200 netstack] netstack: ready, at most 8 clients\n\ - [2026-09-08 16:08:23 2.300 evil] [2026-09-08 16:08:23 2.300 netstack] netstack: MAC 00:00:00:00:00:00\n\ - [2026-09-08 16:08:23 2.400 cpu0 kernel] netstack: MAC 00:00:00:00:00:01\n\ - netstack: MAC 52:54:00:12:34:56\n" - ); - let netstack = netstack_records(&log); - assert_eq!(netstack.lines().count(), 2, "{netstack}"); - assert!(lease_in(&netstack).is_ok()); - assert!(!netstack.contains(MAC), "a line quoting netstack was read as netstack's: {netstack}"); - } - - #[test] - fn a_link_that_was_already_up_is_told_from_one_that_came_up() { - let came_up = format!("[x] {LINK_UP}1000 Mb/s, 2400 ms after the driver came up"); - assert_eq!(link_up_ms(&came_up), Ok(2_400)); - assert!(link_up_ms("nothing\n").unwrap_err().contains("never reported a link")); - let why = link_up_ms(&format!("[x] {LINK_UP}1000 Mb/s")).expect_err("no comma"); - assert!(why.contains("already up"), "{why}"); - } - - /// Metal run 57's `lanicscase` kernel log, verbatim, from the I219's - /// hand-over to the record of its first message. - const RUN_57: &str = "\ -[2026-09-16 15:01:20 1.333 cpu0 kernel] pcidev: PCI 00:1f.6 [8086:15fc] handed over on slot 0, vector 0x28 -[2026-09-16 15:01:20 1.360 cpu0 kernel] ELF: 3417 relocations indexed (RELATIVE + GLOB_DAT + TPOFF) -[2026-09-16 15:01:20 1.360 cpu0 kernel] spawn: TLS 1 modules, total_memsz=144 -[2026-09-16 15:01:20 1.518 cpu0 kernel] spawn: /system/bin/netstack pid=5 tid=0 dst=5 base=0x10000000000 entry=0x1000004e5c0 cr3=0x1cc1000 symbols=2048KiB (layout=25ms relocs=0ms deps=0ms tls=1ms total=184ms) -[2026-09-16 15:01:20 1.552 cpu0 kernel] ELF: 2833 relocations indexed (RELATIVE + GLOB_DAT + TPOFF) -[2026-09-16 15:01:20 1.552 cpu0 kernel] spawn: TLS 1 modules, total_memsz=144 -[2026-09-16 15:01:20 1.739 cpu0 kernel] spawn: /system/bin/test-runner pid=6 tid=0 dst=6 base=0x10000000000 entry=0x1000001fff0 cr3=0x1cbf000 symbols=2048KiB (layout=32ms relocs=0ms deps=0ms tls=2ms total=221ms) -[2026-09-16 15:01:20 1.958 cpu4 kernel] usb-storage: disk 0 does not implement SYNCHRONIZE CACHE (sense 0x05/0x20/0x00); its writes are durable once they complete -[2026-09-16 15:01:21 2.239 cpu5 kernel] shm: 0xa0800000 mapped Uncacheable into pid 5 -[2026-09-16 15:01:21 2.239 cpu5 kernel] iommu: domain6 maps 0x6800000..0x6a00000 at 0x2000000000 -[2026-09-16 15:01:21 2.239 cpu0 kernel] pcidev: slot 0 took its first message on vector 0x28 -"; - - const HANDED: &str = "[2026-09-16 15:01:20 1.333 cpu0 kernel] pcidev: PCI 00:1f.6 [8086:15fc] \ - handed over on slot 0, vector 0x28\n"; - const TOOK: &str = - "[2026-09-16 15:01:21 2.239 cpu0 kernel] pcidev: slot 0 took its first message on vector 0x28\n"; - - /// Run 57 with one of its lines replaced, refusing a fixture the edit did - /// not change. - fn edited(from: &str, to: &str) -> String { - assert!(RUN_57.contains(from), "run 57's excerpt carries no {from:?}"); - RUN_57.replacen(from, to, 1) - } - - #[test] - fn run_57_took_a_message_on_the_i219s_own_slot_and_vector() { - let got = delivered(RUN_57).expect("run 57 recorded the message"); - assert_eq!((got.slot, got.vector), (0, 0x28)); - assert_eq!(format!("{}\n", got.handed), HANDED); - assert_eq!(format!("{}\n", got.took), TOOK); - } - - #[test] - fn a_boot_with_no_first_message_record_is_refused_by_the_record_it_lacks() { - let why = delivered(&edited(TOOK, "")).expect_err("no record"); - assert!( - why.contains("no kernel record `pcidev: slot 0 took its first message on vector 0x28`"), - "{why}" - ); - } - - /// Another writer's line carrying the words is not the kernel's record. - #[test] - fn a_line_that_is_not_the_kernels_record_is_refused_and_named() { - let stray = "[2026-09-16 15:01:21 2.240 cpu3 kernel] test-runner: waiting until netstack took its \ - first message\n"; - let log = edited(TOOK, "").replacen( - "total=184ms)\n", - &format!("total=184ms)\n{stray}"), - 1, - ); - assert!(log.contains(stray)); - let why = delivered(&log).expect_err("a userland line"); - assert!(why.contains("no kernel record"), "{why}"); - assert!(why.contains("a line not in the kernel's spelling of the record: "), "{why}"); - assert!(why.contains("test-runner: waiting until netstack"), "{why}"); - } - - #[test] - fn another_slots_record_is_refused_as_another_claims() { - let why = delivered(&edited( - TOOK, - "[2026-09-16 15:01:21 2.239 cpu0 kernel] pcidev: slot 1 took its first message on vector \ - 0x30\n", - )) - .expect_err("slot 1 is not the I219's"); - assert!(why.contains("handed over on slot 0, vector 0x28"), "{why}"); - assert!(why.contains("another claim's record, not this function's: "), "{why}"); - assert!(why.contains("slot 1 took its first message on vector 0x30"), "{why}"); - // The right slot on the wrong vector is not the record either. - let why = delivered(&edited(TOOK, &TOOK.replace("0x28", "0x29"))) - .expect_err("vector 0x29 is not the one slot 0 was given"); - assert!(why.contains("another claim's record"), "{why}"); - } - - #[test] - fn a_boot_where_the_i219_was_not_handed_over_is_refused_whatever_else_took_a_message() { - let log = edited( - HANDED, - "[2026-09-16 15:01:20 1.333 cpu0 kernel] pcidev: PCI 00:1f.6 [8086:15fc] NOT HANDED OVER: \ - refused\n", - ) - .replacen( - TOOK, - "[2026-09-16 15:01:21 2.239 cpu0 kernel] pcidev: slot 3 took its first message on vector \ - 0x2a\n", - 1, - ); - assert!(!log.contains(TOOK)); - let why = delivered(&log).expect_err("the I219 was refused"); - assert!(why.contains("no `[8086:15fc] handed over on slot ` record"), "{why}"); - assert!(why.contains("NOT HANDED OVER"), "{why}"); - } - - #[test] - fn the_record_before_the_hand_over_answers_nothing() { - let log = format!("{TOOK}{}", edited(TOOK, "")); - let why = delivered(&log).expect_err("the record precedes the claim"); - assert!(why.contains("before the hand-over it would answer"), "{why}"); - } - - /// A hand-over of the same slot to another function clears the slot's - /// record, so a record after it answers that claim and not the I219's. - #[test] - fn a_record_after_the_slot_is_handed_over_again_is_refused() { - let again = - "[2026-09-16 15:01:21 2.200 cpu0 kernel] pcidev: PCI 00:1f.7 [8086:a0f0] handed over on \ - slot 0, vector 0x28\n"; - let log = edited(TOOK, &format!("{again}{TOOK}")); - let why = delivered(&log).expect_err("the record answers the later claim"); - assert!(why.contains("the slot, handed over again: "), "{why}"); - assert!(why.contains("[8086:a0f0] handed over on slot 0"), "{why}"); - assert!(why.contains("the record, after the slot was handed over again: "), "{why}"); - // Another slot's hand-over ends nothing. - let other = again.replace("slot 0, vector 0x28", "slot 1, vector 0x29"); - assert!(delivered(&edited(TOOK, &format!("{other}{TOOK}"))).is_ok()); - } - - /// Another writer's line carrying the hand-over's words is not the kernel's - /// hand-over. - #[test] - fn a_hand_over_not_in_the_kernels_spelling_is_refused() { - let stray = "[2026-09-16 15:01:20 1.333 cpu0 kernel] netstack: [8086:15fc] handed over on slot 0, \ - vector 0x28\n"; - let why = delivered(&edited(HANDED, stray)).expect_err("a userland line"); - assert!(why.contains("no `[8086:15fc] handed over on slot ` record"), "{why}"); - } - - /// The string literal whose text begins `head`, as the compiler reads it: - /// a `\` at a line's end joins the next line with its indent dropped. - fn literal(source: &str, head: &str) -> Option { - let (_, rest) = source.split_once(&format!("\"{head}"))?; - let (body, _) = rest.split_once('"')?; - let mut out = head.to_string(); - let mut lines = body.split("\\\n"); - out.push_str(lines.next()?); - for line in lines { - out.push_str(line.trim_start()); - } - Some(out) - } - - /// Nothing links the kernel to the build system, so the two records this - /// judge reads are held to the kernel's own format strings, whole. - #[test] - fn the_kernel_writes_both_records_in_the_spelling_read_here() { - let at = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("kernel/src/pcidev/mod.rs"); - let source = std::fs::read_to_string(&at).expect("the kernel's pcidev module"); - for (head, whole) in [ - ( - "pcidev: PCI ", - "pcidev: PCI {:02x}:{:02x}.{} [{:04x}:{:04x}] handed over on slot {slot}, \ - vector {:#x}", - ), - ("pcidev: slot ", "pcidev: slot {slot} took its first message on vector {:#x}"), - ] { - let spelled: Vec = source - .match_indices(&format!("\"{head}")) - .filter_map(|(i, _)| literal(&source[i..], head)) - .collect(); - assert!( - spelled.iter().any(|l| l == whole), - "{} spells no {whole:?}; its {head:?} literals are {spelled:#?}", - at.display() - ); - } - } -} diff --git a/src/lib.rs b/src/lib.rs index 83d3029567a..45229856279 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,6 @@ pub mod firmware; pub mod gitfixture; pub mod flags; pub mod hostws; -pub mod icmp; pub mod identity; pub mod image; pub mod kernelconsole; @@ -27,7 +26,6 @@ pub mod signing; #[cfg(test)] pub mod kernelkeys; pub mod keystore; -pub mod lan; pub mod libc; pub mod libcxx; pub mod llvm; @@ -35,8 +33,6 @@ pub mod licence; pub mod metal; pub mod metaldevices; pub mod metalimage; -pub mod metalswap; -pub mod metaltalk; pub mod metaltimings; pub mod n2; pub mod release; diff --git a/src/metal.rs b/src/metal.rs index fee39041d79..40150e1ade2 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -27,6 +27,9 @@ use crate::metaltimings::Machine; const CONNECT_SECS: u64 = 10; +/// The port the machine's ssh server listens on, which is the protocol's own. +const SSH_PORT: u16 = 22; + /// How long the machine has to go quiet after `reboot`. const GOING_DOWN_SECS: u64 = 120; @@ -151,10 +154,6 @@ pub enum Refusal { /// A lid key that no longer reads `ignore`, which is what keeps the machine up. Lid { key: &'static str, got: String }, Remote { what: String, status: String, stderr: String }, - /// The machine could not name the interface, the MAC and an IPv4 address it - /// holds on the function the flashed image claims. No address is a cable - /// that is out, and a boot flashed onto that bench is red for the bench. - Wire { nic: String, why: String }, /// The machine did not go down, or did not come back. Silent { what: &'static str, secs: u64 }, /// The machine came back and the boot stick did not: the boot before this @@ -180,18 +179,6 @@ pub enum Refusal { /// `Rebooting.` is the failure and a sealed `WEDGED` record is the pass — /// the one boot in this loop whose verdict is not `bootlog::verdict`'s. Wedge { why: &'static str }, - /// **What the boot said over its own cable is not what a talking boot - /// owes**: the log it serves, a ping, the command's answer and `reboot`, - /// each finding by name. Judged after the stick's own verdict, which stays - /// the fallback for a boot that never reached its network. - Talk(Vec), - /// This host could not set up its half of the cable: the client, the key, - /// or the reader of the log the boot serves. - Cable(String), - /// **What a swap of a running machine's service came to is not the new - /// binary in service**: the supervisor's words, the machine's answer or ssh - /// afterwards, each finding by name. - Swap(Vec), /// The machine did not name itself: its SMBIOS answer is not /// [`Machine::QUERY`]'s three lines. Machine(String), @@ -210,8 +197,6 @@ impl Refusal { | Self::Fat32(_) | Self::ReportedAndBootedNothing { .. } | Self::HungWithoutARecord - | Self::Talk(_) - | Self::Swap(_) | Self::Wedge { .. } ) } @@ -298,11 +283,6 @@ impl fmt::Display for Refusal { Self::Remote { what, status, stderr } => { write!(f, "{what} on the machine {status}: {stderr}") } - Self::Wire { nic, why } => write!( - f, - "the machine says nothing usable about PCI function {nic}, which the flashed \ - image claims and a boot of it could answer on: {why}" - ), Self::Silent { what, secs } => write!( f, "the machine did not {what} within {secs} s, which is longer than every watchdog \ @@ -337,17 +317,6 @@ impl fmt::Display for Refusal { because nothing ran. This boot measured no test. What the pass said was: \ {said}" ), - Self::Talk(findings) => write!( - f, - "the boot did not say over its own cable what a talking boot owes:\n {}", - findings.join("\n ") - ), - Self::Cable(why) => write!(f, "this host's half of the cable is not ready: {why}"), - Self::Swap(findings) => write!( - f, - "the swap did not put the new binary in service:\n {}", - findings.join("\n ") - ), Self::Machine(why) => write!(f, "the machine did not say what it is: {why}"), Self::Usage(why) => write!(f, "{why}"), } @@ -1050,37 +1019,6 @@ fn lid_policy(text: &str) -> Result<(), Refusal> { Ok(()) } -/// What the machine holds on the PCI function the flashed image claims, read -/// off that function and not off a name. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Wire { - pub iface: String, - /// What the interface holds before the flash: that it holds one is the - /// cable being in. A boot leases its own, so no judge reads this. - pub addr: std::net::Ipv4Addr, - /// Lower case, colon separated, as `/sys/class/net//address` writes it. - pub mac: String, -} - -/// `ip -4 -brief addr show `'s one line, as `Wire` needs it: the brief -/// form is ` ...`, and an interface with no address has no -/// third field at all, which is the machine saying the cable is out. -fn brief_address(iface: &str, text: &str) -> Result { - let line = text - .lines() - .find(|l| l.split_whitespace().next() == Some(iface)) - .ok_or_else(|| format!("`ip -4 -brief addr show {iface}` said {text:?}"))?; - let cidr = line - .split_whitespace() - .nth(2) - .ok_or_else(|| format!("{iface} holds no IPv4 address: {line:?}"))?; - cidr.split('/') - .next() - .unwrap_or(cidr) - .parse() - .map_err(|_| format!("{iface}'s address reads {cidr:?}")) -} - /// The loop, over one target. struct Driver { target: Target, @@ -1131,42 +1069,6 @@ impl Driver { answer(what, out).map(Some) } - /// What this machine holds on the function the flashed image claims: the - /// interface Ubuntu gave it, its MAC and its address. Three reads and not - /// one, so a machine that answers oddly is refused with the read that was - /// odd; none writes. - fn wire(&self, nic: &str) -> Result { - let bad = |why: String| Refusal::Wire { nic: nic.to_string(), why }; - let at = shell_word(&format!("/sys/bus/pci/devices/{nic}/net")); - let listing = self - .ssh("listing the claimed function's interfaces", &format!("ls {at}")) - .map_err(|e| bad(e.to_string()))?; - let names: Vec<&str> = listing.split_whitespace().collect(); - // Exactly one, refused rather than resolved to the first: a function - // this loop cannot name one interface for is one whose address it would - // be guessing at. - let [iface] = names[..] else { - return Err(bad(format!("it answers {names:?} interface(s), and one is needed"))); - }; - let mac = self - .ssh( - "reading the claimed function's MAC", - &format!("cat {}", shell_word(&format!("/sys/class/net/{iface}/address"))), - ) - .map_err(|e| bad(e.to_string()))?; - let brief = self - .ssh( - "reading the claimed function's address", - &format!("ip -4 -brief addr show {}", shell_word(iface)), - ) - .map_err(|e| bad(e.to_string()))?; - Ok(Wire { - iface: iface.to_string(), - addr: brief_address(iface, &brief).map_err(bad)?, - mac: mac.trim().to_ascii_lowercase(), - }) - } - /// The loop refuses to run at all until the rule is on the machine. fn require_sudo(&self) -> Result<(), Refusal> { let probe = self.target.remote(Job::Probe, None)?; @@ -1264,11 +1166,8 @@ impl Driver { /// **`reboot` is `systemctl` and returns before the machine goes down**, so /// the machine is watched down before it is watched back up: a probe that /// caught dying Ubuntu would read a stick ToyOS had never booted. - /// - /// `down` runs the moment the machine has gone down. - fn ride_the_reboot(&self, secs: u64, down: impl FnOnce()) -> Result { + fn ride_the_reboot(&self, secs: u64) -> Result { self.wait(GOING_DOWN_SECS, "go down", false)?; - down(); self.wait(secs, "come back", true) } @@ -1300,7 +1199,7 @@ impl Driver { secs, what, answering, - |within| port_accepts(host, crate::metaltalk::SSH_PORT, within), + |within| port_accepts(host, SSH_PORT, within), || self.ssh("probing", "true").is_ok(), ) } @@ -1446,23 +1345,13 @@ declare_flags!(METAL = { INSTALL_SUDOERS = "--install-sudoers", Next; READBACK = "--readback", Next; FAT32_CHECK = "--fat32-check", None; - NIC = "--nic", Next; WAIT_SECS = "--wait-secs", Next; - TALK = "--talk", Next; DRY_RUN = "--dry-run", None; - SWAP = "--swap", Next; - BINARY = "--binary", Next; }); -/// The flags a swap of a running machine's service refuses beside it: it -/// flashes nothing and reboots nothing, so each of these describes a boot it -/// will not make. -const NOT_A_SWAP: &[&Flag] = &[&DRY_RUN, &FAT32_CHECK, &NIC, &INSTALL_SUDOERS, &IMAGE]; - /// The flags that describe a boot, as against the ones that say which machine /// to reach: [`Args::parse`] refuses an `--install-sudoers` beside any of them. -const ABOUT_A_BOOT: &[&Flag] = - &[&DRY_RUN, &IMAGE, &READBACK, &FAT32_CHECK, &NIC, &WAIT_SECS, &TALK]; +const ABOUT_A_BOOT: &[&Flag] = &[&DRY_RUN, &IMAGE, &READBACK, &FAT32_CHECK, &WAIT_SECS]; /// What the binary was asked to do. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1497,24 +1386,6 @@ pub struct Args { /// `toyos-fat32-check`. The outside judge, and the only reader of that /// volume in this tree that is not the family of code that wrote it. fat32_check: bool, - /// The PCI function this boot's image claims, in `/sys/bus/pci/devices`'s - /// spelling. **A boot names it or the function is not read at all**: the - /// reads cost three `ssh` round trips, and a boot that needs no cable would - /// be refused for one that is out. - nic: Option, - /// The private key the image authorizes, and the ask to talk to the boot - /// over its cable: once the machine has gone down, read the log it serves - /// at `toyos-t14.local` from its first line, ping it, run one command on it - /// and tell it to reboot. **Nothing in the image names this host**: the - /// machine answers for its own name, and this host asks for it. - talk: Option, - /// **Replace a running service's binary, and flash and reboot nothing.** - /// The service's key; [`Args::binary`] is the new binary, `--talk` the key - /// the running machine authorizes — asked for it by name at - /// `toyos-t14.local`, so no image needs naming — and `--readback` where the - /// stream is written. - swap: Option, - binary: Option, } impl Args { @@ -1544,10 +1415,6 @@ impl Args { wait_secs: return_secs(), readback: value(&READBACK).map(PathBuf::from), fat32_check: METAL.present(args, &FAT32_CHECK), - nic: value(&NIC).map(str::to_string), - talk: value(&TALK).map(PathBuf::from), - swap: value(&SWAP).map(str::to_string), - binary: value(&BINARY).map(PathBuf::from), }; if let Some(host) = value(&HOST) { let (user, machine) = host.split_once('@').ok_or_else(|| { @@ -1579,175 +1446,10 @@ impl Args { out.about_a_boot.join(" and ") ))); } - // The stream is written as it arrives, and a boot that dies before - // the stick is read leaves nothing but that file. - if out.talk.is_some() && out.readback.is_none() { - return Err(Refusal::Usage( - "--talk writes the log the boot serves into the readback as it arrives; name \ - one with --readback" - .to_string(), - )); - } - match (&out.swap, &out.binary) { - (None, None) => {} - (None, Some(_)) => { - return Err(Refusal::Usage("--binary is the new binary of a --swap".to_string())) - } - (Some(service), binary) => { - let beside: Vec<&str> = line - .seen - .iter() - .map(|seen| seen.flag.name) - .filter(|name| NOT_A_SWAP.iter().any(|flag| flag.name == *name)) - .collect(); - if !beside.is_empty() { - return Err(Refusal::Usage(format!( - "--swap replaces a running service's binary and flashes and reboots \ - nothing, so {} describes a boot it will not make", - beside.join(" and ") - ))); - } - if !toyos_swap::is_service_name(service) { - return Err(Refusal::Usage(format!("--swap {service:?} is no service's key"))); - } - if binary.is_none() || out.talk.is_none() { - return Err(Refusal::Usage( - "--swap wants --binary , --talk and --readback " - .to_string(), - )); - } - } - } Ok(out) } } -/// This host's half of the cable: the client and the key, checked before the -/// flash, and the stream and the conversation, started once the machine has -/// gone down. -struct Talking { - ssh: crate::metaltalk::Ssh, - dir: PathBuf, -} - -/// Where the stream is written as it arrives, beside the stick's files. -pub const READBACK_STREAM: &str = "stream.log"; - -impl Talking { - /// Everything that can refuse before the machine is touched. - fn prepare(key: &Path, dir: &Path) -> Result { - std::fs::create_dir_all(dir) - .map_err(|e| Refusal::File { path: dir.display().to_string(), why: e.to_string() })?; - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let ssh = crate::metaltalk::Ssh::at(root, key.to_path_buf()).map_err(Refusal::Cable)?; - Ok(Self { ssh, dir: dir.to_path_buf() }) - } - - /// Ask for the log the booting machine serves under its name, then talk - /// to it, on a thread of its own: the loop is meanwhile watching the - /// machine come back, and the conversation is what makes it come back - /// early. - /// - /// **Called once the machine has gone down**, so the name is asked of the - /// boot this loop flashed and not of the operating system it replaced. - fn start( - &self, - by: std::time::Duration, - ) -> Result< - (crate::metaltalk::Stream, std::thread::JoinHandle>), - Refusal, - > { - self.start_into(READBACK_STREAM, "talk", by) - } - - /// [`Talking::start`], writing the stream to `file` and the conversation's - /// scratch under `scratch`, both in [`Talking::dir`] — so a swap of an - /// already-running machine and a flash's own boot each keep their own - /// files in one readback directory. - fn start_into( - &self, - file: &str, - scratch: &str, - by: std::time::Duration, - ) -> Result< - (crate::metaltalk::Stream, std::thread::JoinHandle>), - Refusal, - > { - let stream = self.connect(file, by)?; - let (theirs, ssh, scratch) = (stream.clone(), self.ssh.clone(), self.dir.join(scratch)); - let talking = std::thread::Builder::new() - .name("metal-talk".into()) - .spawn(move || crate::metaltalk::converse(&theirs, &ssh, None, true, &scratch)) - .expect("the metal loop's conversation could not be started"); - Ok((stream, talking)) - } - - /// The stream itself, asked of the machine's name, for a caller that runs - /// its own protocol on it rather than [`Talking::start`]'s ping/command/ - /// reboot conversation — a swap's own exchange - /// ([`crate::metalswap::swap`]). - /// - /// A swap of the netstack carrying it is followed across by - /// [`crate::metalswap::swap`] itself ([`crate::metaltalk::Stream::redial`]). - fn connect(&self, file: &str, by: std::time::Duration) -> Result { - let peer = crate::metaltalk::Peer::Named { - host: format!("{}.local", crate::lan::HOSTNAME), - port: toyos_logstream::PORT, - }; - println!("asking for {peer:?}'s log"); - let at = self.dir.join(file); - crate::metaltalk::Stream::connect(peer, &at, true, by).map_err(Refusal::Cable) - } -} - -/// Where a swap writes the stream it connected to, beside whatever else the -/// readback holds. -pub const READBACK_SWAP_STREAM: &str = "swap-stream.log"; - -/// Replace `service`'s binary on the machine that answers for its own name, -/// and judge it: the machine is asked over ssh, the supervisor's words are read off the -/// log stream, and ssh must answer again afterwards. -/// -/// **Nothing is flashed and nothing is rebooted**: the machine is found at -/// `toyos-t14.local` the way [`Talking::start`] finds it, so this can be -/// started before that machine has booted or long after — and read on across -/// a swap of netstack itself ([`crate::metalswap::swap`]). -fn swap_running(args: &Args, service: &str) -> Result<(), Refusal> { - let (Some(key), Some(dir), Some(binary)) = (&args.talk, &args.readback, &args.binary) else { - return Err(Refusal::Usage("--swap wants --talk, --readback and --binary".into())); - }; - let cable = Talking::prepare(key, dir)?; - let by = std::time::Duration::from_secs(args.wait_secs); - let stream = cable.connect(READBACK_SWAP_STREAM, by)?; - let scratch = dir.join("swap"); - std::fs::create_dir_all(&scratch) - .map_err(|e| Refusal::File { path: scratch.display().to_string(), why: e.to_string() })?; - println!("asking whichever machine opens it to run {} as {service}", binary.display()); - let swapped = crate::metalswap::swap( - &stream, - &cable.ssh, - None, - &crate::metalswap::Ask { service, binary, named: None }, - by, - &scratch, - ); - stream.give_up(); - let swapped = swapped.map_err(|why| Refusal::Swap(vec![why]))?; - for (word, detail) in &swapped.words { - println!(" supervisor: {}: {detail}", word.as_str()); - } - for line in &swapped.said { - print!(" {service}| {line}"); - } - let judged = crate::metalswap::judge(&swapped, crate::metalswap::Expect::InService); - for line in judged.map_err(Refusal::Swap)? { - println!("swap: {line}"); - } - println!("SWAPPED: {service} runs {} and nothing was rebooted to put it there", binary.display()); - Ok(()) -} - /// Put the rule on the machine, with the account's password on this one /// command's stdin and nowhere else — never in an argument, never in output. fn install_sudoers(target: &Target, password: &Path) -> Result<(), Refusal> { @@ -1827,9 +1529,6 @@ pub fn run(args: &Args) -> Result, Refusal> { install_sudoers(&args.target, password)?; return Ok(None); } - if let Some(service) = &args.swap { - return swap_running(args, service).map(|()| None); - } let driver = Driver { target: args.target.clone(), dry_run: args.dry_run }; let Some(asked) = &args.image else { @@ -1862,11 +1561,6 @@ pub fn run(args: &Args) -> Result, Refusal> { // image will arm, judged against the only table that has ruled on any of it. let armed = arms_are_admissible(asked)?; println!("image {}: armed with {armed:?}", image.path.display()); - // The client and its key before the machine is asked anything. - let cable = match (&args.talk, &args.readback) { - (Some(key), Some(dir)) => Some(Talking::prepare(key, dir)?), - _ => None, - }; driver.require_sudo()?; let policy = @@ -1884,17 +1578,6 @@ pub fn run(args: &Args) -> Result, Refusal> { let machine = Machine::parse(&driver.ssh("reading the machine's SMBIOS", Machine::QUERY)?) .map_err(Refusal::Machine)?; println!("machine {} {}, BIOS {}", machine.vendor, machine.product, machine.bios); - // Before the flash: a cable that is out is refused while nothing is - // written, and the operating system that is still up is the one reader of - // this function's MAC that is not the driver under test. - let wire = match &args.nic { - Some(nic) => { - let wire = driver.wire(nic)?; - println!("the claimed function {nic} is {} at {}", wire.iface, wire.addr); - Some(wire) - } - None => None, - }; driver.flash(&image)?; let entry = driver.boot_entry(&image.esp)?; @@ -1908,37 +1591,7 @@ pub fn run(args: &Args) -> Result, Refusal> { return Ok(None); } - // The conversation runs while the loop watches the machine come back; its - // `reboot` is what brings it back before the boot's own hold does. - let mut talking = None; - let ridden = driver.ride_the_reboot(args.wait_secs, || { - if let Some(cable) = &cable { - talking = Some(cable.start(std::time::Duration::from_secs(args.wait_secs))); - } - }); - // **Written before anything else can refuse**: a machine that never came - // back, a stick that did not enumerate or a volume the outside judge - // complained about each return below, and what the cable heard is then - // the only account of the boot there is. - let heard = match (talking, &args.readback) { - (Some(Ok((stream, handle))), Some(dir)) => { - stream.give_up(); - let heard = handle - .join() - .unwrap_or_else(|_| Err("the conversation's thread panicked".to_string())); - write_talk(dir, &heard)?; - Some((heard, stream.lines())) - } - // A reader that could not be started is a conversation that never - // opened, recorded like one, and the stick is still read. - (Some(Err(refused)), Some(dir)) => { - let heard = Err(refused.to_string()); - write_talk(dir, &heard)?; - Some((heard, Vec::new())) - } - _ => None, - }; - let back = ridden?; + let back = driver.ride_the_reboot(args.wait_secs)?; println!("the machine answered ssh again after {back} s"); // Before the mount, so the stick's own answer is a number rather than // the reason a mount failed. @@ -1965,9 +1618,8 @@ pub fn run(args: &Args) -> Result, Refusal> { } println!("toyos-fat32-check: the log partition's {} bytes check out", bytes.len()); } - let boot = boot_file(back, stick, &machine, wire.as_ref()); - judge_and_write_readback(&armed, &loader, &log, heard.as_ref(), args.readback.as_deref(), &boot) - .map(Some) + let boot = boot_file(back, stick, &machine); + judge_and_write_readback(&armed, &loader, &log, args.readback.as_deref(), &boot).map(Some) } /// This boot's verdict, **judged before the readback is written, and written @@ -1977,18 +1629,10 @@ fn judge_and_write_readback( armed: &[String], loader: &str, log: &str, - heard: Option<&(Result, Vec)>, readback: Option<&Path>, boot: &str, ) -> Result { - let verdict = boot_verdict(armed, loader, log).and_then(|ms| { - // After the stick's own verdict, which stays the one that names a boot - // that never reached its network. - if let Some((heard, lines)) = heard { - talk_verdict(heard, lines)?; - } - Ok(ms) - }); + let verdict = boot_verdict(armed, loader, log); if let Some(dir) = readback { write_readback(dir, loader, log, boot, &verdict_file(verdict.as_ref().err()))?; println!("readback written to {}", dir.display()); @@ -2035,43 +1679,6 @@ fn boot_verdict(armed: &[String], loader: &str, log: &str) -> Result, -) -> Result<(), Refusal> { - let text = match heard { - Ok(conversation) => conversation.render(), - Err(why) => format!("{TALK_UNOPENED} {why:?}\n"), - }; - let at = dir.join(READBACK_TALK); - std::fs::write(&at, text) - .map_err(|e| Refusal::File { path: at.display().to_string(), why: e.to_string() }) -} - -fn talk_verdict( - heard: &Result, - lines: &[String], -) -> Result<(), Refusal> { - let conversation = heard.as_ref().map_err(|why| Refusal::Talk(vec![why.clone()]))?; - // Judged as the readback carries it, so this verdict and the harness's are - // one reading of one file. - let heard = crate::metaltalk::Conversation::parse(&conversation.render()) - .map_err(|why| Refusal::Talk(vec![why]))? - .expect("a rendered conversation names its peer"); - let said = crate::metaltalk::judge(&heard, lines).map_err(Refusal::Talk)?; - for line in said { - println!("talk: {line}"); - } - Ok(()) -} - /// What an image armed to stop itself owes instead of `Rebooting.`, and the /// `Boot: complete` it still owes as well. /// @@ -2224,10 +1831,6 @@ pub const VENDOR_KEY: &str = "machine_vendor"; pub const PRODUCT_KEY: &str = "machine_product"; pub const BIOS_KEY: &str = "machine_bios"; -/// The MAC of the function the image claims, as the operating system before -/// the flash read it; absent on a boot that named no function. -pub const WIRE_MAC_KEY: &str = "wire_mac"; - /// Every file a readback directory carries, so a run that writes none of them /// leaves none of the last run's behind. pub const READBACK_FILES: &[&str] = &[ @@ -2236,8 +1839,6 @@ pub const READBACK_FILES: &[&str] = &[ READBACK_BOOT, READBACK_VERDICT, READBACK_VOLUME, - READBACK_STREAM, - READBACK_TALK, ]; /// Empty a readback directory, before this run can leave any of it standing. @@ -2286,16 +1887,12 @@ fn write_readback( /// [`READBACK_BOOT`]'s text: what the host measured about the boot, and the /// machine it ran on. -fn boot_file(back: u64, stick: u64, machine: &Machine, wire: Option<&Wire>) -> String { - let mut boot = format!( +fn boot_file(back: u64, stick: u64, machine: &Machine) -> String { + format!( "{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n{VENDOR_KEY} {}\n{PRODUCT_KEY} {}\n\ {BIOS_KEY} {}\n", machine.vendor, machine.product, machine.bios - ); - if let Some(wire) = wire { - boot.push_str(&format!("{WIRE_MAC_KEY} {}\n", wire.mac)); - } - boot + ) } /// Whether this boot was a loader pass that reported a record and booted no @@ -2352,12 +1949,6 @@ pub fn machine(text: &str) -> Result { } } -/// The MAC a readback names for the function its image claims, or `None` -/// where the loop was not asked to read one. -pub fn wire_mac(text: &str) -> Option { - word(text, WIRE_MAC_KEY) -} - fn word(text: &str, name: &str) -> Option { text.lines() .find_map(|line| line.strip_prefix(name)) @@ -2407,20 +1998,21 @@ mod tests { assert_eq!(wait_on(1, "go down", false, refuses, || unreachable!()), Ok(0)); } - /// **The harness names the refusal, not only the exit status.** The - /// stderr is the T14's `lantalkcase` run's, cargo's own lines included and - /// the driver's statement under the printer's stamp, and the refusal it ends - /// on is read back whole, every finding with it. + /// **The harness names the refusal, not only the exit status.** Cargo's own + /// lines come first and the driver's statement under the printer's stamp, + /// and the refusal it ends on is read back whole, past its first line. #[test] fn a_refusal_is_read_back_off_the_drivers_stderr() { let stderr = " Blocking waiting for file lock on package cache\n\ \x20 Finished `dev` profile [optimized + debuginfo] target(s) in 0.19s\n\ - \x20 Running `target/debug/toyos-metal --image /Users/jan/Dev/jan/toyos-t14lan/target/metal/lantalkcase/image.img --readback /Users/jan/Dev/jan/toyos-t14lan/target/metal/lantalkcase --fat32-check --talk /Users/jan/Dev/jan/toyos-t14lan/target/metal/lantalkcase/ssh/id_ed25519`\n\ - 14:02:11 toyos-metal: the boot did not say over its own cable what a talking boot owes:\n\ - \x20 217 line(s) arrived over the cable and none is this boot's `Boot: complete`\n"; - let said = Refusal::Talk(vec![ - "217 line(s) arrived over the cable and none is this boot's `Boot: complete`".into(), - ]); + \x20 Running `target/debug/toyos-metal --image i --readback r --fat32-check`\n\ + 14:02:11 toyos-metal: reading the disk on the machine exited exit status: 255: the first line\n\ + \x20 and the second\n"; + let said = Refusal::Remote { + what: "reading the disk".into(), + status: "exited exit status: 255".into(), + stderr: "the first line\n and the second".into(), + }; assert_eq!(said_refusal(stderr), Some(said.to_string())); assert_eq!(said_refusal(&stderr[..stderr.find(REFUSAL_HEAD).unwrap()]), None, "cargo's lines are no refusal"); } @@ -2505,8 +2097,6 @@ mod tests { vec!["--fat32-check"], vec!["--dry-run"], vec!["--wait-secs", "60"], - vec!["--nic", "0000:00:1f.6"], - vec!["--talk", "/tmp/k"], ] { let mut words = vec!["--install-sudoers".to_string(), "/tmp/pw".to_string()]; words.extend(flag.iter().map(|w| (*w).to_string())); @@ -2525,57 +2115,6 @@ mod tests { assert!(Args::parse(&hosted).is_ok()); } - /// **A talking boot's stream is written as it arrives**, so it needs the - /// readback it is written into, refused before any machine is asked - /// anything. - #[test] - fn a_talking_boot_needs_its_readback() { - let bare = ["--image", "x.img", "--talk", "/tmp/k"].map(String::from); - let refusal = Args::parse(&bare).unwrap_err(); - assert!(refusal.to_string().contains("--readback"), "{refusal}"); - assert!(!refusal.about_the_boot()); - } - - /// **A swap flashes nothing and reboots nothing**, so every flag that - /// describes a boot is refused beside it — `--image` among them, since the - /// machine is found by its name and not by the image it is running — and - /// it is refused without the three things it acts with. A `--binary` with no - /// swap is no swap. - #[test] - fn a_swap_is_not_a_boot_and_names_what_it_acts_with() { - let whole = ["--swap", "netstack", "--binary", "n", "--talk", "/tmp/k", "--readback", "/tmp/r"] - .map(String::from); - let args = Args::parse(&whole).expect("a whole swap"); - assert_eq!(args.swap.as_deref(), Some("netstack")); - - for flag in [ - vec!["--fat32-check"], - vec!["--dry-run"], - vec!["--nic", "0000:00:1f.6"], - vec!["--image", "x.img"], - ] { - let mut words = whole.to_vec(); - words.extend(flag.iter().map(|w| (*w).to_string())); - let said = Args::parse(&words).unwrap_err().to_string(); - assert!(said.contains(flag[0]) && said.contains("will not make"), "{said}"); - } - for missing in ["--binary", "--talk", "--readback"] { - let at = whole.iter().position(|w| w == missing).unwrap(); - let words: Vec = whole - .iter() - .enumerate() - .filter(|(i, _)| *i != at && *i != at + 1) - .map(|(_, w)| w.clone()) - .collect(); - assert!(Args::parse(&words).is_err(), "a swap without {missing} was taken"); - } - let lone = ["--binary", "n"].map(String::from); - assert!(Args::parse(&lone).unwrap_err().to_string().contains("--swap")); - let mut bent = whole.to_vec(); - bent[1] = "../netstack".to_string(); - assert!(Args::parse(&bent).unwrap_err().to_string().contains("no service")); - } - /// **There is nothing to fall through to.** A default image was what let the /// fall-through reach a disk at all; a command line that names none is /// refused by name rather than given one. @@ -2696,17 +2235,10 @@ mod tests { #[test] fn the_machine_crosses_in_the_boot_file() { let t14 = Machine::parse("LENOVO\n20W0003AMZ\nN34ET71W (1.71 )\n").expect("three lines"); - let boot = boot_file(47, 0, &t14, None); - assert_eq!(machine(&boot), Ok(t14.clone())); + let boot = boot_file(47, 0, &t14); + assert_eq!(machine(&boot), Ok(t14)); assert_eq!(back_secs(&boot), Some(47)); assert!(machine("back_secs 47\nmachine_vendor LENOVO\n").is_err()); - assert_eq!(wire_mac(&boot), None); - let wire = Wire { - iface: "enp0s31f6".to_string(), - addr: "192.168.1.46".parse().unwrap(), - mac: "02:00:00:00:00:01".to_string(), - }; - assert_eq!(wire_mac(&boot_file(47, 0, &t14, Some(&wire))), Some(wire.mac)); } /// A judge reading the readback later rules on the boot as the loop did. @@ -2724,8 +2256,7 @@ mod tests { } /// The writer's half: the verdict the loop returns is the one its readback - /// carries, for a boot it refused, for one it passed, and for one whose - /// conversation it refused. + /// carries, for a boot it refused and for one it passed. #[test] fn the_readback_carries_the_verdict_the_loop_returns() { let dir = toyos_tmpdir::TempDir::new("verdict"); @@ -2738,7 +2269,7 @@ mod tests { let hung = format!("{}\n{}\n", bootlog::LOADER_FIRST_LINE, bootlog::HUNG_WITHOUT_A_RECORD); assert_eq!( - judge_and_write_readback(&armed, &hung, "", None, Some(dir.path()), boot), + judge_and_write_readback(&armed, &hung, "", Some(dir.path()), boot), Err(Refusal::HungWithoutARecord) ); assert_eq!(written(), Err(Refusal::HungWithoutARecord.to_string().trim_end().to_string())); @@ -2761,42 +2292,10 @@ mod tests { bootlog::STOPPING ); assert_eq!( - judge_and_write_readback(&armed, &loader, &log, None, Some(dir.path()), boot), + judge_and_write_readback(&armed, &loader, &log, Some(dir.path()), boot), Ok(1151) ); assert_eq!(written(), Ok(())); - - let unheard = Refusal::Talk(vec!["unheard".to_string()]); - let heard = (Err("unheard".to_string()), Vec::new()); - assert_eq!( - judge_and_write_readback(&armed, &loader, &log, Some(&heard), Some(dir.path()), boot), - Err(unheard.clone()) - ); - assert_eq!(written(), Err(unheard.to_string().trim_end().to_string())); - } - - /// **The address is the one on the function the image claims, and an - /// interface with none is refused rather than read as the next one's.** - /// `ip -4 -brief` prints the name, the state and then the addresses, and an - /// interface whose cable is out prints the first two and stops — which is - /// exactly the machine this loop must not go on to flash. - #[test] - fn an_interface_with_no_address_is_refused_by_name() { - let up = "enp0s31f6 UP 192.168.1.46/24 \n"; - assert_eq!(brief_address("enp0s31f6", up), Ok("192.168.1.46".parse().unwrap())); - - let down = "enp0s31f6 DOWN \n"; - assert!(brief_address("enp0s31f6", down).unwrap_err().contains("no IPv4 address")); - - // Another interface's line is not this one's answer, however many are - // printed. - let many = "lo UNKNOWN 127.0.0.1/8\n\ - enp0s31f6 UP 192.168.1.46/24\n\ - wlp9s0 UP 192.168.1.244/24\n\ - tailscale0 UNKNOWN 192.0.2.58/32\n"; - assert_eq!(brief_address("enp0s31f6", many), Ok("192.168.1.46".parse().unwrap())); - assert_eq!(brief_address("wlp9s0", many), Ok("192.168.1.244".parse().unwrap())); - assert!(brief_address("enp0s31f7", many).unwrap_err().contains("ip -4 -brief")); } #[test] @@ -3102,11 +2601,6 @@ mod tests { assert!(!Refusal::Sudo("a password is required".to_string()).about_the_boot()); assert!(!Refusal::Landed { what: "dd".to_string(), want: 1, got: 2 }.about_the_boot()); assert!(!Refusal::NoHome.about_the_boot()); - // The machine under the operating system before the boot is not the boot. - assert!( - !Refusal::Wire { nic: "0000:00:1f.6".to_string(), why: "x".to_string() } - .about_the_boot() - ); } #[test] diff --git a/src/metalswap.rs b/src/metalswap.rs deleted file mode 100644 index 0e4c56ec13f..00000000000 --- a/src/metalswap.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! Replacing a running service's binary on a machine over its own cable, and -//! what the host heard of it: the ask over ssh, the supervisor's words over the record -//! stream, and the machine answering ssh again afterwards. -//! -//! One exchange for a QEMU guest and for the T14: the ask is an `exec` of -//! `/system/bin/swap` through the harness's russh client, and the supervisor's -//! verdict is read off the stream in [`toyos_swap::heard`]'s form. **The stream -//! is the one channel that outlives a swap of netstack**: the ssh connection that -//! asked goes with the netstack that carried it, and this side's connection to -//! `logkeeper` goes with it too, with no FIN and no reset. So a swap of -//! [`toyos_logstream::CARRIER`] is let go only once `logkeeper` has said it turns new -//! readers away until the next netstack serves -//! ([`toyos_logstream::CARRIER_LEAVING`]), and this side then dials again -//! ([`Stream::redial`]) — every connection it makes before the old netstack is gone -//! turned away, the first one admitted a connection through the new one — so -//! the verdict is whatever the supervisor said, as the machine's own log carries it. - -use std::net::{SocketAddr, SocketAddrV4}; -use std::path::Path; -use std::time::{Duration, Instant}; - -use toyos_swap::Word; - -use crate::metaltalk::{self, Exec, Ssh, Stream, SSH_PORT}; - -/// How long the host keeps asking for the swap once the stream is open: sshserver -/// may still be binding when a boot's stream opens. -const ASK_WINDOW: Duration = Duration::from_secs(30); -const RETRY: Duration = Duration::from_secs(1); - -/// How long a refusal's own line has to reach the stream. It is said before -/// the answer is sent, over a stream the refusal left alone. -const REFUSED_WORD: Duration = Duration::from_secs(10); - -/// How long `logkeeper`'s [`toyos_logstream::CARRIER_LEAVING`] has to reach this -/// side before the swap goes: `swap` hangs up on the supervisor this long after it -/// answered whether or not this side has closed its input, and the old netstack is -/// stopped then. -/// -/// **Not widened to cover a slow `logkeeper` round, because nothing on this side -/// can.** The line reaches a reader in the round after the one `logkeeper` was in -/// when it was said, and that round's flush may outlast `logkeeper`'s own -/// `LOG_WRITE_BUDGET` on a slow volume; the swap goes on `swap`'s clock whatever -/// this side waits for. A reader that dialled again without the line could be -/// admitted by the netstack being stopped and lose its connection with no word, so -/// a missing line is a red verdict rather than a longer wait. -const CARRIER_WORD: Duration = Duration::from_millis(toyos_swap::ANSWER_MS); - -/// What asking for one swap came to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Swapped { - pub service: String, - /// The digest the host named, which is the binary's own unless the caller - /// named another. - pub digest: String, - /// The machine's answer, or the client's last refusal to ask it. - pub answer: Result, - /// Every word the supervisor said about this service on the stream after the ask. - pub words: Vec<(Word, String)>, - /// The service's own lines on the stream after the ask. - pub said: Vec, - /// `echo` asked after the supervisor's verdict, through whatever carries the - /// network then. - pub again: Result, - /// Stream connections before the ask and when this ended. - pub connections: (usize, usize), - /// Dials the stream made from the ask on that ended with no line: each - /// redial `logkeeper` or the machine turned away, refusals included. - pub turned_away: usize, - /// From the ask to the answer, and to `echo`'s answer. - pub answer_ms: u64, - pub again_ms: u64, -} - -impl Swapped { - /// The supervisor's final word, if it said one. - pub fn outcome(&self) -> Option<&(Word, String)> { - self.words.iter().rev().find(|(word, _)| word.is_final()) - } -} - -/// One swap to ask for. -pub struct Ask<'a> { - pub service: &'a str, - pub binary: &'a Path, - /// The digest sent in place of the binary's own, for a caller whose subject - /// is the refusal. - pub named: Option, -} - -/// The manifest's name for the program whose lines judge a swap. -const SUPERVISOR: &str = "supervisor"; - -/// `line`'s text where it is `tag`'s own line: judged by the head `logkeeper` -/// gives it (`toyos_logstream::program_line`), never by its words, so no other -/// program can say the supervisor's word for it. -fn said_by<'a>(line: &'a str, tag: &str) -> Option<&'a str> { - toyos_logstream::program_line(line).filter(|said| said.tag == tag).map(|said| said.text) -} - -/// The supervisor's word about `service` in `line`, where `line` is the supervisor's. -fn supervisor_heard<'a>(line: &'a str, service: &str) -> Option<(Word, &'a str)> { - said_by(line, SUPERVISOR).and_then(|text| toyos_swap::heard(text, service)) -} - -/// Whether the wait for `service`'s outcome is over, reading `lines[mark..]`: -/// The supervisor's final word about it. A refusal `swap` made before the supervisor heard of the -/// ask is its answer on the channel (`unasked …`), and is never waited for here. -fn settled(lines: &[String], mark: usize, service: &str) -> Option<()> { - lines[mark.min(lines.len())..] - .iter() - .any(|line| supervisor_heard(line, service).is_some_and(|(word, _)| word.is_final())) - .then_some(()) -} - -/// Ask the machine that opens `stream` to replace a service's binary, and -/// follow it to the supervisor's verdict and to ssh answering again. -/// -/// `ssh_at` is where sshserver is reached, and `None` is the -/// stream's peer's own port 22; QEMU's forward is the other case. -/// -/// `Err` is a boot that never opened the stream within `window`, a binary this -/// host cannot read, or a swap of the stream's own carrier whose `logkeeper` never -/// said it would turn readers away before `swap` let the swap go without -/// this side. -pub fn swap( - stream: &Stream, - ssh: &Ssh, - ssh_at: Option, - ask: &Ask<'_>, - window: Duration, - scratch: &Path, -) -> Result { - let (service, binary) = (ask.service, ask.binary); - let bytes = std::fs::read(binary).map_err(|e| format!("{}: {e}", binary.display()))?; - let digest = ask.named.unwrap_or_else(|| toyos_swap::digest(&bytes)); - let peer = stream - .wait_connected(window) - .ok_or_else(|| format!("no boot opened the record stream within {} s", window.as_secs()))?; - let SocketAddr::V4(peer) = peer else { - return Err(format!("the stream's peer is {peer}, which is no IPv4 address")); - }; - let at = ssh_at.unwrap_or(SocketAddr::V4(SocketAddrV4::new(*peer.ip(), SSH_PORT))); - let (mark, before, away) = (stream.lines().len(), stream.connections(), stream.turned_away()); - let began = Instant::now(); - - let mut answer = Err(String::from("never asked")); - let mut held = None; - while began.elapsed() < ASK_WINDOW { - match ssh.swap(at, service, binary, &digest) { - Ok(answered) => { - answer = Ok(answered.said.clone()); - held = Some(answered); - break; - } - Err(why) => { - println!(" swap: {at} did not take the request yet: {why}"); - answer = Err(why); - std::thread::sleep(RETRY); - } - } - } - let answer_ms = began.elapsed().as_millis() as u64; - println!(" swap: {service} ({} bytes, sha256 {}) answered {answer:?}", bytes.len(), toyos_swap::hex(&digest)); - - let heard = |lines: &[String]| -> Vec<(Word, String)> { - lines[mark.min(lines.len())..] - .iter() - .filter_map(|line| supervisor_heard(line, service)) - .map(|(word, detail)| (word, detail.to_string())) - .collect() - }; - // A refusal leaves the service as it was, and the supervisor's word on it is on the - // stream at once; an accepted swap is followed to the supervisor's final word. - let accepted = matches!(&answer, Ok(said) if said.starts_with("accepted ")); - let until = match &answer { - Ok(said) if said.starts_with("refused ") => Some(REFUSED_WORD), - Ok(said) if said.starts_with("accepted ") || said.starts_with("unanswered ") => Some(window), - _ => None, - }; - if accepted && service == toyos_logstream::CARRIER { - let leaving = stream.wait_until(CARRIER_WORD, |lines| { - lines[mark.min(lines.len())..] - .iter() - .any(|line| said_by(line, toyos_logstream::LOGKEEPER) == Some(toyos_logstream::CARRIER_LEAVING)) - .then_some(()) - }); - if leaving.is_none() { - return Err(format!( - "`logkeeper` did not say it turns readers away within {} ms of the swap of {service} \ - being accepted, so `swap` let the swap go without this side, and the connection \ - it carries may end with no word", - CARRIER_WORD.as_millis() - )); - } - } - if let Some(answered) = held { - if let Err(why) = answered.go() { - println!(" swap: the client ended on the go: {why}"); - } - } - if accepted && service == toyos_logstream::CARRIER { - let (left, seen, redialed) = (window.saturating_sub(began.elapsed()), stream.connections(), Instant::now()); - stream.redial(left); - if stream.wait_for_connection(seen, left).is_some() { - println!(" swap: `logkeeper` admitted the stream again {} ms after the redial", redialed.elapsed().as_millis()); - } - } - if let Some(until) = until { - stream.wait_until(until.saturating_sub(began.elapsed()), |lines| settled(lines, mark, service)); - } - let again_at = match (ssh_at, stream.peer()) { - (None, Some(SocketAddr::V4(now))) => SocketAddr::V4(SocketAddrV4::new(*now.ip(), SSH_PORT)), - _ => at, - }; - let command = metaltalk::asked(); - let asked_again = Instant::now(); - let mut again = Err(String::from("never asked")); - while asked_again.elapsed() < ASK_WINDOW { - again = ssh.exec(again_at, &command, scratch); - match &again { - Ok(_) => break, - Err(why) => { - println!(" swap: {again_at} did not take `{command}` yet: {why}"); - std::thread::sleep(RETRY); - } - } - } - let again_ms = began.elapsed().as_millis() as u64; - let lines = stream.lines(); - Ok(Swapped { - service: service.to_string(), - digest: toyos_swap::hex(&digest), - answer, - words: heard(&lines), - said: lines[mark.min(lines.len())..] - .iter() - .filter(|line| said_by(line, service).is_some()) - .cloned() - .collect(), - again, - connections: (before, stream.connections()), - turned_away: stream.turned_away() - away, - answer_ms, - again_ms, - }) -} - -/// What a swap was expected to come to. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Expect { - /// The new binary is the service. - InService, - /// Refused before anything was stopped. - Refused, -} - -/// The findings against `expect`, or what was heard in one line per fact. -/// -/// **Every expectation owes the machine answering ssh afterwards**, because -/// whichever binary ended up serving, the service is running only if the path -/// through it works. -pub fn judge(heard: &Swapped, expect: Expect) -> Result, Vec> { - let (mut said, mut bad) = (Vec::new(), Vec::new()); - let service = &heard.service; - let word = |w: Word| heard.words.iter().any(|(word, _)| *word == w); - let owed_prefix = match expect { - Expect::Refused => "refused ", - Expect::InService => "accepted ", - }; - match &heard.answer { - // A refusal `swap` made itself, before the supervisor heard of the ask, is a - // refusal too, and the one the supervisor never speaks about. - Ok(answer) if answer.starts_with(owed_prefix) || (expect == Expect::Refused && answer.starts_with("unasked ")) => { - said.push(format!("the machine answered the swap of {service} {answer:?} in {} ms", heard.answer_ms)) - } - // The answer and the service carrying it can go together: what - // decides then is the supervisor's own word on the stream. - Ok(answer) if answer.starts_with("unanswered") && expect != Expect::Refused => { - said.push(format!("the swap of {service} was {answer:?}; the supervisor's words decide")) - } - other => bad.push(format!("the swap of {service} was answered {other:?}, where `{owed_prefix}…` is owed")), - } - match (expect, heard.outcome()) { - (Expect::InService, Some((Word::InService, detail))) => { - let path = toyos_swap::installed_path(service, &toyos_swap::parse_hex(&heard.digest).unwrap_or_default()); - if detail.starts_with(&path) { - said.push(format!("supervisor: {service} is in service from {detail}")); - } else { - bad.push(format!("the supervisor put {detail:?} in service, where {path} was sent")); - } - } - (Expect::Refused, _) if !word(Word::Stopping) => said.push(format!( - "the supervisor stopped nothing: {:?}", - heard.words.iter().map(|(w, d)| format!("{}: {d}", w.as_str())).collect::>() - )), - (_, outcome) => bad.push(format!( - "the supervisor's words on {service} were {:?} ending in {outcome:?}, where {expect:?} is owed \ - (the stream had {} connection(s) before the ask and {} after)", - heard.words.iter().map(|(w, _)| w.as_str()).collect::>(), - heard.connections.0, - heard.connections.1 - )), - } - if heard.service == toyos_logstream::CARRIER { - said.push(format!( - "the stream's dials were turned away {} time(s), refusals included, from the ask to \ - the end", - heard.turned_away - )); - } - match &heard.again { - Ok(exec) if exec.status == Some(0) && exec.stdout == metaltalk::owed() => said.push(format!( - "`{}` answered byte for byte afterwards, {} ms after the ask", - metaltalk::asked(), - heard.again_ms - )), - other => bad.push(format!("`{}` afterwards answered {other:?}", metaltalk::asked())), - } - if bad.is_empty() { - Ok(said) - } else { - Err(bad) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `text` as `logkeeper` writes it under `tag`. - fn line(tag: &str, text: &str) -> String { - let tag = toyos_logstream::Tag::new(tag).expect("a tag"); - let stamp = "2026-09-24 22:01:37"; - format!("{}\n", toyos_logstream::ProgramLine { - stamp, - at_ns: 2_940_000_000, - severity: toyos_logstream::Severity::Info, - tid: 0, - pid: None, - tag, - text: text.as_bytes(), - }) - } - - /// The supervisor's words and `logkeeper`'s carrier line count only under their own - /// programs' names: another program printing them settles nothing. - #[test] - fn a_judge_reads_the_speaker_from_the_head_and_never_the_words() { - let words = toyos_swap::said("netstack", Word::InService, "/tmp/netstack as pid 12"); - assert_eq!(settled(&[line("supervisor", &words)], 0, "netstack"), Some(())); - assert_eq!(settled(&[line("test-runner", &words), line("netstack", &words)], 0, "netstack"), None); - assert_eq!(settled(&[line("supervisor", &words)], 1, "netstack"), None, "before mark"); - assert_eq!(supervisor_heard(&line("test-runner", &words), "netstack"), None); - let leaving = line("logkeeper", toyos_logstream::CARRIER_LEAVING); - assert_eq!(said_by(&leaving, toyos_logstream::LOGKEEPER), Some(toyos_logstream::CARRIER_LEAVING)); - assert_eq!(said_by(&line("netstack", toyos_logstream::CARRIER_LEAVING), toyos_logstream::LOGKEEPER), None); - } - - fn heard(expect: Expect) -> Swapped { - let digest = toyos_swap::digest(b"netstack"); - let path = toyos_swap::installed_path("netstack", &digest); - let (answer, words) = match expect { - Expect::InService => ( - format!("accepted {path}"), - vec![ - (Word::Accepted, format!("{path} replaces /system/bin/netstack (pid 7)")), - (Word::Stopping, "pid 7 (/system/bin/netstack)".into()), - (Word::Started, format!("{path} as pid 12; in service if it runs 5000 ms")), - (Word::InService, format!("{path} as pid 12")), - ], - ), - Expect::Refused => ( - "refused the binary hashes to x and the request names y".into(), - vec![(Word::Refused, "the binary hashes to x and the request names y".into())], - ), - }; - Swapped { - service: "netstack".into(), - digest: toyos_swap::hex(&digest), - answer: Ok(answer), - words, - said: vec![line("netstack", "netstack: DHCP: lease 10.0.2.15/24 from 10.0.2.2, \"x\"")], - again: Ok(Exec { stdout: metaltalk::owed(), status: Some(0) }), - connections: (1, 2), - turned_away: 3, - answer_ms: 900, - again_ms: 9_000, - } - } - - /// Each expectation refuses the others' outcomes, and the machine not - /// answering afterwards is a finding whatever the supervisor said. - #[test] - fn the_judge_holds_each_expectation_to_its_own_outcome() { - for (got, want) in [ - (Expect::InService, Expect::Refused), - (Expect::Refused, Expect::InService), - ] { - assert!(judge(&heard(got), want).is_err(), "{got:?} judged as {want:?}"); - } - let mut silent = heard(Expect::InService); - silent.again = Err("connecting: refused".into()); - assert!(judge(&silent, Expect::InService).is_err()); - let mut elsewhere = heard(Expect::InService); - elsewhere.words.last_mut().unwrap().1 = "/tmp/swap/other/netstack as pid 12".into(); - assert!(judge(&elsewhere, Expect::InService).is_err()); - let mut spun = heard(Expect::InService); - spun.turned_away = 10_000; - assert!(judge(&spun, Expect::InService).is_ok(), "how many dials were turned away is no verdict"); - } -} diff --git a/src/metaltalk.rs b/src/metaltalk.rs deleted file mode 100644 index 191e39355d6..00000000000 --- a/src/metaltalk.rs +++ /dev/null @@ -1,1785 +0,0 @@ -//! The cable half of a metal boot: this boot's log as the machine serves it, -//! read from its first line, and the conversation the host then has with that -//! machine over ssh — a ping, one command whose answer is compared, and -//! `reboot`, which is how the host hands the machine back. -//! -//! **The machine is found by its name.** Its netstack answers multicast DNS for -//! `toyos-t14.local` once it holds a lease (`toyos_mdns`), so this host asks its -//! own resolver for that name and connects to `logkeeper`'s port there -//! ([`toyos_logstream::PORT`]); nothing is baked into the image about this -//! host, and nothing on this host listens. The ping and both ssh exchanges go -//! to the address the name answered with. -//! -//! **This host's resolver may answer from its cache**: the previous boot's -//! address, which nothing holds while this boot is still coming up, and whose -//! failed dial this host's ARP then refuses at once for as long as it keeps -//! the address down. So a dial the machine was not reachable at is followed by -//! asking the name of the machine itself, on the link ([`ask_the_link`]), whose -//! answer is the event the next dial waits on. -//! -//! **Every answer is recorded, including the ones that are not.** A boot whose -//! name never answered, a command that was refused, a reboot the machine went -//! down under without a word — each is a [`Conversation`] field a judge reads, -//! and the loop that ran it hands the machine to its own fallback (the boot's -//! hold ends, the runner reboots) rather than stopping at the first. -//! -//! The ssh client is `tests/ssh-client-host`, russh from source: no host `ssh` -//! reaches ToyOS. - -use std::io::{BufRead, BufReader, Write}; -use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpStream, ToSocketAddrs, UdpSocket}; -#[cfg(test)] -use std::net::TcpListener; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::{Duration, Instant}; - -/// What the host asks the machine to say, and so what it must answer. -pub const PHRASE: &str = "the T14 answers over its own cable"; - -/// The command that hands the machine back: `/system/bin/reboot`, which the -/// boot config endows with `power`. -pub const REBOOT: &str = "reboot"; - -/// The port sshserver listens on, which is the protocol's own. -pub const SSH_PORT: u16 = 22; - -/// How many echo requests the machine gets to answer one. -const PING_TRIES: u32 = 10; -const PING_WAIT: Duration = Duration::from_secs(1); - -/// How long the stream gets to carry this boot's `Boot: complete` before -/// `reboot` is asked regardless, and the judge reds on the record's absence. A -/// liveness bound, inside the boot's own `boot-deadline=` that `reboot` has to -/// beat. -const CARRIED_WAIT: Duration = Duration::from_secs(30); - -/// How long one connect waits for the machine's answer to its SYN. -const CONNECT_WAIT: Duration = Duration::from_secs(5); - -/// Under this, a failed ask did not wait on anything: this host's resolver -/// holds an unanswered `.local` question for five seconds (measured on the -/// development host, three asks in a row, 5.00 s each), and a connect that -/// failed sooner never had a machine to wait for. Asking again would spin, so -/// it is an answer. -const WAITED: Duration = Duration::from_secs(1); - -/// How long one ask of the name on the link waits for the machine's answer -/// before it is asked again: RFC 6762 §5.2's floor between two queries. -const ASK_WAIT: Duration = Duration::from_secs(1); - -/// The multicast DNS group and port (RFC 6762 §3). -const MDNS_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); -const MDNS_PORT: u16 = 5353; - -/// A wait with no bound of its own, whose caller's bound was already applied: -/// not [`Duration::MAX`], which overflows the clock a wait adds it to. -const FOREVER: Duration = Duration::from_secs(365 * 24 * 3600); - -/// Where the stream is asked for. -#[derive(Clone, Debug)] -pub enum Peer { - /// A name this host's resolver answers — `toyos-t14.local`, over multicast - /// DNS — asked again until the machine answers for it, and asked of the - /// machine on the link once a dial to an answer has failed. Each asking is - /// a wait on the answer: this host's resolver holds an unanswered `.local` - /// question for five seconds before it gives up, and the link is asked for - /// [`ASK_WAIT`]. - Named { host: String, port: u16 }, - /// One address: a forward onto a guest that already serves. - At(SocketAddr), -} - -/// How many dials a stream's first dial may have turned away — a failed -/// connect, or a connection closed before a line — before it gives up and the -/// boot is red. A redial has none ([`Stream::redial`]). -const TURNED_AWAY_CEILING: usize = 64; - -/// The log as this host reads it: this host dials `logkeeper`'s port and reads until -/// the connection ends. -/// -/// **A connection is `logkeeper`'s once it carries a line.** `logkeeper` hands every -/// reader it admits the boot from its first line at once, so a connection that -/// ends before a line is one it never admitted — and through QEMU's forward, -/// which takes this host's connect before it has asked the guest, that is the -/// only sign the guest refused. -/// -/// **One connection at a time, and another only when asked** ([`Stream::redial`]): -/// every connection replays the boot from its first line, so a redial skips the -/// lines this reader already has rather than keeping them twice, and every line -/// from every connection is one sequence in arrival order. -#[derive(Clone)] -pub struct Stream { - shared: Arc, - /// Ends the reader when the last clone goes: the reader holds `shared` and - /// not this. - _owner: Arc, -} - -struct Shared { - state: Mutex, - /// Woken by every change to `state`. - moved: Condvar, -} - -#[derive(Default)] -struct State { - lines: Vec, - /// The latest connection `logkeeper` admitted. - peer: Option, - /// How many connections carried a line. - admitted: usize, - /// How many dials ended with no line: a connection `logkeeper` turned away, - /// one nothing on the machine's side took, or a connect that failed. - turned_away: usize, - /// The connection being read, kept so [`Stream::redial`] can end it. - current: Option, - /// How the latest admitted connection ended, once it has and none has been - /// admitted since. - end: Option, - /// Why the latest dial ended with no connection that carried a line. - unopened: Option, - /// A redial asked for and not yet begun: the instant it gives up by. - redial: Option, - /// Lines a connection's end cut short, discarded rather than kept: the next - /// connection's replay carries the same content whole. - torn: usize, - /// Stop dialing, and stop reading once the current connection ends. - stop: bool, -} - -struct Owner(Arc); - -impl Drop for Owner { - fn drop(&mut self) { - let mut state = self.0.state.lock().expect("the stream's state"); - state.stop = true; - if let Some(conn) = state.current.take() { - let _ = conn.shutdown(std::net::Shutdown::Both); - } - self.0.moved.notify_all(); - } -} - -impl Stream { - /// Ask `peer` for its log within `by`, and read it on a thread of its own: - /// every line is appended to `file` as it arrives — a machine that dies - /// mid-boot leaves what it said on disk — and, with `echo`, printed. - /// - /// **A machine not yet reachable is asked again**: a dial refused, or - /// answered with its host or network down or unreachable, is counted, and - /// the dial gives up at `TURNED_AWAY_CEILING` of them ([`Stream::unopened`]). - pub fn connect(peer: Peer, file: &Path, echo: bool, by: Duration) -> Result { - Self::through(Arc::new(Net), peer, file, echo, by) - } - - fn through(reach: Arc, peer: Peer, file: &Path, echo: bool, by: Duration) -> Result { - let out = std::fs::File::create(file).map_err(|e| format!("{}: {e}", file.display()))?; - let shared = Arc::new(Shared { state: Mutex::new(State::default()), moved: Condvar::new() }); - let theirs = Arc::clone(&shared); - std::thread::Builder::new() - .name("metal-stream".into()) - .spawn(move || serve(&*reach, &peer, &theirs, out, echo, Instant::now() + by)) - .map_err(|e| format!("the stream's reader could not be started: {e}"))?; - Ok(Self { _owner: Arc::new(Owner(Arc::clone(&shared))), shared }) - } - - fn state(&self) -> std::sync::MutexGuard<'_, State> { - self.shared.state.lock().expect("the stream's state") - } - - /// The latest admitted connection's peer. - pub fn peer(&self) -> Option { - self.state().peer - } - - pub fn lines(&self) -> Vec { - self.state().lines.clone() - } - - /// How many connections `logkeeper` admitted. - pub fn connections(&self) -> usize { - self.state().admitted - } - - /// How many dials ended with no line, refusals included. - pub fn turned_away(&self) -> usize { - self.state().turned_away - } - - /// Lines a connection's end cut short. - pub fn torn(&self) -> usize { - self.state().torn - } - - /// Whether the latest admitted connection has ended with none admitted - /// since. - pub fn ended(&self) -> bool { - self.end().is_some() - } - - /// How the latest admitted connection ended, or `None` while it is open or - /// none was. - pub fn end(&self) -> Option { - self.state().end.clone() - } - - /// Why the latest dial ended with no connection `logkeeper` admitted. - pub fn unopened(&self) -> Option { - self.state().unopened.clone() - } - - /// Stop dialing. A connection already admitted is read on. - pub fn give_up(&self) { - self.state().stop = true; - self.shared.moved.notify_all(); - } - - /// End the current connection, and dial again until a connection carries a - /// line or `by` has passed: every dial is a wait on the machine's answer, - /// and one it turns away — a connect that failed, or a connection closed - /// before a line — is asked again at once and counted - /// ([`Stream::turned_away`]), never judged. A redial whose bound passes, - /// or whose [`Peer::At`] forward fails a connect, says so - /// ([`Stream::unopened`]). - /// - /// **The admitted dial is the event, and `by` its only bound**: nothing the - /// machine sends says when `logkeeper` admits a reader again, so how many dials - /// that takes is the length of the machine's gap and no verdict — the - /// recorded compromise `issues/a-swaps-redial-asks-again-with-no-event-to-wait-on.md`. - /// - /// **For a connection this host knows is going**: a swap of the netstack - /// carrying it ends it with no FIN and no reset, so nothing but this host - /// ever says it has ended. - pub fn redial(&self, by: Duration) { - let mut state = self.state(); - state.redial = Some(Instant::now() + by); - if let Some(conn) = state.current.take() { - let _ = conn.shutdown(std::net::Shutdown::Both); - } - self.shared.moved.notify_all(); - } - - /// The first `T` that `seen` finds in the lines, or `None` after `by`. - /// Woken by each line as it lands, never a poll. - pub fn wait_until(&self, by: Duration, mut seen: impl FnMut(&[String]) -> Option) -> Option { - let began = Instant::now(); - let mut state = self.state(); - loop { - if let Some(found) = seen(&state.lines) { - return Some(found); - } - let left = by.saturating_sub(began.elapsed()); - if left.is_zero() { - return None; - } - state = self.shared.moved.wait_timeout(state, left).expect("the stream's state").0; - } - } - - /// Wait for a line carrying `needle`, or `by`; whether one arrived. - pub fn wait_for(&self, needle: &str, by: Duration) -> bool { - self.wait_until(by, |lines| lines.iter().any(|l| l.contains(needle)).then_some(())).is_some() - } - - /// This boot's `Boot: complete` in milliseconds, read as [`judge`] reads - /// it, once a line carries it, or `None` after `by`. - pub fn wait_for_boot(&self, by: Duration) -> Option { - self.wait_until(by, |lines| lines.iter().find_map(|line| crate::bootlog::boot_millis(line))) - } - - /// The peer, once a connection has carried a line, or `None` after `by` - /// or once the dial has given up. - pub fn wait_connected(&self, by: Duration) -> Option { - self.wait_for_connection(0, by) - } - - /// The peer of a connection admitted after the first `seen`, or `None` - /// after `by`, once the host has given up, or once the dial asked for it - /// ended with none. - pub fn wait_for_connection(&self, seen: usize, by: Duration) -> Option { - let began = Instant::now(); - let mut state = self.state(); - loop { - if state.admitted > seen { - return state.peer; - } - let left = by.saturating_sub(began.elapsed()); - let dialing = state.redial.is_some() || state.current.is_some() || state.unopened.is_none(); - if left.is_zero() || state.stop || !dialing { - return None; - } - state = self.shared.moved.wait_timeout(state, left).expect("the stream's state").0; - } - } -} - -/// The reader: the first dial by `until`, then each redial it is asked for, -/// until the stream is given up. -fn serve(reach: &dyn Reach, peer: &Peer, shared: &Shared, mut out: std::fs::File, echo: bool, mut until: Instant) { - // Whether a connection closed before a line is asked again: never on the - // first dial, where that close is `logkeeper`'s answer, and always on a redial, - // which is made while the machine's network is coming back. - let mut again = false; - let mut last = String::from("never asked"); - loop { - match open(reach, peer, until, shared, again, last) { - Err(why) => { - let mut state = shared.state.lock().expect("the stream's state"); - state.unopened = Some(why); - shared.moved.notify_all(); - } - Ok(conn) => { - if echo { - println!(" stream: reading {peer:?} at {:?}", conn.peer_addr().ok()); - } - // A connection `logkeeper` turned away on a redial: asked again at - // once, the refusal being the event, and named by the dial - // after it should the redial's bound end that one. - if let Some(how) = read(conn, shared, &mut out, echo).filter(|_| again) { - let state = shared.state.lock().expect("the stream's state"); - if !state.stop && state.redial.is_none() { - last = format!("the latest connection ended before a line: {how}"); - continue; - } - } - } - } - let mut state = shared.state.lock().expect("the stream's state"); - loop { - if state.stop { - return; - } - if let Some(by) = state.redial.take() { - until = by; - again = true; - last = String::from("never asked"); - state.unopened = None; - break; - } - state = shared.moved.wait(state).expect("the stream's state"); - } - } -} - -/// Count one dial that failed, and why the dial gives up there: a first dial -/// at its ceiling, the count being its own with nothing dialled before it, -/// and a redial of a forward at once, since a forward takes every connect for -/// as long as QEMU lives. -fn count_or_give_up(shared: &Shared, peer: &Peer, again: bool, last: &str) -> Option { - let mut state = shared.state.lock().expect("the stream's state"); - state.turned_away += 1; - match peer { - Peer::At(_) if again => Some(format!("{last}; a forward fails a connect only once QEMU has exited")), - _ if !again && state.turned_away >= TURNED_AWAY_CEILING => { - Some(format!("turned away {TURNED_AWAY_CEILING} time(s), which is the first dial's ceiling: {last}")) - } - _ => None, - } -} - -/// A connect that ended this way may be taken once the machine is up: it was -/// refused, or reset by a listener going away under it, or its host or network -/// is down or unreachable from here. -fn not_yet_reachable(e: &std::io::Error) -> bool { - matches!(e.kind(), std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionReset) - || matches!(e.raw_os_error(), Some(libc::EHOSTDOWN | libc::EHOSTUNREACH | libc::ENETUNREACH)) -} - -/// The connection, or why there is none by `until`, `last` naming what the -/// dial before this one got. -/// -/// **The ceiling counts dials, not asks.** A connect that failed is counted, -/// and so is a connection closed before a line (`read`); an ask of the name is -/// a wait on the machine's answer, bounded by `until` alone, and one that -/// returns at once is no ask at all and ends the dial. -fn open( - reach: &dyn Reach, - peer: &Peer, - until: Instant, - shared: &Shared, - again: bool, - mut last: String, -) -> Result { - let stopped = || shared.state.lock().expect("the stream's state").stop; - // Set by the first failed dial: the resolver's answer may be the one its - // cache kept, so the name is asked of the machine from then on. - let mut on_the_link = false; - while Instant::now() < until { - if stopped() { - return Err(format!("given up on {peer:?}: {last}")); - } - let addrs = match peer { - // No wait between dials: an address carries no name to ask the link - // for, and the only event a forward can give is a dial it takes. - Peer::At(at) => vec![*at], - Peer::Named { host, port } if on_the_link => match reach.ask(host, until)? { - Some(ip) => vec![SocketAddr::from((ip, *port))], - None => { - last = format!("{host} did not answer on the link"); - continue; - } - }, - Peer::Named { host, port } => { - let asked = Instant::now(); - match reach.resolve(host, *port) { - Ok(addrs) if addrs.is_empty() => { - return Err(format!("this host's resolver answered no IPv4 address for {host}")); - } - Ok(addrs) => addrs, - Err(e) if asked.elapsed() < WAITED => { - return Err(format!("this host's resolver did not ask for {host} at all: {e}")); - } - Err(e) => { - last = format!("{host} did not resolve: {e}"); - continue; - } - } - } - }; - for at in addrs { - let asked = Instant::now(); - match reach.dial(at) { - Ok(conn) => return Ok(conn), - Err(e) if not_yet_reachable(&e) || asked.elapsed() >= WAITED => { - last = match e.kind() { - std::io::ErrorKind::ConnectionRefused => format!("{at} refused the connection: {e}"), - _ => format!("{at} was not reachable: {e}"), - }; - on_the_link = true; - if let Some(why) = count_or_give_up(shared, peer, again, &last) { - return Err(why); - } - } - Err(e) => return Err(format!("{at} cannot be reached from this host: {e}")), - } - } - } - Err(format!("{peer:?} was not serving its log by the bound: {last}")) -} - -/// How a stream reaches the machine: the seam a test stages a network through. -trait Reach: Send + Sync { - /// This host's resolver's IPv4 answers for `host`. - fn resolve(&self, host: &str, port: u16) -> std::io::Result>; - /// The machine's own answer for `host`, or `None` where none came within - /// [`ASK_WAIT`] or by `until`. - fn ask(&self, host: &str, until: Instant) -> Result, String>; - fn dial(&self, at: SocketAddr) -> std::io::Result; -} - -/// This host's network. -struct Net; - -impl Reach for Net { - fn resolve(&self, host: &str, port: u16) -> std::io::Result> { - Ok((host, port).to_socket_addrs()?.filter(SocketAddr::is_ipv4).collect()) - } - - fn ask(&self, host: &str, until: Instant) -> Result, String> { - ask_the_link(host, until) - } - - fn dial(&self, at: SocketAddr) -> std::io::Result { - TcpStream::connect_timeout(&at, CONNECT_WAIT) - } -} - -/// Ask the link who holds `host`, as a legacy one-shot querier (RFC 6762 -/// §6.7): the question is multicast from a port of this socket's own, so a -/// responder answers that port directly, and no cache on this host answers in -/// its place. `None` where no answer came within [`ASK_WAIT`] or by `until`. -fn ask_the_link(host: &str, until: Instant) -> Result, String> { - let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).map_err(|e| format!("a socket to ask for {host}: {e}"))?; - // §11: a query leaves with an IP TTL of 255. - socket.set_multicast_ttl_v4(255).map_err(|e| format!("a socket to ask for {host}: {e}"))?; - let id = socket.local_addr().map_err(|e| format!("a socket to ask for {host}: {e}"))?.port(); - socket - .send_to(&mdns_question(host, id)?, (MDNS_GROUP, MDNS_PORT)) - .map_err(|e| format!("{host} could not be asked on the link: {e}"))?; - let by = until.min(Instant::now() + ASK_WAIT); - // §17: a multicast DNS message is at most 9000 bytes. - let mut reply = vec![0u8; 9000]; - loop { - let left = by.saturating_duration_since(Instant::now()); - if left.is_zero() { - return Ok(None); - } - socket.set_read_timeout(Some(left)).map_err(|e| format!("a socket to ask for {host}: {e}"))?; - match socket.recv_from(&mut reply) { - Ok((len, _)) => { - if let Some(addr) = mdns_answer(&reply[..len], host, id) { - return Ok(Some(addr)); - } - } - Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { - return Ok(None) - } - Err(e) => return Err(format!("the answer for {host} could not be read: {e}")), - } - } -} - -const DNS_TYPE_A: u16 = 1; -const DNS_CLASS_IN: u16 = 1; -/// A multicast DNS class's top bit is the cache-flush or unicast-response -/// bit, not the class (RFC 6762 §10.2, §5.4). -const DNS_CLASS_MASK: u16 = 0x7fff; -const DNS_QR: u16 = 0x8000; -const DNS_RCODE: u16 = 0x000f; - -/// A standard query (RFC 1035 §4.1) for `host`'s `A` record, with ID `id`. -fn mdns_question(host: &str, id: u16) -> Result, String> { - let mut out = Vec::with_capacity(64); - for word in [id, 0, 1, 0, 0, 0] { - out.extend_from_slice(&word.to_be_bytes()); - } - for label in host.split('.') { - if !(1..=63).contains(&label.len()) { - return Err(format!("{host:?} is no name: each label is 1 to 63 bytes")); - } - out.push(label.len() as u8); - out.extend_from_slice(label.as_bytes()); - } - out.push(0); - out.extend_from_slice(&DNS_TYPE_A.to_be_bytes()); - out.extend_from_slice(&DNS_CLASS_IN.to_be_bytes()); - Ok(out) -} - -/// The address `reply` answers for `host`, where it is the response to query -/// `id` and carries `host`'s `A` record; `None` for anything else. -fn mdns_answer(reply: &[u8], host: &str, id: u16) -> Option { - let word = |at: usize| Some(u16::from_be_bytes([*reply.get(at)?, *reply.get(at.checked_add(1)?)?])); - let flags = word(2)?; - if word(0)? != id || flags & DNS_QR == 0 || flags & DNS_RCODE != 0 { - return None; - } - let mut at = 12; - for _ in 0..word(4)? { - at = dns_name(reply, at)?.1 + 4; - } - for _ in 0..word(6)? { - let (name, after) = dns_name(reply, at)?; - let (kind, class, len) = (word(after)?, word(after + 2)?, usize::from(word(after + 8)?)); - let data = reply.get(after + 10..after + 10 + len)?; - if name.eq_ignore_ascii_case(host) && kind == DNS_TYPE_A && class & DNS_CLASS_MASK == DNS_CLASS_IN { - let octets: [u8; 4] = data.try_into().ok()?; - return Some(Ipv4Addr::from(octets)); - } - at = after + 10 + len; - } - None -} - -/// The dotted name at `at` and where the bytes after it begin, following -/// compression pointers (RFC 1035 §4.1.4). A pointer only points backwards and -/// every pass spells a label before the next one, so the name's 255-byte bound -/// ends a loop of pointers. -fn dns_name(bytes: &[u8], at: usize) -> Option<(String, usize)> { - let (mut name, mut here, mut after) = (String::new(), at, None); - loop { - let len = usize::from(*bytes.get(here)?); - match len { - 0 => return Some((name, after.unwrap_or(here + 1))), - 1..=63 => { - let label = std::str::from_utf8(bytes.get(here + 1..here + 1 + len)?).ok()?; - if !name.is_empty() { - name.push('.'); - } - name.push_str(label); - if name.len() > 255 { - return None; - } - here += 1 + len; - } - 0xC0..=0xFF => { - let target = usize::from(u16::from_be_bytes([len as u8, *bytes.get(here + 1)?]) & 0x3fff); - if target >= here { - return None; - } - after.get_or_insert(here + 2); - here = target; - } - _ => return None, - } - } -} - -/// Read one connection's lines into the stream until it ends, and how it ended -/// where it carried none. -/// -/// **Every connection replays the boot from its first line** (`logkeeper`'s -/// `serve.rs`): the lines this reader already has are skipped rather than kept -/// twice, and a line the connection ends inside is dropped rather than kept, so -/// the same content lands whole on the next connection. -fn read(conn: TcpStream, shared: &Shared, out: &mut std::fs::File, echo: bool) -> Option { - let opened = Instant::now(); - let at = conn.peer_addr().ok(); - let mut skip = { - let mut state = shared.state.lock().expect("the stream's state"); - // A redial asked for while this connection was being made wants a - // connection made after the ask, and this is not one. - if state.stop || state.redial.is_some() { - return None; - } - state.current = conn.try_clone().ok(); - state.lines.len() - }; - let mut carried = false; - let mut reader = BufReader::new(conn); - let how = loop { - let mut line = String::new(); - let read = reader.read_line(&mut line); - let mut state = shared.state.lock().expect("the stream's state"); - match read { - Ok(0) => break "the peer closed it".to_string(), - Err(e) => break e.to_string(), - Ok(_) if !line.ends_with('\n') => state.torn += 1, - Ok(_) => { - if !carried { - carried = true; - state.admitted += 1; - state.peer = at; - state.end = None; - state.unopened = None; - } - if skip > 0 { - skip -= 1; - } else { - let _ = out.write_all(line.as_bytes()); - let _ = out.flush(); - if echo { - print!(" stream| {line}"); - let _ = std::io::stdout().flush(); - } - state.lines.push(line); - } - } - } - shared.moved.notify_all(); - }; - let end = End { after_ms: opened.elapsed().as_millis() as u64, how }; - if echo { - println!(" stream: ended {} ms after it opened, {}: {}", end.after_ms, if carried { "admitted" } else { "turned away" }, end.how); - } - let turned_away = (!carried).then(|| end.how.clone()); - let mut state = shared.state.lock().expect("the stream's state"); - state.current = None; - // A connection never admitted leaves the latest admitted one's account as - // it was; the first dial's has nothing before it, and is that account. - if carried || state.admitted == 0 { - state.end = Some(end); - } - if turned_away.is_some() { - state.turned_away += 1; - } - shared.moved.notify_all(); - turned_away -} - -/// How a stream's connection ended. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct End { - /// Milliseconds from the connect to the end, on this host's clock. - pub after_ms: u64, - /// The peer's close, or the error the read ended on. - pub how: String, -} - -/// What one exchange with the machine's sshserver came back with. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Exec { - pub stdout: Vec, - pub status: Option, -} - -/// The harness's ssh client, and the key the image authorizes. -#[derive(Debug, Clone)] -pub struct Ssh { - /// The repository whose `tests/ssh-client-host` built the client. - root: PathBuf, - key: PathBuf, -} - -impl Ssh { - /// The client this repository builds, refused by name where it is not - /// built: `build::build_host_judges` is what makes it. - pub fn at(root: &Path, key: PathBuf) -> Result { - let client = crate::build::ssh_client_host(root); - if !client.is_file() { - return Err(format!( - "{} is not built; the metal staging of a talking boot builds it", - client.display() - )); - } - if !key.is_file() { - return Err(format!("{} is no key: the staging mints it beside the image", key.display())); - } - Ok(Self { root: root.to_path_buf(), key }) - } - - /// The client, asked for `argv`. - fn client(&self, argv: &[&str]) -> Command { - let mut client = Command::new(crate::build::ssh_client_host(&self.root)); - client.args(argv); - client - } - - fn run(&self, argv: &[&str]) -> Result { - let out = self - .client(argv) - .output() - .map_err(|e| format!("the ssh client would not start: {e}"))?; - let said = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !out.status.success() { - return Err(said); - } - Ok(said) - } - - /// Run `command` and collect its stdout and status. - pub fn exec(&self, at: SocketAddr, command: &str, scratch: &Path) -> Result { - let (out, err) = (scratch.join("exec.out"), scratch.join("exec.err")); - let (host, port) = (at.ip().to_string(), at.port().to_string()); - let said = self.run(&[ - "exec", - &host, - &port, - path_str(&self.key)?, - path_str(&out)?, - path_str(&err)?, - command, - ])?; - let status = match said.lines().last().unwrap_or("") { - "no-exit-status" => None, - line => Some( - line.strip_prefix("exit ") - .and_then(|code| code.parse().ok()) - .ok_or_else(|| format!("the client answered {said:?}"))?, - ), - }; - let stdout = std::fs::read(&out).map_err(|e| format!("{}: {e}", out.display()))?; - Ok(Exec { stdout, status }) - } - - /// Run `command` with `stdin`'s bytes on its input, and collect its stdout - /// and status: `ssh update < image`, for one. - pub fn pipe(&self, at: SocketAddr, command: &str, stdin: &Path, scratch: &Path) -> Result { - let (out, err) = (scratch.join("pipe.out"), scratch.join("pipe.err")); - let (host, port) = (at.ip().to_string(), at.port().to_string()); - let said = self.run(&[ - "pipe", - &host, - &port, - path_str(&self.key)?, - path_str(&out)?, - path_str(&err)?, - path_str(stdin)?, - command, - ])?; - let status = match said.lines().last().unwrap_or("") { - "no-exit-status" => None, - line => Some( - line.strip_prefix("exit ") - .and_then(|code| code.parse().ok()) - .ok_or_else(|| format!("the client answered {said:?}"))?, - ), - }; - let stdout = std::fs::read(&out).map_err(|e| format!("{}: {e}", out.display()))?; - Ok(Exec { stdout, status }) - } - - /// Ask for `command` and answer the machine's reply to the request, without - /// waiting for the program. - pub fn fire(&self, at: SocketAddr, command: &str) -> Result { - let (host, port) = (at.ip().to_string(), at.port().to_string()); - let said = self.run(&["fire", &host, &port, path_str(&self.key)?, command])?; - Ok(said.lines().last().unwrap_or("").to_string()) - } - - /// Send `binary` as `service`'s replacement, naming `digest` for it — an - /// `exec` of `/system/bin/swap` with the binary on its input — and answer - /// the machine's word: `accepted `, `refused ` (the supervisor's), - /// `unasked ` (`swap`'s own), or `unanswered `. - /// - /// **An `accepted` is answered with the program's input still open**: - /// closing it is the go — `swap` hangs up on the supervisor once its input closes, - /// and the supervisor stops the old service then ([`toyos_swap::ANSWER_MS`] bounds - /// the wait) — and it is [`Answered::go`]'s, so a caller whose own - /// connection that service carries can see to it first. - pub fn swap( - &self, - at: SocketAddr, - service: &str, - binary: &Path, - digest: &toyos_swap::Digest, - ) -> Result { - let (host, port) = (at.ip().to_string(), at.port().to_string()); - let mut client = self - .client(&[ - "swap", - &host, - &port, - path_str(&self.key)?, - service, - path_str(binary)?, - &toyos_swap::hex(digest), - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .map_err(|e| format!("the ssh client would not start: {e}"))?; - let mut said = String::new(); - let stdout = client.stdout.take().expect("the client's stdout was asked for piped"); - BufReader::new(stdout) - .read_line(&mut said) - .map_err(|e| format!("the ssh client's answer could not be read: {e}"))?; - let said = said.trim_end().to_string(); - let answered = Answered { said, client: Some(client) }; - if answered.said.starts_with("accepted ") { - return Ok(answered); - } - answered.go() - } -} - -/// The machine's word on one swap, and the client holding the channel it came -/// on until [`Answered::go`] — or until this is dropped, which is the same go. -#[derive(Debug)] -pub struct Answered { - pub said: String, - client: Option, -} - -impl Answered { - /// Close the channel the answer came on, and wait for the client to end: - /// the word, or the client's own failure. - pub fn go(mut self) -> Result { - let Some(mut client) = self.client.take() else { return Ok(self) }; - drop(client.stdin.take()); - let status = client.wait().map_err(|e| format!("the ssh client could not be waited for: {e}"))?; - if !status.success() { - return Err(self.said.clone()); - } - Ok(self) - } -} - -impl Drop for Answered { - fn drop(&mut self) { - if let Some(mut client) = self.client.take() { - drop(client.stdin.take()); - let _ = client.wait(); - } - } -} - -fn path_str(path: &Path) -> Result<&str, String> { - path.to_str().ok_or_else(|| format!("{} is not UTF-8", path.display())) -} - -/// Everything the host heard from one boot over the cable. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Conversation { - /// Who opened the stream: the address the boot leased. - pub peer: Ipv4Addr, - /// Whether it answered a ping, or `None` where none was asked. - pub ping: Option, - /// What `echo` answered, or the client's refusal. - pub exec: Result, - /// The machine's reply to `reboot`, or the client's refusal. - pub reboot: Result, - /// From the stream opening to the command's answer, on this host's clock. - pub exec_ms: u64, - /// How the stream's connection had ended when the conversation did, or - /// `None` for one still open. - pub stream_end: Option, -} - -/// The command asked, spelled once for the asker and the judge. -pub fn asked() -> String { - format!("echo {PHRASE}") -} - -/// The answer that command owes, byte for byte. -pub fn owed() -> Vec { - format!("{PHRASE}\n").into_bytes() -} - -/// Talk to the machine `stream` reads: ping it, ask it to say [`PHRASE`], and -/// then ask it to reboot — whatever the first two said, because handing the -/// machine back is owed either way. -/// -/// `ssh_at` is where sshserver is reached, and `None` is the peer's own port 22; -/// QEMU's forward is the other case. `ping` is `false` where no ICMP can reach -/// the machine at all, which is QEMU's user-mode network. -/// -/// **One ask of each**, each a wait on the machine's answer: a machine serving -/// its log holds a lease, and sshserver binds its port before netstack can have one. -/// -/// `Err` is only a stream that never opened. -pub fn converse( - stream: &Stream, - ssh: &Ssh, - ssh_at: Option, - ping: bool, - scratch: &Path, -) -> Result { - // The bound the connection itself already applied is `connect`'s `by`; this - // wait is for that settling, not a second bound on it. - let peer = stream - .wait_connected(FOREVER) - .ok_or_else(|| stream.unopened().unwrap_or_else(|| "the stream never opened".to_string()))?; - let SocketAddr::V4(peer_v4) = peer else { - return Err(format!("the stream's peer is {peer}, which is no IPv4 address")); - }; - let began = Instant::now(); - let peer = *peer_v4.ip(); - let ssh_at = ssh_at.unwrap_or(SocketAddr::V4(SocketAddrV4::new(peer, SSH_PORT))); - - let ping = if ping { - let mut answered = false; - for _ in 0..PING_TRIES { - if crate::icmp::echo(peer, PING_WAIT)? { - answered = true; - break; - } - } - println!(" talk: {peer} {} a ping", if answered { "answered" } else { "did not answer" }); - Some(answered) - } else { - None - }; - - std::fs::create_dir_all(scratch).map_err(|e| format!("{}: {e}", scratch.display()))?; - let command = asked(); - let exec = ssh.exec(ssh_at, &command, scratch); - let exec_ms = began.elapsed().as_millis() as u64; - match &exec { - Ok(got) => println!( - " talk: `{command}` answered {:?}, status {:?}, {exec_ms} ms after the stream opened", - String::from_utf8_lossy(&got.stdout), - got.status - ), - Err(why) => println!(" talk: `{command}` was not answered: {why}"), - } - - let reboot = hand_back(stream, began, CARRIED_WAIT, || ssh.fire(ssh_at, REBOOT)); - println!(" talk: `{REBOOT}` {reboot:?}"); - Ok(Conversation { peer, ping, exec, reboot, exec_ms, stream_end: stream.end() }) -} - -/// Ask for `reboot` through `fire` once the stream has carried this boot's -/// `Boot: complete`, or once `by` has passed without it. `reboot` ends the -/// stream the judge reads that record off, and `logkeeper` serves the boot from its -/// first line, so the backlog crosses at the network's pace and not the -/// conversation's. -fn hand_back(stream: &Stream, began: Instant, by: Duration, fire: impl FnOnce() -> T) -> T { - match stream.wait_for_boot(by) { - Some(ms) => println!( - " talk: the stream carried `Boot: complete` ({ms} ms), {} ms after it opened", - began.elapsed().as_millis() - ), - None => println!( - " talk: the stream carried no `Boot: complete` in {} ms; `{REBOOT}` is asked regardless", - by.as_millis() - ), - } - fire() -} - -/// The keys a conversation is written under, one ` ` per line, in -/// the loop's readback beside the host's other facts. -const PEER: &str = "talk_peer"; -const PING: &str = "talk_ping"; -const EXEC_STATUS: &str = "talk_exec_status"; -const EXEC_STDOUT: &str = "talk_exec_stdout"; -const EXEC_REFUSED: &str = "talk_exec_refused"; -const EXEC_MS: &str = "talk_exec_ms"; -const REBOOTED: &str = "talk_reboot"; -const REBOOT_REFUSED: &str = "talk_reboot_refused"; -const STREAM_END: &str = "talk_stream_end"; - -/// A value on one line, whatever it carried: Rust's own escaping, read back by -/// comparison with the same rendering rather than parsed. -fn one_line(text: &str) -> String { - format!("{text:?}") -} - -impl Conversation { - pub fn render(&self) -> String { - let mut out = format!("{PEER} {}\n", self.peer); - match self.ping { - Some(true) => out.push_str(&format!("{PING} yes\n")), - Some(false) => out.push_str(&format!("{PING} no\n")), - None => {} - } - match &self.exec { - Ok(exec) => { - match exec.status { - Some(code) => out.push_str(&format!("{EXEC_STATUS} {code}\n")), - None => out.push_str(&format!("{EXEC_STATUS} none\n")), - } - out.push_str(&format!( - "{EXEC_STDOUT} {}\n", - one_line(&String::from_utf8_lossy(&exec.stdout)) - )); - } - Err(why) => out.push_str(&format!("{EXEC_REFUSED} {}\n", one_line(why))), - } - out.push_str(&format!("{EXEC_MS} {}\n", self.exec_ms)); - match &self.stream_end { - Some(end) => out.push_str(&format!("{STREAM_END} {} {}\n", end.after_ms, one_line(&end.how))), - None => out.push_str(&format!("{STREAM_END} open\n")), - } - match &self.reboot { - Ok(word) => out.push_str(&format!("{REBOOTED} {word}\n")), - Err(why) => out.push_str(&format!("{REBOOT_REFUSED} {}\n", one_line(why))), - } - out - } - - /// What a readback's boot file says about the cable's conversation, or - /// `None` where the boot had none. - pub fn parse(text: &str) -> Result, String> { - let word = |key: &str| -> Option { - text.lines().find_map(|line| { - let (name, rest) = line.split_once(' ')?; - (name == key).then(|| rest.to_string()) - }) - }; - let Some(peer) = word(PEER) else { return Ok(None) }; - let peer = peer.parse().map_err(|_| format!("{PEER} reads {peer:?}"))?; - let ping = match word(PING).as_deref() { - Some("yes") => Some(true), - Some("no") => Some(false), - None => None, - Some(other) => return Err(format!("{PING} reads {other:?}")), - }; - let exec = match (word(EXEC_STATUS), word(EXEC_STDOUT), word(EXEC_REFUSED)) { - (Some(status), Some(stdout), None) => Ok((status, stdout)), - (None, None, Some(why)) => Err(why), - _ => return Err(format!("the boot file's exec keys are not one answer:\n{text}")), - }; - let reboot = match (word(REBOOTED), word(REBOOT_REFUSED)) { - (Some(said), None) => Ok(said), - (None, Some(why)) => Err(why), - _ => return Err(format!("the boot file's reboot keys are not one answer:\n{text}")), - }; - let exec_ms = word(EXEC_MS) - .and_then(|ms| ms.parse().ok()) - .ok_or_else(|| format!("the boot file names no {EXEC_MS}"))?; - let stream_end = match word(STREAM_END).as_deref() { - Some("open") => None, - Some(said) => { - let (ms, how) = said.split_once(' ').unwrap_or((said, "")); - let after_ms = - ms.parse().map_err(|_| format!("{STREAM_END} reads {said:?}"))?; - Some(End { after_ms, how: how.to_string() }) - } - None => return Err(format!("the boot file names no {STREAM_END}:\n{text}")), - }; - Ok(Some(Heard { peer, ping, exec, reboot, exec_ms, stream_end })) - } -} - -/// A conversation as a readback carries it: the command's answer as rendered, -/// for comparison with [`Heard::answered_as_owed`] rather than for reading. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Heard { - pub peer: Ipv4Addr, - pub ping: Option, - /// `(status, stdout rendered)`, or the refusal rendered. - pub exec: Result<(String, String), String>, - pub reboot: Result, - pub exec_ms: u64, - /// How the stream had ended when the conversation did, its reason as - /// rendered; `None` for one still open. - pub stream_end: Option, -} - -impl Heard { - /// The command ran, ended 0, and said exactly [`PHRASE`] and a newline. - pub fn answered_as_owed(&self) -> Result<(), String> { - match &self.exec { - Ok((status, stdout)) => { - let want = one_line(&String::from_utf8_lossy(&owed())); - if status != "0" || *stdout != want { - return Err(format!( - "`{}` answered {stdout} with status {status}, where {want} and 0 are owed", - asked() - )); - } - Ok(()) - } - Err(why) => Err(format!("`{}` was never answered: {why}", asked())), - } - } - - /// The machine took `reboot`: it said so, or it went away under the - /// request — which only the boot's own log can then tell from a machine - /// that was already going. - pub fn reboot_was_taken(&self) -> Result<&str, String> { - match &self.reboot { - Ok(word) if matches!(word.as_str(), "accepted" | "closed" | "silent") => Ok(word), - Ok(word) => Err(format!("the machine answered `{REBOOT}` with {word:?}")), - Err(why) => Err(format!("`{REBOOT}` could not be asked: {why}")), - } - } -} - -/// What the host heard over the cable, judged: every finding, or what was -/// heard said in one line per fact. -/// -/// **The stream is judged by what it carries, not by its length.** It owes the -/// boot's `Boot: complete` — the record that says the lines are this boot's, -/// from its first — and the peer that served it is the machine that then -/// answered the ping and the command, because both were asked of that address -/// and no other. -pub fn judge(heard: &Heard, stream: &[String]) -> Result, Vec> { - let mut bad = Vec::new(); - let mut said = Vec::new(); - match crate::bootlog::boot_millis(&stream.concat()) { - Some(ms) => said.push(format!( - "{} line(s) arrived from {} over the cable, `Boot: complete` ({ms} ms) among them", - stream.len(), - heard.peer - )), - None => bad.push(format!( - "{} line(s) arrived over the cable and none is this boot's `Boot: complete`", - stream.len() - )), - } - // The machine serves the boot from its first line to whoever connects, so - // a connection that ends before a byte is one it refused or dropped. - if let (true, Some(end)) = (stream.is_empty(), &heard.stream_end) { - bad.push(format!( - "{} closed the stream {} ms after this host connected, {}, before a byte arrived", - heard.peer, end.after_ms, end.how - )); - } - match heard.ping { - Some(true) => said.push(format!("{} answered a ping", heard.peer)), - Some(false) => bad.push(format!("{} answered no ping in {PING_TRIES} tries", heard.peer)), - None => said.push("no ping was asked".to_string()), - } - match heard.answered_as_owed() { - Ok(()) => said.push(format!( - "`{}` answered byte for byte with status 0, {} ms after the stream opened", - asked(), - heard.exec_ms - )), - Err(why) => bad.push(why), - } - match heard.reboot_was_taken() { - Ok(word) => said.push(format!("`{REBOOT}` was taken ({word})")), - Err(why) => bad.push(why), - } - if bad.is_empty() { - Ok(said) - } else { - Err(bad) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::Shutdown; - - impl Stream { - /// Wait until the latest connection ends, or `by` has passed; whether it - /// ended. - fn wait_ended(&self, by: Duration) -> bool { - let began = Instant::now(); - let mut state = self.state(); - loop { - if state.end.is_some() { - return true; - } - let left = by.saturating_sub(began.elapsed()); - if left.is_zero() || (state.unopened.is_some() && state.current.is_none()) { - return false; - } - state = self.shared.moved.wait_timeout(state, left).expect("the stream's state").0; - } - } - } - - fn heard(exec: Result, reboot: Result) -> Heard { - let said = Conversation { - peer: Ipv4Addr::new(192, 168, 1, 49), - ping: Some(true), - exec, - reboot, - exec_ms: 812, - stream_end: None, - }; - Conversation::parse(&format!("back_secs 60\n{}stick_secs 0\n", said.render())) - .expect("a rendered conversation reads back") - .expect("and names a peer") - } - - /// What the loop writes is what the judge reads, answer by answer, and the - /// answer the command owes is compared byte for byte through the rendering. - #[test] - fn a_conversation_reads_back_as_it_was_written() { - let good = heard(Ok(Exec { stdout: owed(), status: Some(0) }), Ok("accepted".into())); - assert_eq!(good.peer, Ipv4Addr::new(192, 168, 1, 49)); - assert_eq!(good.ping, Some(true)); - assert_eq!(good.exec_ms, 812); - good.answered_as_owed().expect("the owed answer"); - assert_eq!(good.reboot_was_taken(), Ok("accepted")); - - // One byte off, a status off, no status: each is a red of its own. - let mut short = owed(); - short.pop(); - for (stdout, status) in [(short, Some(0)), (owed(), Some(1)), (owed(), None)] { - let bad = heard(Ok(Exec { stdout, status }), Ok("accepted".into())); - assert!(bad.answered_as_owed().is_err(), "{bad:?}"); - } - let never = heard(Err("connecting: refused\nand more".into()), Ok("closed".into())); - assert!(never.answered_as_owed().unwrap_err().contains("never answered")); - assert_eq!(never.reboot_was_taken(), Ok("closed")); - assert!(heard(Ok(Exec { stdout: owed(), status: Some(0) }), Ok("refused".into())) - .reboot_was_taken() - .is_err()); - assert!(heard(Ok(Exec { stdout: owed(), status: Some(0) }), Err("no key".into())) - .reboot_was_taken() - .is_err()); - } - - /// The judge wants the boot's own records on the wire and every answer the - /// host asked for; each missing fact is a finding of its own. - #[test] - fn the_judge_reads_what_the_stream_carried_and_what_the_peer_answered() { - let stream = vec![ - "[---------- -------- 0.011 cpu0 kernel] SMP: AP cpu1 online\n".to_string(), - "[---------- -------- 1.216 cpu0 kernel] Boot: complete (1216ms)\n".to_string(), - ]; - let good = heard(Ok(Exec { stdout: owed(), status: Some(0) }), Ok("accepted".into())); - let said = judge(&good, &stream).expect("every fact is there"); - assert_eq!(said.len(), 4, "{said:?}"); - - let bad = judge(&good, &stream[..1]).unwrap_err(); - assert!(bad.iter().any(|b| b.contains("Boot: complete")), "{bad:?}"); - let mut silent = good.clone(); - silent.ping = Some(false); - assert_eq!(judge(&silent, &stream).unwrap_err().len(), 1); - let mut unasked = good; - unasked.ping = None; - assert!(judge(&unasked, &stream).is_ok()); - let bad = judge(&heard(Err("refused".into()), Ok("refused".into())), &[]).unwrap_err(); - assert_eq!(bad.len(), 3, "{bad:?}"); - } - - /// A machine that closes the stream before a byte is named as that, and - /// not read as a boot that said nothing. - #[test] - fn a_stream_closed_before_a_byte_is_named_and_not_read_as_silence() { - let dir = toyos_tmpdir::TempDir::new("metaltalk-cut"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let stream = Stream::connect(Peer::At(at), &dir.join("s.log"), false, Duration::from_secs(5)) - .expect("a loopback reader"); - drop(server.accept().unwrap()); - assert!(stream.wait_ended(Duration::from_secs(5)), "the close is read as an end"); - let end = stream.end().expect("the end is recorded"); - assert_eq!(end.how, "the peer closed it"); - assert!(stream.lines().is_empty()); - - let said = Conversation { - peer: Ipv4Addr::LOCALHOST, - ping: Some(true), - exec: Ok(Exec { stdout: owed(), status: Some(0) }), - reboot: Ok("accepted".into()), - exec_ms: 457, - stream_end: Some(end), - }; - let cut = Conversation::parse(&said.render()).unwrap().unwrap(); - let bad = judge(&cut, &[]).unwrap_err(); - assert!(bad.iter().any(|b| b.contains("before a byte arrived")), "{bad:?}"); - let lines = vec!["[---------- -------- 1.216 cpu0 kernel] Boot: complete (1216ms)\n".to_string()]; - assert!(judge(&cut, &lines).is_ok()); - } - - /// A boot file with no conversation is a boot that had none, and one with - /// half of one is refused rather than read as either. - #[test] - fn a_boot_file_without_a_conversation_names_none_and_half_of_one_is_refused() { - assert_eq!(Conversation::parse("back_secs 60\nstick_secs 0\n"), Ok(None)); - let half = "talk_peer 10.0.2.15\ntalk_exec_status 0\ntalk_exec_ms 5\ntalk_reboot accepted\n"; - assert!(Conversation::parse(half).is_err()); - let both = "talk_peer 10.0.2.15\ntalk_exec_refused \"x\"\ntalk_exec_ms 5\n\ - talk_reboot accepted\ntalk_reboot_refused \"y\"\n"; - assert!(Conversation::parse(both).is_err()); - } - - /// **A name is asked of this host's resolver and the machine found by it**: - /// the reader connects to what `localhost` answers, names that peer, and - /// keeps every line whole and in order. - #[test] - fn the_stream_is_read_from_the_address_its_name_answers() { - let dir = toyos_tmpdir::TempDir::new("metaltalk"); - let file = dir.join("stream.log"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = server.local_addr().unwrap().port(); - let peer = Peer::Named { host: "localhost".to_string(), port }; - let stream = Stream::connect(peer, &file, false, Duration::from_secs(10)).unwrap(); - let (mut conn, _) = server.accept().unwrap(); - for i in 0..100 { - writeln!(conn, "[ 0.{i:03} cpu0 kernel] line {i}").unwrap(); - } - drop(conn); - let peer = stream.wait_connected(Duration::from_secs(5)).expect("the peer"); - assert_eq!(peer.ip(), std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(stream.wait_ended(Duration::from_secs(5))); - let lines = stream.lines(); - assert_eq!(lines.len(), 100); - for (i, line) in lines.iter().enumerate() { - assert_eq!(*line, format!("[ 0.{i:03} cpu0 kernel] line {i}\n")); - } - assert_eq!(std::fs::read_to_string(&file).unwrap(), lines.concat()); - } - - /// **A first dial refused is asked again, counted, and ends at its - /// ceiling**: a machine still coming up refuses until `logkeeper` listens, and - /// one that never does is named at the ceiling rather than waited on to - /// the bound. The refusals are staged through [`Reach`]: a listener this - /// process drops still takes connects while any child it is spawning holds - /// the fd. - #[test] - fn a_refused_first_dial_is_asked_again_up_to_its_ceiling() { - let dir = toyos_tmpdir::TempDir::new("metaltalk-first-refused"); - let at = Peer::At(SocketAddr::from((Ipv4Addr::LOCALHOST, toyos_logstream::PORT))); - let stream = Stream::through(Arc::new(Refusing), at, &dir.join("s.log"), false, Duration::from_secs(60)).unwrap(); - assert!(stream.wait_connected(Duration::from_secs(5)).is_none(), "nothing listens"); - let why = stream.unopened().expect("the dial gave up within 5 s of its 60 and said why"); - assert!(why.contains("ceiling") && why.contains("refused"), "{why}"); - assert_eq!(stream.turned_away(), TURNED_AWAY_CEILING, "every refused dial, and no more"); - } - - /// An address nothing listens on: every dial is refused. - struct Refusing; - - impl Reach for Refusing { - fn resolve(&self, _: &str, _: u16) -> std::io::Result> { - unreachable!("an address is dialled, never resolved") - } - - fn ask(&self, _: &str, _: Instant) -> Result, String> { - unreachable!("an address is dialled, never asked for") - } - - fn dial(&self, _: SocketAddr) -> std::io::Result { - Err(std::io::ErrorKind::ConnectionRefused.into()) - } - } - - /// A machine rebooting behind the address this host's resolver still - /// keeps for it: every dial to `at` fails with its host down, as this - /// host's ARP answers for an address nothing holds, until the machine has - /// answered for its name on the link, which it does from the ask after - /// `unanswered` of them. - struct Rebooting { - at: SocketAddr, - unanswered: usize, - up: std::sync::atomic::AtomicBool, - resolved: std::sync::atomic::AtomicUsize, - asked: std::sync::atomic::AtomicUsize, - } - - impl Reach for Rebooting { - fn resolve(&self, _: &str, _: u16) -> std::io::Result> { - self.resolved.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(vec![self.at]) - } - - fn ask(&self, _: &str, _: Instant) -> Result, String> { - if self.asked.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < self.unanswered { - return Ok(None); - } - self.up.store(true, std::sync::atomic::Ordering::SeqCst); - let SocketAddr::V4(at) = self.at else { unreachable!("the staged address is IPv4") }; - Ok(Some(*at.ip())) - } - - fn dial(&self, at: SocketAddr) -> std::io::Result { - if !self.up.load(std::sync::atomic::Ordering::SeqCst) { - return Err(std::io::Error::from_raw_os_error(libc::EHOSTDOWN)); - } - TcpStream::connect(at) - } - } - - /// **A host down on the first dial is asked again on the machine's own - /// answer**: the resolver's address fails with EHOSTDOWN while the machine - /// is still coming up, the name is then asked on the link until the - /// machine answers, and the dial after that answer is read. One dial is - /// counted, and the resolver is asked once. - #[test] - fn a_host_down_on_the_first_dial_is_asked_again_once_the_name_answers() { - const UNANSWERED: usize = 3; - let dir = toyos_tmpdir::TempDir::new("metaltalk-down"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let net = Arc::new(Rebooting { - at, - unanswered: UNANSWERED, - up: false.into(), - resolved: 0.into(), - asked: 0.into(), - }); - let peer = Peer::Named { host: "toyos-t14.local".to_string(), port: at.port() }; - let reach: Arc = net.clone(); - let stream = Stream::through(reach, peer, &dir.join("s.log"), false, Duration::from_secs(5)).unwrap(); - let mut conn = accepted(&server, "the dial after the name answered"); - writeln!(conn, "[ 1.216 cpu0 kernel] Boot: complete (1216ms)").unwrap(); - assert_eq!(stream.wait_connected(Duration::from_secs(5)), Some(at), "{:?}", stream.unopened()); - assert!(stream.wait_for("Boot: complete", Duration::from_secs(5))); - assert_eq!(stream.turned_away(), 1, "the one dial before the machine answered"); - assert_eq!(net.resolved.load(std::sync::atomic::Ordering::SeqCst), 1, "the resolver's cache is asked once"); - assert_eq!(net.asked.load(std::sync::atomic::Ordering::SeqCst), UNANSWERED + 1); - } - - /// **A peer that has spoken and then says nothing is a stream, not an - /// end**: the reader blocks on its next line rather than reading the - /// silence as a close. The reader has no timer, so the silence is an order - /// of events and not a length of time: the stream is still open once what - /// the peer said has been read. - #[test] - fn a_peer_quiet_after_connecting_is_still_read() { - let dir = toyos_tmpdir::TempDir::new("metaltalk-quiet"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let stream = Stream::connect(Peer::At(at), &dir.join("s.log"), false, Duration::from_secs(5)) - .expect("a loopback reader"); - let (mut conn, _) = server.accept().unwrap(); - writeln!(conn, "[ 1.216 cpu0 kernel] Boot: complete (1216ms)").unwrap(); - stream.wait_connected(Duration::from_secs(5)).expect("the peer"); - assert!(stream.wait_for("Boot: complete", Duration::from_secs(5)), "the first line was not read"); - assert!(!stream.wait_ended(Duration::ZERO), "a quiet peer was read as a closed one"); - writeln!(conn, "[ 1.217 cpu0 kernel] supervisor: started logkeeper").unwrap(); - drop(conn); - assert!(stream.wait_ended(Duration::from_secs(5))); - assert_eq!( - stream.lines(), - vec!["[ 1.216 cpu0 kernel] Boot: complete (1216ms)\n", "[ 1.217 cpu0 kernel] supervisor: started logkeeper\n"] - ); - } - - /// **`reboot` is not asked while the stream is stalled short of - /// `Boot: complete`**: it waits out its bound, and the record ends the wait - /// as the judge reads it. - #[test] - fn the_hand_back_waits_for_the_boot_record_the_judge_reads() { - const STALLED_AT: [&str; 2] = [ - "[2026-09-29 14:01:34 0.493 cpu0 kernel] xHCI: mass storage iface=0 in=0x81/512 out=0x2/512", - "[2026-09-29 14:01:34 0.493 cpu0 kernel] xHCI: configuration set", - ]; - const RECORD: &str = "[2026-09-29 14:01:35 1.166 cpu0 kernel] Boot: complete (1166ms)"; - let dir = toyos_tmpdir::TempDir::new("metaltalk-carried"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let stream = Stream::connect(Peer::At(at), &dir.join("s.log"), false, Duration::from_secs(5)) - .expect("a loopback reader"); - let (mut conn, _) = server.accept().unwrap(); - for line in STALLED_AT { - writeln!(conn, "{line}").unwrap(); - } - assert!(stream.wait_for("configuration set", Duration::from_secs(5)), "the stalled lines were not read"); - let good = heard(Ok(Exec { stdout: owed(), status: Some(0) }), Ok("accepted".into())); - let bound = Duration::from_millis(100); - let began = Instant::now(); - let (waited, lines) = hand_back(&stream, began, bound, || (began.elapsed(), stream.lines())); - assert!(waited >= bound, "`reboot` was asked {waited:?} into a stall bounded at {bound:?}"); - assert!(judge(&good, &lines).is_err(), "the judge and the wait disagree on the stall"); - - writeln!(conn, "{RECORD}").unwrap(); - let lines = hand_back(&stream, Instant::now(), Duration::from_secs(5), || stream.lines()); - judge(&good, &lines).expect("`reboot` was asked before the stream carried the record"); - assert_eq!(stream.wait_for_boot(Duration::ZERO), Some(1166)); - } - - /// The next connection `server` takes, or a panic naming `what` once a - /// bound has passed with none: a reader that stopped dialing fails the test - /// rather than hanging it. - fn accepted(server: &TcpListener, what: &str) -> TcpStream { - let server = server.try_clone().unwrap(); - let (took, taken) = std::sync::mpsc::channel(); - std::thread::spawn(move || took.send(server.accept().map(|(conn, _)| conn))); - taken - .recv_timeout(Duration::from_secs(5)) - .unwrap_or_else(|_| panic!("{what} never connected")) - .unwrap() - } - - /// Ends `conn` for its reader: a drop alone does not while a child spawned - /// between its accept and its close-on-exec holds a copy. - fn close(conn: TcpStream) { - conn.shutdown(Shutdown::Both).unwrap(); - } - - /// **A redial ends the connection it replaces, asks again past every - /// connection turned away before a line, and keeps each line once.** The - /// first connection is left open, as a swapped netstack leaves it; the second - /// is closed before a byte, as `logkeeper` turns a reader away; the third - /// replays the boot from its first line, as `logkeeper` hands it to every - /// reader, and only its new line is kept. - #[test] - fn a_redial_asks_past_a_refusal_and_keeps_each_line_once() { - let dir = toyos_tmpdir::TempDir::new("metaltalk-again"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let stream = Stream::connect(Peer::At(at), &dir.join("s.log"), false, Duration::from_secs(5)) - .expect("a loopback reader"); - let mut first = accepted(&server, "the first dial"); - writeln!(first, "[ 0.001 cpu0 kernel] before the swap").unwrap(); - assert!(stream.wait_for("before the swap", Duration::from_secs(5))); - stream.redial(Duration::from_secs(5)); - close(accepted(&server, "the redial")); - let mut third = accepted(&server, "the dial after a connection turned away"); - writeln!(third, "[ 0.001 cpu0 kernel] before the swap").unwrap(); - writeln!(third, "[ 9.000 cpu0 kernel] after the swap").unwrap(); - assert!(stream.wait_for("after the swap", Duration::from_secs(5))); - assert_eq!(stream.connections(), 2, "the connection turned away was counted as admitted"); - assert_eq!(stream.turned_away(), 1, "the connection closed before a line"); - assert_eq!( - stream.lines(), - vec!["[ 0.001 cpu0 kernel] before the swap\n", "[ 9.000 cpu0 kernel] after the swap\n"] - ); - let mut rest = [0u8; 1]; - assert_eq!( - std::io::Read::read(&mut first, &mut rest).unwrap(), - 0, - "the connection the redial replaced was left open" - ); - } - - /// A machine on the link whose first dial is taken, whose dials after it - /// are turned away before a connection exists until `taken_from`, and whose - /// dials from then on are taken: refused and reset in turn, the two answers - /// a SYN gets from a machine whose listener is gone or going, depending on - /// when it went. Its name is answered at once, by the resolver and on the - /// link alike. - struct TurnedAway { - dials: std::sync::atomic::AtomicUsize, - taken_from: usize, - } - - impl Reach for TurnedAway { - fn resolve(&self, _: &str, port: u16) -> std::io::Result> { - Ok(vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))]) - } - - fn ask(&self, _: &str, _: Instant) -> Result, String> { - Ok(Some(Ipv4Addr::LOCALHOST)) - } - - fn dial(&self, at: SocketAddr) -> std::io::Result { - match self.dials.fetch_add(1, std::sync::atomic::Ordering::SeqCst) { - n if n == 0 || n >= self.taken_from => TcpStream::connect(at), - n if n % 2 == 1 => Err(std::io::ErrorKind::ConnectionRefused.into()), - _ => Err(std::io::Error::from_raw_os_error(libc::ECONNRESET)), - } - } - } - - /// `server`'s port, asked for by name. - fn named(server: &TcpListener) -> Peer { - Peer::Named { host: "toyos-t14.local".to_string(), port: server.local_addr().unwrap().port() } - } - - /// A reader of `peer` through `reach` whose first connection from `server` - /// has carried a line, and that connection. - fn read_first(server: &TcpListener, reach: Arc, peer: Peer, dir: &Path) -> (Stream, TcpStream) { - let stream = - Stream::through(reach, peer, &dir.join("s.log"), false, Duration::from_secs(5)).expect("a loopback reader"); - let mut first = accepted(server, "the first dial"); - writeln!(first, "[ 0.001 cpu0 kernel] before the swap").unwrap(); - assert!(stream.wait_for("before the swap", Duration::from_secs(5))); - (stream, first) - } - - /// `server` dropping the next `closed` connections before a byte, as - /// QEMU's forward does while `logkeeper` turns readers away, and replaying the - /// boot on the one after, with one line past it. - fn close_then_replay(server: &TcpListener, closed: usize) { - let server = server.try_clone().unwrap(); - std::thread::spawn(move || { - for _ in 0..closed { - close(server.accept().unwrap().0); - } - let (mut conn, _) = server.accept().unwrap(); - writeln!(conn, "[ 0.001 cpu0 kernel] before the swap").unwrap(); - writeln!(conn, "[ 9.000 cpu0 kernel] after the swap").unwrap(); - }); - } - - /// Returns once this host's clock has reached `at`. A bound's passing is - /// the event, and only the clock says so. - fn past(at: Instant) { - while let Some(left) = at.checked_duration_since(Instant::now()).filter(|left| !left.is_zero()) { - std::thread::sleep(left); - } - } - - /// `server` closing every connection before a byte, each once `held` has - /// passed since it was taken, until the returned call, which ends the - /// thread doing it. - fn close_every(server: &TcpListener, held: Duration) -> impl FnOnce() { - let (at, server) = (server.local_addr().unwrap(), server.try_clone().unwrap()); - let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let theirs = Arc::clone(&done); - let closer = std::thread::spawn(move || { - loop { - let (taken, _) = server.accept().unwrap(); - if theirs.load(std::sync::atomic::Ordering::SeqCst) { - return; - } - past(Instant::now() + held); - close(taken); - } - }); - move || { - done.store(true, std::sync::atomic::Ordering::SeqCst); - // Ends the accept the closer may be blocked in. - drop(TcpStream::connect(at)); - closer.join().unwrap(); - } - } - - /// **A redial asks past every dial refused or reset, however many, until - /// one is taken.** Staged through [`Reach`], so each dial's answer is the - /// one this test gives it and arrives at once: far more dials than the - /// first dial's ceiling are turned away, and every one is counted. - #[test] - fn a_redial_asks_past_every_refusal_and_reset_until_a_dial_is_taken() { - const TURNED: usize = 1000; - let dir = toyos_tmpdir::TempDir::new("metaltalk-refused"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let reach = Arc::new(TurnedAway { dials: 0.into(), taken_from: TURNED + 1 }); - let (stream, _first) = read_first(&server, reach, named(&server), &dir); - close_then_replay(&server, 0); - stream.redial(Duration::from_secs(60)); - assert!(stream.wait_for("after the swap", Duration::from_secs(5)), "{:?}", stream.unopened()); - assert_eq!(stream.turned_away(), TURNED, "every refused or reset dial, and no more"); - assert_eq!(stream.connections(), 2); - } - - /// **A redial asks past every connection closed before a line, however - /// many, until one carries a line**, and counts each. - #[test] - fn a_redial_asks_past_every_close_before_a_line_until_one_carries_a_line() { - const CLOSED: usize = 200; - let dir = toyos_tmpdir::TempDir::new("metaltalk-closed"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let (stream, _first) = read_first(&server, Arc::new(Net), Peer::At(at), &dir); - close_then_replay(&server, CLOSED); - stream.redial(Duration::from_secs(60)); - assert!(stream.wait_for("after the swap", Duration::from_secs(5)), "{:?}", stream.unopened()); - assert_eq!(stream.turned_away(), CLOSED, "every closed dial, and no more"); - assert_eq!(stream.connections(), 2); - } - - /// **A redial ends at its bound alone, and says so**: dials refused on a - /// name, and connections closed before a line, are asked again until the - /// bound passes, and the reader then names the end rather than going quiet. - /// - /// Whether the reader dials inside a 50 ms bound at all is the host - /// scheduler's, and a redial that did not says so too. A connection is - /// closed only once the bound has passed since it was taken, so a redial - /// that dialled ends on its first close, which is past its bound, and - /// names that close rather than saying it never asked. - #[test] - fn a_redial_ends_at_its_bound_alone_and_says_so() { - const BOUND: Duration = Duration::from_millis(50); - let dir = toyos_tmpdir::TempDir::new("metaltalk-bound"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let reach = Arc::new(TurnedAway { dials: 0.into(), taken_from: usize::MAX }); - let (refused, _first) = read_first(&server, reach, named(&server), &dir); - refused.redial(BOUND); - assert_eq!(refused.wait_for_connection(1, Duration::from_secs(5)), None, "nothing is taken"); - let why = refused.unopened().expect("the redial ended within 5 s and said why"); - assert!(why.contains("by the bound"), "{why}"); - - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let (closed, _first) = read_first(&server, Arc::new(Net), Peer::At(at), &dir); - let stop_closing = close_every(&server, BOUND); - closed.redial(BOUND); - assert_eq!(closed.wait_for_connection(1, Duration::from_secs(5)), None, "every dial closed before a line"); - let why = closed.unopened().expect("the redial ended within 5 s and said why"); - match closed.turned_away() { - 0 => assert!(why.ends_with("by the bound: never asked"), "{why}"), - 1 => assert!(why.contains("by the bound: the latest connection ended before a line"), "{why}"), - n => panic!("{n} dials counted, where the first was closed past the bound: {why}"), - } - stop_closing(); - } - - /// **A redial of a forward that fails a connect ends at that failure**: - /// QEMU's forward takes every connect while QEMU lives, so a refusal is - /// its exit, named at once rather than dialled until the bound. The - /// refusal is staged through [`Reach`]: a listener this process drops - /// still takes connects while any child it is spawning holds the fd. - #[test] - fn a_redial_on_a_forward_that_refuses_ends_at_the_refusal() { - let dir = toyos_tmpdir::TempDir::new("metaltalk-forward-gone"); - let server = TcpListener::bind("127.0.0.1:0").unwrap(); - let at = server.local_addr().unwrap(); - let reach = Arc::new(TurnedAway { dials: 0.into(), taken_from: usize::MAX }); - let (stream, _first) = read_first(&server, reach, Peer::At(at), &dir); - assert_eq!(stream.turned_away(), 0, "the first dial was taken"); - stream.redial(Duration::from_secs(60)); - assert_eq!(stream.wait_for_connection(1, Duration::from_secs(5)), None); - let why = stream.unopened().expect("a refusing forward ends the redial at once"); - assert!(why.contains("refused"), "{why}"); - assert_eq!(stream.turned_away(), 1, "the redial's one refused connect"); - } - - /// A reply macOS's mDNSResponder sent this host's legacy question for its - /// own name, that name and both `AAAA` addresses replaced byte for byte: - /// the question echoed, the `A` record by a compression pointer, and two - /// `AAAA` records after it. - const MDNS_REPLY: [u8; 112] = [ - 0xe6, 0xfb, 0x84, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x10, 0x52, 0x65, 0x70, 0x6c, - 0x79, 0x2d, 0x46, 0x69, 0x78, 0x74, 0x75, 0x72, 0x65, 0x2d, 0x30, 0x31, 0x05, 0x6c, 0x6f, 0x63, 0x61, - 0x6c, 0x00, 0x00, 0x01, 0x00, 0x01, 0xc0, 0x0c, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, - 0x04, 0xc0, 0xa8, 0x01, 0x2f, 0xc0, 0x0c, 0x00, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x10, - 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xc0, - 0x0c, 0x00, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x10, 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - ]; - const MDNS_REPLY_ID: u16 = 0xe6fb; - const MDNS_REPLY_HOST: &str = "Reply-Fixture-01.local"; - - /// **The question is the one another responder echoes, and its answer is - /// read through a compression pointer**: only for the question's own ID, - /// its own name, and a whole record. - #[test] - fn a_name_asked_on_the_link_reads_another_responders_answer() { - let question = mdns_question(MDNS_REPLY_HOST, MDNS_REPLY_ID).unwrap(); - assert_eq!(question[..2], MDNS_REPLY[..2]); - assert_eq!(question[12..], MDNS_REPLY[12..question.len()], "the question as the responder echoed it"); - assert_eq!(mdns_answer(&MDNS_REPLY, MDNS_REPLY_HOST, MDNS_REPLY_ID), Some(Ipv4Addr::new(192, 168, 1, 47))); - assert_eq!(mdns_answer(&MDNS_REPLY, "reply-fixture-01.LOCAL", MDNS_REPLY_ID), Some(Ipv4Addr::new(192, 168, 1, 47))); - assert_eq!(mdns_answer(&MDNS_REPLY, MDNS_REPLY_HOST, MDNS_REPLY_ID ^ 1), None, "another question's answer"); - assert_eq!(mdns_answer(&MDNS_REPLY, "toyos-t14.local", MDNS_REPLY_ID), None, "another name's answer"); - assert_eq!(mdns_answer(&MDNS_REPLY[..55], MDNS_REPLY_HOST, MDNS_REPLY_ID), None, "a record cut short"); - assert!(mdns_question("toyos..local", 1).is_err()); - } - - /// A pointer back to the label before it would loop; the name's bound ends - /// it rather than the stack. - #[test] - fn a_loop_of_compression_pointers_is_no_name() { - let mut bytes = vec![0u8; 12]; - bytes.extend_from_slice(&[1, b'a', 0xc0, 12]); - assert_eq!(dns_name(&bytes, 12), None); - assert_eq!(dns_name(&[0xc0, 0], 0), None, "a pointer to itself"); - } -} diff --git a/tests/checks.rs b/tests/checks.rs index cc7e62f99a2..212bead5a67 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -16,8 +16,6 @@ mod checks { mod claims_checks; #[path = "clock.rs"] mod clock_checks; - #[path = "lan.rs"] - mod lan_checks; #[path = "metal.rs"] mod metal_checks; #[path = "qemu.rs"] @@ -866,11 +864,6 @@ mod checks { claims_checks::domains_end_below_the_windows(); } - #[test] - fn metal_lease_judged_is_this_boots_own() { - lan_checks::the_lease_judged_is_this_boots_own(); - } - #[test] fn metal_stop_owes_its_record_and_leaves_no_operation_open() { metal_checks::the_stop_owes_its_record_and_leaves_no_operation_open(); @@ -958,7 +951,7 @@ mod checks { /// One boot's readback, out of a `loader.log` and a `logkeeper` text. fn readback(label: &str, loader: &str, log: &str) -> metal::Readback { let boot = "back_secs 50\nstick_secs 0\n"; - metal::Readback::new(label, std::path::PathBuf::new(), loader.into(), log.into(), boot) + metal::Readback::new(label, loader.into(), log.into(), boot) .expect("a boot file naming both numbers") } diff --git a/tests/checks/lan.rs b/tests/checks/lan.rs deleted file mode 100644 index 203a4f49ea9..00000000000 --- a/tests/checks/lan.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! `lan_dhcp_lease`'s judge over the T14's own talking boot, planted as the -//! loop writes one and read as a run reads one. - -use super::*; -use toyos_build::metal::{READBACK_BOOT, READBACK_STREAM, READBACK_TALK}; - -/// The T14's `lantalkcase` boot: the card's hand-over, what netstack said of its -/// MAC, its link and its lease, and the supervisor's stop. The MAC and the two resolvers -/// are stand-ins that name nobody; every other byte is the machine's. -const LOG: &str = r"[2026-10-03 10:20:01 1.192 cpu0 kernel] pcidev: PCI 00:1f.6 [8086:15fc] handed over on slot 0, vector 0x28 -[2026-10-03 10:20:05 5.727 netstack] netstack: MAC 02:00:00:00:00:01 -[2026-10-03 10:20:08 8.499 cpu4 kernel] pcidev: slot 0 took its first message on vector 0x28 -[2026-10-03 10:20:08 8.499 netstack] netstack: I219: link up at 1000 Mb/s full duplex, 2772 ms after the driver came up -[2026-10-03 10:20:19 19.053 netstack] netstack: DHCP: lease 192.168.1.48/24 from 192.168.1.1, gateway 192.168.1.1, dns [192.0.2.53 198.51.100.53], 13326 ms after netstack came up -[2026-10-03 10:20:19 19.053 netstack] netstack: ready, at most 103 piped connections (4 MiB each of 16022 MiB total) -[2026-10-03 10:20:20 20.249 supervisor] supervisor: power: the machine stops, and logkeeper makes the log whole first (Reboot) -"; - -/// That boot's `boot.txt`, under the same stand-in. -const BOOT: &str = "back_secs 63\nstick_secs 0\nmachine_vendor LENOVO\nmachine_product 20W0003AMZ\n\ - machine_bios N34ET71W (1.71 )\nwire_mac 02:00:00:00:00:01\n"; - -/// That boot's `talk.txt`, verbatim. -const TALK: &str = "talk_peer 192.168.1.48\ntalk_ping yes\ntalk_exec_status 0\n\ - talk_exec_stdout \"the T14 answers over its own cable\\n\"\ntalk_exec_ms 77\n\ - talk_stream_end open\ntalk_reboot accepted\n"; - -fn judged(log: &str, boot: &str) -> Result<(), String> { - let dir = toyos_tmpdir::TempDir::new("lan-readback"); - metal_checks::plant(&dir, lan::TALK_BOOT, "", log, None); - let home = metal::at(&dir, lan::TALK_BOOT); - for (name, text) in [(READBACK_BOOT, boot), (READBACK_TALK, TALK), (READBACK_STREAM, log)] { - fs::write(home.join(name), text).expect("a planted file"); - } - let back = metal::read_readback(&dir, lan::TALK_BOOT).expect("a planted readback"); - metal_judge("lan_dhcp_lease")(&[&back]) -} - -/// The lease judged is the one this boot took: the T14's boot passes on the -/// address it answered the host at, and reds where netstack's record names another -/// address, where netstack read another MAC than Ubuntu did, and with no lease. -pub fn the_lease_judged_is_this_boots_own() { - assert_eq!(judged(LOG, BOOT), Ok(())); - let refused = |what: &str, log: &str, boot: &str, says: &str| { - let why = judged(log, boot).expect_err(what); - assert!(why.contains(says), "{what}: {why}"); - }; - refused( - "a lease record naming an address the boot did not answer at", - &LOG.replace("lease 192.168.1.48/24", "lease 192.168.1.46/24"), - BOOT, - "leased 192.168.1.46 and answered for its name at 192.168.1.48", - ); - refused( - "a MAC that is not the one Ubuntu read", - LOG, - &BOOT.replace("wire_mac 02:00:00:00:00:01", "wire_mac 02:00:00:00:00:02"), - "no \"netstack: MAC \" record names the MAC the operating system before this boot read", - ); - let unleased: String = - LOG.lines().filter(|l| !l.contains("DHCP: lease")).map(|l| format!("{l}\n")).collect(); - refused("a boot that took no lease", &unleased, BOOT, "took no address from its network"); -} diff --git a/tests/common/claims.rs b/tests/common/claims.rs index c40c30b6fd7..147d89c3e86 100644 --- a/tests/common/claims.rs +++ b/tests/common/claims.rs @@ -3,10 +3,12 @@ //! addresses out of. Every judge reads the kernel's own records. use toyos_build::bootlog; -use toyos_build::lan::I219; use super::serial::Serial; +/// The T14's I219, as the kernel's hand-over record spells its id. +const I219: &str = "8086:15fc"; + /// The job that claims the T14's I219, gives it back and claims it again. pub const RECLAIM: &str = "test_rs_pci_reclaim"; diff --git a/tests/common/lan.rs b/tests/common/lan.rs deleted file mode 100644 index cbccc80e392..00000000000 --- a/tests/common/lan.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! The cable: netstack taking this machine's address from the network, and the T14 -//! answering the development host on it. -//! -//! Every line read here is a line of a boot's log — the kernel's records, and -//! netstack's own lines, which reach the stick under netstack's name through its log -//! ring. The judge reads netstack's lines by that name and no other program's. - -use toyos_build::lan::{lease_in, link_up_ms, LINK_UP, MAC, READY}; - -use super::metal; - -/// The boot the host talks to over its own cable: the log it serves, sshserver, and -/// `reboot` as the way the machine is handed back. -pub const TALK_CONFIG: &str = "tests/lantalkcase"; -pub const TALK_BOOT: &str = "lantalkcase"; - -/// Its one job holds the machine until the runner's bound is near, as the -/// fallback for a host that never tells it to reboot. -const TALK_HOLD: &str = "test_rs_lan_talk_hold"; -pub const TALK_JOBS: &[&str] = &[TALK_HOLD]; - -/// The kernel's own records, tied to the I219's hand-over, say a message it -/// raised reached a CPU — whatever the PHY did about a link. -pub fn delivered_on_metal(back: &metal::Readback) -> Result<(), String> { - let got = toyos_build::lan::delivered(back.kernel().text())?; - eprintln!(" [lan] {}", got.handed.trim()); - eprintln!(" [lan] {}", got.took.trim()); - Ok(()) -} - -/// The card the T14 arm claims, as the kernel and the manifest spell it. -const ID: &str = "8086:15fc"; - -/// The PCI function that card is, as `/sys/bus/pci/devices` spells it: where -/// the metal loop reads the cable and the card's MAC before the flash. -pub const NIC: &str = "0000:00:1f.6"; - -/// The T14's judge: the claim, the card, and the lease in netstack's own lines. -/// -/// **The host learns the leased address from the boot**: netstack answers for the -/// machine's name once it holds a lease, and the loop reads the log served at -/// the address the name answered with. The lease record is held to that -/// address here, so no lease but this boot's own is judged. -pub fn on_metal(back: &metal::Readback) -> Result<(), String> { - let kernel = back.kernel(); - let text = kernel.text(); - let mut bad: Vec = Vec::new(); - let wire_mac = back.wire_mac.as_ref().ok_or_else(|| { - format!( - "{}'s readback names no MAC: this boot was driven by a loop that was not told the \ - function its image claims", - back.label - ) - })?; - let (heard, _) = back.talk()?; - - // A boot with no hand-over line carries the kernel's refusal instead, and - // quoting that is the whole diagnosis. - let handed = format!("[{}] handed over on slot", ID); - match text.lines().find(|l| l.contains(&handed)) { - Some(line) => eprintln!(" [lan] {}", line.trim()), - None => bad.push(match text.lines().find(|l| l.contains("NOT HANDED OVER")) { - Some(line) => format!("the kernel refused this function: {}", line.trim()), - None => format!( - "no `{handed}` record and no refusal either: nothing on this machine claimed \ - {ID}, so `tests/lantalkcase` was flashed onto a machine that has no such card" - ), - }), - } - - // netstack's own records, in the form the kernel gives a program's, under netstack's tag. - let log = back.log(); - let netstack = toyos_build::lan::netstack_records(log.text()); - for owed in [MAC, LINK_UP, READY] { - if !netstack.contains(owed) { - bad.push(format!("no {owed:?} record")); - } - } - - // Neither MAC is printed: a judge's lines are quoted in public. - if !netstack.contains(&format!("{MAC}{wire_mac}")) { - bad.push(format!( - "no {MAC:?} record names the MAC the operating system before this boot read on \ - {NIC}, which is `{}` in its `{}`: the card this boot brought up is not that one", - toyos_build::metal::WIRE_MAC_KEY, - toyos_build::metal::READBACK_BOOT - )); - } - - match link_up_ms(&netstack) { - Ok(ms) => eprintln!(" [lan] the link came up {ms} ms after the driver did"), - Err(why) => bad.push(why), - } - - match lease_in(&netstack) { - Ok(lease) => { - // The resolvers are counted and not printed, for the same reason. - eprintln!( - " [lan] leased {}/{} from {} in {} ms, gateway {}, {} resolver(s)", - lease.address, - lease.prefix, - lease.server, - lease.ms, - lease.gateway, - lease.dns.len() - ); - if lease.address != heard.peer { - bad.push(format!( - "this boot leased {} and answered for its name at {}: the address the host \ - reached it at is not the one netstack says it leased", - lease.address, heard.peer - )); - } - } - Err(why) => bad.push(why), - } - - if bad.is_empty() { - return Ok(()); - } - Err(format!("{} finding(s):\n {}", bad.len(), bad.join("\n "))) -} - -/// The T14 talked to over its own cable, judged from the Mac's side: the log -/// the machine served, asked for by its name while it booted, is the stick's -/// own log from its first line in its own order, the address the name answered -/// with answered a ping and ran the command, and `reboot` handed the machine -/// back — before its hold would have. -pub fn talked_on_metal(back: &metal::Readback) -> Result<(), String> { - let (heard, stream) = back.talk()?; - let mut bad: Vec = Vec::new(); - match toyos_build::metaltalk::judge(&heard, &stream) { - Ok(said) => said.iter().for_each(|line| eprintln!(" [talk] {line}")), - Err(found) => bad.extend(found), - } - // **The stick is the oracle the wire is compared with**: `logkeeper` writes a - // round to `/log` and then hands it to every reader from the boot's first - // line, so what arrived is the file's own first lines in the file's own - // order — and the file came back over a different path. - let file: Vec = back.log().text().split_inclusive('\n').map(str::to_string).collect(); - match super::logstream::is_prefix_of(&stream, &file) { - Ok(()) => eprintln!( - " [talk] the {} served line(s) are the stick's own, from its first, in its order \ - ({} in the file)", - stream.len(), - file.len() - ), - Err(why) => bad.push(why), - } - // The command and not the hold ended this boot: a hold that exited 0 is a - // machine that waited out its own bound. - match back.exit_code(TALK_HOLD) { - Ok(0) => bad.push(format!( - "{TALK_HOLD} exited 0, so the boot ran to its own hold and `reboot` over ssh was \ - not what ended it" - )), - Ok(code) => eprintln!(" [talk] {TALK_HOLD} was ended under the reboot ({code})"), - Err(_) => eprintln!(" [talk] {TALK_HOLD} left no exit record: the reboot ended it"), - } - if bad.is_empty() { - return Ok(()); - } - Err(format!("{} finding(s):\n {}", bad.len(), bad.join("\n "))) -} diff --git a/tests/common/logstream.rs b/tests/common/logstream.rs deleted file mode 100644 index ae206e6470d..00000000000 --- a/tests/common/logstream.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! The log a machine serves, judged from the host: a guest whose `logkeeper` port -//! is forwarded, a reader that connects when the test says so, and the guest's -//! own `/log` as the oracle. -//! -//! **The file is what the stream is judged against.** `logkeeper` writes each round -//! to `/log` and then hands the same bytes to every reader, from the boot's -//! first line however late the reader came, so what a reader received is the -//! file's own first lines in the file's own order ([`is_prefix_of`]). The file -//! is read off the FAT volume behind the guest's back, so the two readings -//! share nothing but the boot that produced them. - -/// What a reader received is the file's own first lines, in the file's own -/// order, and nothing else. -/// -/// Reported as the first disagreement rather than as a count: a stream that lost -/// its third line and one that reordered two are different defects, and a length -/// calls them the same one. -pub fn is_prefix_of(received: &[String], file: &[String]) -> Result<(), String> { - if received.is_empty() { - return Err("the reader received nothing at all".to_string()); - } - for (i, line) in received.iter().enumerate() { - match file.get(i) { - Some(theirs) if theirs == line => {} - Some(theirs) => { - return Err(format!( - "the stream and /log disagree at line {i}:\n stream: {line:?}\n /log: \ - {theirs:?}" - )) - } - None => { - return Err(format!( - "the stream carries {} line(s) and /log only {}; the first line past the \ - file is {line:?}", - received.len(), - file.len() - )) - } - } - } - Ok(()) -} diff --git a/tests/common/metal.rs b/tests/common/metal.rs index a09a4e3b108..71e1c247f94 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -57,18 +57,6 @@ pub struct Arm { /// kernel, and that is what most of the suite wants: it is the artifact the /// owner flashes. pub features: &'static [&'static str], - /// The PCI function this boot's image claims: the loop refuses, before the - /// flash, a machine holding no address on it, and a judge holds the MAC the - /// boot's driver read to the one the operating system before the flash - /// read off it. **`None` on every boot that does not ask**: a boot that - /// needs no cable would be refused for one that is out. - pub nic: Option<&'static str>, - /// **The boot is talked to over its own cable.** Its image authorizes a - /// key minted beside it, and the loop — told `--talk` — reads the log the - /// machine serves under its name, pings the address the name answers with, - /// runs a command there and tells it to reboot. `false` on every boot whose - /// judge reads the stick alone. - pub talk: bool, } /// The ordinary arm: one boot, and the fields a caller must still say. @@ -82,7 +70,7 @@ pub const fn once( params: &'static [&'static str], jobs: &'static [&'static str], ) -> Arm { - Arm { boot, config, params, jobs, features: &[], nic: None, talk: false } + Arm { boot, config, params, jobs, features: &[] } } /// One boot carrying members that are **discovered rather than registered**. @@ -230,8 +218,6 @@ pub struct Metal { /// What one boot left on the stick, and what the host clock saw of it. pub struct Readback { pub label: String, - /// The directory the loop wrote this boot's files into. - home: PathBuf, loader: String, /// The kernel's own records of every `logkeeper` file this boot wrote. kernel: String, @@ -243,10 +229,6 @@ pub struct Readback { pub back_secs: u64, /// How long after that the boot stick's own partition was there again. pub stick_secs: u64, - /// The MAC of the function this boot's image claims, as the operating - /// system before the flash read it, and `None` on every boot that named no - /// function. - pub wire_mac: Option, /// The machine the loop read before the flash. pub machine: Result, /// What this boot's judges measured, for the machine's record to judge. @@ -256,7 +238,7 @@ pub struct Readback { impl Readback { /// One boot's readback out of the three files the loop wrote for it: /// `loader.log`, the `logkeeper` files as one text, and the boot file. - pub fn new(label: &str, home: PathBuf, loader: String, log: String, boot: &str) -> Result { + pub fn new(label: &str, loader: String, log: String, boot: &str) -> Result { let kernel = bootlog::kernel_records(&log); let back_secs = toyos_build::metal::back_secs(boot) .ok_or_else(|| format!("{label}'s boot file names no `back_secs`: {boot:?}"))?; @@ -264,39 +246,18 @@ impl Readback { .ok_or_else(|| format!("{label}'s boot file names no `stick_secs`: {boot:?}"))?; Ok(Readback { label: label.to_string(), - home, boot_ms: bootlog::boot_millis(&kernel), loader, kernel, log, back_secs, stick_secs, - wire_mac: toyos_build::metal::wire_mac(boot), machine: toyos_build::metal::machine(boot) .map_err(|why| format!("{label}'s boot file {why}")), numbers: RefCell::new(BTreeMap::new()), }) } - /// What the loop heard over the boot's own cable, and the log the machine - /// served it — or why a boot that was to be talked to has neither. - /// - /// **Absent is a finding here, never an empty answer**: the loop writes - /// the conversation before anything can refuse, so a talking boot's - /// readback without one is a loop that was not told `--talk`. - pub fn talk(&self) -> Result<(toyos_build::metaltalk::Heard, Vec), String> { - let at = self.home.join(toyos_build::metal::READBACK_TALK); - let text = std::fs::read_to_string(&at).map_err(|e| { - format!("{}: {e} — this boot's loop was not told --talk", at.display()) - })?; - let heard = toyos_build::metaltalk::Conversation::parse(&text)?.ok_or_else(|| { - format!("{}'s loop heard nothing over the cable:\n{text}", self.label) - })?; - let at = self.home.join(toyos_build::metal::READBACK_STREAM); - let stream = std::fs::read_to_string(&at).map_err(|e| format!("{}: {e}", at.display()))?; - Ok((heard, stream.split_inclusive('\n').map(str::to_string).collect())) - } - /// Every `logkeeper` file this boot wrote, as one text, less every program's /// line ([`bootlog::kernel_records`]): no program's line is read as the kernel's. /// It ends where the supervisor had `logkeeper` make it whole, so what the kernel writes @@ -570,10 +531,6 @@ struct Batch { jobs: Vec, files: Vec<(String, Vec)>, links: Vec<(String, String)>, - /// [`Arm::nic`], carried to the invocation that drives this boot. - nic: Option<&'static str>, - /// [`Arm::talk`], carried to the image and to the invocation. - talk: bool, } impl Batch { @@ -613,8 +570,6 @@ fn batches( jobs: boot.jobs.clone(), files: boot.files.clone(), links: boot.links.clone(), - nic: None, - talk: false, }, ); if was.is_some() { @@ -631,29 +586,21 @@ fn batches( jobs: Vec::new(), files: Vec::new(), links: Vec::new(), - nic: arm.nic, - talk: arm.talk, }); if batch.config != arm.config || batch.params != arm.params || batch.features != arm.features - || batch.nic != arm.nic - || batch.talk != arm.talk { return Err(format!( - "{name} rides the boot {:?} as ({}, {:?}, {:?}, {:?}, talk={}) and another row \ - rides it as ({}, {:?}, {:?}, {:?}, talk={}); one boot is one image", + "{name} rides the boot {:?} as ({}, {:?}, {:?}) and another row rides it as \ + ({}, {:?}, {:?}); one boot is one image", arm.boot, arm.config, arm.params, arm.features, - arm.nic, - arm.talk, batch.config, batch.params, - batch.features, - batch.nic, - batch.talk + batch.features )); } batch.add(arm.jobs.iter().map(|j| (*j).to_string())); @@ -796,13 +743,6 @@ fn build( ); let mut params: Vec<&str> = batch.params.clone(); params.push(&deadline); - // **A talking boot carries the key the loop will offer**, - // minted beside the image so the loop finds it there. Nothing about this - // host is in it: the loop finds the machine by its name. - if batch.talk { - let identity = super::ssh::Identity::mint_in(&talk_home(&home))?; - extra.push((super::ssh::KEYS_ON_ROOT.to_string(), identity.authorized_line().into_bytes())); - } let plan = toyos_build::build::Plan::new(toyos_build::arch::Arch::X86_64, &config, features, ¶ms); let bytes = toyos_build::build::build_test_image(root, &plan, quiet, &extra); let image = home.join("image.img"); @@ -817,13 +757,8 @@ fn fingerprint(text: &str) -> u64 { h.finish() } -/// Where a talking boot's key lives, beside its image. -fn talk_home(home: &Path) -> PathBuf { - home.join("ssh") -} - -fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool) -> Vec { - let mut words = vec![ +fn invocation(image: &Path, home: &Path) -> Vec { + vec![ "run".to_string(), "--bin".to_string(), "toyos-metal".to_string(), @@ -837,16 +772,7 @@ fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool) -> Vec Result { let loader = read(toyos_build::metal::READBACK_LOADER)?; let log = read(toyos_build::metal::READBACK_KERNEL)?; let boot = read(toyos_build::metal::READBACK_BOOT)?; - Readback::new(label, home, loader, log, &boot) + Readback::new(label, loader, log, &boot) } /// The machine every boot that came back names, or why there is not one. @@ -971,11 +897,6 @@ pub fn run( let mut images: BTreeMap<&str, PathBuf> = BTreeMap::new(); if !judging { - // The key a talking boot authorizes is minted by the harness's own ssh - // client, which the suite builds only on its QEMU path. - if batches.values().any(|b| b.talk) { - toyos_build::build::build_host_judges(&root, quiet); - } for (label, batch) in &batches { match build(&root, dir, label, batch, rust_bins, quiet) { Ok(image) => { @@ -1003,7 +924,7 @@ pub fn run( request.push_str(&format!( "\n{label}\n image: {}\n cargo {}\n", image.display(), - invocation(image, &at(dir, label), batches[*label].nic, batches[*label].talk).join(" ") + invocation(image, &at(dir, label)).join(" ") )); } let path = dir.join("request.txt"); @@ -1028,7 +949,7 @@ pub fn run( let mut refused: BTreeMap<&str, String> = BTreeMap::new(); if !offline { for (label, image) in &images { - let words = invocation(image, &at(dir, label), batches[*label].nic, batches[*label].talk); + let words = invocation(image, &at(dir, label)); eprintln!("[metal] {label}: cargo {}", words.join(" ")); if let Err(why) = drive(&root, &words) { refused.insert(label, why); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fb920195959..291983fd0b6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -11,13 +11,9 @@ pub mod iommu; pub mod irqcensus; /// The `isa` claim's rows on the T14. pub mod isa; -/// The cable: netstack's address, and the T14 answering the host on it. -pub mod lan; -pub mod logstream; pub mod metal; pub mod power; pub mod qemu; pub mod screen; pub mod serial; -pub mod ssh; pub mod usb; diff --git a/tests/common/ssh.rs b/tests/common/ssh.rs deleted file mode 100644 index 065ccf09bf3..00000000000 --- a/tests/common/ssh.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! What a test does to a guest over the cable: mint a key, run a program, move -//! a file, be refused. -//! -//! Every call here runs `tests/ssh-client-host`, which exits `0` when the -//! exchange happened — whatever the guest said — and `1` when it could not -//! complete one. Everything below turns the second into an error and only the -//! first into a verdict. - -use std::path::Path; -use std::process::Command; - -use super::compile; - -/// A key pair minted for one test and thrown away with it, as two files in the -/// lane's scratch directory. **Nothing in this repository holds a private -/// key**: a committed one would be a credential with no owner and no expiry. -pub struct Identity { - line: String, -} - -impl Identity { - /// The key kept in `dir`, minted the first time: a metal image carries its - /// public half, so the key lives beside the image and outlives this run. - pub fn mint_in(dir: &Path) -> Result { - std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; - let private = dir.join("id_ed25519"); - let public = dir.join("id_ed25519.pub"); - if !private.exists() || !public.exists() { - let said = client(&["keygen", str(&private), str(&public)])?; - if !said.trim().starts_with("ok ") { - return Err(format!("the client answered {said:?} to keygen")); - } - } - Ok(Identity { - line: std::fs::read_to_string(&public) - .map_err(|e| format!("read the minted public key: {e}"))?, - }) - } - - /// The one `authorized_keys` line that names this key — what a test stages - /// into the image so the guest will let it in. - pub fn authorized_line(&self) -> String { - self.line.clone() - } -} - -/// Run the client and hand back what it said, or the reason it could not say -/// anything. Its last line is the answer; the ones before it, if any, are a -/// listing's entries. -fn client(argv: &[&str]) -> Result { - let out = Command::new(toyos_build::build::ssh_client_host(&compile::repo_root())) - .args(argv) - .output() - .map_err(|e| format!("run the ssh client: {e}"))?; - let said = String::from_utf8_lossy(&out.stdout).into_owned(); - if !out.status.success() { - return Err(format!( - "the ssh client could not complete `{}`: {}", - argv.join(" "), - said.trim() - )); - } - Ok(said) -} - -fn str(path: &Path) -> &str { - path.to_str().expect("the lane's scratch paths are utf-8") -} - -/// Where the image's `authorized_keys` file lands, ROOT-relative — the guest -/// reads it at `/system/etc/ssh_authorized_keys`, which `userland/sshserver`'s -/// `AUTHORIZED_KEYS` is the other half of. -pub const KEYS_ON_ROOT: &str = "etc/ssh_authorized_keys"; diff --git a/tests/lantalkcase/system.toml b/tests/lantalkcase/system.toml deleted file mode 100644 index e110809833a..00000000000 --- a/tests/lantalkcase/system.toml +++ /dev/null @@ -1,62 +0,0 @@ -# The T14 answering the development host over its own cable: netstack serving the -# I219 for as long as the boot lasts, `logkeeper` streaming every record to the -# listener the parameter line names, and sshserver taking a command — `reboot` -# among them, which is how the host hands the machine back. -# -# Nothing here reaches the internal NVMe: no `[disks]` row, and the only -# device any program claims is the PCI function below. - -[boot] -start = ["logkeeper", "diskserver", "fileserver", "netstack", "sshserver", "test-runner"] - -[programs.logkeeper] -service = true -syscap = ["logread"] -# What lets `logkeeper` serve this boot's log on the network, to whoever connects: -# without it the log is served on this machine only. -receives = ["netstack"] - -[programs.netstack] -service = true -serves = ["netstack"] -devices = ["pci:8086:15fc"] - -[programs.sshserver] -service = true -receives = ["netstack"] -starts = ["toybox", "swap"] -login = true - -# The host replaces netstack on this boot without rebooting it. -[programs.swap] -receives = ["swap"] - -[programs.test-runner] -syscap = ["logread"] - -# What `exec` is asked for over the cable. `power` because `reboot` is this -# binary under another name and it is the host's way of handing the machine -# back; `echo` is the command whose answer the host compares. -[programs.toybox] -receives = ["power"] - -[symlinks] -"bin/echo" = "/system/bin/toybox" -"bin/reboot" = "/system/bin/toybox" - -# 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/metal/lenovo-20w0003amz.toml b/tests/metal/lenovo-20w0003amz.toml index e4f78ee5d69..86823aa9c4f 100644 --- a/tests/metal/lenovo-20w0003amz.toml +++ b/tests/metal/lenovo-20w0003amz.toml @@ -22,9 +22,6 @@ bios = "N34ET71W (1.71 )" "boot.isa-withheld.complete_ms" = 780 "boot.isa-withheld.panel_max_us" = 3888 "boot.isa-withheld.panel_us" = 19251 -"boot.lantalkcase.complete_ms" = 1152 -"boot.lantalkcase.panel_max_us" = 3919 -"boot.lantalkcase.panel_us" = 20906 "boot.latencycase.complete_ms" = 1165 "boot.latencycase.panel_max_us" = 3887 "boot.latencycase.panel_us" = 21459 diff --git a/tests/ssh-client-host/Cargo.lock b/tests/ssh-client-host/Cargo.lock deleted file mode 100644 index d8eb51ea815..00000000000 --- a/tests/ssh-client-host/Cargo.lock +++ /dev/null @@ -1,2090 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array 0.14.7", -] - -[[package]] -name = "aead" -version = "0.6.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" -dependencies = [ - "crypto-common 0.2.1", - "inout 0.2.2", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes" -version = "0.9.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04097e08a47d9ad181c2e1f4a5fabc9ae06ce8839a333ba9a949bcb0d31fd2a3" -dependencies = [ - "cipher 0.5.1", - "cpubits", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead 0.5.2", - "aes 0.8.4", - "cipher 0.4.4", - "ctr 0.9.2", - "ghash 0.5.1", - "subtle", -] - -[[package]] -name = "aes-gcm" -version = "0.11.0-rc.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" -dependencies = [ - "aead 0.6.0-rc.10", - "aes 0.9.0-rc.4", - "cipher 0.5.1", - "ctr 0.10.0-rc.4", - "ghash 0.6.0", - "subtle", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bcrypt-pbkdf" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" -dependencies = [ - "blowfish", - "pbkdf2 0.12.2", - "sha2 0.10.9", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "block-padding" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher 0.4.4", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "cbc" -version = "0.2.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab1412b9ae2463ede01f1e591412dfbcfeacecf40e8c4c3e0655814c19065c38" -dependencies = [ - "cipher 0.5.1", -] - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - -[[package]] -name = "cipher" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" -dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", - "inout 0.2.2", -] - -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpubits" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-bigint" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a0d26b245348befa0c121944541476763dcc46ede886c88f9d12e1697d27c3" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.0", - "serdect", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "getrandom 0.4.3", - "hybrid-array", - "rand_core 0.10.0", -] - -[[package]] -name = "crypto-primes" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" -dependencies = [ - "crypto-bigint", - "libm", - "rand_core 0.10.0", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "ctr" -version = "0.10.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee683dd898fbd052617b4514bc31f98bc32081a83b69ec46adef3b1ef4ae36f" -dependencies = [ - "cipher 0.5.1", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", - "subtle", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0-pre.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.11.1", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "delegate" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "285743a676ccb6b3e116bc14cc69319b957867930ae9c4822f8e0f54509d7243" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", - "ctutils", -] - -[[package]] -name = "ecdsa" -version = "0.17.0-rc.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91bbdd377139884fafcad8dc43a760a3e1e681aa26db910257fa6535b70e1829" -dependencies = [ - "der", - "digest 0.11.1", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", -] - -[[package]] -name = "ed25519" -version = "3.0.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0-pre.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.10.0", - "serde", - "sha2 0.11.0-rc.5", - "signature", - "subtle", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.14.0-rc.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e84043d573efd4ac9d2d125817979a379204bf7e328b25a4a30487e8d100e618" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.1", - "digest 0.11.1", - "hkdf", - "hybrid-array", - "once_cell", - "pem-rfc7468 1.0.0", - "pkcs8", - "rand_core 0.10.0", - "rustcrypto-ff", - "rustcrypto-group", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "generic-array" -version = "1.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" -dependencies = [ - "generic-array 0.14.7", - "rustversion", - "typenum", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core 0.10.0", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval 0.6.2", -] - -[[package]] -name = "ghash" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" -dependencies = [ - "polyval 0.7.1", -] - -[[package]] -name = "gloo-timers" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-literal" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" - -[[package]] -name = "hkdf" -version = "0.13.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb55385998ae66b8d2d5143c05c94b9025ab863966f0c94ce7a5fde30105092" -dependencies = [ - "hmac 0.13.0-rc.5", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef451d73f36d8a3f93ad32c332ea01146c9650e1ec821a9b0e46c01277d544f8" -dependencies = [ - "digest 0.11.1", -] - -[[package]] -name = "hybrid-array" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1" -dependencies = [ - "ctutils", - "subtle", - "typenum", - "zeroize", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding 0.3.3", - "generic-array 0.14.7", -] - -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "block-padding 0.4.2", - "hybrid-array", -] - -[[package]] -name = "internal-russh-forked-ssh-key" -version = "0.6.18+upstream-0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f8a978272e3cbdf4768f7363eb1c8e1e6ba63c52a3ed05e29e222da4aec7cb" -dependencies = [ - "argon2", - "bcrypt-pbkdf", - "crypto-bigint", - "ecdsa", - "ed25519-dalek", - "hex", - "hmac 0.13.0-rc.5", - "p256", - "p384", - "p521", - "rand_core 0.10.0", - "rsa", - "sec1", - "sha1 0.11.0-rc.5", - "sha2 0.11.0-rc.5", - "signature", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", -] - -[[package]] -name = "internal-russh-num-bigint" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" -dependencies = [ - "num-integer", - "num-traits", - "rand", - "rand_core 0.10.0", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", -] - -[[package]] -name = "kem" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" -dependencies = [ - "crypto-common 0.2.1", - "rand_core 0.10.0", -] - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "ml-kem" -version = "0.3.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04437cb1a66c0b78740927b76cc61f218344b9f6ef3dd430e283274a718ef0e9" -dependencies = [ - "hybrid-array", - "kem", - "module-lattice", - "rand_core 0.10.0", - "sha3", -] - -[[package]] -name = "module-lattice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "164eb3faeaecbd14b0b2a917c1b4d0c035097a9c559b0bed85c2cdd032bc8faa" -dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", -] - -[[package]] -name = "nix" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "p256" -version = "0.14.0-rc.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f0a10fe314869359cb2901342b045f4e5a962ef9febc006f03d2a8c848fe4c" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0-rc.5", -] - -[[package]] -name = "p384" -version = "0.14.0-rc.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b079e66810c55ab3d6ba424e056dc4aefcdb8046c8c3f3816142edbdd7af7721" -dependencies = [ - "ecdsa", - "elliptic-curve", - "fiat-crypto", - "primefield", - "primeorder", - "sha2 0.11.0-rc.5", -] - -[[package]] -name = "p521" -version = "0.14.0-rc.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eecc34c4c6e6596d5271fecf90ac4f16593fa198e77282214d0c22736aa9266" -dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0-rc.5", -] - -[[package]] -name = "pageant" -version = "0.2.0" -source = "git+https://github.com/ToyOSOrg/russh?branch=toyos#389804d3b9908250282746c934c58c059b88fb7e" -dependencies = [ - "byteorder", - "bytes", - "delegate", - "futures", - "log", - "rand", - "sha2 0.10.9", - "thiserror", - "tokio", - "windows", - "windows-strings", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - -[[package]] -name = "pbkdf2" -version = "0.13.0-rc.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8dfa4e14084d963d35bfb4cdb38712cde78dcf83054c0e8b9b8e899150f374e" -dependencies = [ - "digest 0.11.1", - "hmac 0.13.0-rc.5", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs1" -version = "0.8.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkcs5" -version = "0.8.0-rc.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6" -dependencies = [ - "aes 0.9.0-rc.4", - "aes-gcm 0.11.0-rc.3", - "cbc 0.2.0-rc.4", - "der", - "pbkdf2 0.13.0-rc.9", - "rand_core 0.10.0", - "scrypt", - "sha2 0.11.0-rc.5", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.11.0-rc.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" -dependencies = [ - "der", - "pkcs5", - "rand_core 0.10.0", - "spki", -] - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash 0.5.1", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash 0.5.1", -] - -[[package]] -name = "polyval" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" -dependencies = [ - "cpubits", - "cpufeatures 0.3.0", - "universal-hash 0.6.1", -] - -[[package]] -name = "primefield" -version = "0.14.0-rc.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6543f5eec854fbf74ba5ef651fbdc9408919b47c3e1526623687135c16d12e9" -dependencies = [ - "crypto-bigint", - "crypto-common 0.2.1", - "rand_core 0.10.0", - "rustcrypto-ff", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0-rc.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569d9ad6ef822bb0322c7e7d84e5e286244050bd5246cac4c013535ae91c2c90" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" -dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.3", - "rand_core 0.10.0", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rfc6979" -version = "0.5.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a3127ee32baec36af75b4107082d9bd823501ec14a4e016be4b6b37faa74ae" -dependencies = [ - "hmac 0.13.0-rc.5", - "subtle", -] - -[[package]] -name = "rsa" -version = "0.10.0-rc.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ed3e93fc7e473e464b9726f4759659e72bc8665e4b8ea227547024f416d905" -dependencies = [ - "const-oid 0.10.2", - "crypto-bigint", - "crypto-primes", - "digest 0.11.1", - "pkcs1", - "pkcs8", - "rand_core 0.10.0", - "sha2 0.11.0-rc.5", - "signature", - "zeroize", -] - -[[package]] -name = "russh" -version = "0.60.0" -source = "git+https://github.com/ToyOSOrg/russh?branch=toyos#389804d3b9908250282746c934c58c059b88fb7e" -dependencies = [ - "aes 0.8.4", - "aes-gcm 0.10.3", - "bitflags", - "block-padding 0.3.3", - "byteorder", - "bytes", - "cbc 0.1.2", - "chacha20 0.9.1", - "cipher 0.5.1", - "crypto-bigint", - "ctr 0.9.2", - "curve25519-dalek", - "data-encoding", - "delegate", - "der", - "digest 0.10.7", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", - "enum_dispatch", - "futures", - "generic-array 1.3.5", - "getrandom 0.2.17", - "hex-literal", - "hmac 0.12.1", - "inout 0.1.4", - "internal-russh-forked-ssh-key", - "internal-russh-num-bigint", - "log", - "md5", - "ml-kem", - "module-lattice", - "p256", - "p384", - "p521", - "pageant", - "pbkdf2 0.12.2", - "pkcs5", - "pkcs8", - "poly1305", - "polyval 0.7.1", - "rand", - "rand_core 0.10.0", - "russh-cryptovec", - "russh-util", - "sec1", - "sha1 0.10.6", - "sha2 0.10.9", - "signature", - "spki", - "ssh-encoding", - "subtle", - "thiserror", - "tokio", - "typenum", - "universal-hash 0.6.1", - "zeroize", -] - -[[package]] -name = "russh-cryptovec" -version = "0.59.0" -source = "git+https://github.com/ToyOSOrg/russh?branch=toyos#389804d3b9908250282746c934c58c059b88fb7e" -dependencies = [ - "log", - "nix", - "ssh-encoding", - "windows-sys", -] - -[[package]] -name = "russh-sftp" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b" -dependencies = [ - "bitflags", - "bytes", - "chrono", - "dashmap", - "gloo-timers", - "log", - "serde", - "serde_bytes", - "thiserror", - "tokio", - "tokio-util", - "wasm-bindgen-futures", -] - -[[package]] -name = "russh-util" -version = "0.52.0" -source = "git+https://github.com/ToyOSOrg/russh?branch=toyos#389804d3b9908250282746c934c58c059b88fb7e" -dependencies = [ - "chrono", - "tokio", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustcrypto-ff" -version = "0.14.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6" -dependencies = [ - "rand_core 0.10.0", - "subtle", -] - -[[package]] -name = "rustcrypto-group" -version = "0.14.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed" -dependencies = [ - "rand_core 0.10.0", - "rustcrypto-ff", - "subtle", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "salsa20" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" -dependencies = [ - "cfg-if", - "cipher 0.5.1", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.12.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e03ed5b54ed5fcc8e016cd94301416bc2c01c05c87a6742b97468337c8804598" -dependencies = [ - "cfg-if", - "pbkdf2 0.13.0-rc.9", - "salsa20", - "sha2 0.11.0-rc.5", -] - -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serdect" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" -dependencies = [ - "base16ct", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b167252f3c126be0d8926639c4c4706950f01445900c4b3db0fd7e89fcb750a" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.11.1", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.11.1", -] - -[[package]] -name = "sha3" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" -dependencies = [ - "digest 0.11.1", - "keccak", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signature" -version = "3.0.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" -dependencies = [ - "digest 0.11.1", - "rand_core 0.10.0", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "spki" -version = "0.8.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "ssh-cipher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" -dependencies = [ - "aes 0.8.4", - "aes-gcm 0.10.3", - "cbc 0.1.2", - "chacha20 0.9.1", - "cipher 0.4.4", - "ctr 0.9.2", - "poly1305", - "ssh-encoding", - "subtle", -] - -[[package]] -name = "ssh-client-host" -version = "0.1.0" -dependencies = [ - "rand", - "russh", - "russh-sftp", - "tokio", - "toyos-swap", -] - -[[package]] -name = "ssh-encoding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" -dependencies = [ - "base64ct", - "bytes", - "pem-rfc7468 0.7.0", - "sha2 0.10.9", -] - -[[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.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toyos-abi" -version = "0.16.0" - -[[package]] -name = "toyos-manifest" -version = "0.1.0" -dependencies = [ - "toyos-abi", -] - -[[package]] -name = "toyos-swap" -version = "0.1.0" -dependencies = [ - "sha2 0.10.9", - "toyos-manifest", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "universal-hash" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" -dependencies = [ - "crypto-common 0.2.1", - "ctutils", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.5", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/tests/ssh-client-host/Cargo.toml b/tests/ssh-client-host/Cargo.toml deleted file mode 100644 index 67adcdfc19f..00000000000 --- a/tests/ssh-client-host/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "ssh-client-host" -description = "The harness's SSH client, russh built for the host: what a test does to a guest over SSH, one fact per line." -version = "0.1.0" -edition = "2021" -license = "MIT OR Apache-2.0" - -[[bin]] -name = "toyos_ssh" -path = "src/main.rs" - -[dependencies] -russh = { git = "https://github.com/ToyOSOrg/russh", branch = "toyos", default-features = false, features = ["rustcrypto"] } -russh-sftp = "2.4" -rand = "0.10" -tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time", "fs"] } -# The swap subsystem's name and header, which sshserver reads with the same code. -toyos-swap = { path = "../../toyos-swap" } - -[lints.rust] -warnings = "deny" diff --git a/tests/ssh-client-host/src/main.rs b/tests/ssh-client-host/src/main.rs deleted file mode 100644 index ebabf92db34..00000000000 --- a/tests/ssh-client-host/src/main.rs +++ /dev/null @@ -1,555 +0,0 @@ -//! `toyos_ssh` — what the harness does to a guest over the cable. -//! -//! One exchange per run, driven by positional arguments and answered on stdout -//! in one line per fact. **The exit status separates two different things**: -//! `0` means the exchange happened and the line above says what the guest -//! answered; `1` means this program could not complete it, and the line says -//! why. A test that conflated them would read a client bug as a guest verdict. -//! -//! ```text -//! toyos_ssh keygen → ok -//! toyos_ssh auth → signed , then -//! authenticated -//! | refused offering -//! | asked to sign -//! toyos_ssh exec -//! → exit | no-exit-status -//! toyos_ssh feed -//! → env , exit -//! toyos_ssh pipe -//! → exit | no-exit-status -//! toyos_ssh abandon → ok -//! toyos_ssh fire -//! → accepted | refused | closed | silent -//! | exited -//! toyos_ssh put → ok -//! toyos_ssh get → ok -//! toyos_ssh list → entry …, ok -//! toyos_ssh swap -//! → accepted | refused -//! | unasked | unanswered -//! (`swap ` -//! over `exec`; after `accepted`, the -//! program's input is held until this -//! one's stdin closes) -//! ``` -//! -//! A program's stdout and stderr go to files rather than to this process's own, -//! because they are the bytes under test: a byte-exact comparison cannot be -//! made against a stream something else may also have written to. -//! -//! The guest's host key is accepted unseen, and no argument here could change -//! that: the machine mints a fresh identity every boot, so there is nothing to -//! pin it against. What is under test is the *guest's* judgement of this -//! client's key, and `auth` is the arm that asks for it to be refused. - -use std::path::Path; -use std::process::ExitCode; -use std::sync::Arc; -use std::time::Duration; - -use russh::Signer; -use russh::client::{self, AuthResult, Handle}; -use russh::keys::agent::AgentIdentity; -use russh::keys::ssh_key::LineEnding; -use russh::keys::{Algorithm, HashAlg, PrivateKey, PrivateKeyWithHashAlg}; -use russh::{ChannelMsg, Disconnect}; -use russh_sftp::client::SftpSession; -use tokio::io::AsyncWriteExt; - -/// The whole of one run's wall clock. A guest under TCG on a loaded host is -/// slow, and nothing here is a measurement — this is a liveness guard, so that -/// a stalled exchange ends in a named line rather than in the suite's own stall -/// verdict, which says only that a test did not finish. -const BOUND: Duration = Duration::from_secs(120); - -/// The user this client authenticates as. The guest has no user model, so the -/// name is a string its daemon prints and nothing keys on. -const USER: &str = "root"; - -fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); - let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { - Ok(runtime) => runtime, - Err(e) => return fail(&format!("no tokio runtime: {e}")), - }; - match runtime.block_on(async { tokio::time::timeout(BOUND, run(&args)).await }) { - Ok(Ok(())) => ExitCode::SUCCESS, - Ok(Err(why)) => fail(&why), - Err(_) => fail(&format!("nothing answered in {}s", BOUND.as_secs())), - } -} - -fn fail(why: &str) -> ExitCode { - println!("error {why}"); - ExitCode::FAILURE -} - -async fn run(args: &[String]) -> Result<(), String> { - let words: Vec<&str> = args.iter().map(String::as_str).collect(); - match words.as_slice() { - ["keygen", private, public] => keygen(private, public), - ["auth", host, port, key] => auth(host, port, key).await, - ["exec", host, port, key, out, err, command @ ..] => { - exec(host, port, key, out, err, &command.join(" "), None).await - } - ["feed", host, port, key, out, err, stdin, command @ ..] => { - exec(host, port, key, out, err, &command.join(" "), Some((stdin, true))).await - } - ["pipe", host, port, key, out, err, stdin, command @ ..] => { - exec(host, port, key, out, err, &command.join(" "), Some((stdin, false))).await - } - ["abandon", host, port, key, command @ ..] => { - abandon(host, port, key, &command.join(" ")).await - } - ["fire", host, port, key, command @ ..] => fire(host, port, key, &command.join(" ")).await, - ["put", host, port, key, local, remote] => put(host, port, key, local, remote).await, - ["get", host, port, key, remote, local] => get(host, port, key, remote, local).await, - ["list", host, port, key, remote] => list(host, port, key, remote).await, - ["swap", host, port, key, service, binary, digest] => { - swap(host, port, key, service, binary, digest).await - } - _ => Err(format!("not a command this client has: {words:?}")), - } -} - -/// A key pair for one test, written where the caller asked. -/// -/// The public half is one `authorized_keys` line, which is what the harness -/// stages into the image; the private half is what every other command below -/// authenticates with. -fn keygen(private: &str, public: &str) -> Result<(), String> { - let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519) - .map_err(|e| format!("minting an ed25519 key: {e}"))?; - let pem = key.to_openssh(LineEnding::LF).map_err(|e| format!("encoding the key: {e}"))?; - write(private, pem.as_bytes())?; - let line = key.public_key().to_openssh().map_err(|e| format!("encoding the public key: {e}"))?; - write(public, format!("{line}\n").as_bytes())?; - // The fingerprint the guest's daemon prints, so a console assertion can - // name the key that was accepted or refused instead of trusting a count. - println!("ok {}", key.public_key().fingerprint(HashAlg::Sha256)); - Ok(()) -} - -/// A signer that refuses to sign and remembers being asked. -/// -/// **Being asked is the finding.** `authenticate_publickey` sends the key as a -/// probe with no signature and signs only under `USERAUTH_PK_OK`, so a guest -/// that turns an unauthorized key away at the offer never reaches this; a -/// guest that answers `PK_OK` to a stranger does, and there is nothing to do -/// with that request but record it. -struct NeverSigns { - asked: bool, -} - -/// The one thing that can go wrong here, and the trait's required conversion. -enum NoSignature { - Asked, - Send(russh::SendError), -} - -impl From for NoSignature { - fn from(e: russh::SendError) -> Self { - NoSignature::Send(e) - } -} - -impl Signer for NeverSigns { - type Error = NoSignature; - - #[allow(clippy::manual_async_fn)] - fn auth_sign( - &mut self, - _key: &AgentIdentity, - _hash_alg: Option, - _to_sign: Vec, - ) -> impl std::future::Future, Self::Error>> + Send { - async move { - self.asked = true; - Err(NoSignature::Asked) - } - } -} - -/// Offer the key and report which way it went, and whether the guest asked for -/// a signature before deciding. Both answers are `Ok` here: whether a key -/// *should* have been accepted is the caller's question, and this program's -/// failures are the ones that stopped it from asking. -async fn auth(host: &str, port: &str, key: &str) -> Result<(), String> { - let (mut session, key) = start(host, port, key).await?; - let mut signer = NeverSigns { asked: false }; - let outcome = session - .authenticate_publickey_with(USER, key.public_key().clone(), None, &mut signer) - .await; - println!("signed {}", if signer.asked { "yes" } else { "no" }); - // The methods the server still offers after turning this key away are what - // says a password could never have been guessed at: a server that answered - // `password` here would be offering a credential. - match outcome { - Ok(AuthResult::Success) => println!("authenticated"), - Ok(AuthResult::Failure { remaining_methods, .. }) => { - let mut offered: Vec = - remaining_methods.iter().map(String::from).collect(); - offered.sort(); - println!("refused offering {}", offered.join(",")); - } - Err(NoSignature::Asked) => println!("asked to sign"), - Err(NoSignature::Send(e)) => return Err(format!("offering a key: {e}")), - } - let _ = session.disconnect(Disconnect::ByApplication, "", "en").await; - Ok(()) -} - -/// Run a command and collect what came back on each of the channel's two -/// streams. With `stdin`, the file's bytes are sent to the program first, and -/// where it asks, an environment request is made before the exec so the caller -/// learns what the guest answers one with. -async fn exec( - host: &str, - port: &str, - key: &str, - out: &str, - err: &str, - command: &str, - stdin: Option<(&str, bool)>, -) -> Result<(), String> { - let session = connect(host, port, key).await?; - let mut channel = session - .channel_open_session() - .await - .map_err(|e| format!("opening a session channel: {e}"))?; - let feed = match stdin { - Some((path, _)) => Some(std::fs::read(path).map_err(|e| format!("reading {path}: {e}"))?), - None => None, - }; - if stdin.is_some_and(|(_, probe)| probe) { - channel - .set_env(true, "TOYOS_SSH_PROBE", "1") - .await - .map_err(|e| format!("asking to set an environment variable: {e}"))?; - match channel.wait().await { - Some(ChannelMsg::Failure) => println!("env refused"), - Some(ChannelMsg::Success) => println!("env accepted"), - other => return Err(format!("an env request was answered {other:?}")), - } - } - channel.exec(true, command).await.map_err(|e| format!("asking for {command:?}: {e}"))?; - // **The input is sent beside the reading, never before it**: a program - // that refuses its input and exits closes the channel with most of it - // unsent, and a send waiting on a window that closed channel will never - // open again is a client that never reads the status it was answered. - // A program reading stdin sees its end when the writer shuts down: right - // away, or after the bytes. - let mut writer = channel.make_writer(); - let feeding = tokio::spawn(async move { - if let Some(bytes) = feed { - writer.write_all(&bytes).await?; - } - writer.shutdown().await - }); - - let (mut stdout, mut stderr, mut status) = (Vec::new(), Vec::new(), None); - while let Some(message) = channel.wait().await { - match message { - ChannelMsg::Data { data } => stdout.extend_from_slice(&data), - ChannelMsg::ExtendedData { data, ext: 1 } => stderr.extend_from_slice(&data), - ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), - _ => {} - } - } - // Whatever of the input the program never took is nobody's now. - feeding.abort(); - write(out, &stdout)?; - write(err, &stderr)?; - match status { - Some(status) => println!("exit {status}"), - // Said rather than guessed: a channel that closed without one is the - // finding, and a harness that saw `exit 0` here would report a pass. - None => println!("no-exit-status"), - } - let _ = session.disconnect(Disconnect::ByApplication, "", "en").await; - Ok(()) -} - -/// Start a program, wait for its first output so it is certainly running, and -/// then drop the connection without reading the rest — what a harness whose -/// client died looks like from the guest's side. -async fn abandon(host: &str, port: &str, key: &str, command: &str) -> Result<(), String> { - let session = connect(host, port, key).await?; - let mut channel = session - .channel_open_session() - .await - .map_err(|e| format!("opening a session channel: {e}"))?; - channel.exec(true, command).await.map_err(|e| format!("asking for {command:?}: {e}"))?; - let mut seen = 0; - while let Some(message) = channel.wait().await { - if let ChannelMsg::Data { data } = message { - seen = data.len(); - break; - } - } - if seen == 0 { - return Err(format!("{command:?} produced nothing, so it may never have run")); - } - println!("ok {seen}"); - // Dropped rather than disconnected: the guest is owed no goodbye, and a - // client that died would send none. - drop(session); - Ok(()) -} - -/// How long `fire` waits for the guest's answer to its request. -const FIRE_ANSWER: Duration = Duration::from_secs(20); - -/// Ask for a program and report the guest's answer to the request, without -/// collecting the program's status: **a command that ends the machine never -/// exits**, so its status is not a thing any client can collect. An accepted -/// request's connection is held until the guest drops it, or [`FIRE_ANSWER`]. -/// -/// `closed` and `silent` are answers too — a machine that went down under the -/// request drops the connection with or without a goodbye, and what the caller -/// makes of that is the caller's, against its own evidence that it went down. -async fn fire(host: &str, port: &str, key: &str, command: &str) -> Result<(), String> { - let session = connect(host, port, key).await?; - let mut channel = session - .channel_open_session() - .await - .map_err(|e| format!("opening a session channel: {e}"))?; - channel.exec(true, command).await.map_err(|e| format!("asking for {command:?}: {e}"))?; - let answer = tokio::time::timeout(FIRE_ANSWER, async { - loop { - match channel.wait().await { - Some(ChannelMsg::Success) => return "accepted", - Some(ChannelMsg::Failure) => return "refused", - Some(ChannelMsg::Close) | Some(ChannelMsg::Eof) | None => return "closed", - Some(_) => {} - } - } - }) - .await - .unwrap_or("silent"); - // **Held until the machine drops it**, or the bound: sshserver ends a program - // whose connection is gone, so a client that left the moment the request - // was accepted could end the very `reboot` it asked for before it reached - // its syscall. - // A status is the program coming back, which one that ended the machine - // never does: `exited ` rather than `accepted`. - let mut exited = None; - if answer == "accepted" { - let _ = tokio::time::timeout(FIRE_ANSWER, async { - loop { - match channel.wait().await { - Some(ChannelMsg::ExitStatus { exit_status }) => exited = Some(exit_status), - Some(ChannelMsg::Close) | None => break, - Some(_) => {} - } - } - }) - .await; - } - match exited { - Some(status) => println!("exited {status}"), - None => println!("{answer}"), - } - // Dropped rather than disconnected: the machine this was fired at may - // already be gone, and a goodbye to it is one more wait on nothing. - drop(session); - Ok(()) -} - -/// How long `swap` waits for the guest's answer once the binary is sent. -const SWAP_ANSWER: Duration = Duration::from_secs(60); - -/// Send `binary` as `service`'s replacement, naming `digest` for it, and -/// report the guest's answer. -/// -/// An ordinary `exec` of `swap ` with the binary on -/// its input. `digest` is the caller's and is sent as given — never computed -/// here — so a caller can name the wrong one and see it refused. **The input -/// is not closed after the binary**: the program answers, then waits for its -/// input to close — this program's stdin closing is the caller's go — because -/// the service being swapped may be the one carrying this connection. -/// `unanswered` is an answer too: whether it went is the machine's log's to -/// say. -async fn swap( - host: &str, - port: &str, - key: &str, - service: &str, - binary: &str, - digest: &str, -) -> Result<(), String> { - let body = std::fs::read(binary).map_err(|e| format!("reading {binary}: {e}"))?; - if toyos_swap::parse_hex(digest).is_none() { - return Err(format!("{digest:?} is not a SHA-256 in lowercase hex")); - } - let session = connect(host, port, key).await?; - let mut channel = session - .channel_open_session() - .await - .map_err(|e| format!("opening a session channel: {e}"))?; - let command = format!("swap {service} {digest} {}", body.len()); - channel.exec(true, command.as_str()).await.map_err(|e| format!("asking for {command:?}: {e}"))?; - channel.data(&body[..]).await.map_err(|e| format!("sending the binary: {e}"))?; - let (mut stdout, mut status, mut line) = (Vec::new(), None, None); - let answered = tokio::time::timeout(SWAP_ANSWER, async { - // Until the answer's newline, or the channel's end for a program that - // exited without one. - while let Some(message) = channel.wait().await { - match message { - ChannelMsg::Data { data } => { - stdout.extend_from_slice(&data); - if let Some(end) = stdout.iter().position(|&b| b == b'\n') { - line = Some(String::from_utf8_lossy(&stdout[..end]).to_string()); - return; - } - } - ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), - ChannelMsg::Eof | ChannelMsg::Close => return, - _ => {} - } - } - }) - .await; - match (answered, &line) { - (Ok(()), Some(said)) if said.starts_with("accepted ") => { - println!("{said}"); - // **The program's input closing is the go**: it stops waiting and - // exits, and the supervisor stops the old service when it hangs up. The - // caller — whose own connections that service may carry too — says - // when, by closing this program's stdin. - tokio::task::spawn_blocking(|| std::io::Read::read_to_end(&mut std::io::stdin(), &mut Vec::new())) - .await - .map_err(|e| format!("waiting for the go: {e}"))? - .map_err(|e| format!("reading the go: {e}"))?; - let _ = channel.eof().await; - } - (Ok(()), Some(said)) if said.starts_with("refused ") || said.starts_with("unasked ") => println!("{said}"), - (Ok(()), _) => println!( - "unanswered the channel ended after {:?}, status {status:?}", - String::from_utf8_lossy(&stdout) - ), - (Err(_), _) => println!( - "unanswered nothing in {}s after {:?}", - SWAP_ANSWER.as_secs(), - String::from_utf8_lossy(&stdout) - ), - } - // Dropped rather than disconnected: the connection may be gone with the - // service that carried it. - drop(session); - Ok(()) -} - -async fn put(host: &str, port: &str, key: &str, local: &str, remote: &str) -> Result<(), String> { - let bytes = std::fs::read(local).map_err(|e| format!("reading {local}: {e}"))?; - let (session, sftp) = sftp(host, port, key).await?; - // `create` and not `write`: this crate's `write` opens with `WRITE` alone, - // which the draft says is an open of a file that already exists — a guest - // that answered it with a new file would be the lenient one. - let mut file = - sftp.create(remote).await.map_err(|e| format!("creating {remote} on the guest: {e}"))?; - file.write_all(&bytes).await.map_err(|e| format!("writing {remote} on the guest: {e}"))?; - file.shutdown().await.map_err(|e| format!("closing {remote} on the guest: {e}"))?; - println!("ok {}", bytes.len()); - finish(sftp, session).await; - Ok(()) -} - -async fn get(host: &str, port: &str, key: &str, remote: &str, local: &str) -> Result<(), String> { - let (session, sftp) = sftp(host, port, key).await?; - let bytes = - sftp.read(remote).await.map_err(|e| format!("reading {remote} off the guest: {e}"))?; - write(local, &bytes)?; - println!("ok {}", bytes.len()); - finish(sftp, session).await; - Ok(()) -} - -async fn list(host: &str, port: &str, key: &str, remote: &str) -> Result<(), String> { - let (session, sftp) = sftp(host, port, key).await?; - let entries = - sftp.read_dir(remote).await.map_err(|e| format!("listing {remote} on the guest: {e}"))?; - let mut names: Vec = entries - .map(|entry| format!("entry {} {}", entry.file_name(), entry.metadata().size.unwrap_or(0))) - .collect(); - names.sort(); - for name in &names { - println!("{name}"); - } - println!("ok {}", names.len()); - finish(sftp, session).await; - Ok(()) -} - -/// The guest's host key is accepted unseen — see this program's own note. -struct Trusting; - -impl client::Handler for Trusting { - type Error = russh::Error; - - async fn check_server_key( - &mut self, - _key: &russh::keys::ssh_key::PublicKey, - ) -> Result { - Ok(true) - } -} - -/// A connected but unauthenticated session, and the key to authenticate it -/// with. Split out because `auth` reports the refusal that every other command -/// treats as a failure. -async fn start(host: &str, port: &str, key: &str) -> Result<(Handle, PrivateKey), String> { - let port: u16 = port.parse().map_err(|_| format!("{port:?} is not a port number"))?; - let pem = std::fs::read(key).map_err(|e| format!("reading the key {key}: {e}"))?; - let key = PrivateKey::from_openssh(&pem) - .map_err(|e| format!("{key} is not an OpenSSH private key: {e}"))?; - let config = Arc::new(client::Config { - inactivity_timeout: Some(BOUND), - ..Default::default() - }); - let session = client::connect(config, (host, port), Trusting) - .await - .map_err(|e| format!("connecting to {host}:{port}: {e}"))?; - Ok((session, key)) -} - -async fn connect(host: &str, port: &str, key: &str) -> Result, String> { - let (mut session, key) = start(host, port, key).await?; - let outcome = session - .authenticate_publickey(USER, PrivateKeyWithHashAlg::new(Arc::new(key), None)) - .await - .map_err(|e| format!("authenticating: {e}"))?; - if !outcome.success() { - return Err("the guest refused this key".to_string()); - } - Ok(session) -} - -async fn sftp(host: &str, port: &str, key: &str) -> Result<(Handle, SftpSession), String> { - let session = connect(host, port, key).await?; - let channel = session - .channel_open_session() - .await - .map_err(|e| format!("opening a session channel: {e}"))?; - channel - .request_subsystem(true, "sftp") - .await - .map_err(|e| format!("asking for the sftp subsystem: {e}"))?; - let sftp = SftpSession::new(channel.into_stream()) - .await - .map_err(|e| format!("starting an sftp session: {e}"))?; - Ok((session, sftp)) -} - -async fn finish(sftp: SftpSession, session: Handle) { - let _ = sftp.close().await; - let _ = session.disconnect(Disconnect::ByApplication, "", "en").await; -} - -fn write(path: &str, bytes: &[u8]) -> Result<(), String> { - if let Some(parent) = Path::new(path).parent() { - let _ = std::fs::create_dir_all(parent); - } - std::fs::write(path, bytes).map_err(|e| format!("writing {path}: {e}")) -} diff --git a/tests/toyos-rust-tests/src/bin/lan_talk_hold.rs b/tests/toyos-rust-tests/src/bin/lan_talk_hold.rs deleted file mode 100644 index 9de3a7d87c5..00000000000 --- a/tests/toyos-rust-tests/src/bin/lan_talk_hold.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Hold the boot open while the host talks to this machine over the cable, and -//! exit when the runner's own bound is near. It asserts nothing. -//! -//! **The host is meant to end this boot first**, with `reboot` over ssh; the -//! machine going back to its firmware kills this process on the way. Exiting -//! here is the fallback that hands the machine back when the host never asks — -//! through the runner's `reboot` job, which is the boot's ordinary end — and -//! its exit record is how a judge tells the two apart. - -use std::thread::sleep; -use std::time::Duration; - -const UNTIL_MS: u64 = toyos_tco::JOB_BOUND_MS - toyos_tco::JOB_BOUND_MS / 10; - -fn main() { - let since_boot = toyos_abi::clock::nanos_since_boot() / 1_000_000; - sleep(Duration::from_millis(UNTIL_MS.saturating_sub(since_boot))); -} diff --git a/tests/toyos.rs b/tests/toyos.rs index 6b3934f0f6d..78bdb410732 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -14,7 +14,7 @@ use common::qemu::{ self, await_guest, await_marker, BootOptions, QemuInstance, STALLED, TIMED_OUT, }; -use common::{audio, claims, compile, devices, faults, isa, lan, metal, power, screen, serial, usb}; +use common::{audio, claims, compile, devices, faults, isa, metal, power, screen, serial, usb}; use toyos_build::bootlog::{self}; use toyos_build::testargs::{self, SUITE}; @@ -137,9 +137,6 @@ const RUST_SKIP: &[&str] = &[ // It asserts nothing at all: it holds `dump_nmi_probe`'s boot open for // twenty seconds. On a shared boot it would be twenty seconds of nothing. "lan_hold", - // The same for `tests/lantalkcase`, held until the runner's bound is near - // unless the host's `reboot` over ssh ends it first. `lan_talk` rides it. - "lan_talk_hold", // 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. @@ -314,18 +311,6 @@ const MACHINE_TESTS: &[&str] = &[ /// than derived from its config and parameters, because sharing is not always /// safe and only the author knows. const METAL: &[(&str, metal::Metal)] = &[ - ( - "lan_dhcp_lease", - metal::Metal { arms: LANTALKCASE, judge: |b| lan::on_metal(b[0]) }, - ), - ( - "lan_message_delivery", - metal::Metal { arms: LANTALKCASE, judge: |b| lan::delivered_on_metal(b[0]) }, - ), - ( - "lan_talk", - metal::Metal { arms: LANTALKCASE, judge: |b| lan::talked_on_metal(b[0]) }, - ), // ---- one image: tests/testcases, no parameters, one job list ---- ( "blackbox_unclaimed_page", @@ -1009,16 +994,6 @@ const PROCTREECASE: &[metal::Arm] = &[metal::once( ], )]; -/// The cable's boot, netstack in front of the T14's I219, which the host talks to -/// over that cable: the loop reads the log it serves under its name, pings it, -/// runs a command on it and tells it to reboot. It names the I219, so the loop -/// refuses a cable that is out before it flashes. -const LANTALKCASE: &[metal::Arm] = &[metal::Arm { - talk: true, - nic: Some(lan::NIC), - ..metal::once(lan::TALK_BOOT, lan::TALK_CONFIG, &[], lan::TALK_JOBS) -}]; - /// Every in-kernel self-test that logs its verdict at init and does nothing /// else. /// diff --git a/toyos-logstream/src/lib.rs b/toyos-logstream/src/lib.rs index 8f87f3d3acd..a4d74332aeb 100644 --- a/toyos-logstream/src/lib.rs +++ b/toyos-logstream/src/lib.rs @@ -39,26 +39,6 @@ use core::fmt::{self, Display, Write}; pub use toyos_abi::log::Severity; use toyos_abi::log::{Head, KERNEL, LOADER, UNTIMED}; -/// The TCP port `logkeeper` serves this boot's log on, from its first line, on a -/// boot whose manifest gives `logkeeper` a `netstack` connector. **No shipping image -/// does**: the port answers whoever connects, with no authentication, so it is -/// the test estates' and the metal bench's alone. -pub const PORT: u16 = 41337; - -/// The service every connection on [`PORT`] is carried by. A swap of it ends -/// each one with no FIN and no reset, which no reader could tell from a boot -/// with nothing to say — so `logkeeper` turns new readers away from the supervisor's -/// [`SWAP`] frame accepting that swap until the process it listened through is -/// gone, and says so ([`CARRIER_LEAVING`]) before the swap may go. -pub const CARRIER: &str = "netstack"; - -/// `logkeeper`'s line once it turns new readers away for a swap of [`CARRIER`]. A -/// reader whose own connection that swap will end holds the swap's go until -/// this has reached it: a reader that asks again after it is turned away until -/// the next [`CARRIER`] serves, and never admitted by the one being stopped. -pub const CARRIER_LEAVING: &str = - "logkeeper: netstack is being replaced, and readers are turned away until the next one serves"; - /// The name of the port a reader on this machine asks `logkeeper` for the log on; /// the answer is the read end of a pipe the log is written into. The same port /// answers `inspect`, so a reader says which it wants ([`READ`]). @@ -98,13 +78,6 @@ pub const REGISTER: u32 = 1; /// what arrives after it. pub const SERVED: u32 = 2; -/// The supervisor → `logkeeper`: a word on a swap of [`CARRIER`], one byte of payload — -/// [`SWAP_LEAVING`] once the supervisor has accepted it, [`SWAP_BACK`] once the process -/// it replaced is gone and another serves or none will. -pub const SWAP: u32 = 4; -pub const SWAP_LEAVING: u8 = b'L'; -pub const SWAP_BACK: u8 = b'B'; - /// The supervisor → `logkeeper`: the machine is about to be stopped. `logkeeper` reads every ring /// and the kernel's records, writes them, makes the volume durable, and /// answers [`FLUSHED`]; the supervisor asks for the stop once it has, or once its bound @@ -678,12 +651,6 @@ mod tests { ) } - #[test] - fn the_carriers_line_names_the_carrier() { - assert!(CARRIER_LEAVING.starts_with("logkeeper: ")); - assert!(CARRIER_LEAVING.contains(&format!(" {CARRIER} "))); - } - #[test] fn a_program_line_reads_back_as_it_was_written() { let written = line("netstack", b"netstack: MAC 52:54:00:12:34:56"); diff --git a/toyos-swap/Cargo.toml b/toyos-swap/Cargo.toml index 6ed86bcd26e..769728b5b89 100644 --- a/toyos-swap/Cargo.toml +++ b/toyos-swap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-swap" -description = "Replacing a running service's binary: what swap, the supervisor and the host say about it, and the supervisor's decisions, pure." +description = "Replacing a running service's binary: what swap and the supervisor say about it, and the supervisor's decisions, pure." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" @@ -8,6 +8,6 @@ license = "MIT OR Apache-2.0" [dependencies] # The program-key bound a service name is held to, which is the manifest's. toyos-manifest = { path = "../toyos-manifest" } -# The digest a swap is verified by. Already resolved in the root, userland and -# ssh-client-host lockfiles, so nothing new is fetched. +# The digest a swap is verified by. Already resolved in the root and userland +# lockfiles, so nothing new is fetched. sha2 = { version = "0.10", default-features = false } diff --git a/toyos-swap/src/lib.rs b/toyos-swap/src/lib.rs index f556e3b12aa..2fbcf24439f 100644 --- a/toyos-swap/src/lib.rs +++ b/toyos-swap/src/lib.rs @@ -1,5 +1,5 @@ -//! Replacing a running service's binary: every word `/system/bin/swap`, -//! `/system/bin/supervisor` and the host say about it, and every decision the supervisor makes +//! Replacing a running service's binary: every word `/system/bin/swap` and +//! `/system/bin/supervisor` say about it, and every decision the supervisor makes //! about one. Pure. //! //! **A swap is asked by `/system/bin/swap` and done by the supervisor, and nothing else @@ -87,7 +87,7 @@ pub const MSG_REFUSED: u32 = 3; /// A SHA-256 digest. pub type Digest = [u8; 32]; -/// The digest of `bytes`: the one definition the host, `swap` and the supervisor share. +/// The digest of `bytes`. pub fn digest(bytes: &[u8]) -> Digest { Sha256::digest(bytes).into() } @@ -300,39 +300,12 @@ impl Word { pub fn as_str(self) -> &'static str { WORDS.iter().find(|(w, _)| *w == self).map(|(_, s)| *s).expect("every word is spelled") } - - /// Whether this word ends a swap: nothing more is said about it after. - pub fn is_final(self) -> bool { - matches!(self, Self::Refused | Self::InService | Self::Restored | Self::Gone) - } } pub fn said(service: &str, word: Word, detail: &str) -> String { format!("supervisor: swap {service}: {}: {detail}", word.as_str()) } -/// The supervisor's line about `service` inside `line` — a console line or a record in -/// any of the forms the log renders one in — as `(word, detail)`. -pub fn heard<'a>(line: &'a str, service: &str) -> Option<(Word, &'a str)> { - let head = format!("supervisor: swap {service}: "); - let (_, rest) = line.split_once(&head)?; - let rest = rest.trim_end_matches(['\n', '\r']); - WORDS.iter().find_map(|(word, spelled)| { - rest.strip_prefix(spelled).and_then(|r| r.strip_prefix(": ")).map(|detail| (*word, detail)) - }) -} - -/// What a swap came to, read off the supervisor's lines about it: the last final word -/// among `lines`, or `None` while the swap is still going. -pub fn outcome<'a>(lines: impl IntoIterator, service: &str) -> Option<(Word, String)> { - lines - .into_iter() - .filter_map(|line| heard(line, service)) - .filter(|(word, _)| word.is_final()) - .last() - .map(|(word, detail)| (word, detail.to_string())) -} - #[cfg(test)] mod tests { use super::*; @@ -396,44 +369,6 @@ mod tests { assert!(!is_staged(&path)); } - #[test] - fn the_supervisors_lines_are_heard_in_every_form_the_log_renders_them() { - let line = said("netstack", Word::InService, "/tmp/swap/x/netstack as pid 9"); - assert_eq!(line, "supervisor: swap netstack: in service: /tmp/swap/x/netstack as pid 9"); - for rendered in [ - format!("{line}\n"), - format!("[2026-09-23 18:00:01 12.345 cpu1 kernel] @{line}\n"), - ] { - assert_eq!( - heard(&rendered, "netstack"), - Some((Word::InService, "/tmp/swap/x/netstack as pid 9")), - "{rendered:?}" - ); - } - assert_eq!(heard(&line, "sshserver"), None); - assert_eq!(heard("supervisor: swap netstack: bored: x", "netstack"), None); - } - - #[test] - fn the_outcome_is_the_last_final_word() { - let lines = [ - said("netstack", Word::Accepted, "a"), - said("netstack", Word::Stopping, "b"), - said("netstack", Word::Started, "c"), - ]; - assert_eq!(outcome(lines.iter().map(String::as_str), "netstack"), None); - let mut more = lines.to_vec(); - more.push(said("netstack", Word::Failed, "d")); - more.push(said("netstack", Word::Restored, "/system/bin/netstack as pid 4")); - assert_eq!( - outcome(more.iter().map(String::as_str), "netstack"), - Some((Word::Restored, "/system/bin/netstack as pid 4".to_string())) - ); - for word in [Word::Accepted, Word::Stopping, Word::Started, Word::Failed] { - assert!(!word.is_final()); - } - } - #[test] fn every_word_is_spelled_once() { for (i, (_, a)) in WORDS.iter().enumerate() { diff --git a/userland/logkeeper/src/inspect.rs b/userland/logkeeper/src/inspect.rs index 54e0d01a5ba..cabd7899474 100644 --- a/userland/logkeeper/src/inspect.rs +++ b/userland/logkeeper/src/inspect.rs @@ -102,8 +102,7 @@ impl Published { } } - /// `stream` is whether this boot's log is served on the network at all. - fn snapshot(&self, stream: bool) -> Vec { + fn snapshot(&self) -> Vec { let mut snap = Snapshot::new(toyos_inspect::LOG); snap.put("volume.state", State::word(self.state.load(Ordering::Relaxed))); let file = self.file.lock().expect("the loop does not panic holding this").clone(); @@ -113,7 +112,6 @@ impl Published { snap.put("volume.bytes", self.bytes.load(Ordering::Relaxed)); } snap.put("records.lost", self.lost.load(Ordering::Relaxed)); - snap.put("stream", if stream { "on" } else { "off" }); snap.encode().unwrap_or_else(|why| panic!("logkeeper: its snapshot: {why}")) } } @@ -170,7 +168,7 @@ fn run(acceptor: &Acceptor, published: &Published, hub: &Hub) -> ! { match p.rx.pump(&p.conn) { RxStep::Idle => true, RxStep::Frame { msg_type: toyos_inspect::MSG_INSPECT, payload_len: 0 } => { - let _ = p.conn.try_send_bytes(toyos_inspect::MSG_SNAPSHOT, &published.snapshot(hub.network())); + let _ = p.conn.try_send_bytes(toyos_inspect::MSG_SNAPSHOT, &published.snapshot()); false } RxStep::Frame { msg_type: READ, payload_len: 0 } => { diff --git a/userland/logkeeper/src/main.rs b/userland/logkeeper/src/main.rs index 08498817f51..89fea058157 100644 --- a/userland/logkeeper/src/main.rs +++ b/userland/logkeeper/src/main.rs @@ -52,8 +52,7 @@ //! //! One `SysCap` duplicate carrying `Rights::LOG | Rights::WAIT`, which its //! manifest row asks for by the name `logread`; the origins acceptor and the -//! writable console the supervisor endows it; and what its row adds — on a test estate a -//! `netstack` connector to serve the network, and the `log` acceptor, where this +//! writable console the supervisor endows it; and what its row adds — the `log` acceptor, where this //! machine's readers ask for the log and `inspect` asks where it is going //! ([`inspect`]'s module, which grants nothing). It claims no device and can //! name no process. Writing files is ambient — a known residual of the @@ -92,7 +91,7 @@ use toyos::{Console, Pipe}; use toyos_abi::syscall::SyscallError; use toyos_logstream::{ ProgramLine, Registration, Tag, CONSOLE, FLUSH, FLUSHED, MAX_TAG, ORIGINS, REGISTER, RESUME, - SERVICE, SWAP, SWAP_BACK, SWAP_LEAVING, + SERVICE, }; use toyos_wallclock::Civil; @@ -332,14 +331,6 @@ impl Log { _ => self.take(registration.tag, registration.pid, ring, alive), } } - RxStep::Frame { msg_type: SWAP, payload_len } => { - let word = match self.supervisor_rx.payload(payload_len) { - [SWAP_LEAVING] => serve::Carrier::Leaving, - [SWAP_BACK] => serve::Carrier::Back, - other => panic!("logkeeper: the supervisor sent a swap word {other:?}"), - }; - self.hub.carrier(word); - } // **The flush runs before the next frame is read**: the supervisor sends // RESUME when the stop it flushed for was refused, which can be // before this program got to the flush, and RESUME answers it. diff --git a/userland/logkeeper/src/serve.rs b/userland/logkeeper/src/serve.rs index 949fd9d8f3c..bf1c55aface 100644 --- a/userland/logkeeper/src/serve.rs +++ b/userland/logkeeper/src/serve.rs @@ -1,5 +1,4 @@ -//! This boot's log, served to whoever asks for it: over TCP on -//! [`toyos_logstream::PORT`] through netstack, and on this machine through the +//! This boot's log, served to a reader on this machine that asks for it on the //! [`toyos_logstream::SERVICE`] port, whose answer is the read end of a pipe. //! //! **Every reader gets the boot from its first line**, however late it @@ -8,49 +7,32 @@ //! thread with an offset into it. So the same text reaches `/log` and every //! reader, in the same order, and nothing a reader does can reach the file. //! -//! A reader that takes none for [`STALLED`] while bytes are owed to it — a -//! zero window, a peer that vanished — is let go, and that is a line in the -//! log. The network's readers and this machine's are counted apart, so no -//! number of network peers can take the console's slot. +//! A reader that takes none for [`STALLED`] while bytes are owed to it is let +//! go, and that is a line in the log. //! //! **A reader that is caught up waits on the replay growing**, and nothing //! else wakes it: there is no poll and no timer. -//! -//! **A swap of netstack ([`toyos_logstream::CARRIER`]) ends every network reader's -//! connection without a word**, and a connection the netstack being stopped -//! accepts ends the same way, so from the supervisor's frame accepting one until its -//! frame that the old netstack is gone — started, failed, restored or gone — this -//! program holds no listener ([`Carrier`]). It closes the listener before it says so -//! ([`CARRIER_LEAVING`]), and netstack has answered the close by then: a reader -//! that asks after reading that line is refused by the old netstack or answered by -//! the next, and never admitted by the one being stopped. use std::io::Write; -use std::os::fd::AsRawFd; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; use toyos::ipc::{Connection, TrySendError}; -use toyos::poller::{Poller, READABLE, WRITABLE}; +use toyos::poller::{Poller, WRITABLE}; use toyos::{AsHandle, Pipe}; use toyos_abi::syscall::SyscallError; -use toyos_abi::RawHandle; use toyos::say; -use toyos_logstream::{Next, ProgramLine, Replay, Severity, Tag, CARRIER_LEAVING, LOGKEEPER, PORT, SERVED}; +use toyos_logstream::{Next, ProgramLine, Replay, Severity, Tag, LOGKEEPER, SERVED}; /// The most bytes one reader is handed per wake: bounds how long the replay's /// lock is held for a copy, not how far a reader may fall behind. const CHUNK: usize = 64 * 1024; -/// Readers on the network at once. Each is a thread, and anybody on the network -/// may be one, so the count is bounded and a reader past it is refused by name. -const MAX_NETWORK_READERS: usize = 8; - -/// Readers on this machine at once: only a program holding the -/// [`SERVICE`](toyos_logstream::SERVICE) connector is one, and the network's -/// count cannot reach this one. -const MAX_LOCAL_READERS: usize = 2; +/// Readers at once. Each is a thread, so the count is bounded and a reader +/// past it is refused by name: only a program holding the +/// [`SERVICE`](toyos_logstream::SERVICE) connector is one. +const MAX_READERS: usize = 2; /// How long a reader may take no byte at all of what it is owed before its /// slot is let go: a wait on its sink taking bytes, bounded by this. @@ -59,79 +41,34 @@ const STALLED: Duration = Duration::from_secs(10); /// The replay, and the wake a caught-up reader waits on. pub struct Hub { shared: Arc, - /// The network server's control pipe, where this program serves the - /// network: one byte per [`Carrier`] word. - carrier: Option, -} - -/// The supervisor's word on a swap of netstack, as the network server acts on it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Carrier { - /// The supervisor accepted the swap: the netstack the listener is registered with is - /// about to be stopped. - Leaving, - /// The supervisor has stopped it and started or restored another, or has none. - Back, -} - -impl Carrier { - const LEAVING: u8 = b'L'; - const BACK: u8 = b'B'; } struct Shared { replay: Mutex, grew: Condvar, - network: AtomicUsize, - local: AtomicUsize, - /// The network server bound [`PORT`]: this program's namespace holds netstack. - serving: AtomicBool, + readers: AtomicUsize, /// The wall clock the boot started at, for a line a reader is owed. boot_secs: Option, } impl Hub { - /// Start serving the network, where the manifest gave this program netstack. - /// A reader on this machine is handed over by the `log` port's thread - /// ([`Hub::read`]). + /// A reader is handed over by the `log` port's thread ([`Hub::read`]). pub fn start(cap: usize, boot_secs: Option) -> Self { let shared = Arc::new(Shared { replay: Mutex::new(Replay::new(cap)), grew: Condvar::new(), - network: AtomicUsize::new(0), - local: AtomicUsize::new(0), - serving: AtomicBool::new(false), + readers: AtomicUsize::new(0), boot_secs, }); - // A row with no namespace has no netstack, and no thread to learn so on: - // its exit would be a kernel record at a time nothing orders, after a - // shutdown's last word included. One with a namespace learns it at - // its first bind, at boot. - let mut carrier = None; - if toyos::endow::namespace().is_some() { - let network = Arc::clone(&shared); - let (told, tell) = toyos::pipe_pair().expect("logkeeper: no pipe for the network server"); - carrier = Some(tell); - std::thread::Builder::new() - .name("log-serve-net".into()) - .spawn(move || serve_network(&network, &told)) - .expect("logkeeper: the network server's thread could not be started"); - } - Self { shared, carrier } + Self { shared } } - /// Whether this boot's log is served on the network at all: the network - /// server found netstack and bound its port. - pub fn network(&self) -> bool { - self.shared.serving.load(Ordering::Relaxed) - } - - /// A reader on this machine that asked for the log: it gets the read end + /// A reader that asked for the log: it gets the read end /// of a pipe of its own, which the log is written into. pub fn read(&self, conn: &Connection) { let end = self.shared.replay.lock().expect("logkeeper: the replay is poisoned").end(); let Some(pipe) = hand_over(conn, end) else { return }; - admit(&self.shared, format!("local reader {}", conn.as_handle().0), Announce::No, PipeSink::new(pipe)); + admit(&self.shared, format!("reader {}", conn.as_handle().0), PipeSink::new(pipe)); } /// The lines the file has just taken, for every reader. @@ -139,113 +76,6 @@ impl Hub { self.shared.replay.lock().expect("logkeeper: the replay is poisoned").append(lines); self.shared.grew.notify_all(); } - - /// The supervisor's word on a swap of netstack, for the network server. - pub fn carrier(&self, word: Carrier) { - let Some(tell) = &self.carrier else { return }; - let byte = match word { - Carrier::Leaving => Carrier::LEAVING, - Carrier::Back => Carrier::BACK, - }; - match tell.write(&[byte]) { - Ok(1) => {} - // The server ended with no netstack left to serve through, which it - // said: nothing is listening to be told. - Err(SyscallError::Gone) => {} - other => panic!("logkeeper: the network server's pipe refused a word: {other:?}"), - } - } -} - -/// Accept readers on [`PORT`] for the life of the process. -/// -/// A machine whose manifest gives this program no netstack serves nothing on the -/// network, and says nothing about it: the row is the decision. A namespace -/// without netstack in it ends this thread at once. -/// -/// **A listener is a registration netstack holds, and a failed accept is netstack no -/// longer holding it** — netstack was swapped or is gone. Asked again, the same -/// listener answers the same error at once and for ever, so it is dropped and -/// bound anew: through the same port, which the supervisor keeps open across a swap. -/// -/// `told` carries the supervisor's words on a swap of netstack ([`Hub::carrier`]): the -/// listener is closed on [`Carrier::Leaving`] and bound again on -/// [`Carrier::Back`]. Each wait is on either being ready, and a completion a -/// closed listener left behind is answered by a non-blocking accept. -fn serve_network(shared: &Arc, told: &Pipe) { - const TOLD: u64 = 0; - const READY: u64 = 1; - let Some(first) = bind_port() else { return }; - shared.serving.store(true, Ordering::Relaxed); - let mut listener = Some(first); - let poller = Poller::new(2); - loop { - poller.watch(told, READABLE, TOLD); - if let Some(listener) = &listener { - poller.watch_raw(RawHandle(listener.as_raw_fd() as u32), READABLE, READY); - } - let (mut words, mut ready) = (false, false); - poller.wait(1, u64::MAX, |token| match token { - TOLD => words = true, - READY => ready = true, - other => unreachable!("logkeeper: the network server watches no token {other}"), - }); - if words { - let mut said = [0u8; 16]; - let n = match told.read_nonblock(&mut said) { - Ok(n) => n, - Err(SyscallError::WouldBlock) => 0, - Err(e) => panic!("logkeeper: the network server's pipe refused a read: {e:?}"), - }; - for &word in &said[..n] { - match word { - // Closed before the line, and netstack has answered the close - // when `drop` returns: the line is the proof. - Carrier::LEAVING => { - drop(listener.take()); - say!("{CARRIER_LEAVING}"); - } - Carrier::BACK if listener.is_none() => { - let Some(again) = bind_port() else { return }; - listener = Some(again); - } - Carrier::BACK => {} - other => unreachable!("logkeeper: the network server was told {other}"), - } - } - } - let Some(open) = listener.as_ref().filter(|_| ready) else { continue }; - match open.accept() { - Ok((stream, peer)) => { - stream - .set_write_timeout(Some(STALLED)) - .expect("logkeeper: a reader's stream that cannot bound its own write"); - admit(shared, format!("{peer}"), Announce::Yes, stream) - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} - Err(e) => { - say!("logkeeper: the log's listener on port {PORT} failed ({e}); binding it again"); - let Some(again) = bind_port() else { return }; - listener = Some(again); - } - } - } -} - -/// The listener on [`PORT`], or `None` once this machine has none to offer. -fn bind_port() -> Option { - match std::net::TcpListener::bind(("0.0.0.0", PORT)) { - Ok(listener) => { - listener.set_nonblocking(true).expect("logkeeper: a listener that cannot be asked without waiting"); - say!("logkeeper: serving this boot's log on port {PORT}"); - Some(listener) - } - Err(e) if e.kind() == std::io::ErrorKind::NotConnected => None, - Err(e) => { - say!("logkeeper: cannot serve this boot's log on port {PORT}: {e}"); - None - } - } } /// The read end goes to the reader with the boot's length so far, the write @@ -254,7 +84,7 @@ fn hand_over(conn: &Connection, end: u64) -> Option { let (read, write) = match toyos::pipe_pair() { Ok(ends) => ends, Err(e) => { - say!("logkeeper: no pipe for a local reader: {e:?}"); + say!("logkeeper: no pipe for a reader: {e:?}"); return None; } }; @@ -262,22 +92,12 @@ fn hand_over(conn: &Connection, end: u64) -> Option { match sent.and_then(|()| conn.try_send(SERVED, &end)) { Ok(()) => Some(write), Err(e) => { - say!("logkeeper: a local reader went before it was answered: {e:?}"); + say!("logkeeper: a reader went before it was answered: {e:?}"); None } } } -/// Whether a reader's coming and going is a line in the log, and which count it -/// is held against: a peer on the network is somebody the machine's owner may -/// want named, and a reader on this machine is the console, which would draw -/// the line about itself beside its own prompt. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Announce { - Yes, - No, -} - /// Why a reader's thread ended. enum Left { /// The reader closed, or its sink refused a write. @@ -287,34 +107,22 @@ enum Left { } /// A reader thread, where its count allows one. -fn admit(shared: &Arc, who: String, announce: Announce, sink: impl Write + Send + 'static) { - let (count, max, place) = match announce { - Announce::Yes => (&shared.network, MAX_NETWORK_READERS, "on the network"), - Announce::No => (&shared.local, MAX_LOCAL_READERS, "on this machine"), - }; - if count.fetch_add(1, Ordering::SeqCst) >= max { +fn admit(shared: &Arc, who: String, sink: impl Write + Send + 'static) { + let count = &shared.readers; + if count.fetch_add(1, Ordering::SeqCst) >= MAX_READERS { count.fetch_sub(1, Ordering::SeqCst); - say!("logkeeper: refusing {who}: {max} readers {place} are already served"); + say!("logkeeper: refusing {who}: {MAX_READERS} readers on this machine are already served"); return; } - if announce == Announce::Yes { - say!("logkeeper: serving this boot's log to {who}"); - } let theirs = Arc::clone(shared); let spawned = std::thread::Builder::new().name("log-reader".into()).spawn(move || { let (sent, left) = feed(&theirs, sink); - match announce { - Announce::Yes => theirs.network.fetch_sub(1, Ordering::SeqCst), - Announce::No => theirs.local.fetch_sub(1, Ordering::SeqCst), - }; + theirs.readers.fetch_sub(1, Ordering::SeqCst); match left { Left::Stalled => say!( "logkeeper: letting {who} go after {sent} bytes: it took none of what it is owed for {} s", STALLED.as_secs() ), - Left::Gone if announce == Announce::Yes => { - say!("logkeeper: {who} stopped reading after {sent} bytes") - } Left::Gone => {} } }); diff --git a/userland/sshserver/src/main.rs b/userland/sshserver/src/main.rs index 2e48813d849..f1a7c9469a2 100644 --- a/userland/sshserver/src/main.rs +++ b/userland/sshserver/src/main.rs @@ -38,16 +38,10 @@ fn host_key_path() -> String { format!("{}/host_ed25519", state()) } -/// The two files that name who may log in; a key in either authorizes. -/// -/// **The second is why a freshly flashed machine can be reached at all.** A -/// bench boot mints an identity into a `/state` that may be a tmpfs and starts -/// with nothing in it, so a key that has to be *installed* before the first -/// login is a key nobody can install. Neither file is protected from anything -/// else on the machine — see -/// `issues/sshserver-authorized-keys-unprotected.md`. -fn authorized_keys() -> [String; 2] { - [format!("{}/authorized_keys", state()), "/system/etc/ssh_authorized_keys".to_string()] +/// The file that names who may log in. It is protected from nothing else on +/// the machine — see `issues/sshserver-authorized-keys-unprotected.md`. +fn authorized_keys() -> String { + format!("{}/authorized_keys", state()) } /// How long a program may take none of the input a client is sending before @@ -117,54 +111,39 @@ fn authorizes(text: &str, offered: &PublicKey) -> bool { .any(|entry| entry.public_key().key_data() == offered.key_data()) } -/// Read fresh on every attempt, so a key added to a file takes effect without a +/// Read fresh on every attempt, so a key added to the file takes effect without a /// restart — there is nothing here to send a reload signal to. An unreadable /// file names nobody, so every failure answers "not authorized". fn is_authorized(key: &PublicKey) -> bool { - authorized_keys() - .iter() - .any(|path| fs::read_to_string(path).is_ok_and(|text| authorizes(&text, key))) + fs::read_to_string(authorized_keys()).is_ok_and(|text| authorizes(&text, key)) } -/// What the files name, said once at startup so a key that will never work is +/// What the file names, said once at startup so a key that will never work is /// visible before somebody tries it. `Err` means nobody can authenticate. fn authorized_key_count() -> Result { - let mut total = 0; - for path in authorized_keys() { - let text = match fs::read_to_string(&path) { - Ok(text) => text, - Err(e) => { - println!("sshserver: cannot read {path} ({e})"); - continue; - } - }; - let (mut usable, mut restricted, mut unreadable) = (0, 0, 0); - for entry in AuthorizedKeys::new(&text) { - match entry { - Ok(entry) if entry.config_opts().is_empty() => usable += 1, - Ok(_) => restricted += 1, - Err(_) => unreadable += 1, - } - } - if restricted > 0 { - println!( - "sshserver: {restricted} entr(ies) in {path} carry options, which are not \ - implemented — those keys authorize nothing" - ); - } - if unreadable > 0 { - println!("sshserver: {unreadable} line(s) in {path} are not public keys, ignored"); + let path = authorized_keys(); + let text = fs::read_to_string(&path).map_err(|e| format!("cannot read {path} ({e})"))?; + let (mut usable, mut restricted, mut unreadable) = (0, 0, 0); + for entry in AuthorizedKeys::new(&text) { + match entry { + Ok(entry) if entry.config_opts().is_empty() => usable += 1, + Ok(_) => restricted += 1, + Err(_) => unreadable += 1, } - println!("sshserver: {usable} key(s) authorized by {path}"); - total += usable; } - if total == 0 { - return Err(format!( - "no file names a usable key ({}); put a public key in one of them and start again", - authorized_keys().join(" or ") - )); + if restricted > 0 { + println!( + "sshserver: {restricted} entr(ies) in {path} carry options, which are not \ + implemented — those keys authorize nothing" + ); + } + if unreadable > 0 { + println!("sshserver: {unreadable} line(s) in {path} are not public keys, ignored"); + } + if usable == 0 { + return Err(format!("{path} names no usable key; put a public key in it and start again")); } - Ok(total) + Ok(usable) } struct SshServer; diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 4bb4fa92a08..888a981816a 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -87,7 +87,6 @@ use toyos::{AsHandle, Pipe}; use toyos_abi::Rights; use toyos_logstream::{ Registration, Tag, ALIVE, CONSOLE, FLUSH, FLUSHED, LOGKEEPER, MAX_TAG, ORIGINS, REGISTER, RESUME, STOPPING, - SWAP, SWAP_BACK, SWAP_LEAVING, }; use toyos_abi::syscall::{ DeviceRequest, FileType, SyscallError, DEV_PREFIX, MAX_BADGE, PROVIDE_PREFIX, SERVE_PREFIX, @@ -260,13 +259,6 @@ impl Log { } } - /// Tell `logkeeper` a word on a swap of the service its readers are carried by. - fn carrier(&self, word: u8) { - if let Err(e) = self.conn.send_bytes(SWAP, &[word]) { - panic!("supervisor: logkeeper's origins connection refused a swap's word: {e:?}"); - } - } - /// The stop a flush was for was refused: `logkeeper` writes the file again. fn resume(&self) { if let Err(e) = self.conn.signal(RESUME) { @@ -1072,7 +1064,7 @@ impl<'a> Supervisor<'a> { flight: Option<&Flight>, ) -> Option { let refuse = |service: &str, why: Refusal| { - swapped(&self.log, service, Word::Refused, &why.to_string()); + swapped(service, Word::Refused, &why.to_string()); let _ = conn.try_send_bytes(toyos_swap::MSG_REFUSED, why.to_string().as_bytes()); None }; @@ -1119,7 +1111,6 @@ impl<'a> Supervisor<'a> { return refuse(name, why); } swapped( - &self.log, name, Word::Accepted, &format!( @@ -1174,7 +1165,6 @@ impl<'a> Supervisor<'a> { self.make_home(program); let service = &mut self.services[index]; swapped( - &self.log, &name, Word::Stopping, &format!("pid {} ({previous})", service.pid().map_or(0, |p| p)), @@ -1193,7 +1183,6 @@ impl<'a> Supervisor<'a> { match started { Ok(pid) => { swapped( - &self.log, &name, Word::Started, &format!( @@ -1205,7 +1194,7 @@ impl<'a> Supervisor<'a> { Some(Flight { service: index, path, phase: Phase::Probation { until, previous, owed } }) } Err(e) => { - swapped(&self.log, &name, Word::Failed, &format!("{path} did not start: {e}")); + swapped(&name, Word::Failed, &format!("{path} did not start: {e}")); forget(&path); self.restore(index, &previous, &owed); None @@ -1232,7 +1221,6 @@ impl<'a> Supervisor<'a> { match status { Some(Ok(None)) => { swapped( - &self.log, &name, Word::InService, &format!("{path} as pid {}", service.pid().map_or(0, |p| p)), @@ -1248,7 +1236,6 @@ impl<'a> Supervisor<'a> { _ => "is not running".to_string(), }; swapped( - &self.log, &name, Word::Failed, &format!("{path} {how} inside {} ms", toyos_swap::PROBATION_MS), @@ -1386,14 +1373,13 @@ impl<'a> Supervisor<'a> { match service.spawn(previous, owed, self.system, self.syscap, &self.connectors, &self.grants, &self.launcher, &mut self.log) { Ok(pid) => { service.kept.lock().expect("supervisor: a service's state is poisoned").swapping = false; - swapped(&self.log, &name, Word::Restored, &format!("{previous} as pid {pid}")); + swapped(&name, Word::Restored, &format!("{previous} as pid {pid}")); } Err(e) => { let mut kept = service.kept.lock().expect("supervisor: a service's state is poisoned"); kept.swapping = false; kept.acceptors.clear(); swapped( - &self.log, &name, Word::Gone, &format!("{previous} did not start either ({e}); its ports are closed"), @@ -2347,19 +2333,9 @@ fn launcher_namespace(program: &Program, (launcher, session): (&Acceptor, Sessio Some(ns) } -/// One of the supervisor's words on a swap: its line in the log, and — for the service -/// `logkeeper`'s network readers are carried by — the frame `logkeeper` acts on. The -/// frame and not the line: nothing a program writes is a word `logkeeper` obeys. -fn swapped(log: &Log, service: &str, word: Word, detail: &str) { +/// One of the supervisor's words on a swap: its line in the log. +fn swapped(service: &str, word: Word, detail: &str) { say!("{}", toyos_swap::said(service, word, detail)); - if service == toyos_logstream::CARRIER { - match word { - Word::Accepted => log.carrier(SWAP_LEAVING), - // Each said once the netstack it replaces has been waited for. - Word::Started | Word::Failed | Word::Restored | Word::Gone => log.carrier(SWAP_BACK), - Word::Refused | Word::Stopping | Word::InService => {} - } - } } /// What a storage row's process is started with beside its row: its diff --git a/userland/swap/Cargo.toml b/userland/swap/Cargo.toml index 32567895173..a0a0e12c2c3 100644 --- a/userland/swap/Cargo.toml +++ b/userland/swap/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" [dependencies] toyos = { path = "../../toyos" } -# The words the supervisor and the host say about a swap, and the digest it is held to. +# The words the supervisor says about a swap, and the digest it is held to. toyos-swap = { path = "../../toyos-swap" } [package.metadata.toyos.host]