diff --git a/src/build.rs b/src/build.rs index f3791dbae73..5bf6d586f27 100644 --- a/src/build.rs +++ b/src/build.rs @@ -3026,6 +3026,7 @@ mod tests { "tests/metalcase/system.toml", "tests/metaldevicecase/system.toml", "tests/netcase/system.toml", + "tests/outboundcase/system.toml", "tests/panelcase/system.toml", "tests/proctreecase/system.toml", "tests/testcases/system.toml", diff --git a/tests/checks.rs b/tests/checks.rs index 212bead5a67..2a853028ce6 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -18,6 +18,8 @@ mod checks { mod clock_checks; #[path = "metal.rs"] mod metal_checks; + #[path = "outbound.rs"] + mod outbound_checks; #[path = "qemu.rs"] mod qemu_checks; #[path = "screen.rs"] @@ -864,6 +866,21 @@ mod checks { claims_checks::domains_end_below_the_windows(); } + #[test] + fn metal_outbound_rows_are_red_by_their_cause() { + outbound_checks::each_verdict_names_its_cause(); + } + + #[test] + fn metal_outbound_line_holds_no_address() { + outbound_checks::no_line_holds_an_address(); + } + + #[test] + fn metal_outbound_resolver_stands_where_the_lease_puts_it() { + outbound_checks::a_resolver_stands_where_the_lease_puts_it(); + } + #[test] fn metal_stop_owes_its_record_and_leaves_no_operation_open() { metal_checks::the_stop_owes_its_record_and_leaves_no_operation_open(); diff --git a/tests/checks/outbound.rs b/tests/checks/outbound.rs new file mode 100644 index 00000000000..748a0fa3369 --- /dev/null +++ b/tests/checks/outbound.rs @@ -0,0 +1,407 @@ +//! The outbound rows' judges over logs written by hand, one per verdict, and +//! the job's vocabulary: every line it can write reads back, and none holds an +//! address. Every address here is RFC 5737's and the MAC is locally +//! administered. + +use super::*; + +use outbound::said::{self, Anchor, Connect, Driver, Frames, Line, Link, Lookup, Neighbour, Resolver, Word}; + +/// The kernel's record of handing the card to netstack. +const HANDED: &str = + "[2026-10-08 12:00:01 1.192 cpu0 kernel] pcidev: PCI 00:1f.6 [8086:15fc] handed over on slot 0, vector 0x28\n"; + +/// netstack's own lines, which name the network and which no verdict quotes. +const NETSTACK: &str = "[2026-10-08 12:00:01 1.300 netstack] netstack: MAC 02:00:00:00:00:01 +[2026-10-08 12:00:14 14.400 netstack] netstack: DHCP: lease 192.0.2.17/24 from 192.0.2.1, gateway 192.0.2.1, dns [192.0.2.1 198.51.100.53], 13100 ms after netstack came up +"; + +const LEASED: &[&str] = &[ + "netstack said=lease", + "card driver=i219 link=up sent=41 received=57", + "lease held=yes router=named resolver=router", +]; + +const GREEN: &[&str] = &[ + "netstack said=lease", + "card driver=i219 link=up sent=41 received=57", + "lease held=yes router=named resolver=router", + "anchor name=dns.quad9.net lookup=addresses connect=connected", + "anchor name=dns.google lookup=addresses connect=connected", + "gateway neighbour=reachable", + "done", +]; + +/// A boot's log: the kernel's records, netstack's lines, and the job's `said` +/// as the runner's ring carries them. +fn log(kernel: &str, said: &[&str]) -> (serial::Serial, serial::Serial) { + let mut whole = format!("{kernel}{NETSTACK}"); + for (nth, line) in said.iter().enumerate() { + // An anchor's line is its own thread's. + let thread = if line.starts_with("anchor") { format!(" tid={nth}") } else { String::new() }; + whole.push_str(&format!("[2026-10-08 12:00:15 15.{nth:03} test-runner{thread} pid=9] outbound: {line}\n")); + } + ( + serial::Serial::named("the kernel's log", toyos_build::bootlog::kernel_records(&whole)), + serial::Serial::named("the log", whole), + ) +} + +/// `said` with the lines after the lease replaced by `rest`. +fn leased(rest: &[&'static str]) -> Vec<&'static str> { + LEASED.iter().chain(rest).copied().collect() +} + +/// `said` with the line opening with `head` replaced by `with`. +fn with(said: &[&'static str], head: &str, with: &'static str) -> Vec<&'static str> { + assert!(said.iter().any(|line| line.starts_with(head)), "no line opens with {head:?}"); + said.iter().map(|line| if line.starts_with(head) { with } else { *line }).collect() +} + +/// Whether `text` holds a dotted quad or a MAC. +fn holds_an_address(text: &str) -> bool { + let numbers = |token: &str, by: char, count: usize, radix: u32| { + let parts: Vec<&str> = token.split(by).collect(); + parts.len() == count && parts.iter().all(|p| !p.is_empty() && u8::from_str_radix(p, radix).is_ok()) + }; + text.split(|c: char| !(c.is_ascii_hexdigit() || c == '.' || c == ':')) + .any(|token| numbers(token.trim_matches('.'), '.', 4, 10) || numbers(token.trim_matches(':'), ':', 6, 16)) +} + +/// What a row must answer: green, or a red saying this. +type Want = Result<(), &'static str>; + +fn judged(case: &str, row: &str, got: Result<(), String>, want: Want) { + match (&got, want) { + (Ok(()), Ok(())) => {} + (Err(why), Err(saying)) if why.contains(saying) => {} + _ => panic!("{case}: the {row} row answered {got:?}, and it has to answer {want:?}"), + } + if let Err(why) = got { + assert!(!holds_an_address(&why), "{case}: the {row} row's verdict holds an address:\n{why}"); + assert!(!why.contains("toyos-t14"), "{case}: the {row} row's verdict quotes netstack:\n{why}"); + } +} + +pub fn each_verdict_names_its_cause() { + let no_lease = |card: &'static str| -> Vec<&'static str> { + vec!["netstack said=no-lease", card, "lease held=no router=none resolver=none", "done"] + }; + let silent = [ + "anchor name=dns.google lookup=addresses connect=timeout", + "anchor name=dns.quad9.net lookup=addresses connect=timeout", + "gateway neighbour=reachable", + "done", + ]; + let unanswered = [ + "anchor name=dns.google lookup=timeout connect=not-tried", + "anchor name=dns.quad9.net lookup=timeout connect=not-tried", + ]; + // The case, the kernel's records, what the job said, and each row's answer. + let cases: Vec<(&str, &str, Vec<&'static str>, Want, Want)> = vec![ + ("green", HANDED, GREEN.to_vec(), Ok(()), Ok(())), + ( + "green on a stack that says nothing of its neighbours", + HANDED, + with(GREEN, "gateway", "gateway neighbour=not-asked"), + Ok(()), + Ok(()), + ), + ( + "no card", + "", + vec!["netstack said=no-card", "done"], + Err("no card: the kernel recorded handing 8086:15fc to no program"), + Ok(()), + ), + ( + "a card netstack does not find", + HANDED, + vec!["netstack said=no-card", "done"], + Err("netstack says it was endowed no card it drives"), + Ok(()), + ), + ( + "no link", + HANDED, + no_lease("card driver=i219 link=down sent=0 received=0"), + Err("no link: the card's link is `down`; the card counts 0 frame(s) sent and 0 received"), + Ok(()), + ), + ( + "nothing asked", + HANDED, + no_lease("card driver=i219 link=up sent=0 received=12"), + Err("ToyOS asked for none on a link that is up"), + Ok(()), + ), + ( + "no offer", + HANDED, + no_lease("card driver=i219 link=up sent=6 received=0"), + Err("nothing on this wire answered"), + Ok(()), + ), + ( + "an offer and no lease", + HANDED, + no_lease("card driver=i219 link=up sent=6 received=31"), + Err("the card counts 6 frame(s) sent and 31 received: the wire carries traffic"), + Ok(()), + ), + ( + "a lease with no router", + HANDED, + with(GREEN, "lease", "lease held=yes router=none resolver=on-link"), + Err("the lease names no router"), + Ok(()), + ), + ( + "the router's neighbour entry failed", + HANDED, + [ + &with(LEASED, "lease", "lease held=yes router=named resolver=off-link")[..], + &unanswered, + &["gateway neighbour=failed", "done"], + ] + .concat(), + Err("the router did not answer for its link address: netstack's neighbour entry for it is `failed`"), + Ok(()), + ), + ( + "a resolver at the router that is silent", + HANDED, + [&leased(&unanswered)[..], &["gateway neighbour=reachable", "done"]].concat(), + Err("the resolver the lease names is the router, and it answered neither lookup"), + Ok(()), + ), + ( + // Nothing left by the router, so its entry is not this red's cause. + "a resolver on the link that is silent", + HANDED, + [ + &with(LEASED, "lease", "lease held=yes router=named resolver=on-link")[..], + &unanswered, + &["gateway neighbour=none", "done"], + ] + .concat(), + Err("the resolver the lease names stands on the link, and it answered neither lookup"), + Ok(()), + ), + ( + "a lookup netstack ended itself", + HANDED, + leased(&[ + "anchor name=dns.google lookup=refused connect=not-tried", + "anchor name=dns.quad9.net lookup=timeout connect=not-tried", + "gateway neighbour=reachable", + "done", + ]), + Err("is the router, and netstack ended a lookup itself"), + Ok(()), + ), + ( + "one anchor", + HANDED, + with(GREEN, "anchor name=dns.quad9.net", "anchor name=dns.quad9.net lookup=addresses connect=timeout"), + Ok(()), + Ok(()), + ), + ("both silent", HANDED, leased(&silent), Ok(()), Err("this log cannot tell ToyOS from the uplink")), + ( + "silent behind a resolver beyond the link", + HANDED, + [ + &with(LEASED, "lease", "lease held=yes router=named resolver=off-link")[..], + &unanswered, + &["gateway neighbour=reachable", "done"], + ] + .concat(), + Ok(()), + Err("this log cannot tell ToyOS from the uplink"), + ), + ( + "both answered and refused", + HANDED, + leased(&[ + "anchor name=dns.google lookup=addresses connect=refused", + "anchor name=dns.quad9.net lookup=addresses connect=reset", + "gateway neighbour=reachable", + "done", + ]), + Ok(()), + Err("an answer came back and it was no"), + ), + ( + "a resolver that answers no", + HANDED, + leased(&[ + "anchor name=dns.google lookup=no-address connect=not-tried", + "anchor name=dns.quad9.net lookup=failed connect=not-tried", + "gateway neighbour=reachable", + "done", + ]), + Ok(()), + Err("the uplink or the service\n dns.google: lookup no-address, connect not-tried"), + ), + ( + "a lease with no resolver", + HANDED, + [ + &with(LEASED, "lease", "lease held=yes router=named resolver=none")[..], + &[ + "anchor name=dns.google lookup=no-resolver connect=not-tried", + "anchor name=dns.quad9.net lookup=no-resolver connect=not-tried", + "gateway neighbour=none", + "done", + ], + ] + .concat(), + Ok(()), + Err("the lease names no resolver"), + ), + ( + "a connect netstack ended itself", + HANDED, + leased(&[ + "anchor name=dns.google lookup=addresses connect=error", + "anchor name=dns.quad9.net lookup=addresses connect=timeout", + "gateway neighbour=reachable", + "done", + ]), + Ok(()), + Err("ToyOS's own refusal"), + ), + ( + "a job its runner ended", + HANDED, + GREEN[..GREEN.len() - 1].to_vec(), + Err("the job ended before it said its last line"), + Ok(()), + ), + ( + "a job ended inside an anchor", + HANDED, + GREEN[..4].to_vec(), + Err("the job ended before it said what dns.google answered"), + Ok(()), + ), + ( + "an address in a line", + HANDED, + with(GREEN, "lease", "lease held=yes router=192.0.2.1 resolver=router"), + Err("line 3 is none its vocabulary writes"), + Ok(()), + ), + ( + "a word after a line", + HANDED, + with(GREEN, "gateway", "gateway neighbour=reachable 02:00:00:00:00:01"), + Err("line 6 is none its vocabulary writes"), + Ok(()), + ), + ( + "one thing said twice", + HANDED, + [GREEN, &["anchor name=dns.google lookup=timeout connect=not-tried"]].concat(), + Err("the job said one thing twice"), + Ok(()), + ), + ]; + for (case, kernel, said, router, internet) in cases { + let (kernel, log) = log(kernel, &said); + judged(case, "router", outbound::router(&kernel, &log), router); + judged(case, "internet", outbound::internet(&kernel, &log), internet); + } + + // The log's own lines trip the scan the verdicts were held to. + assert!(holds_an_address(NETSTACK) && holds_an_address("at 192.0.2.1.") && holds_an_address("02:00:00:00:00:01")); + assert!(!holds_an_address("the card counts 6 frame(s) sent and 31 received: 8086:15fc on port 443")); + + // Lines another program says under the job's head are not the job's. + let (kernel, theirs) = log(HANDED, GREEN); + let theirs = serial::Serial::named("the log", theirs.text().replace(" test-runner", " netstack")); + judged( + "another program's lines", + "router", + outbound::router(&kernel, &theirs), + Err("the job ended before it said netstack's word on its lease"), + ); +} + +/// Every line the job can write reads back as itself and holds no address, +/// and text that is not such a line is not read as one. +pub fn no_line_holds_an_address() { + let counts = [Frames(None), Frames(Some(0)), Frames(Some(19_216_811)), Frames(Some(u64::MAX))]; + let mut lines = vec![Line::Done]; + lines.extend(Word::ALL.iter().map(|word| Line::Netstack(*word))); + lines.extend(Neighbour::ALL.iter().map(|neighbour| Line::Gateway(*neighbour))); + for driver in Driver::ALL { + for link in Link::ALL { + for sent in counts { + for received in counts { + lines.push(Line::Card { driver: *driver, link: *link, sent, received }); + } + } + } + } + for resolver in Resolver::ALL { + for (held, router) in [(false, false), (false, true), (true, false), (true, true)] { + lines.push(Line::Lease { held, router, resolver: *resolver }); + } + } + for anchor in Anchor::ALL { + for lookup in Lookup::ALL { + for connect in Connect::ALL { + lines.push(Line::Anchor { anchor: *anchor, lookup: *lookup, connect: *connect }); + } + } + } + for line in lines { + let text = line.to_string(); + assert_eq!(Line::read(&text), Some(Some(line)), "{text}"); + assert!(!holds_an_address(&text), "{text}"); + } + + assert_eq!(Line::read("netstack: DHCP: lease 192.0.2.17/24"), None); + for not_one in [ + "outbound: lease held=yes router=192.0.2.1 resolver=router", + "outbound: card driver=i219 link=up sent=02:00:00:00:00:01 received=1", + "outbound: card driver=i219 link=up sent=+1 received=1", + "outbound: card driver=i219 link=up sent=01 received=1", + "outbound: anchor name=192.0.2.9 lookup=addresses connect=connected", + "outbound: anchor name=dns.google lookup=addresses connect=connected to 192.0.2.9", + "outbound: gateway neighbour=reachable ", + "outbound: gateway 192.0.2.1", + "outbound: done 192.0.2.1", + "outbound: ", + ] { + assert_eq!(Line::read(not_one), Some(None), "{not_one}"); + } +} + +/// The nearest resolver of a lease, off the words netstack writes for it. +pub fn a_resolver_stands_where_the_lease_puts_it() { + let stands = |dns: &str| said::resolver("192.0.2.17/24", Some("192.0.2.1"), dns); + assert_eq!(stands("192.0.2.1"), Some(Resolver::Router)); + assert_eq!(stands("192.0.2.53"), Some(Resolver::OnLink)); + assert_eq!(stands("198.51.100.53"), Some(Resolver::OffLink)); + assert_eq!(stands(""), Some(Resolver::None)); + // The nearest of several, whatever their order. + assert_eq!(stands("198.51.100.53 192.0.2.53"), Some(Resolver::OnLink)); + assert_eq!(stands("192.0.2.53 198.51.100.53 192.0.2.1"), Some(Resolver::Router)); + // The prefix decides the link, at its ends too. + assert_eq!(said::resolver("192.0.2.17/28", None, "192.0.2.53"), Some(Resolver::OffLink)); + assert_eq!(said::resolver("192.0.2.17/0", None, "203.0.113.9"), Some(Resolver::OnLink)); + assert_eq!(said::resolver("192.0.2.17/32", None, "192.0.2.17"), Some(Resolver::OnLink)); + // With no router named, the address a router would have is on the link. + assert_eq!(said::resolver("192.0.2.17/24", None, "192.0.2.1"), Some(Resolver::OnLink)); + for (address, router, dns) in [ + ("192.0.2.17", None, "192.0.2.1"), + ("192.0.2.17/33", None, "192.0.2.1"), + ("192.0.2.17/24", Some("the-router"), "192.0.2.1"), + ("192.0.2.17/24", None, "192.0.2.1,192.0.2.2"), + ] { + assert_eq!(said::resolver(address, router, dns), None, "{address} {router:?} {dns}"); + } +} diff --git a/tests/common/claims.rs b/tests/common/claims.rs index 147d89c3e86..d456db4a314 100644 --- a/tests/common/claims.rs +++ b/tests/common/claims.rs @@ -7,7 +7,7 @@ use toyos_build::bootlog; use super::serial::Serial; /// The T14's I219, as the kernel's hand-over record spells its id. -const I219: &str = "8086:15fc"; +pub 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/mod.rs b/tests/common/mod.rs index 291983fd0b6..24ab210e1f5 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -12,6 +12,8 @@ pub mod irqcensus; /// The `isa` claim's rows on the T14. pub mod isa; pub mod metal; +/// The T14 on its own cable: its router and the internet. +pub mod outbound; pub mod power; pub mod qemu; pub mod screen; diff --git a/tests/common/outbound.rs b/tests/common/outbound.rs new file mode 100644 index 00000000000..7cb70ad1e1f --- /dev/null +++ b/tests/common/outbound.rs @@ -0,0 +1,281 @@ +//! The T14 on its own cable: whether it reached its router, and the internet. +//! Both rows judge one boot by what its `outbound` job said in the log the +//! stick came back with, and by the kernel's record of handing the card over. +//! +//! **A verdict quotes the job's lines and nothing else of the log**, each +//! written again from what it read as ([`said::Line`]), which holds no +//! address: netstack's own lines name the network the machine is on, and they +//! stay on the stick. +//! +//! **The router row is red by the first step that failed**, in the order a +//! frame needs them: the card, its link, the lease, the router's link address, +//! the resolver on the link. The internet row is judged only over a green +//! router row, so one cause reds one row. + +#[path = "../toyos-rust-tests/src/outbound_said.rs"] +#[allow(dead_code, reason = "the job calls `resolver`, and here only `toyos-checks` does")] +pub mod said; + +use said::{Anchor, Connect, Driver, Frames, Line, Link, Lookup, Neighbour, Resolver, Word}; +use toyos_build::bootlog; + +use super::claims::I219; +use super::serial::Serial; + +/// Whose lines the job's are: it writes into the ring of the runner that +/// spawned it. +const RUNNER: &str = "test-runner"; + +/// What the job said, each subject at most once. +struct Said { + lines: Vec, +} + +struct Card { + driver: Driver, + link: Link, + sent: Frames, + received: Frames, +} + +struct Lease { + held: bool, + router: bool, + resolver: Resolver, +} + +#[derive(Clone, Copy)] +struct Reached { + anchor: Anchor, + lookup: Lookup, + connect: Connect, +} + +impl std::fmt::Display for Reached { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: lookup {}, connect {}", self.anchor.word(), self.lookup.word(), self.connect.word()) + } +} + +impl Said { + fn of(log: &Serial) -> Result { + let mut lines: Vec = Vec::new(); + let said = log + .text() + .lines() + .filter_map(toyos_logstream::program_line) + .filter(|said| said.tag == RUNNER) + .filter_map(|said| Line::read(said.text)); + for (nth, line) in said.enumerate() { + let line = line.ok_or_else(|| { + format!( + "the job's `{}` line {} is none its vocabulary writes. It is not quoted: a line \ + outside the vocabulary can hold anything", + said::HEAD.trim_end(), + nth + 1 + ) + })?; + let subject = |line: &Line| match line { + Line::Anchor { anchor, .. } => (std::mem::discriminant(line), Some(*anchor)), + other => (std::mem::discriminant(other), None), + }; + if let Some(first) = lines.iter().find(|had| subject(had) == subject(&line)) { + return Err(format!("the job said one thing twice:\n {first}\n {line}")); + } + lines.push(line); + } + Ok(Self { lines }) + } + + /// Why a line the job owes is not there. + fn ended_before(&self, what: &str) -> String { + format!( + "the job ended before it said {what}: its runner's bound ended it or it ended itself, and \ + its own lines in this boot's log say which" + ) + } + + fn netstack(&self) -> Result { + self.lines + .iter() + .find_map(|line| match line { + Line::Netstack(word) => Some(*word), + _ => None, + }) + .ok_or_else(|| self.ended_before("netstack's word on its lease")) + } + + fn card(&self) -> Result { + self.lines + .iter() + .find_map(|line| match *line { + Line::Card { driver, link, sent, received } => Some(Card { driver, link, sent, received }), + _ => None, + }) + .ok_or_else(|| self.ended_before("what card netstack drives")) + } + + fn lease(&self) -> Result { + self.lines + .iter() + .find_map(|line| match *line { + Line::Lease { held, router, resolver } => Some(Lease { held, router, resolver }), + _ => None, + }) + .ok_or_else(|| self.ended_before("what lease netstack holds")) + } + + fn anchors(&self) -> Result, String> { + Anchor::ALL + .iter() + .map(|wanted| { + self.lines + .iter() + .find_map(|line| match *line { + Line::Anchor { anchor, lookup, connect } if anchor == *wanted => { + Some(Reached { anchor, lookup, connect }) + } + _ => None, + }) + .ok_or_else(|| self.ended_before(&format!("what {} answered", wanted.word()))) + }) + .collect() + } + + fn gateway(&self) -> Result { + self.lines + .iter() + .find_map(|line| match line { + Line::Gateway(neighbour) => Some(*neighbour), + _ => None, + }) + .ok_or_else(|| self.ended_before("the router's neighbour entry")) + } + + fn quoted(&self) -> String { + self.lines.iter().map(|line| format!("\n {line}")).collect() + } +} + +/// The machine reached its router. +pub fn router(kernel: &Serial, log: &Serial) -> Result<(), String> { + let said = Said::of(log)?; + reached_router(kernel, &said).map_err(|why| format!("{why}\n the job said:{}", said.quoted()))?; + eprintln!(" [outbound] the job said:{}", said.quoted()); + Ok(()) +} + +fn reached_router(kernel: &Serial, said: &Said) -> Result<(), String> { + let handed = format!("[{I219}] handed over on slot "); + let handed_over = kernel + .text() + .lines() + .filter_map(bootlog::message) + .any(|record| record.starts_with("pcidev: PCI ") && record.contains(&handed)); + if !handed_over { + return Err(format!("no card: the kernel recorded handing {I219} to no program")); + } + if said.netstack()? == Word::NoCard { + return Err(format!( + "no card: the kernel handed {I219} over, and netstack says it was endowed no card it drives" + )); + } + let card = said.card()?; + if card.driver != Driver::I219 { + return Err(format!("netstack drives `{}`, which is not this machine's wired card", card.driver.word())); + } + let frames = format!("the card counts {} frame(s) sent and {} received", card.sent, card.received); + if card.link != Link::Up { + return Err(format!("no link: the card's link is `{}`; {frames}", card.link.word())); + } + let lease = said.lease()?; + if !lease.held { + return Err(match (card.sent.0, card.received.0) { + (Some(0), _) => format!("no lease, and {frames}: ToyOS asked for none on a link that is up"), + (_, Some(0)) => format!( + "no lease, and {frames}: nothing on this wire answered, which is the cable, the switch \ + or the DHCP server" + ), + _ => format!( + "no lease, and {frames}: the wire carries traffic, and either no DHCP server answered \ + ToyOS or ToyOS did not take its answer. netstack counts no DHCP message, so this log \ + cannot tell which" + ), + }); + } + if !lease.router { + return Err("the lease names no router".to_string()); + } + let anchors = said.anchors()?; + // Whether anything left by the router: a lookup of a resolver that is the + // router or stands behind it, or a connect, whose peer is no neighbour. + let through = matches!(lease.resolver, Resolver::Router | Resolver::OffLink) + || anchors.iter().any(|a| a.connect != Connect::NotTried); + let gateway = said.gateway()?; + if through && gateway.answered() == Some(false) { + return Err(format!( + "the router did not answer for its link address: netstack's neighbour entry for it is `{}`", + gateway.word() + )); + } + if matches!(lease.resolver, Resolver::Router | Resolver::OnLink) { + // Any answer but silence: a resolver that says no has answered. + let answered = + |a: &Reached| matches!(a.lookup, Lookup::Addresses | Lookup::NoAddress | Lookup::Failed); + if !anchors.iter().any(answered) { + let stands = match lease.resolver { + Resolver::Router => "is the router", + _ => "stands on the link", + }; + let whose = if anchors.iter().all(|a| a.lookup == Lookup::Timeout) { + "it answered neither lookup" + } else { + "netstack ended a lookup itself, which is ToyOS's own refusal" + }; + return Err(format!("the resolver the lease names {stands}, and {whose}")); + } + } + if !said.lines.contains(&Line::Done) { + return Err(said.ended_before("its last line")); + } + Ok(()) +} + +/// The machine reached one anchor. Judged only where [`router`] is green: a +/// red there is this row's cause too, and is said once. +pub fn internet(kernel: &Serial, log: &Serial) -> Result<(), String> { + let Ok(said) = Said::of(log) else { + eprintln!(" [outbound] the internet is not judged: the router row is red"); + return Ok(()); + }; + if reached_router(kernel, &said).is_err() { + eprintln!(" [outbound] the internet is not judged: the router row is red"); + return Ok(()); + } + let anchors = said.anchors()?; + let connected: Vec<&str> = + anchors.iter().filter(|a| a.connect == Connect::Connected).map(|a| a.anchor.word()).collect(); + if !connected.is_empty() { + eprintln!(" [outbound] connected to port 443 of {}", connected.join(" and ")); + return Ok(()); + } + let each = anchors.iter().map(|a| format!("\n {a}")).collect::(); + let said_no = |a: &Reached| { + matches!(a.connect, Connect::Refused | Connect::Reset) || matches!(a.lookup, Lookup::NoAddress | Lookup::Failed) + }; + let own = |a: &Reached| { + matches!(a.lookup, Lookup::NoResolver | Lookup::Refused) || matches!(a.connect, Connect::NoAddress | Connect::Error) + }; + let whose = if anchors.iter().any(said_no) { + "an answer came back and it was no, so what ToyOS sent was carried and answered: the uplink or \ + the service" + } else if said.lease()?.resolver == Resolver::None { + "the lease names no resolver, so no name could be looked up: the network's DHCP server" + } else if anchors.iter().any(own) { + "netstack ended a request itself, which is ToyOS's own refusal" + } else { + "both were silent, and silence from beyond the link is the same whether ToyOS's packets were \ + wrong or the uplink carried none: this log cannot tell ToyOS from the uplink" + }; + Err(format!("neither anchor connected over a green router row; {whose}{each}")) +} diff --git a/tests/outboundcase/system.toml b/tests/outboundcase/system.toml new file mode 100644 index 00000000000..6ad4380b3e8 --- /dev/null +++ b/tests/outboundcase/system.toml @@ -0,0 +1,42 @@ +# The T14 on its own cable: netstack on the machine's wired card, and a job +# that says in the log what the card, the lease, the router and two public +# services answered. `outbound_router` and `outbound_internet` read it off the +# stick; no guest boots it, since no guest has this card or this network. + +[boot] +start = ["logkeeper", "diskserver", "fileserver", "netstack", "test-runner"] + +# `log` is the port the job reads netstack's own lines on: a program's lines +# are in no record the kernel's cursor serves. +[programs.logkeeper] +service = true +syscap = ["logread"] +serves = ["log"] + +# The T14's onboard I219, by vendor and device. +[programs.netstack] +service = true +serves = ["netstack"] +devices = ["pci:8086:15fc"] + +# `netstack` for the job's lookups, its connects and what it asks `inspect`; +# `log` for netstack's word on its lease. +[programs.test-runner] +receives = ["netstack", "log"] + +# 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/toyos-rust-tests/src/bin/counters_metal.rs b/tests/toyos-rust-tests/src/bin/counters_metal.rs index 5fac26e4f39..fc58389d0c0 100644 --- a/tests/toyos-rust-tests/src/bin/counters_metal.rs +++ b/tests/toyos-rust-tests/src/bin/counters_metal.rs @@ -35,13 +35,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use toyos::endow::{Endowments, SYSCAP_LABEL}; -use toyos::poller::{Poller, READABLE}; use toyos::syscap::SysCap; -use toyos::Pipe; use toyos_abi::clock::stamp_ns; use toyos_abi::counters::{Counter, RawRecord, Record}; -use toyos_abi::syscall::{self, SyscallError}; -use toyos_logstream::{parse, program_line, Lines, Source}; +use toyos_abi::syscall; +use toyos_logstream::{parse, program_line, Source}; + +#[path = "../served_log.rs"] +mod served_log; +use served_log::Log; /// The idle span: long enough that a CPU's busy fraction is its idle one and /// not the reads'. @@ -166,47 +168,6 @@ fn print(phase: &str, read: &Read) { } } -/// This boot's log as logkeeper serves it, from its first line. A reader of -/// the `log` port is handed each round only after it is on the stick. -struct Log { - pipe: Pipe, - poller: Poller, - lines: Lines, - chunk: Vec, -} - -impl Log { - fn open() -> Log { - let pipe = logkeeper_api::read().unwrap_or_else(|why| panic!("test-runner's `log` port: {why}")).pipe; - Log { pipe, poller: Poller::new(1), lines: Lines::new(), chunk: vec![0u8; 64 * 1024] } - } - - /// Hand `seen` each line in turn until it has answered `true`, for at most - /// `bound`; `what` names what it waits for. - fn until(&mut self, what: &str, bound: Duration, mut seen: impl FnMut(&str) -> bool) { - let by = Instant::now() + bound; - let mut done = false; - while !done { - let left = by - .checked_duration_since(Instant::now()) - .unwrap_or_else(|| panic!("the log did not show {what} within {bound:?}")); - match self.pipe.read_nonblock(&mut self.chunk) { - Ok(0) => panic!("logkeeper closed the log before it showed {what}"), - Ok(n) => self.lines.push(&self.chunk[..n], |line, _| { - let line = std::str::from_utf8(line) - .unwrap_or_else(|e| panic!("logkeeper served a line that is not UTF-8 ({e}): {line:?}")); - done |= seen(line); - }), - Err(SyscallError::WouldBlock) => { - self.poller.watch(&self.pipe, READABLE, 0); - self.poller.wait(1, left.as_nanos() as u64, |_| {}); - } - Err(e) => panic!("the log's pipe refused a read: {e:?}"), - } - } - } -} - /// Wait until `acpiserver` has armed and, where it serves an embedded /// controller, the kernel has logged the claim's first interrupt and the server /// its first query. diff --git a/tests/toyos-rust-tests/src/bin/outbound.rs b/tests/toyos-rust-tests/src/bin/outbound.rs new file mode 100644 index 00000000000..188304f48d8 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/outbound.rs @@ -0,0 +1,197 @@ +//! Whether this machine reaches its router and the internet, said in words a +//! judge reads off the stick (`tests/common/outbound.rs`): the +//! `outbound_router` and `outbound_internet` metal rows run it. +//! +//! It waits for netstack's own word on its lease, asks netstack what it holds, +//! then for each [`Anchor`] at once looks the name up and opens one connection +//! to port 443 of its first address, which it closes without writing a byte, +//! and asks netstack for the router's neighbour entry. Each fact is a line the +//! moment it is known, so a job its runner ends has said what it found. +//! +//! **It asserts nothing about the network**: a machine with no cable exits 0 +//! having said so, and the rows judge. It panics only where netstack or +//! logkeeper does not answer it at all. +//! +//! **Every line is a [`Line`]**, which holds no address. +//! +//! Its time, against the runner's `toyos_tco::JOB_BOUND_MS` from boot: +//! netstack says it has no lease `toyos_tco::LEASE_BOUND_MS` after it came up, +//! so a lease is at most that late; a lookup asks at most three resolvers +//! `toyos_dns::ROUNDS` times, `toyos_dns::WAIT_MS` each; and a connect has +//! [`CONNECT_MS`]: 20 s, 18 s and 5 s, the anchors side by side. + +use std::collections::BTreeMap; +use std::time::Duration; + +use toyos::net::NetError; +use toyos_inspect::{Value, NET}; +use toyos_logstream::program_line; + +#[path = "../outbound_said.rs"] +#[allow(dead_code, reason = "the judges read with the half this job does not call")] +mod said; +#[path = "../served_log.rs"] +mod served_log; + +use said::{Anchor, Connect, Driver, Frames, Line, Link, Lookup, Neighbour, Word}; + +/// The program whose lines these are, as the supervisor names its ring. +const NETSTACK: &str = "netstack"; + +/// What netstack's line on a lease it took opens with, and on its bound +/// passing with none; and its whole line where it was endowed no card. +const LEASED: &str = "netstack: DHCP: lease "; +const NO_LEASE: &str = "netstack: DHCP: no lease as "; +const NO_CARD: &str = "netstack: no NIC on this machine, exiting"; + +/// The ceiling on netstack's word reaching this job: netstack's own bound on +/// saying it, and two of logkeeper's rounds at its write budget +/// (`userland/logkeeper/src/policy.rs`, 5 s), since a served line is one the +/// stick already holds. +const WORD_BOUND: Duration = Duration::from_millis(toyos_tco::LEASE_BOUND_MS + 10_000); + +/// How long one connect has, which netstack keeps. +const CONNECT_MS: u32 = 5_000; + +const HTTPS: u16 = 443; + +/// The router's neighbour entry in netstack's `inspect` answer, where its +/// stack says one. +const NEIGHBOUR: &str = "net.neighbour.router"; + +type Snapshot = BTreeMap; + +fn say(line: Line) { + println!("{line}"); +} + +/// netstack's first word about its lease, off the served log. +fn netstack_said() -> Word { + let mut word = None; + served_log::Log::open().until("netstack's word on its lease", WORD_BOUND, |line| { + let Some(said) = program_line(line).filter(|said| said.tag == NETSTACK) else { return false }; + let this = if said.text.starts_with(LEASED) { + Some(Word::Lease) + } else if said.text.starts_with(NO_LEASE) { + Some(Word::NoLease) + } else if said.text == NO_CARD { + Some(Word::NoCard) + } else { + None + }; + word = word.or(this); + word.is_some() + }); + word.expect("the wait ended on a word") +} + +fn ask() -> Snapshot { + inspect::ask(NET).unwrap_or_else(|why| panic!("netstack's inspect answer: {why}")) +} + +/// A key netstack's answer must carry as text. Its value is in no panic: it +/// may be an address. +fn text<'a>(snapshot: &'a Snapshot, key: &str) -> &'a str { + match snapshot.get(key) { + Some(Value::Text(text)) => text, + Some(_) => panic!("netstack's snapshot carries {key} as something that is not text"), + None => panic!("netstack's snapshot carries no {key}"), + } +} + +fn frames(snapshot: &Snapshot, key: &str) -> Frames { + match snapshot.get(key) { + Some(Value::U64(count)) => Frames(Some(*count)), + Some(_) => panic!("netstack's snapshot carries {key} as something that is not a count"), + None => Frames(None), + } +} + +fn card(snapshot: &Snapshot) -> Line { + let driver = text(snapshot, "net.driver"); + let link = text(snapshot, "net.link.state"); + Line::Card { + driver: Driver::read(driver).unwrap_or(Driver::Other), + link: Link::read(link).unwrap_or_else(|| panic!("netstack's snapshot carries a link state this job has no word for")), + sent: frames(snapshot, "net.wire.sent"), + received: frames(snapshot, "net.wire.received"), + } +} + +fn lease(snapshot: &Snapshot) -> Line { + let held = match snapshot.get("net.lease.held") { + Some(Value::Bool(held)) => *held, + _ => panic!("netstack's snapshot does not say whether it holds a lease"), + }; + if !held { + return Line::Lease { held, router: false, resolver: said::Resolver::None }; + } + let router = snapshot.contains_key("net.lease.router").then(|| text(snapshot, "net.lease.router")); + let resolver = said::resolver(text(snapshot, "net.lease.address"), router, text(snapshot, "net.lease.dns")) + .expect("netstack's words for its lease read as an address with its prefix, a router and resolvers"); + Line::Lease { held, router: router.is_some(), resolver } +} + +/// One anchor: its name looked up, and one connection to its first address +/// opened and closed. +fn reach(anchor: Anchor) -> Line { + let mut first = [[0u8; 4]; 1]; + // A wildcard and not every variant: one source builds against the ABI + // before a word is added to it and after. + let lookup = match toyos::net::dns_lookup(anchor.word(), &mut first) { + Ok(0) => Lookup::NoAddress, + Ok(_) => Lookup::Addresses, + Err(NetError::TimedOut) => Lookup::Timeout, + Err(NetError::Io) => Lookup::Failed, + Err(NetError::NotConnected) => Lookup::NoResolver, + Err(_) => Lookup::Refused, + }; + let connect = match lookup { + Lookup::Addresses => match toyos::net::tcp_connect(first[0], HTTPS, CONNECT_MS) { + Ok(open) => { + let id = open.socket_id; + drop(open); + // Unread, as std's own drop leaves it: the peer may have ended + // the connection first, and what netstack answers then is no + // fact about reaching it. + let _ = toyos::net::tcp_close(id); + Connect::Connected + } + Err(NetError::ConnectionRefused) => Connect::Refused, + Err(NetError::ConnectionReset) => Connect::Reset, + Err(NetError::TimedOut) => Connect::Timeout, + Err(NetError::NotConnected) => Connect::NoAddress, + Err(_) => Connect::Error, + }, + _ => Connect::NotTried, + }; + Line::Anchor { anchor, lookup, connect } +} + +fn neighbour(snapshot: &Snapshot) -> Neighbour { + if !snapshot.contains_key(NEIGHBOUR) { + return Neighbour::NotAsked; + } + Neighbour::read(text(snapshot, NEIGHBOUR)) + .unwrap_or_else(|| panic!("netstack's snapshot carries {NEIGHBOUR} as a word this job has none for")) +} + +fn main() { + let word = netstack_said(); + say(Line::Netstack(word)); + if word != Word::NoCard { + let held = ask(); + say(card(&held)); + let lease = lease(&held); + say(lease); + if matches!(lease, Line::Lease { held: true, .. }) { + std::thread::scope(|anchors| { + for anchor in Anchor::ALL { + anchors.spawn(|| say(reach(*anchor))); + } + }); + say(Line::Gateway(neighbour(&ask()))); + } + } + say(Line::Done); +} diff --git a/tests/toyos-rust-tests/src/outbound_said.rs b/tests/toyos-rust-tests/src/outbound_said.rs new file mode 100644 index 00000000000..65107111fc2 --- /dev/null +++ b/tests/toyos-rust-tests/src/outbound_said.rs @@ -0,0 +1,291 @@ +//! What the `outbound` job says, and the whole of what a line of it can hold. +//! +//! The job writes [`Line`]s and the harness's judges (`tests/common/outbound.rs`) +//! read them back, each through this one file. **A line carries words of a +//! closed list and counts, and nothing else**: no address, no MAC and no name +//! of the network the machine is on can be put in one, so none reaches a +//! verdict that quotes it. +//! +//! Pure: `std` only. + +use std::fmt; +use std::net::Ipv4Addr; + +/// What opens every line. +pub const HEAD: &str = "outbound: "; + +/// An enum whose every value is one word of a line. +macro_rules! words { + ($(#[$doc:meta])* $name:ident { $($(#[$vdoc:meta])* $value:ident = $word:literal,)+ }) => { + $(#[$doc])* + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum $name { + $($(#[$vdoc])* $value,)+ + } + + impl $name { + pub const ALL: &'static [Self] = &[$(Self::$value,)+]; + + pub const fn word(self) -> &'static str { + match self { + $(Self::$value => $word,)+ + } + } + + pub fn read(word: &str) -> Option { + Self::ALL.iter().copied().find(|value| value.word() == word) + } + } + }; +} + +words! { + /// A service the row asks for by name: a public resolver that takes + /// anonymous connections on port 443. + Anchor { + Google = "dns.google", + Quad9 = "dns.quad9.net", + } +} + +words! { + /// netstack's own word about this machine's address, off the log. + Word { + Lease = "lease", + NoLease = "no-lease", + /// It was endowed no card and left. + NoCard = "no-card", + } +} + +words! { + Driver { + I219 = "i219", + E82574 = "82574", + VirtioNet = "virtio-net", + /// One this file has no word for. + Other = "other", + } +} + +words! { + Link { + Up = "up", + Down = "down", + /// The driver is told nothing about its link. + Unreported = "unreported", + } +} + +words! { + /// Where the nearest resolver the lease names stands, nearest first. + Resolver { + /// It is the router. + Router = "router", + OnLink = "on-link", + /// Every one is reached through the router. + OffLink = "off-link", + /// The lease names none. + None = "none", + } +} + +words! { + /// How one name's lookup ended. + Lookup { + Addresses = "addresses", + /// A resolver answered that the name has no address. + NoAddress = "no-address", + /// netstack ended it with its word for anything else: on the stack the + /// rows were first read on, a reply no address came of. + Failed = "failed", + /// No resolver answered. + Timeout = "timeout", + /// netstack holds no resolver to ask. + NoResolver = "no-resolver", + /// netstack refused the request for a reason of its own. + Refused = "refused", + } +} + +words! { + /// How one connect to port 443 ended. + Connect { + Connected = "connected", + /// The peer, or something on the way to it, answered no. + Refused = "refused", + Reset = "reset", + Timeout = "timeout", + /// netstack holds no address to connect from. + NoAddress = "no-address", + /// netstack refused the request for a reason of its own. + Error = "error", + /// The lookup gave no address to connect to. + NotTried = "not-tried", + } +} + +words! { + /// The router's entry in netstack's neighbour table. + Neighbour { + Reachable = "reachable", + Stale = "stale", + Delay = "delay", + Probe = "probe", + Incomplete = "incomplete", + Unreachable = "unreachable", + Failed = "failed", + /// The table holds no entry for it. + None = "none", + /// netstack's answer carries no word about it. + NotAsked = "not-asked", + } +} + +impl Neighbour { + /// Whether the router has answered for its link address. + pub const fn answered(self) -> Option { + match self { + Self::Reachable | Self::Stale | Self::Delay | Self::Probe => Some(true), + Self::Incomplete | Self::Unreachable | Self::Failed | Self::None => Some(false), + Self::NotAsked => None, + } + } +} + +/// A frame count off the card's own statistics, where the driver keeps them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Frames(pub Option); + +const UNREPORTED: &str = "unreported"; + +impl Frames { + fn read(word: &str) -> Option { + if word == UNREPORTED { + return Some(Self(None)); + } + // Digits and nothing else, which `parse` alone does not hold a word to. + let digits = !word.is_empty() && word.bytes().all(|b| b.is_ascii_digit()); + digits.then(|| word.parse().ok()).flatten().map(|count| Self(Some(count))) + } +} + +impl fmt::Display for Frames { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(count) => write!(f, "{count}"), + None => f.write_str(UNREPORTED), + } + } +} + +/// One line of the job's. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Line { + Netstack(Word), + Card { driver: Driver, link: Link, sent: Frames, received: Frames }, + Lease { held: bool, router: bool, resolver: Resolver }, + Anchor { anchor: Anchor, lookup: Lookup, connect: Connect }, + Gateway(Neighbour), + /// The job's last line. + Done, +} + +impl fmt::Display for Line { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(HEAD)?; + match self { + Self::Netstack(word) => write!(f, "netstack said={}", word.word()), + Self::Card { driver, link, sent, received } => { + write!(f, "card driver={} link={} sent={sent} received={received}", driver.word(), link.word()) + } + Self::Lease { held, router, resolver } => write!( + f, + "lease held={} router={} resolver={}", + if *held { "yes" } else { "no" }, + if *router { "named" } else { "none" }, + resolver.word() + ), + Self::Anchor { anchor, lookup, connect } => { + write!(f, "anchor name={} lookup={} connect={}", anchor.word(), lookup.word(), connect.word()) + } + Self::Gateway(neighbour) => write!(f, "gateway neighbour={}", neighbour.word()), + Self::Done => f.write_str("done"), + } + } +} + +impl Line { + /// `text` as a line of the job's: `None` for text [`HEAD`] does not open, + /// and `Some(None)` for a line it opens that is not one a [`Line`] writes. + pub fn read(text: &str) -> Option> { + let said = text.strip_prefix(HEAD)?; + let line = Self::read_said(said); + // Byte for byte what it reads as, so nothing rides a line beside its words. + Some(line.filter(|line| line.to_string() == text)) + } + + fn read_said(said: &str) -> Option { + let mut words = said.split(' '); + let subject = words.next()?; + let mut value = |key: &str| words.next()?.strip_prefix(key)?.strip_prefix('='); + let yes = |word: &str, yes: &str, no: &str| match word { + w if w == yes => Some(true), + w if w == no => Some(false), + _ => None, + }; + Some(match subject { + "netstack" => Self::Netstack(Word::read(value("said")?)?), + "card" => Self::Card { + driver: Driver::read(value("driver")?)?, + link: Link::read(value("link")?)?, + sent: Frames::read(value("sent")?)?, + received: Frames::read(value("received")?)?, + }, + "lease" => Self::Lease { + held: yes(value("held")?, "yes", "no")?, + router: yes(value("router")?, "named", "none")?, + resolver: Resolver::read(value("resolver")?)?, + }, + "anchor" => Self::Anchor { + anchor: Anchor::read(value("name")?)?, + lookup: Lookup::read(value("lookup")?)?, + connect: Connect::read(value("connect")?)?, + }, + "gateway" => Self::Gateway(Neighbour::read(value("neighbour")?)?), + "done" => Self::Done, + _ => return None, + }) + } +} + +/// Where the nearest of a lease's resolvers stands, off netstack's own words +/// for the lease: its address with its prefix length, its router where it +/// names one, and its resolvers with a space between two. `None` where a word +/// is not what netstack writes there. +pub fn resolver(address: &str, router: Option<&str>, dns: &str) -> Option { + let (address, prefix) = address.split_once('/')?; + let address = u32::from(address.parse::().ok()?); + let prefix: u32 = prefix.parse().ok().filter(|len| *len <= 32)?; + let mask = u32::MAX.checked_shl(32 - prefix).unwrap_or(0); + let router = match router { + Some(router) => Some(router.parse::().ok()?), + None => None, + }; + let mut nearest = Resolver::None; + for server in dns.split_whitespace() { + let server: Ipv4Addr = server.parse().ok()?; + let stands = if Some(server) == router { + Resolver::Router + } else if u32::from(server) & mask == address & mask { + Resolver::OnLink + } else { + Resolver::OffLink + }; + let rank = |r: Resolver| Resolver::ALL.iter().position(|v| *v == r); + if rank(stands) < rank(nearest) { + nearest = stands; + } + } + Some(nearest) +} diff --git a/tests/toyos-rust-tests/src/served_log.rs b/tests/toyos-rust-tests/src/served_log.rs new file mode 100644 index 00000000000..83d6985309b --- /dev/null +++ b/tests/toyos-rust-tests/src/served_log.rs @@ -0,0 +1,50 @@ +//! This boot's log as logkeeper serves it, from its first line: every +//! program's lines and the kernel's records, which the kernel's own cursor +//! holds only the second of. A reader of the `log` port is handed each round +//! only after it is on the stick. + +use std::time::{Duration, Instant}; + +use toyos::poller::{Poller, READABLE}; +use toyos::Pipe; +use toyos_abi::syscall::SyscallError; +use toyos_logstream::Lines; + +pub struct Log { + pipe: Pipe, + poller: Poller, + lines: Lines, + chunk: Vec, +} + +impl Log { + pub fn open() -> Log { + let pipe = logkeeper_api::read().unwrap_or_else(|why| panic!("test-runner's `log` port: {why}")).pipe; + Log { pipe, poller: Poller::new(1), lines: Lines::new(), chunk: vec![0u8; 64 * 1024] } + } + + /// Hand `seen` each line in turn until it has answered `true`, for at most + /// `bound`; `what` names what it waits for. + pub fn until(&mut self, what: &str, bound: Duration, mut seen: impl FnMut(&str) -> bool) { + let by = Instant::now() + bound; + let mut done = false; + while !done { + let left = by + .checked_duration_since(Instant::now()) + .unwrap_or_else(|| panic!("the log did not show {what} within {bound:?}")); + match self.pipe.read_nonblock(&mut self.chunk) { + Ok(0) => panic!("logkeeper closed the log before it showed {what}"), + Ok(n) => self.lines.push(&self.chunk[..n], |line, _| { + let line = std::str::from_utf8(line) + .unwrap_or_else(|e| panic!("logkeeper served a line that is not UTF-8 ({e}): {line:?}")); + done |= seen(line); + }), + Err(SyscallError::WouldBlock) => { + self.poller.watch(&self.pipe, READABLE, 0); + self.poller.wait(1, left.as_nanos() as u64, |_| {}); + } + Err(e) => panic!("the log's pipe refused a read: {e:?}"), + } + } + } +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 78bdb410732..86eae4058f9 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, metal, power, screen, serial, usb}; +use common::{audio, claims, compile, devices, faults, isa, metal, outbound, power, screen, serial, usb}; use toyos_build::bootlog::{self}; use toyos_build::testargs::{self, SUITE}; @@ -103,6 +103,10 @@ const RUST_SKIP: &[&str] = &[ // `claim_reuses_its_remapping_entry` and `claim_refused_without_remapping` // metal rows run it. "pci_reclaim", + // It asks netstack on the T14's I219 for the bench's router and two public + // services, which no guest has: the `outbound_router` and + // `outbound_internet` metal rows judge what it says. + "outbound", // Its product is the T14's counters across an idle span and a spin on // every CPU, which the `counters` metal row judges; on a guest it would be // seconds of four CPUs spinning, read by nothing. @@ -889,8 +893,26 @@ const METAL: &[(&str, metal::Metal)] = &[ }, }, ), + // ---- one image: tests/outboundcase, netstack on the machine's own card ---- + ( + // The card handed over, its link, the lease, the router's link address + // and the resolver on the link, red by the first that failed. + "outbound_router", + metal::Metal { arms: OUTBOUND, judge: |b| outbound::router(&b[0].kernel(), &b[0].log()) }, + ), + ( + // One of two public services connected on port 443, judged only over + // a green router row. + "outbound_internet", + metal::Metal { arms: OUTBOUND, judge: |b| outbound::internet(&b[0].kernel(), &b[0].log()) }, + ), ]; +/// netstack on the T14's I219 and the job that says what it reached: the one +/// boot that drives the card, and both rows read it. +const OUTBOUND: &[metal::Arm] = + &[metal::once("outbound", "tests/outboundcase", &[], &["test_rs_outbound"])]; + /// A boot whose kernel leaves the i8042 unprobed, so the one grantable row is /// free: the ports' job first, since its last holder keeps them until it ends. const ISA_WITHHELD: &[metal::Arm] = &[metal::once(