From 0b13818a443e7d39c5aae5eb5769d8749caf3f14 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 18:05:05 +0200 Subject: [PATCH 1/3] toyos-net-node: the resolver on ToyOS's own UDP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage C of the cut of the own-stack track's stage 5: the socket half of netstack's resolve module, as node calls over the node's own datagram sockets. Host-tested, shipped in nothing; netstack's modules and manifest are untouched. resolve: Node::resolve starts a lookup of a name at the held lease's resolvers and answers a LookupId; Node::take_resolved hands over the lookups that ended; Node::let_go ends one nobody waits for. Every decision about a question and every byte of a reply stays toyos_dns::Lookup's. The node gives it one socket a query, on an ephemeral port of one draw with an id of another (RFC 5452 §9.2), the source address and port each reply arrived from, and the query whose socket it reached. A wait is a deadline of the node's, and the lookups are carried on in the node's pass over the stack, after the DHCP client and the name. A refusal is one of the pipe ABI's words datagram.rs already has: NotConnected with no lease or no resolver, ResourceExhausted at MAX_LOOKUPS or with no ephemeral port. An ended lookup holds its place until its answer is taken, so the list is bounded whatever the shell does. What netstack's resolver does on smoltcp and this does not: it does not close the socket of a query "whose wait ended before it left". On smoltcp a datagram stayed at the head of its socket while its neighbour was unresolved and kept the interface asking for that neighbour; here [udp] hands the datagram to [ip] at the next opportunity and [ip]'s neighbour machine bounds its own requests, so a socket cannot say whether its query left and there is nothing to let go of. A query [udp] refuses outright is counted node.query-unsent and holds no socket. name::millis becomes pub(crate): the resolver keeps the same clock. Tests are tests/resolve.rs, on stage B's Lan. Every DNS message in them is spelled by hand from RFC 1035 §4.1; every frame the node emits is read by etherparse. netstack's own resolver tests are not carried across as a differential arm: they go with the flip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- Cargo.lock | 1 + issues/toyos-has-its-own-network-stack.md | 5 +- userland/netstack/node/Cargo.toml | 1 + userland/netstack/node/src/lib.rs | 20 +- userland/netstack/node/src/name.rs | 4 +- userland/netstack/node/src/resolve.rs | 220 ++++++++ userland/netstack/node/tests/resolve.rs | 637 ++++++++++++++++++++++ 7 files changed, 879 insertions(+), 9 deletions(-) create mode 100644 userland/netstack/node/src/resolve.rs create mode 100644 userland/netstack/node/tests/resolve.rs diff --git a/Cargo.lock b/Cargo.lock index 5e9ac110be2..46dd63f2c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5920,6 +5920,7 @@ version = "0.1.0" dependencies = [ "etherparse", "toyos-dhcp", + "toyos-dns", "toyos-mdns", "toyos-net-ip", "toyos-net-shard", diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index f48e849b092..13504926025 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -16,7 +16,7 @@ netd runs smoltcp. Its replacement is ToyOS's own stack, built clean-room: reade - 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. -Stage 5 lands in slices, the orchestrator's cut under the owner's words "i want smoltcp out as fast as possible": netd's decisions go into `toyos-net-node` (`userland/netstack/node`), pure and host-tested and shipped in nothing, and then one change moves netd onto it and deletes smoltcp. In the tree: the node, its DHCP lease, its clients' datagram sockets and its mDNS name. Still to build on it: the resolver, streams, listeners; then the move. +Stage 5 lands in slices, the orchestrator's cut under the owner's words "i want smoltcp out as fast as possible": netd's decisions go into `toyos-net-node` (`userland/netstack/node`), pure and host-tested and shipped in nothing, and then one change moves netd onto it and deletes smoltcp. In the tree: the node, its DHCP lease, its clients' datagram sockets, its mDNS name and its resolver. Still to build on it: streams, listeners; then the move. What the node does not yet meet: @@ -28,6 +28,9 @@ What the node does not yet meet: - A refusal [udp] logs against a client's call waits in the stack until the node's next `receive`, `fire` or `link`, because only those pass over the stack's reports. Exit: the datagram calls end in that pass, or the move drains it once per turn of netd's loop. - The machine's name is told to the node twice, as `toyos_dhcp::HostName` in `Node::new` and as `toyos_mdns::Host` in `Node::answer_as`: the first owns its bytes and the second borrows them. Exit: one type carries the label to both. - The node's name is not probed for or defended (RFC 6762 §8.1, §9), as `toyos-mdns` records of itself, and is not announced again when the link comes back with the lease kept (§8.3). Exit: `toyos-mdns` does both, on the node's `link`. +- A query [udp] refuses (`node.query-unsent`: no route to its resolver, or the lease gone under a lookup in flight) is waited out for `toyos_dns::WAIT_MS` like one nobody answered, because `toyos_dns::Lookup` has no word for a query that never left. Exit: `Lookup` is told and asks the next resolver at once, and `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms. +- Lookups started together at a resolver whose link address is not yet known lose all but the newest `PENDING_PER_NEIGHBOUR` queries to [ip]'s queue for an unresolved next hop (`nb.pending-overflow`, RFC 4861 §7.2.2), and each lost one costs its lookup a `WAIT_MS`; smoltcp kept each query in its own socket until the neighbour answered. `a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later` holds it as it stands. Exit: that test finds every query of the burst on the wire once the resolver answers ARP, or the owner accepts RFC 4861's queue for it. +- No reply a third party wrote has reached the node's resolver: its tests' replies are spelled by hand from RFC 1035 §4.1, and the real replies `toyos-dns`'s own tests read never pass through the node. Exit: slirp's resolver answers the node in a guest, at the move. - `node.address-refused` has no test: `toyos-dhcp` accepts no address or prefix [ip] refuses, by the same `toyos-net-wire` checks in both, so the refusal cannot be reached from the wire. Exit: the client hands [ip] a type that carries the check, and the counter goes. 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/userland/netstack/node/Cargo.toml b/userland/netstack/node/Cargo.toml index 8131c8ccafa..75199db49cd 100644 --- a/userland/netstack/node/Cargo.toml +++ b/userland/netstack/node/Cargo.toml @@ -11,6 +11,7 @@ doctest = false [dependencies] toyos-dhcp = { path = "../../../toyos-dhcp" } +toyos-dns = { path = "../../../toyos-dns" } toyos-mdns = { path = "../mdns" } toyos-net-ip = { path = "../../../toyos-net-ip" } toyos-net-shard = { path = "../../../toyos-net-shard" } diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs index e88047e9d7b..1518769b70d 100644 --- a/userland/netstack/node/src/lib.rs +++ b/userland/netstack/node/src/lib.rs @@ -8,8 +8,9 @@ //! interface holds is `lease`'s to write and nobody else's: see that module for the three rules //! its types keep. //! -//! A client's datagram sockets are `datagram`'s and the machine's `.local` name is `name`'s: -//! both read the lease and write none of it. +//! A client's datagram sockets are `datagram`'s, the machine's `.local` name is `name`'s +//! and the lookups of other machines' names are `resolve`'s: each reads the lease and writes none +//! of it. //! //! **Untrusted input.** A received frame is never read here: every byte goes through //! `toyos-net-wire`'s parsers inside the shard, and a DHCP payload through the client's. What @@ -17,7 +18,8 @@ //! [`Node::drain_events`]. //! //! **Draws.** Each `draw` is handed to the client, whose order is its own: a call that starts an -//! exchange draws its transaction id first. +//! exchange draws its transaction id first. The lookups in flight draw after it, an id for each +//! reply read and each wait looked at, and a port for each query sent. #![no_std] #![forbid(unsafe_code)] @@ -35,6 +37,7 @@ extern crate alloc; mod datagram; mod lease; mod name; +mod resolve; use alloc::vec; use alloc::vec::Vec; @@ -45,12 +48,14 @@ use toyos_net_wire::Instant; pub use datagram::{Datagram, DatagramId, Refused}; use lease::{Report, Stack, Verified}; +pub use resolve::{Ended, LookupId, Resolved}; toyos_net_wire::counters! { DhcpUnsent = "node.dhcp-unsent"; AddressRefused = "node.address-refused"; RouterRefused = "node.router-refused"; NameUnsent = "node.name-unsent"; + QueryUnsent = "node.query-unsent"; } /// A line for the log. @@ -66,6 +71,7 @@ pub struct Node { client: Client, /// The responder for the machine's name, once [`Self::answer_as`] started it. name: Option, + resolver: resolve::Resolver, counters: Counters, events: Vec, /// Room for the largest message the client accepts. @@ -83,6 +89,7 @@ impl Node { stack, client, name: None, + resolver: resolve::Resolver::new(), counters: Counters::default(), events: Vec::new(), datagram: vec![0; usize::from(toyos_dhcp::limits::MAX_MESSAGE)], @@ -141,7 +148,7 @@ impl Node { pub fn next_deadline(&self) -> Option { let name = self.name.as_ref().and_then(name::Name::next_deadline); - self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).min() + self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.resolver.next_deadline()).min() } /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`]. @@ -154,8 +161,8 @@ impl Node { } /// Hands the client what the shard reported and what reached its socket, and carries out - /// what it answers, until neither has more; then the name is served, against the lease as - /// that left it. + /// what it answers, until neither has more; then the name is served and the lookups are + /// carried on, against the lease as that left it. fn settle(&mut self, now: Instant, draw: &mut impl FnMut() -> u32) { loop { let (out, verified) = if let Some(report) = self.stack.report() { @@ -179,6 +186,7 @@ impl Node { let unsent = name.pass(now, &mut self.stack); self.counters.add(Counter::NameUnsent, unsent); } + self.resolver.pass(now, &mut self.stack, &mut self.counters, draw); } /// What one call of the client's asked for: the lease first, so a message leaves from the diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs index 1498e79d9df..528a729b1ce 100644 --- a/userland/netstack/node/src/name.rs +++ b/userland/netstack/node/src/name.rs @@ -35,8 +35,8 @@ pub(crate) struct Name { query: Vec, } -/// `now` on the responder's clock: whole milliseconds of the node's. -fn millis(now: Instant) -> u64 { +/// `now` on the clock `toyos-mdns` and `toyos-dns` keep: whole milliseconds of the node's. +pub(crate) fn millis(now: Instant) -> u64 { u64::try_from(now.since(Instant::from_millis(0)).as_millis()).unwrap_or(u64::MAX) } diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs new file mode 100644 index 00000000000..02cd8de48ec --- /dev/null +++ b/userland/netstack/node/src/resolve.rs @@ -0,0 +1,220 @@ +//! The machine's one resolver: a name's IPv4 addresses, asked of the resolvers the held lease +//! names. Every decision about a question and every byte of a reply is `toyos_dns::Lookup`'s; +//! this is its sockets on [udp], its clock and its draws. +//! +//! - **Each query leaves from a socket of its own**, on an ephemeral port [udp] picks from one +//! draw, and carries an id that is another draw (RFC 5452 §9.2): an off-path sender has to +//! guess both to be read at all. A reply is handed to the lookup as arriving for the query +//! whose socket it reached, and for no other. +//! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets +//! the query go, ends, or is let go itself, and its port is free from then. +//! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits +//! its wait out as for any query nobody answered. +//! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame. +//! - **At most `toyos_dns::MAX_LOOKUPS` lookups are held**, an ended one until +//! [`Node::take_resolved`] hands its answer over. +//! +//! **Untrusted input.** A reply's bytes are read by the lookup's reader alone, cut to the largest +//! datagram [udp] delivers. Its source address and port are the wire's: the lookup reads a reply +//! only from port 53 of the resolver its query went to. + +use alloc::vec; +use alloc::vec::Vec; +use core::net::Ipv4Addr; + +use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT}; +use toyos_net_udp::SocketId; +use toyos_net_wire::{Instant, Port}; + +use crate::datagram::Refused; +use crate::lease::Stack; +use crate::name::millis; +use crate::{Counter, Counters, Node}; + +/// One lookup, from [`Node::resolve`] until its answer is taken or it is let go. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LookupId(u64); + +/// How a lookup ended without an address. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ended { + /// What the resolvers' answers, or their silence, came to. + Failed(Failure), + /// Every ephemeral port is held, so the next query had none to leave from. + NoPort, +} + +/// A lookup that ended: the name's addresses, at least one, or why none. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Resolved { + pub id: LookupId, + pub result: Result, Ended>, +} + +struct Asking { + id: LookupId, + lookup: Lookup, + /// The socket of each query whose answer is still read. + queries: Vec<(Asked, SocketId)>, +} + +pub(crate) struct Resolver { + asking: Vec, + /// Ended, and not yet taken. + ended: Vec, + /// The id the next lookup gets. + next: u64, + /// Room for the largest datagram [udp] delivers. + reply: Vec, +} + +/// A query's id: the low half of one draw. +fn id(draw: &mut impl FnMut() -> u32) -> u16 { + let [_, _, high, low] = draw().to_be_bytes(); + u16::from_be_bytes([high, low]) +} + +fn close(stack: &mut Stack, now: Instant, socket: SocketId) { + if stack.close(now, socket).is_err() { + unreachable!("a query's socket is closed once, by the lookup that bound it"); + } +} + +/// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer +/// reads an answer for. Returns how the lookup ended, if it did. +fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option, Ended>> { + let done = match step { + // An any-address bind that names no port is refused only for want of a free one. + Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) { + Ok((socket, _)) => { + if stack.send_to(now, socket, Ipv4Addr::from(to), PORT, &query).is_ok() { + asking.queries.push((asked, socket)); + } else { + counters.add(Counter::QueryUnsent, 1); + close(stack, now, socket); + } + None + } + Err(_) => Some(Err(Ended::NoPort)), + }, + Step::Wait => None, + Step::Done(result) => Some(result.map_err(Ended::Failed)), + }; + let lookup = &asking.lookup; + asking.queries.retain(|&(asked, socket)| { + let read = lookup.waiting().any(|waiting| waiting == asked); + if !read { + close(stack, now, socket); + } + read + }); + done +} + +impl Resolver { + pub(crate) fn new() -> Self { + Self { asking: Vec::new(), ended: Vec::new(), next: 0, reply: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] } + } + + /// When the soonest lookup's wait is over. + pub(crate) fn next_deadline(&self) -> Option { + self.asking.iter().map(|asking| Instant::from_millis(asking.lookup.due())).min() + } + + fn start(&mut self, now: Instant, name: Name, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Result { + let servers: Vec<[u8; 4]> = stack.lease().map(|lease| lease.dns.iter().map(Ipv4Addr::octets).collect()).unwrap_or_default(); + if servers.is_empty() { + return Err(Refused::NotConnected); + } + if self.asking.len().saturating_add(self.ended.len()) >= MAX_LOOKUPS { + return Err(Refused::ResourceExhausted); + } + let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), id(&mut *draw)) else { unreachable!("the lease names a resolver") }; + let mut asking = Asking { id: LookupId(self.next), lookup, queries: Vec::new() }; + match act(&mut asking, step, now, stack, counters, &mut *draw) { + None => { + self.next = self.next.wrapping_add(1); + let started = asking.id; + self.asking.push(asking); + Ok(started) + } + Some(Err(Ended::NoPort)) => Err(Refused::ResourceExhausted), + Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"), + } + } + + /// Reads every reply that reached a query's socket and ends every wait that is over. A + /// lookup that ended closes its sockets and waits to be taken. + pub(crate) fn pass(&mut self, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) { + let Self { asking: lookups, ended, reply, .. } = self; + let ms = millis(now); + lookups.retain_mut(|asking| { + let mut done = None; + let mut at = 0usize; + while done.is_none() { + let Some(&(asked, socket)) = asking.queries.get(at) else { break }; + let received = match stack.recv_from(socket, reply.as_mut_slice()) { + Ok(Some(received)) => received, + Ok(None) => { + at = at.saturating_add(1); + continue; + } + Err(_) => unreachable!("a query's socket is open while it is listed, and never connected"), + }; + let Some(message) = reply.get(..received.len) else { continue }; + // Port 0 is not port 53: the lookup drops it. + let port = received.source_port.map_or(0, Port::get); + let step = asking.lookup.on_datagram(asked, received.source.octets(), port, message, ms, id(&mut *draw)); + done = act(asking, step, now, stack, counters, &mut *draw); + // The step may have let this query go: its socket is read again from wherever it + // now is, or every socket from the first. + at = asking.queries.iter().position(|&(listed, _)| listed == asked).unwrap_or(0); + } + if done.is_none() { + let step = asking.lookup.on_time(ms, id(&mut *draw)); + done = act(asking, step, now, stack, counters, &mut *draw); + } + let Some(result) = done else { return true }; + for &(_, socket) in &asking.queries { + close(stack, now, socket); + } + ended.push(Resolved { id: asking.id, result }); + false + }); + } + + fn let_go(&mut self, now: Instant, id: LookupId, stack: &mut Stack) { + self.asking.retain(|asking| { + if asking.id != id { + return true; + } + for &(_, socket) in &asking.queries { + close(stack, now, socket); + } + false + }); + self.ended.retain(|resolved| resolved.id != id); + } +} + +impl Node { + /// Starts looking `name` up at the resolvers the held lease names, which spends two draws: + /// the first query's id, then its port. Refused `NotConnected` while no lease is held or it + /// names no resolver, and `ResourceExhausted` while `toyos_dns::MAX_LOOKUPS` are held or no + /// ephemeral port is free. A call refused for want of a port has spent both draws; any other + /// refused call spends none. + pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result { + self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw) + } + + /// The lookups that ended since the last call, each once. + pub fn take_resolved(&mut self) -> Vec { + core::mem::take(&mut self.resolver.ended) + } + + /// Nobody waits for `id`'s answer any more: its sockets close and its place is another's at + /// once, and an answer not yet taken goes with it. + pub fn let_go(&mut self, now: Instant, id: LookupId) { + self.resolver.let_go(now, id, &mut self.stack); + } +} diff --git a/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs new file mode 100644 index 00000000000..c016562c1af --- /dev/null +++ b/userland/netstack/node/tests/resolve.rs @@ -0,0 +1,637 @@ +//! The resolver on the node, against a far end played here frame by frame: what is judged is +//! what leaves the node, not what it queued. No scenario ids: the reader's rules are +//! `toyos-dns`'s own tests; these are the node's sockets, clock and draws under it. +//! +//! What is not ours: `etherparse` reads every frame the node emits (`lan::outside`); every DNS +//! message here, a reply's and the query the node is expected to emit, is spelled by hand from +//! RFC 1035 §4.1's layouts and never by `toyos-dns`; each test names the RFC its expectation is +//! read from. + +mod common; +mod lan; + +use std::net::Ipv4Addr; +use std::time::Duration; + +use common::{arp, terms, A, DNS, MAC, MAC_B, R}; +use lan::{udp, Lan, Seen, Udp, B}; +use toyos_dns::{Failure, Name, MAX_LOOKUPS, ROUNDS, WAIT_MS}; +use toyos_net_node::{Counter, Ended, LookupId, Refused, Resolved}; +use toyos_net_udp::limits::{EPHEMERAL_COUNT, EPHEMERAL_FIRST}; +use toyos_net_udp::Counter as Rule; +use toyos_net_wire::{Instant, Port}; + +const ANY: Ipv4Addr = Ipv4Addr::UNSPECIFIED; +/// On the link, answering ARP and every query its zone holds an answer for. +const ANSWERS: Ipv4Addr = DNS; +const MAC_S: [u8; 6] = [2, 0, 0, 0, 0, 0x53]; +/// On the link, and nothing there answers ARP: a LAN's resolver that is down. +const SILENT: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 52); +/// Off the link, asked of a lease that names no router. +const UNROUTED: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 53); +const ADDRESS: [u8; 4] = [203, 0, 113, 7]; +/// The address every reply that must not be read carries. +const FORGED: [u8; 4] = [203, 0, 113, 66]; +/// RFC 1035 §4.1.1: a response (QR), recursion desired and available, RCODE 0. +const RESPONSE: u16 = 0x8180; +/// RFC 1035 §3.2.2. +const TYPE_A: u16 = 1; +const TYPE_CNAME: u16 = 5; +/// No lookup has ended. +const NONE: [Resolved; 0] = []; + +fn name(text: &str) -> Name { + Name::parse(text).expect("a host name") +} + +/// The draw of a call that draws nothing. +fn undrawn() -> u32 { + panic!("this call draws nothing") +} + +/// These draws in order, and no more. +fn sequence(draws: [u32; N]) -> impl FnMut() -> u32 { + let mut rest = draws.into_iter(); + move || rest.next().expect("the call draws no more than it was given") +} + +fn counter(draws: &mut u32) -> impl FnMut() -> u32 + '_ { + move || { + *draws += 1; + *draws + } +} + +/// RFC 1035 §3.1: each label behind its length octet, then the root's zero. +fn labels(name: &str) -> Vec { + let mut out = Vec::new(); + for label in name.split('.') { + out.push(u8::try_from(label.len()).unwrap()); + out.extend_from_slice(label.as_bytes()); + } + out.push(0); + out +} + +/// RFC 1035 §4.1.1 and §4.1.2: a standard query, recursion desired, for `name`'s A records in IN. +fn question(id: u16, name: &str) -> Vec { + let mut out = id.to_be_bytes().to_vec(); + out.extend_from_slice(&[0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); + out.extend_from_slice(&labels(name)); + out.extend_from_slice(&[0, 1, 0, 1]); + out +} + +/// The dotted name a query asks, read by hand from octet 12 on. +fn asked_name(query: &[u8]) -> String { + let mut found = Vec::new(); + let mut at = 12; + while query[at] != 0 { + let len = usize::from(query[at]); + found.push(std::str::from_utf8(&query[at + 1..at + 1 + len]).unwrap().to_string()); + at += 1 + len; + } + found.join(".") +} + +/// A reply to `query` (RFC 1035 §4.1): §4.1.1's header with the query's id, `flags`, one question +/// and one answer a record; §4.1.2's question as the query spelled it; then §4.1.3's records, each +/// owned by a §4.1.4 pointer to the question's name at octet 12, in class IN with a TTL of 60. +fn reply(query: &[u8], flags: u16, records: &[(u16, Vec)]) -> Vec { + let mut out = query[..2].to_vec(); + out.extend_from_slice(&flags.to_be_bytes()); + out.extend_from_slice(&[0, 1]); + out.extend_from_slice(&u16::try_from(records.len()).unwrap().to_be_bytes()); + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&query[12..]); + for (rtype, data) in records { + out.extend_from_slice(&[0xc0, 12]); + out.extend_from_slice(&rtype.to_be_bytes()); + out.extend_from_slice(&[0, 1]); + out.extend_from_slice(&60u32.to_be_bytes()); + out.extend_from_slice(&u16::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); + } + out +} + +/// A reply to `query` that gives `address`. +fn gives(query: &[u8], address: [u8; 4]) -> Vec { + reply(query, RESPONSE, &[(TYPE_A, address.to_vec())]) +} + +/// What [`ANSWERS`] says for a name. +enum Says { + Address([u8; 4]), + /// A CNAME to this name and nothing else, which the resolver asks again at its end. + Alias(&'static str), +} + +/// A query that reached the wire. +#[derive(Clone, Debug)] +struct Query { + at: Instant, + udp: Udp, + id: u16, + name: String, +} + +struct Net { + lan: Lan, + /// When the lease was held: the origin of every time a test names. + born: Instant, + /// How many of `lan.sent` the far end has seen. + seen: usize, + /// [`ANSWERS`]'s zone: a name, the time before which its answer is held back, and what it + /// says. + zone: Vec<(&'static str, u64, Says)>, + /// Frames [`ANSWERS`] has sent and the wire has not yet delivered, with the time they arrive. + held: Vec<(Instant, Vec)>, + /// Every address the node asked ARP for. + arp_asked: Vec, + /// Every query that left, in order. + queried: Vec, + ended: Vec, + /// The last draw [`Self::resolve`] handed out: its ids are 0x8000 and up, and those the node + /// draws for itself from `lan`'s count are below it. + draws: u32, +} + +impl Net { + /// The node holding a day's lease of `A`/24 that names `servers`, through `router`, at a whole + /// millisecond of its clock. + fn leased(servers: &[Ipv4Addr], router: Option) -> Self { + let mut options = terms(86_400, router); + options.retain(|(code, _)| *code != 6); + options.push((6, servers.iter().flat_map(Ipv4Addr::octets).collect())); + let mut lan = Lan::new(); + lan.lease_on(&options); + assert_eq!(lan.node.lease().expect("held").dns, servers, "the premise: the lease names these resolvers"); + // `toyos-dns` keeps whole milliseconds and conflict detection's waits are drawn finer: the + // origin is the next whole one, so a wait counted from it ends on the millisecond named. + let whole = u64::try_from(lan.now.since(Instant::from_millis(0)).as_millis()).unwrap() + 1; + lan.fire(Instant::from_millis(whole)); + Self { + born: lan.now, + seen: lan.sent.len(), + lan, + zone: Vec::new(), + held: Vec::new(), + arp_asked: Vec::new(), + queried: Vec::new(), + ended: Vec::new(), + draws: 0x7c00_8000, + } + } + + fn at(&self, ms: u64) -> Instant { + self.born.after(Duration::from_millis(ms)) + } + + fn ms_of(&self, at: Instant) -> u64 { + u64::try_from(at.since(self.born).as_millis()).unwrap() + } + + /// Milliseconds since the lease was held. + fn ms(&self) -> u64 { + self.ms_of(self.lan.now) + } + + fn resolve(&mut self, text: &str) -> Result { + self.lan.node.resolve(self.lan.now, name(text), counter(&mut self.draws)) + } + + /// `message` from the resolver's port 53 to the node's `to`. + fn resolver_says(&mut self, to: u16, message: &[u8]) { + self.lan.deliver(&udp(MAC, MAC_S, (ANSWERS, 53), (A, to), message)); + } + + /// Whether a socket holds `port`, by binding it: a port is one socket's. + fn port_held(&mut self, port: u16) -> bool { + match self.lan.node.udp_bind(ANY, Port::new(port), undrawn) { + Ok((id, _)) => { + self.lan.node.udp_close(self.lan.now, id).unwrap(); + false + } + Err(Refused::AddrInUse) => true, + Err(other) => panic!("binding port {port}: {other:?}"), + } + } + + /// What the far end does with one frame of the node's. + fn far_end(&mut self, at: Instant, seen: &Seen) { + match seen { + Seen::Arp { request: true, target, .. } => { + self.arp_asked.push(*target); + if *target == ANSWERS { + self.lan.deliver(&arp(MAC, false, MAC_S, ANSWERS, A)); + } + } + Seen::Udp(udp) if udp.port == 53 => { + assert_eq!((udp.to, udp.destination), (MAC_S, ANSWERS), "a query left for a server the wire cannot reach"); + let query = Query { at, udp: udp.clone(), id: u16::from_be_bytes([udp.payload[0], udp.payload[1]]), name: asked_name(&udp.payload) }; + if let Some((_, not_before, says)) = self.zone.iter().find(|(name, ..)| *name == query.name) { + let record = match says { + Says::Address(address) => (TYPE_A, address.to_vec()), + Says::Alias(target) => (TYPE_CNAME, labels(target)), + }; + let message = reply(&udp.payload, RESPONSE, &[record]); + let due = self.at(*not_before).max(self.lan.now); + self.held.push((due, lan::udp(MAC, MAC_S, (ANSWERS, 53), (A, udp.source_port), &message))); + } + self.queried.push(query); + } + _ => {} + } + } + + /// The frames due now delivered, every frame the node then has to send sent, and the far + /// end's turn, until both are silent. The clock does not move. + fn pass(&mut self) { + for _ in 0..10_000 { + let now = self.lan.now; + let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.held).into_iter().partition(|(at, _)| *at <= now); + self.held = later; + for (_, frame) in &due { + self.lan.deliver(frame); + } + self.lan.pump(); + let fresh = self.lan.sent[self.seen..].to_vec(); + self.seen = self.lan.sent.len(); + for (at, seen) in &fresh { + self.far_end(*at, seen); + } + if due.is_empty() && fresh.is_empty() { + return; + } + } + panic!("the node and its far end never fell silent at {} ms", self.ms()); + } + + /// When the node is next woken: its own next deadline, or the next frame on the wire. + fn next_wake(&self) -> Instant { + let frame = self.held.iter().map(|(at, _)| *at).min(); + self.lan.node.next_deadline().into_iter().chain(frame).min().expect("a held lease has a deadline") + } + + /// Passes at each of the node's wakes up to `ms`, and at `ms`. + fn until(&mut self, ms: u64) { + let end = self.at(ms); + for _ in 0..10_000 { + self.pass(); + if self.lan.now >= end { + return; + } + self.lan.fire(self.next_wake().min(end)); + } + panic!("10,000 wakes and the clock stands at {} ms: a deadline that never passes", self.ms()); + } + + /// Passes at each of the node's wakes until lookup `id` has ended, or the next wake is past + /// `until_ms`. Nothing but the node's own deadline carries a lookup's wait. + fn run(&mut self, id: LookupId, until_ms: u64) -> Option, Ended>> { + let end = self.at(until_ms); + for _ in 0..10_000 { + self.pass(); + self.ended.extend(self.lan.node.take_resolved()); + if let Some(found) = self.ended.iter().position(|resolved| resolved.id == id) { + return Some(self.ended.remove(found).result); + } + let wake = self.next_wake(); + if wake > end { + return None; + } + self.lan.fire(wake); + } + panic!("10,000 wakes and the clock stands at {} ms: a deadline that never passes", self.ms()); + } +} + +// RFC 1035 §4.1.1: ID, then RD alone among the flags, QDCOUNT 1 and the other counts 0. §4.1.2: +// the name as labels, QTYPE A (1), QCLASS IN (1). §4.2.1: to the server's port 53. RFC 5452 §9.2: +// the id and the source port are each drawn. RFC 6056 §3.3.1: the port is the draw's offset into +// 49152 to 65535. +#[test] +fn a_query_is_rfc_1035s_octets_from_a_port_and_with_an_id_of_its_own_draws() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0xabcd_1234, 7])).expect("two draws: the id, then the port"); + net.pass(); + #[rustfmt::skip] + let payload = vec![ + 0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, + 3, b'w', b'w', b'w', 7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 0, + 0, 1, 0, 1, + ]; + let sent: Vec<&Udp> = net.queried.iter().map(|query| &query.udp).collect(); + assert_eq!(sent, [&Udp { to: MAC_S, source: A, source_port: 49_152 + 7, destination: ANSWERS, port: 53, ttl: 64, payload }]); +} + +// RFC 5452 §9.1: a reply is accepted only from the address the query was sent to. +#[test] +fn a_reply_is_read_only_from_the_resolver_its_query_went_to() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + net.lan.deliver(&udp(MAC, MAC_B, (B, 53), (A, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); + assert_eq!(net.lan.node.take_resolved(), NONE, "another host's port 53 answered, with the query's id and question"); + net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); +} + +// RFC 5452 §9.1: and only from the port it was sent to, 53. RFC 768: a source port of zero is +// no port. +#[test] +fn a_reply_is_read_only_from_port_53() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + for source in [5_353, 0] { + net.lan.deliver(&udp(MAC, MAC_S, (ANSWERS, source), (A, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); + assert_eq!(net.lan.node.take_resolved(), NONE, "the resolver's port {source} answered"); + } + net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); +} + +// RFC 5452 §9.2: the port a query left from is half of what a forger must guess, so a reply is +// read for the query whose port it reached. A reply to the first query, id and all, on the second +// query's port is no reply; on its own port it is one still, late as it is. +#[test] +fn a_reply_on_another_querys_port_is_not_read() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + net.until(WAIT_MS); + let (first, second) = (net.queried[0].clone(), net.queried[1].clone()); + assert_eq!(net.queried.len(), 2, "the premise: a second query left when the first one's wait ended"); + assert!(first.id != second.id && first.udp.source_port != second.udp.source_port, "the premise: {first:?} {second:?}"); + net.resolver_says(second.udp.source_port, &gives(&first.udp.payload, FORGED)); + assert_eq!(net.lan.node.take_resolved(), NONE, "the first query's reply was read on the second query's port"); + net.resolver_says(first.udp.source_port, &gives(&first.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); +} + +// RFC 1035 §4.1.1 (the id, QR), §4.1.2 (the question), §4.1.4 (a pointer names a prior +// occurrence): a message that is not the answer to the query ends nothing, whatever it is made +// of, and the node reads the next one. RFC 5452 §9.1: the id and the question must match. +#[test] +fn nothing_but_its_answer_ends_a_lookup() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + let forged = gives(&asked.udp.payload, FORGED); + let after_question = asked.udp.payload.len(); + + let mut hostile: Vec<(String, Vec)> = Vec::new(); + let mut another_id = forged.clone(); + another_id[1] ^= 1; + hostile.push(("another id".into(), another_id)); + hostile.push(("another question".into(), gives(&question(asked.id, "other.example"), FORGED))); + hostile.push(("a query, not a response".into(), reply(&asked.udp.payload, 0x0100, &[(TYPE_A, FORGED.to_vec())]))); + // The question's name is a pointer to itself. + let mut looped = forged[..12].to_vec(); + looped.extend_from_slice(&[0xc0, 12, 0, 1, 0, 1]); + looped.extend_from_slice(&forged[after_question..]); + hostile.push(("a question that points at itself".into(), looped)); + // The answer's owner is a pointer to itself, and one to an octet past the message's end. + for target in [after_question, 0x3fff] { + let mut pointed = forged.clone(); + pointed[after_question..after_question + 2].copy_from_slice(&(0xc000 | u16::try_from(target).unwrap()).to_be_bytes()); + hostile.push((format!("an owner that points to octet {target}"), pointed)); + } + for len in 0..forged.len() { + hostile.push((format!("the first {len} octets of an answer"), forged[..len].to_vec())); + } + // The largest datagram a frame carries: the query's id, a response, and pointers to its end. + let mut largest = forged[..12].to_vec(); + largest.resize(1_472, 0xc0); + hostile.push(("1,472 octets of pointers".into(), largest)); + + for (what, message) in &hostile { + net.resolver_says(asked.udp.source_port, message); + assert_eq!(net.lan.node.take_resolved(), NONE, "{what} ended the lookup"); + } + net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }], "the answer, after all of them"); +} + +// RFC 1035 §4.2.1: a resolver that does not answer is given up on after a wait and the next is +// asked. The first is on the link and answers no ARP: the second is asked the moment the first +// one's wait ends, no sooner for the wakes [ip]'s own requests cause, and answers. +#[test] +fn a_resolver_that_answers_no_arp_holds_up_no_other() { + let mut net = Net::leased(&[SILENT, ANSWERS], Some(R)); + net.zone.push(("www.example", 0, Says::Address(ADDRESS))); + let id = net.resolve("www.example").unwrap(); + let ended = net.run(id, 25_000); + assert!(net.arp_asked.contains(&SILENT), "the premise: the first resolver was asked for its link address"); + assert_eq!(ended, Some(Ok(vec![ADDRESS])), "the second resolver heard {:?}", net.queried); + assert_eq!(net.ms(), WAIT_MS); + assert_eq!(net.queried.len(), 1); +} + +// The same, for a resolver no route leads to: [udp] refuses its query (`udp.no-route`), which is +// counted, holds no port, and is waited out like a query nobody answered. +#[test] +fn a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out() { + let mut net = Net::leased(&[UNROUTED, ANSWERS], None); + net.zone.push(("www.example", 0, Says::Address(ADDRESS))); + let refused = net.lan.counted(Rule::NoRoute); + let id = net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0x8001, 9])).expect("a lookup whose first query cannot leave"); + assert_eq!(net.lan.counted(Rule::NoRoute), refused + 1, "the premise: [udp] refused the query for want of a route"); + assert_eq!(net.lan.node.counters().get(Counter::QueryUnsent), 1); + assert!(!net.port_held(49_152 + 9), "the refused query's socket"); + let ended = net.run(id, 25_000); + assert_eq!(ended, Some(Ok(vec![ADDRESS])), "the second resolver heard {:?}", net.queried); + assert_eq!(net.ms(), WAIT_MS); + assert_eq!(net.lan.node.counters().get(Counter::QueryUnsent), 1); +} + +// RFC 1034 §5.3.3: an alias with no address is asked again at its end, every query afresh. The +// first resolver answers only after six queries have left, three of them to it, and with an alias +// alone: the queries for the old name are let go, so the two other answers to them, arriving +// with the first, find no socket (RFC 1122 §4.1.3.1), and the alias's address ends the lookup. +#[test] +fn an_alias_answered_late_restarts_the_lookup_and_lets_the_old_names_queries_go() { + let mut net = Net::leased(&[ANSWERS, SILENT], Some(R)); + net.zone.push(("www.example", 10_500, Says::Alias("cdn.example"))); + net.zone.push(("cdn.example", 0, Says::Address(ADDRESS))); + let nobodys = net.lan.counted(Rule::RxNoSocket); + let id = net.resolve("www.example").unwrap(); + let ended = net.run(id, 40_000); + assert_eq!(ended, Some(Ok(vec![ADDRESS])), "the resolver heard {:?}", net.queried); + assert_eq!(net.ms(), 10_500); + let names: Vec<&str> = net.queried.iter().map(|query| query.name.as_str()).collect(); + assert_eq!(names, ["www.example", "www.example", "www.example", "cdn.example"]); + assert_eq!(net.lan.counted(Rule::RxNoSocket), nobodys + 2, "the old name's other two answers"); +} + +// `toyos_dns::MAX_LOOKUPS` are held and one more is refused as `toyos::net`'s +// ERR_RESOURCE_EXHAUSTED; an ended lookup holds its place until its answer is taken. +#[test] +fn the_lookup_past_the_cap_is_refused_until_an_answer_is_taken() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.zone.push(("one.example", 1_000, Says::Address(ADDRESS))); + let first = net.resolve("one.example").unwrap(); + net.pass(); + for held in 1..MAX_LOOKUPS { + net.resolve("www.example").unwrap_or_else(|why| panic!("lookup {held} was refused: {why:?}")); + } + assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(Refused::ResourceExhausted)); + net.until(1_000); + assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(Refused::ResourceExhausted), "an answer nobody took holds its place"); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id: first, result: Ok(vec![ADDRESS]) }]); + net.resolve("www.example").expect("a taken answer's place is another's"); +} + +// A lookup is asked of the held lease's resolvers and of nothing else: with none to ask it is +// refused as `toyos::net`'s ERR_NOT_CONNECTED, and draws nothing. +#[test] +fn a_lookup_with_no_resolver_to_ask_is_refused_as_not_connected() { + let mut lan = Lan::new(); + assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(Refused::NotConnected), "before a lease"); + let mut options = terms(3_600, Some(R)); + options.retain(|(code, _)| *code != 6); + lan.lease_on(&options); + assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(Refused::NotConnected), "a lease that names no resolver"); + + let mut lan = Lan::new(); + lan.lease(600); + lan.node.resolve(lan.now, name("www.example"), sequence([1, 2])).expect("the premise: a held lease's resolver is asked"); + assert!(lan.run_until(Duration::from_secs(700), |lan| lan.node.lease().is_none()), "the lease runs out"); + assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(Refused::NotConnected), "after the lease"); +} + +// A lookup nobody waits for is let go at once: the ports its queries left from are free, and its +// place takes another lookup. +#[test] +fn a_lookup_let_go_frees_its_ports_and_its_place_at_once() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let names: Vec = (0..MAX_LOOKUPS).map(|n| format!("n{n}.example")).collect(); + let mut ids = Vec::new(); + for asked in &names { + ids.push(net.resolve(asked).unwrap()); + net.pass(); + } + net.until(WAIT_MS); + assert_eq!(net.lan.node.take_resolved(), NONE, "nothing answers, so nothing has ended"); + assert_eq!(net.queried.len(), 2 * MAX_LOOKUPS, "two queries of each left, neither answered"); + let ports = |net: &Net, asked: &str| -> Vec { net.queried.iter().filter(|query| query.name == asked).map(|query| query.udp.source_port).collect() }; + let (gone, stays) = (ports(&net, &names[3]), ports(&net, &names[4])); + assert_eq!((gone.len(), stays.len()), (2, 2)); + assert_eq!(net.resolve("www.example"), Err(Refused::ResourceExhausted), "the premise: every place is held"); + + net.lan.node.let_go(net.lan.now, ids[3]); + for port in gone { + assert!(!net.port_held(port), "port {port} of the lookup let go"); + } + for port in stays { + assert!(net.port_held(port), "port {port} of a lookup still waited for"); + } + net.resolve("www.example").expect("its place takes another lookup"); +} + +// And an answer nobody took goes with it: the id is nobody's from then on. +#[test] +fn a_lookup_let_go_after_it_ended_takes_its_answer_with_it() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.zone.push(("www.example", 0, Says::Address(ADDRESS))); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let left = net.queried[0].udp.source_port; + assert!(!net.port_held(left), "the premise: the lookup was answered and ended, its port closed"); + net.lan.node.let_go(net.lan.now, id); + assert_eq!(net.lan.node.take_resolved(), NONE); +} + +// RFC 1035 §4.2.1 leaves a late answer readable: a query's port is held while its answer would +// still be read, past its own wait, and every port is free once the lookup has ended. +#[test] +fn a_query_holds_its_port_while_its_answer_is_read_and_an_ended_lookup_holds_none() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + net.until(WAIT_MS); + assert_eq!(net.queried.len(), 2); + let first = net.queried[0].udp.source_port; + assert!(net.port_held(first), "the first query's answer is still read after its wait"); + net.zone.push(("www.example", 0, Says::Address(ADDRESS))); + assert_eq!(net.run(id, 5 * WAIT_MS), Some(Ok(vec![ADDRESS])), "the third query's answer"); + assert_eq!(net.queried.len(), 3); + let left: Vec = net.queried.iter().map(|query| query.udp.source_port).collect(); + for port in left { + assert!(!net.port_held(port), "port {port} of an ended lookup"); + } +} + +// RFC 6335 §6: the dynamic ports are 49152 to 65535. With every one held a lookup is refused as +// `toyos::net`'s ERR_RESOURCE_EXHAUSTED, and one in flight ends the moment its next query has no +// port to leave from, by that name. +#[test] +fn a_query_with_no_port_to_leave_from_ends_its_lookup_by_name() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let last = u32::from(EPHEMERAL_COUNT) - 1; + for offset in 0..last { + net.lan.node.udp_bind(ANY, None, || offset).unwrap(); + } + let id = net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0x8001, last])).expect("the one port left"); + assert_eq!(net.lan.node.resolve(net.lan.now, name("other.example"), sequence([0x8002, 0])), Err(Refused::ResourceExhausted)); + assert_eq!(net.run(id, 10 * WAIT_MS), Some(Err(Ended::NoPort))); + assert_eq!(net.ms(), WAIT_MS); + let left: Vec = net.queried.iter().map(|query| query.udp.source_port).collect(); + assert_eq!(left, [EPHEMERAL_FIRST + (EPHEMERAL_COUNT - 1)]); +} + +// RFC 4861 §7.2.2: [ip] holds a bounded queue for a next hop it is resolving, and keeps the +// newest. Lookups started together at a resolver whose link address is not yet known hand [ip] +// one query each: those past its queue never leave, and each is asked again when its wait ends. +// The track records this against the node (`issues/toyos-has-its-own-network-stack.md`). +#[test] +fn a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let names: Vec = (0..MAX_LOOKUPS).map(|n| format!("n{n}.example")).collect(); + for asked in &names { + net.resolve(asked).unwrap(); + } + net.pass(); + let queue = toyos_net_ip::limits::nud::PENDING_PER_NEIGHBOUR; + let mut left: Vec<&str> = net.queried.iter().map(|query| query.name.as_str()).collect(); + let mut newest: Vec<&str> = names[MAX_LOOKUPS - queue..].iter().map(String::as_str).collect(); + left.sort_unstable(); + newest.sort_unstable(); + assert_eq!(left, newest, "the newest {queue} queries left once the resolver answered ARP"); + net.until(WAIT_MS); + assert_eq!(net.queried.len(), queue + MAX_LOOKUPS, "and every lookup's second query, a wait later"); +} + +// RFC 1035 §4.2.1: each resolver is asked ROUNDS times, WAIT_MS apart. A wait is a deadline of +// the node's: a resolver that is reached and never answers is asked again the moment each wait +// ends, and the lookup ends timed out the moment its last one does, with nothing but the node's +// own next deadline to carry it there. +#[test] +fn a_resolver_that_never_answers_is_asked_at_each_waits_end() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + let ended = net.run(id, 10 * WAIT_MS); + assert_eq!(ended, Some(Err(Ended::Failed(Failure::TimedOut)))); + let rounds = u64::try_from(ROUNDS).unwrap(); + assert_eq!(net.ms(), rounds * WAIT_MS, "the lookup ended late"); + let left: Vec = net.queried.iter().map(|query| net.ms_of(query.at)).collect(); + assert_eq!(left, (0..rounds).map(|round| round * WAIT_MS).collect::>()); +} + +// A lookup's wait is its own: the node's deadline is the soonest of them, so a second lookup +// started half a wait behind the first pushes neither's schedule onto the other. +#[test] +fn a_lookup_is_not_carried_by_a_later_ones_schedule() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let first = net.resolve("www.example").unwrap(); + net.until(WAIT_MS / 2); + let second = net.resolve("other.example").unwrap(); + let rounds = u64::try_from(ROUNDS).unwrap(); + assert_eq!(net.run(first, 10 * WAIT_MS), Some(Err(Ended::Failed(Failure::TimedOut)))); + assert_eq!(net.ms(), rounds * WAIT_MS, "the first lookup waited on the second's schedule"); + assert_eq!(net.run(second, 10 * WAIT_MS), Some(Err(Ended::Failed(Failure::TimedOut)))); + assert_eq!(net.ms(), WAIT_MS / 2 + rounds * WAIT_MS, "the second lookup waited on the first's schedule"); +} From 442ecd55e56608b27e169deafc7dd8120ed4fe66 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 20:36:18 +0200 Subject: [PATCH 2/3] Review round 1 of #774: connected query sockets, lazy draws, a lookup ends with its lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query's socket is connected to its resolver's port 53, so [udp]'s 4-tuple filter decides what reaches it: nothing from another source or port, nothing sent to a broadcast or a joined group (RFC 5452 §9.1's destination match, which the any-bound socket did not make). The node hands the lookup its own record of where the query went, not the wire's. lease.rs gains Stack::connect, [udp]'s call passed on, in the datagram sockets' section. An error the network reports against a connected socket (its resolver unreachable, its port refused) is counted node.query-failed and read past. The reading loop has no cursor: the first listed socket that holds a datagram is read, until none does or the lookup ended. toyos_dns::Lookup takes the draw of a query's id, not the id, and calls it only for a query it sends. netstack's resolver passes its draw at its three call sites; nothing else of it changes. A lookup ends LeaseChanged when the held lease names other resolvers or none is held. Node::resolve answers in NotStarted, the two words it can say. Tests: the destination test, a recorded reply of a public resolver (toyos-dns's REAL_GOOGLE) through the node, the lease's three cases, the unreachable count; the port-0 case is its own test with its premise. a_resolver_that_answers_no_arp_holds_up_no_other is deleted: it held [ip]'s rule, and the track says [ip] has no scenario for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 7 +- toyos-dns/src/lib.rs | 31 ++-- toyos-dns/src/tests.rs | 116 ++++++++----- userland/netstack/node/src/lease.rs | 6 + userland/netstack/node/src/lib.rs | 7 +- userland/netstack/node/src/resolve.rs | 141 +++++++++------- userland/netstack/node/tests/resolve.rs | 191 +++++++++++++++++----- userland/netstack/src/resolve.rs | 6 +- 8 files changed, 346 insertions(+), 159 deletions(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 8540f090263..ca3647226b0 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -29,9 +29,10 @@ What the node does not yet meet: - The machine's name is told to the node twice, as `toyos_dhcp::HostName` in `Node::new` and as `toyos_mdns::Host` in `Node::answer_as`: the first owns its bytes and the second borrows them. Exit: one type carries the label to both; due at the move if handing `answer_as` its `&'static str` takes a leak. - The node's name is not probed for or defended (RFC 6762 §8.1, §9), as `toyos-mdns` records of itself. It is not announced again when the link comes back, which §8.3 asks and netd does today, where link-up resets its DHCP client and the address is new again: here the lease outlives the link. Exit: `toyos-mdns` does both, on the node's `link`. - An answer to a query from a 169.254/16 source leaves in a frame to the router: `toyos-mdns` takes a link-local source for one on the link, and [ip]'s route lookup has no way to 169.254/16 on a leased interface but the gateway, where RFC 3927 §2.6.2 sends it on the link and §7 never to a router. `an_answer_to_a_link_local_asker_leaves_by_the_router` holds the present state. Exit: [ip] sends to 169.254/16 on the link, and that test finds the answer in a frame to the asker's own link address. -- A query [udp] refuses (`node.query-unsent`: no route to its resolver, or the lease gone under a lookup in flight) is waited out for `toyos_dns::WAIT_MS` like one nobody answered, because `toyos_dns::Lookup` has no word for a query that never left. Exit: `Lookup` is told and asks the next resolver at once, and `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms. -- Lookups started together at a resolver whose link address is not yet known lose all but the newest `PENDING_PER_NEIGHBOUR` queries to [ip]'s queue for an unresolved next hop (`nb.pending-overflow`, RFC 4861 §7.2.2), and each lost one costs its lookup a `WAIT_MS`; smoltcp kept each query in its own socket until the neighbour answered. `a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later` holds it as it stands. Exit: that test finds every query of the burst on the wire once the resolver answers ARP, or the owner accepts RFC 4861's queue for it. -- No reply a third party wrote has reached the node's resolver: its tests' replies are spelled by hand from RFC 1035 §4.1, and the real replies `toyos-dns`'s own tests read never pass through the node. Exit: slirp's resolver answers the node in a guest, at the move. +- A query that does not reach its resolver is waited out for `toyos_dns::WAIT_MS` like one nobody answered, whether [udp] refused it for want of a route (`node.query-unsent`) or the network reported it back, its resolver unreachable or its port refused (`node.query-failed`): `toyos_dns::Lookup` has no word for either. Exit, before the move: `Lookup` is told and asks the next resolver at once, and `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms. +- Lookups started together at a resolver whose link address is not yet known lose all but the newest `PENDING_PER_NEIGHBOUR` queries to [ip]'s queue for an unresolved next hop (`nb.pending-overflow`, RFC 4861 §7.2.2), and each lost one costs its lookup a `WAIT_MS`; smoltcp kept each query in its own socket until the neighbour answered. `a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later` holds it as it stands. It is a regression against what ships and would ship at the move. Exit, before the move: that test finds every query of the burst on the wire once the resolver answers ARP. +- `toyos_dns::Lookup::on_datagram` still takes a reply's source address and port, which the node's connected sockets have already matched and the node hands it from its own record of the query: the two parameters are read only by netd's resolver on smoltcp. Exit, at the move: they go with that resolver. +- No test of `toyos-net-ip` holds that a next hop which never answers ARP holds up no datagram to another, which the node's resolver rests on when the lease's first resolver is down (`issues/netstack-resolver-asks-a-dead-primary-first-on-every-lookup.md`, its second cost). Exit: a scenario in `toyos-net-ip/tests/nud.rs` sends to a silent neighbour and to an answering one and finds the second datagram on the wire at once. - `node.address-refused` has no test: `toyos-dhcp` accepts no address or prefix [ip] refuses, by the same `toyos-net-wire` checks in both, so the refusal cannot be reached from the wire. Exit: the client hands [ip] a type that carries the check, and the counter goes. 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/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs index 288232af7ae..1f46cdaa91e 100644 --- a/toyos-dns/src/lib.rs +++ b/toyos-dns/src/lib.rs @@ -2,7 +2,7 @@ //! IPv4 addresses, which datagram answers that question, what the answer says, //! and when the question is asked again. Pure: `core` and `alloc`, no //! `unsafe`, no I/O, and no clock or randomness of its own. The caller hands in -//! the time, every query ID and every datagram with its source. +//! the time, the draw of every query's ID and every datagram with its source. //! //! **A reply has crossed a trust boundary.** Anyone on the path can send a //! datagram to the port a query left from, so a reply is read only if it @@ -471,8 +471,10 @@ struct Sent { /// One name's lookup, from its first query to its answer. /// -/// **Each query carries an ID the caller drew for it**, which the caller takes -/// from a source an off-path sender cannot predict (RFC 5452 §9.2). A reply to +/// **Each query carries an ID the caller draws for it**, from a source an +/// off-path sender cannot predict (RFC 5452 §9.2). Every call that may send a +/// query takes the draw, and calls it once for a query it sends and not at all +/// otherwise. A reply to /// any query this lookup sent for the name now asked is read, so an answer /// late past its query's wait still ends the lookup; it is read only on the /// query's own port, with the query's ID, from the server the query went to. @@ -499,8 +501,8 @@ pub struct Lookup { impl Lookup { /// A lookup of `name` through `servers`, and its first query, sent at - /// `now` with ID `id`. `None` when there is no server to ask. - pub fn start(name: Name, servers: &[[u8; 4]], now: u64, id: u16) -> Option<(Self, Step)> { + /// `now` with the ID `id` draws. `None` when there is no server to ask. + pub fn start(name: Name, servers: &[[u8; 4]], now: u64, id: impl FnOnce() -> u16) -> Option<(Self, Step)> { if servers.is_empty() { return None; } @@ -530,7 +532,7 @@ impl Lookup { } /// The next query for `name`, or the end where every one has been sent. - fn ask(&mut self, now: u64, id: u16) -> Step { + fn ask(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step { if self.asked == ROUNDS * self.servers.len() { return Step::Done(Err(match self.failed { Some(rcode) => Failure::ServerFailed(rcode), @@ -541,14 +543,15 @@ impl Lookup { self.asked += 1; let asked = Asked(self.next); self.next += 1; + let id = id(); self.sent.push(Sent { asked, id, to }); self.due = now + WAIT_MS; Step::Ask { asked, to, query: query(id, &self.name) } } /// The time is `now`: the newest query has had its [`WAIT_MS`] where it - /// is due. `id` is for the query this may send. - pub fn on_time(&mut self, now: u64, id: u16) -> Step { + /// is due. `id` draws the ID of the query this sends. + pub fn on_time(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step { if now < self.due { return Step::Wait; } @@ -556,8 +559,16 @@ impl Lookup { } /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was - /// sent from. `id` is for the query this may send. - pub fn on_datagram(&mut self, asked: Asked, from: [u8; 4], port: u16, msg: &[u8], now: u64, id: u16) -> Step { + /// sent from. `id` draws the ID of the query this sends. + pub fn on_datagram( + &mut self, + asked: Asked, + from: [u8; 4], + port: u16, + msg: &[u8], + now: u64, + id: impl FnOnce() -> u16, + ) -> Step { let Some(carried) = msg.get(..2).map(|b| u16::from_be_bytes([b[0], b[1]])) else { return Step::Wait; }; diff --git a/toyos-dns/src/tests.rs b/toyos-dns/src/tests.rs index 6977135d0fc..9dd5631c916 100644 --- a/toyos-dns/src/tests.rs +++ b/toyos-dns/src/tests.rs @@ -645,14 +645,14 @@ fn waiting(lookup: &Lookup) -> Vec { #[test] fn rfc1035_4_2_1_every_server_is_asked_before_any_again_and_the_lookup_ends_after_its_rounds() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, 100).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 100).unwrap(); assert_eq!(to(&first), (S1, 100)); assert_eq!(lookup.due(), WAIT_MS); - assert_eq!(lookup.on_time(WAIT_MS - 1, 999), Step::Wait, "before its wait is up"); + assert_eq!(lookup.on_time(WAIT_MS - 1, || 999), Step::Wait, "before its wait is up"); let mut now = WAIT_MS; let mut sent = vec![asked(&first)]; loop { - match lookup.on_time(now, 101 + sent.len() as u16) { + match lookup.on_time(now, || 101 + sent.len() as u16) { Step::Done(result) => { assert_eq!(result, Err(Failure::TimedOut)); break; @@ -671,72 +671,72 @@ fn rfc1035_4_2_1_every_server_is_asked_before_any_again_and_the_lookup_ends_afte #[test] fn rfc5452_9_1_only_the_asked_server_port_and_id_are_read() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, 0x4242).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 0x4242).unwrap(); let (q, ..) = asked(&first); assert_eq!(to(&first), (S1, 0x4242)); let good = reply(0x4242, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]); - assert_eq!(lookup.on_datagram(q, S2, PORT, &good, 5, 1), Step::Wait, "another server"); - assert_eq!(lookup.on_datagram(q, S1, 5353, &good, 5, 1), Step::Wait, "another port"); - assert_eq!(lookup.on_datagram(Asked(1), S1, PORT, &good, 5, 1), Step::Wait, "another query's port"); + assert_eq!(lookup.on_datagram(q, S2, PORT, &good, 5, || 1), Step::Wait, "another server"); + assert_eq!(lookup.on_datagram(q, S1, 5353, &good, 5, || 1), Step::Wait, "another port"); + assert_eq!(lookup.on_datagram(Asked(1), S1, PORT, &good, 5, || 1), Step::Wait, "another query's port"); let forged = reply(0x4243, OK, "www.example", &[a("www.example", [6, 6, 6, 6])]); - assert_eq!(lookup.on_datagram(q, S1, PORT, &forged, 5, 1), Step::Wait, "another ID"); - assert_eq!(lookup.on_datagram(q, S1, PORT, &[0x42], 5, 1), Step::Wait, "a byte"); + assert_eq!(lookup.on_datagram(q, S1, PORT, &forged, 5, || 1), Step::Wait, "another ID"); + assert_eq!(lookup.on_datagram(q, S1, PORT, &[0x42], 5, || 1), Step::Wait, "a byte"); let elsewhere = reply(0x4242, OK, "bank.example", &[a("bank.example", [6, 6, 6, 6])]); - assert_eq!(lookup.on_datagram(q, S1, PORT, &elsewhere, 5, 1), Step::Wait, "another question"); - assert_eq!(lookup.on_datagram(q, S1, PORT, &good, 5, 1), Step::Done(Ok(vec![[1, 2, 3, 4]]))); + assert_eq!(lookup.on_datagram(q, S1, PORT, &elsewhere, 5, || 1), Step::Wait, "another question"); + assert_eq!(lookup.on_datagram(q, S1, PORT, &good, 5, || 1), Step::Done(Ok(vec![[1, 2, 3, 4]]))); } #[test] fn a_late_answer_to_an_earlier_query_still_ends_the_lookup() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, 1).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap(); let (q1, ..) = asked(&first); - let second = lookup.on_time(WAIT_MS, 2); + let second = lookup.on_time(WAIT_MS, || 2); assert_eq!(to(&second), (S2, 2)); let (q2, ..) = asked(&second); assert_eq!(waiting(&lookup), [q1, q2], "the first query is still answered"); let late = reply(1, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]); - assert_eq!(lookup.on_datagram(q1, S2, PORT, &late, WAIT_MS + 1, 3), Step::Wait, "ID 1 went to the first server"); - assert_eq!(lookup.on_datagram(q2, S1, PORT, &late, WAIT_MS + 1, 3), Step::Wait, "ID 1 left from the first query's port"); - assert_eq!(lookup.on_datagram(q1, S1, PORT, &late, WAIT_MS + 1, 3), Step::Done(Ok(vec![[1, 2, 3, 4]]))); + assert_eq!(lookup.on_datagram(q1, S2, PORT, &late, WAIT_MS + 1, || 3), Step::Wait, "ID 1 went to the first server"); + assert_eq!(lookup.on_datagram(q2, S1, PORT, &late, WAIT_MS + 1, || 3), Step::Wait, "ID 1 left from the first query's port"); + assert_eq!(lookup.on_datagram(q1, S1, PORT, &late, WAIT_MS + 1, || 3), Step::Done(Ok(vec![[1, 2, 3, 4]]))); } #[test] fn a_server_failure_asks_the_next_server_at_once() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, 1).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap(); let (q1, ..) = asked(&first); let servfail = reply(1, OK | 2, "www.example", &[]); - let second = lookup.on_datagram(q1, S1, PORT, &servfail, 10, 2); + let second = lookup.on_datagram(q1, S1, PORT, &servfail, 10, || 2); assert_eq!(to(&second), (S2, 2)); let (q2, ..) = asked(&second); assert_eq!(waiting(&lookup), [q2], "the failed query is answered no more"); assert_eq!(lookup.due(), 10 + WAIT_MS); // The same failure again is no longer a query of this lookup's. - assert_eq!(lookup.on_datagram(q1, S1, PORT, &servfail, 11, 3), Step::Wait); + assert_eq!(lookup.on_datagram(q1, S1, PORT, &servfail, 11, || 3), Step::Wait); // An older query's failure while the newest still waits asks nobody. - let third = lookup.on_time(10 + WAIT_MS, 3); + let third = lookup.on_time(10 + WAIT_MS, || 3); assert_eq!(to(&third), (S1, 3)); let (q3, ..) = asked(&third); let old = reply(2, OK | 5, "www.example", &[]); - assert_eq!(lookup.on_datagram(q2, S2, PORT, &old, 10 + WAIT_MS + 1, 4), Step::Wait); + assert_eq!(lookup.on_datagram(q2, S2, PORT, &old, 10 + WAIT_MS + 1, || 4), Step::Wait); assert_eq!(waiting(&lookup), [q3]); let ok = reply(3, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]); - assert_eq!(lookup.on_datagram(q3, S1, PORT, &ok, 10 + WAIT_MS + 2, 4), Step::Done(Ok(vec![[1, 2, 3, 4]]))); + assert_eq!(lookup.on_datagram(q3, S1, PORT, &ok, 10 + WAIT_MS + 2, || 4), Step::Done(Ok(vec![[1, 2, 3, 4]]))); } #[test] fn a_lookup_whose_every_server_failed_says_how() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, 0).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 0).unwrap(); let (mut q, _, mut id) = asked(&first); let mut now = 0; for _ in 0..ROUNDS - 1 { - let step = lookup.on_datagram(q, S1, PORT, &reply(id, OK | 2, "www.example", &[]), now, id + 1); + let step = lookup.on_datagram(q, S1, PORT, &reply(id, OK | 2, "www.example", &[]), now, || id + 1); assert_eq!(to(&step), (S1, id + 1)); q = asked(&step).0; id += 1; now += 1; } assert_eq!( - lookup.on_datagram(q, S1, PORT, &reply(id, OK | 2, "www.example", &[]), now, id + 1), + lookup.on_datagram(q, S1, PORT, &reply(id, OK | 2, "www.example", &[]), now, || id + 1), Step::Done(Err(Failure::ServerFailed(2))) ); } @@ -747,16 +747,16 @@ fn a_lookup_whose_every_server_failed_says_how() { /// timed out. #[test] fn a_server_failure_before_an_alias_is_not_the_aliases_failure() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, 1).unwrap(); - let second = lookup.on_datagram(asked(&first).0, S1, PORT, &reply(1, OK | 2, "www.example", &[]), 10, 2); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap(); + let second = lookup.on_datagram(asked(&first).0, S1, PORT, &reply(1, OK | 2, "www.example", &[]), 10, || 2); assert_eq!(to(&second), (S2, 2)); let alias = reply(2, OK, "www.example", &[cname("www.example", "cdn.example")]); - let restart = lookup.on_datagram(asked(&second).0, S2, PORT, &alias, 20, 3); + let restart = lookup.on_datagram(asked(&second).0, S2, PORT, &alias, 20, || 3); assert_eq!(to(&restart), (S1, 3), "the alias is asked afresh"); let mut now = 20; let ended = loop { now += WAIT_MS; - match lookup.on_time(now, 4) { + match lookup.on_time(now, || 4) { Step::Done(result) => break result, step => { asked(&step); @@ -768,58 +768,88 @@ fn a_server_failure_before_an_alias_is_not_the_aliases_failure() { #[test] fn a_truncated_reply_ends_the_lookup_by_name() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, 9).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 9).unwrap(); let cut = reply(9, OK | TC, "www.example", &[a("www.example", [1, 2, 3, 4])]); - assert_eq!(lookup.on_datagram(asked(&first).0, S1, PORT, &cut, 1, 10), Step::Done(Err(Failure::Truncated))); + assert_eq!(lookup.on_datagram(asked(&first).0, S1, PORT, &cut, 1, || 10), Step::Done(Err(Failure::Truncated))); } #[test] fn rfc2308_nxdomain_and_nodata_end_the_lookup_apart() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, 9).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 9).unwrap(); assert_eq!( - lookup.on_datagram(asked(&first).0, S1, PORT, &reply(9, OK | 3, "www.example", &[]), 1, 10), + lookup.on_datagram(asked(&first).0, S1, PORT, &reply(9, OK | 3, "www.example", &[]), 1, || 10), Step::Done(Err(Failure::NoSuchName)) ); - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, 9).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 9).unwrap(); assert_eq!( - lookup.on_datagram(asked(&first).0, S1, PORT, &reply(9, OK, "www.example", &[]), 1, 10), + lookup.on_datagram(asked(&first).0, S1, PORT, &reply(9, OK, "www.example", &[]), 1, || 10), Step::Done(Err(Failure::NoAddress)) ); } #[test] fn rfc1034_5_3_3_a_chain_that_ends_without_an_address_is_asked_again_at_its_end() { - let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, 1).unwrap(); + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap(); let (q1, ..) = asked(&first); let alias = reply(1, OK, "www.example", &[cname("www.example", "cdn.example")]); - let step = lookup.on_datagram(q1, S1, PORT, &alias, 50, 2); + let step = lookup.on_datagram(q1, S1, PORT, &alias, 50, || 2); let (q2, ..) = asked(&step); assert_eq!(step, Step::Ask { asked: q2, to: S1, query: query(2, &name("cdn.example")) }, "the first server, afresh"); assert_eq!(waiting(&lookup), [q2], "no query for the old name is answered any more"); assert_eq!(lookup.due(), 50 + WAIT_MS); - assert_eq!(lookup.on_datagram(q1, S1, PORT, &alias, 51, 3), Step::Wait, "the old name's query is over"); + assert_eq!(lookup.on_datagram(q1, S1, PORT, &alias, 51, || 3), Step::Wait, "the old name's query is over"); let done = reply(2, OK, "cdn.example", &[a("cdn.example", [4, 3, 2, 1])]); - assert_eq!(lookup.on_datagram(q2, S1, PORT, &done, 52, 3), Step::Done(Ok(vec![[4, 3, 2, 1]]))); + assert_eq!(lookup.on_datagram(q2, S1, PORT, &done, 52, || 3), Step::Done(Ok(vec![[4, 3, 2, 1]]))); } #[test] fn aliases_are_counted_across_the_names_a_lookup_asks() { - let (mut lookup, first) = Lookup::start(name("h0.example"), &[S1], 0, 0).unwrap(); + let (mut lookup, first) = Lookup::start(name("h0.example"), &[S1], 0, || 0).unwrap(); let (mut q, _, mut id) = asked(&first); for i in 0..MAX_ALIASES { let from = std::format!("h{i}.example"); let to_name = std::format!("h{}.example", i + 1); - let step = lookup.on_datagram(q, S1, PORT, &reply(id, OK, &from, &[cname(&from, &to_name)]), 0, id + 1); + let step = lookup.on_datagram(q, S1, PORT, &reply(id, OK, &from, &[cname(&from, &to_name)]), 0, || id + 1); assert_eq!(to(&step), (S1, id + 1), "alias {i}"); q = asked(&step).0; id += 1; } let last = std::format!("h{MAX_ALIASES}.example"); let one_more = reply(id, OK, &last, &[cname(&last, "end.example")]); - assert_eq!(lookup.on_datagram(q, S1, PORT, &one_more, 0, id + 1), Step::Done(Err(Failure::TooManyAliases))); + assert_eq!(lookup.on_datagram(q, S1, PORT, &one_more, 0, || id + 1), Step::Done(Err(Failure::TooManyAliases))); } #[test] fn a_lookup_with_no_server_does_not_start() { - assert!(Lookup::start(name("www.example"), &[], 0, 0).is_none()); + assert!(Lookup::start(name("www.example"), &[], 0, || 0).is_none()); +} + +/// A draw that must not be made. +fn undrawn() -> u16 { + panic!("no query is sent, so no ID is drawn") +} + +#[test] +fn rfc5452_9_2_an_id_is_drawn_for_a_query_that_is_sent_and_for_nothing_else() { + let mut drawn = 0u16; + let mut draw = || { + drawn += 1; + drawn + }; + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, &mut draw).unwrap(); + let (q, _, id) = asked(&first); + assert_eq!(id, 1, "the first query carries the first draw"); + assert_eq!(lookup.on_time(WAIT_MS - 1, undrawn), Step::Wait, "a wait that is not over"); + assert_eq!(lookup.on_datagram(q, S2, PORT, &reply(1, OK, "www.example", &[]), 5, undrawn), Step::Wait, "a stray"); + for round in 1..ROUNDS { + let step = lookup.on_time(round as u64 * WAIT_MS, &mut draw); + assert_eq!(asked(&step).2, round as u16 + 1, "each later query carries the next draw"); + } + assert_eq!(lookup.on_time(ROUNDS as u64 * WAIT_MS, undrawn), Step::Done(Err(Failure::TimedOut)), "the end sends nothing"); + assert_eq!(drawn, ROUNDS as u16, "one draw a query"); + + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 7).unwrap(); + let answer = reply(7, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]); + assert_eq!(lookup.on_datagram(asked(&first).0, S1, PORT, &answer, 1, undrawn), Step::Done(Ok(vec![[1, 2, 3, 4]])), "an answer"); + assert!(Lookup::start(name("www.example"), &[], 0, undrawn).is_none(), "a lookup that does not start"); } diff --git a/userland/netstack/node/src/lease.rs b/userland/netstack/node/src/lease.rs index 06e5450cb9b..625ad8624bc 100644 --- a/userland/netstack/node/src/lease.rs +++ b/userland/netstack/node/src/lease.rs @@ -140,6 +140,12 @@ impl Stack { Ok((id, self.shard.udp_port(id)?)) } + /// Connects a socket to one peer: from here [udp] delivers it that peer's datagrams for the + /// address the socket then sends from, and no other. + pub(crate) fn connect(&mut self, now: Instant, id: SocketId, peer: Ipv4Addr, port: u16) -> Result<(), toyos_net_udp::Error> { + self.shard.udp_connect(now, id, peer, port) + } + /// Queues a datagram; accepted means queued, not sent. pub(crate) fn send_to(&mut self, now: Instant, id: SocketId, destination: Ipv4Addr, port: u16, payload: &[u8]) -> Result<(), toyos_net_udp::Error> { self.shard.send_to(now, id, destination, port, payload) diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs index cd5eeaa0576..ae7ef358aad 100644 --- a/userland/netstack/node/src/lib.rs +++ b/userland/netstack/node/src/lib.rs @@ -18,8 +18,8 @@ //! [`Node::drain_events`]. //! //! **Draws.** Each `draw` is handed to the client, whose order is its own: a call that starts an -//! exchange draws its transaction id first. The lookups in flight draw after it, an id for each -//! reply read and each wait looked at, and a port for each query sent. +//! exchange draws its transaction id first. The lookups in flight draw after it: an id and then a +//! port for each query they send, and nothing else. #![no_std] #![forbid(unsafe_code)] @@ -48,7 +48,7 @@ use toyos_net_wire::Instant; pub use datagram::{Datagram, DatagramId, Refused}; use lease::{Report, Stack, Verified}; -pub use resolve::{Ended, LookupId, Resolved}; +pub use resolve::{Ended, LookupId, NotStarted, Resolved}; toyos_net_wire::counters! { DhcpUnsent = "node.dhcp-unsent"; @@ -56,6 +56,7 @@ toyos_net_wire::counters! { RouterRefused = "node.router-refused"; NameUnsent = "node.name-unsent"; QueryUnsent = "node.query-unsent"; + QueryFailed = "node.query-failed"; } /// A line for the log. diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs index 02cd8de48ec..d8a5cfc23dd 100644 --- a/userland/netstack/node/src/resolve.rs +++ b/userland/netstack/node/src/resolve.rs @@ -2,31 +2,34 @@ //! names. Every decision about a question and every byte of a reply is `toyos_dns::Lookup`'s; //! this is its sockets on [udp], its clock and its draws. //! -//! - **Each query leaves from a socket of its own**, on an ephemeral port [udp] picks from one -//! draw, and carries an id that is another draw (RFC 5452 §9.2): an off-path sender has to -//! guess both to be read at all. A reply is handed to the lookup as arriving for the query -//! whose socket it reached, and for no other. +//! - **Each query leaves from a socket of its own, connected to its resolver's port 53**, on an +//! ephemeral port [udp] picks from one draw, and carries an id that is another draw (RFC 5452 +//! §9.2): an off-path sender has to guess both to be read at all. [udp] delivers a connected +//! socket only what its peer's address and port sent to the address the socket sends from +//! (§9.1), so no reply is read from another source, at a broadcast or group address, or for a +//! query other than the one whose socket it reached. //! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets //! the query go, ends, or is let go itself, and its port is free from then. //! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits -//! its wait out as for any query nobody answered. +//! its wait out as for any query nobody answered. So does one the network reports back, its +//! resolver unreachable or its port refused: counted, and waited out. +//! - **A lookup asks the resolvers of the lease it started under, and ends with that lease's +//! word**: when the held lease names other resolvers, or none is held. //! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame. //! - **At most `toyos_dns::MAX_LOOKUPS` lookups are held**, an ended one until //! [`Node::take_resolved`] hands its answer over. //! //! **Untrusted input.** A reply's bytes are read by the lookup's reader alone, cut to the largest -//! datagram [udp] delivers. Its source address and port are the wire's: the lookup reads a reply -//! only from port 53 of the resolver its query went to. +//! datagram [udp] delivers. use alloc::vec; use alloc::vec::Vec; use core::net::Ipv4Addr; use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT}; -use toyos_net_udp::SocketId; -use toyos_net_wire::{Instant, Port}; +use toyos_net_udp::{Error, SocketId}; +use toyos_net_wire::Instant; -use crate::datagram::Refused; use crate::lease::Stack; use crate::name::millis; use crate::{Counter, Counters, Node}; @@ -35,6 +38,16 @@ use crate::{Counter, Counters, Node}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LookupId(u64); +/// Why a lookup was not started, in the pipe ABI's words (`toyos::net`'s `ERR_*`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NotStarted { + /// No lease is held, or the held one names no resolver. + NotConnected, + /// `toyos_dns::MAX_LOOKUPS` are held, or no ephemeral port is free: the same call succeeds + /// later. + ResourceExhausted, +} + /// How a lookup ended without an address. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Ended { @@ -42,6 +55,8 @@ pub enum Ended { Failed(Failure), /// Every ephemeral port is held, so the next query had none to leave from. NoPort, + /// The lease the lookup started under went, or names other resolvers now. + LeaseChanged, } /// A lookup that ended: the name's addresses, at least one, or why none. @@ -51,11 +66,21 @@ pub struct Resolved { pub result: Result, Ended>, } +/// One query whose answer is still read. +#[derive(Clone, Copy)] +struct Query { + asked: Asked, + socket: SocketId, + /// The resolver the socket is connected to. + to: [u8; 4], +} + struct Asking { id: LookupId, lookup: Lookup, - /// The socket of each query whose answer is still read. - queries: Vec<(Asked, SocketId)>, + /// The resolvers of the lease the lookup started under. + resolvers: Vec, + queries: Vec, } pub(crate) struct Resolver { @@ -80,6 +105,22 @@ fn close(stack: &mut Stack, now: Instant, socket: SocketId) { } } +/// The length of the next reply waiting at one of `queries`' sockets, now in `reply`, and the +/// query it is for. An error the network reported against a query is counted and read past. +fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8], counters: &mut Counters) -> Option<(Query, usize)> { + for query in queries { + loop { + match stack.recv_from(query.socket, reply) { + Ok(Some(received)) => return Some((*query, received.len)), + Ok(None) => break, + Err(Error::Failed(_)) => counters.add(Counter::QueryFailed, 1), + Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"), + } + } + } + None +} + /// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer /// reads an answer for. Returns how the lookup ended, if it did. fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option, Ended>> { @@ -87,8 +128,9 @@ fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counter // An any-address bind that names no port is refused only for want of a free one. Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) { Ok((socket, _)) => { - if stack.send_to(now, socket, Ipv4Addr::from(to), PORT, &query).is_ok() { - asking.queries.push((asked, socket)); + let resolver = Ipv4Addr::from(to); + if stack.connect(now, socket, resolver, PORT).is_ok() && stack.send_to(now, socket, resolver, PORT, &query).is_ok() { + asking.queries.push(Query { asked, socket, to }); } else { counters.add(Counter::QueryUnsent, 1); close(stack, now, socket); @@ -101,10 +143,10 @@ fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counter Step::Done(result) => Some(result.map_err(Ended::Failed)), }; let lookup = &asking.lookup; - asking.queries.retain(|&(asked, socket)| { - let read = lookup.waiting().any(|waiting| waiting == asked); + asking.queries.retain(|query| { + let read = lookup.waiting().any(|waiting| waiting == query.asked); if !read { - close(stack, now, socket); + close(stack, now, query.socket); } read }); @@ -121,16 +163,17 @@ impl Resolver { self.asking.iter().map(|asking| Instant::from_millis(asking.lookup.due())).min() } - fn start(&mut self, now: Instant, name: Name, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Result { - let servers: Vec<[u8; 4]> = stack.lease().map(|lease| lease.dns.iter().map(Ipv4Addr::octets).collect()).unwrap_or_default(); - if servers.is_empty() { - return Err(Refused::NotConnected); + fn start(&mut self, now: Instant, name: Name, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Result { + let resolvers = stack.lease().map(|lease| lease.dns.clone()).unwrap_or_default(); + if resolvers.is_empty() { + return Err(NotStarted::NotConnected); } if self.asking.len().saturating_add(self.ended.len()) >= MAX_LOOKUPS { - return Err(Refused::ResourceExhausted); + return Err(NotStarted::ResourceExhausted); } - let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), id(&mut *draw)) else { unreachable!("the lease names a resolver") }; - let mut asking = Asking { id: LookupId(self.next), lookup, queries: Vec::new() }; + let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect(); + let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") }; + let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() }; match act(&mut asking, step, now, stack, counters, &mut *draw) { None => { self.next = self.next.wrapping_add(1); @@ -138,45 +181,35 @@ impl Resolver { self.asking.push(asking); Ok(started) } - Some(Err(Ended::NoPort)) => Err(Refused::ResourceExhausted), + Some(Err(Ended::NoPort)) => Err(NotStarted::ResourceExhausted), Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"), } } - /// Reads every reply that reached a query's socket and ends every wait that is over. A - /// lookup that ended closes its sockets and waits to be taken. + /// Ends every lookup whose lease went or names other resolvers, reads every reply that + /// reached a query's socket, and ends every wait that is over. A lookup that ended closes + /// its sockets and waits to be taken. pub(crate) fn pass(&mut self, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) { let Self { asking: lookups, ended, reply, .. } = self; let ms = millis(now); lookups.retain_mut(|asking| { - let mut done = None; - let mut at = 0usize; + let named = stack.lease().is_some_and(|lease| lease.dns == asking.resolvers); + let mut done = if named { None } else { Some(Err(Ended::LeaseChanged)) }; while done.is_none() { - let Some(&(asked, socket)) = asking.queries.get(at) else { break }; - let received = match stack.recv_from(socket, reply.as_mut_slice()) { - Ok(Some(received)) => received, - Ok(None) => { - at = at.saturating_add(1); - continue; - } - Err(_) => unreachable!("a query's socket is open while it is listed, and never connected"), - }; - let Some(message) = reply.get(..received.len) else { continue }; - // Port 0 is not port 53: the lookup drops it. - let port = received.source_port.map_or(0, Port::get); - let step = asking.lookup.on_datagram(asked, received.source.octets(), port, message, ms, id(&mut *draw)); + let Some((query, len)) = waiting(&asking.queries, stack, reply.as_mut_slice(), counters) else { break }; + let message = reply.get(..len).unwrap_or_default(); + // The socket is connected: [udp] delivered this from port 53 of the resolver the + // query went to, or not at all. + let step = asking.lookup.on_datagram(query.asked, query.to, PORT, message, ms, || id(&mut *draw)); done = act(asking, step, now, stack, counters, &mut *draw); - // The step may have let this query go: its socket is read again from wherever it - // now is, or every socket from the first. - at = asking.queries.iter().position(|&(listed, _)| listed == asked).unwrap_or(0); } if done.is_none() { - let step = asking.lookup.on_time(ms, id(&mut *draw)); + let step = asking.lookup.on_time(ms, || id(&mut *draw)); done = act(asking, step, now, stack, counters, &mut *draw); } let Some(result) = done else { return true }; - for &(_, socket) in &asking.queries { - close(stack, now, socket); + for query in &asking.queries { + close(stack, now, query.socket); } ended.push(Resolved { id: asking.id, result }); false @@ -188,8 +221,8 @@ impl Resolver { if asking.id != id { return true; } - for &(_, socket) in &asking.queries { - close(stack, now, socket); + for query in &asking.queries { + close(stack, now, query.socket); } false }); @@ -199,11 +232,9 @@ impl Resolver { impl Node { /// Starts looking `name` up at the resolvers the held lease names, which spends two draws: - /// the first query's id, then its port. Refused `NotConnected` while no lease is held or it - /// names no resolver, and `ResourceExhausted` while `toyos_dns::MAX_LOOKUPS` are held or no - /// ephemeral port is free. A call refused for want of a port has spent both draws; any other - /// refused call spends none. - pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result { + /// the first query's id, then its port. A call refused for want of a port has spent both; + /// any other refused call spends none. + pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result { self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw) } diff --git a/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs index c016562c1af..69262491d25 100644 --- a/userland/netstack/node/tests/resolve.rs +++ b/userland/netstack/node/tests/resolve.rs @@ -13,10 +13,10 @@ mod lan; use std::net::Ipv4Addr; use std::time::Duration; -use common::{arp, terms, A, DNS, MAC, MAC_B, R}; +use common::{arp, from_server, message, message_of, terms, xid, A, ACK, DNS, MAC, MAC_B, NAK, R}; use lan::{udp, Lan, Seen, Udp, B}; use toyos_dns::{Failure, Name, MAX_LOOKUPS, ROUNDS, WAIT_MS}; -use toyos_net_node::{Counter, Ended, LookupId, Refused, Resolved}; +use toyos_net_node::{Counter, Ended, LookupId, NotStarted, Refused, Resolved}; use toyos_net_udp::limits::{EPHEMERAL_COUNT, EPHEMERAL_FIRST}; use toyos_net_udp::Counter as Rule; use toyos_net_wire::{Instant, Port}; @@ -147,8 +147,6 @@ struct Net { zone: Vec<(&'static str, u64, Says)>, /// Frames [`ANSWERS`] has sent and the wire has not yet delivered, with the time they arrive. held: Vec<(Instant, Vec)>, - /// Every address the node asked ARP for. - arp_asked: Vec, /// Every query that left, in order. queried: Vec, ended: Vec, @@ -177,7 +175,6 @@ impl Net { lan, zone: Vec::new(), held: Vec::new(), - arp_asked: Vec::new(), queried: Vec::new(), ended: Vec::new(), draws: 0x7c00_8000, @@ -197,7 +194,7 @@ impl Net { self.ms_of(self.lan.now) } - fn resolve(&mut self, text: &str) -> Result { + fn resolve(&mut self, text: &str) -> Result { self.lan.node.resolve(self.lan.now, name(text), counter(&mut self.draws)) } @@ -221,12 +218,7 @@ impl Net { /// What the far end does with one frame of the node's. fn far_end(&mut self, at: Instant, seen: &Seen) { match seen { - Seen::Arp { request: true, target, .. } => { - self.arp_asked.push(*target); - if *target == ANSWERS { - self.lan.deliver(&arp(MAC, false, MAC_S, ANSWERS, A)); - } - } + Seen::Arp { request: true, target, .. } if *target == ANSWERS => self.lan.deliver(&arp(MAC, false, MAC_S, ANSWERS, A)), Seen::Udp(udp) if udp.port == 53 => { assert_eq!((udp.to, udp.destination), (MAC_S, ANSWERS), "a query left for a server the wire cannot reach"); let query = Query { at, udp: udp.clone(), id: u16::from_be_bytes([udp.payload[0], udp.payload[1]]), name: asked_name(&udp.payload) }; @@ -326,31 +318,48 @@ fn a_query_is_rfc_1035s_octets_from_a_port_and_with_an_id_of_its_own_draws() { assert_eq!(sent, [&Udp { to: MAC_S, source: A, source_port: 49_152 + 7, destination: ANSWERS, port: 53, ttl: 64, payload }]); } -// RFC 5452 §9.1: a reply is accepted only from the address the query was sent to. +// RFC 5452 §9.1: a reply is accepted only from the address the query was sent to. The query's +// socket is connected to it, so another host's datagram finds no socket (RFC 1122 §4.1.3.1). #[test] fn a_reply_is_read_only_from_the_resolver_its_query_went_to() { let mut net = Net::leased(&[ANSWERS], Some(R)); let id = net.resolve("www.example").unwrap(); net.pass(); let asked = net.queried[0].clone(); + let nobodys = net.lan.counted(Rule::RxNoSocket); net.lan.deliver(&udp(MAC, MAC_B, (B, 53), (A, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); assert_eq!(net.lan.node.take_resolved(), NONE, "another host's port 53 answered, with the query's id and question"); + assert_eq!(net.lan.counted(Rule::RxNoSocket), nobodys + 1, "and its datagram reached no socket"); net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); } -// RFC 5452 §9.1: and only from the port it was sent to, 53. RFC 768: a source port of zero is -// no port. +// RFC 5452 §9.1: and only from the port it was sent to, 53. #[test] fn a_reply_is_read_only_from_port_53() { let mut net = Net::leased(&[ANSWERS], Some(R)); let id = net.resolve("www.example").unwrap(); net.pass(); let asked = net.queried[0].clone(); - for source in [5_353, 0] { - net.lan.deliver(&udp(MAC, MAC_S, (ANSWERS, source), (A, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); - assert_eq!(net.lan.node.take_resolved(), NONE, "the resolver's port {source} answered"); - } + let reached = net.lan.counted(Rule::Rx); + net.lan.deliver(&udp(MAC, MAC_S, (ANSWERS, 5_353), (A, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); + assert_eq!(net.lan.counted(Rule::Rx), reached + 1, "the premise: the datagram reached [udp]"); + assert_eq!(net.lan.node.take_resolved(), NONE, "the resolver's port 5353 answered"); + net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); +} + +// RFC 768: a source port of zero is no port, and so not port 53. +#[test] +fn a_reply_from_port_0_is_not_read() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + let portless = net.lan.counted(Rule::RxSrcPortZero); + net.lan.deliver(&udp(MAC, MAC_S, (ANSWERS, 0), (A, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); + assert_eq!(net.lan.counted(Rule::RxSrcPortZero), portless + 1, "the premise: the datagram reached [udp], which read it as from port 0"); + assert_eq!(net.lan.node.take_resolved(), NONE, "the resolver's port 0 answered"); net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); } @@ -418,22 +427,8 @@ fn nothing_but_its_answer_ends_a_lookup() { } // RFC 1035 §4.2.1: a resolver that does not answer is given up on after a wait and the next is -// asked. The first is on the link and answers no ARP: the second is asked the moment the first -// one's wait ends, no sooner for the wakes [ip]'s own requests cause, and answers. -#[test] -fn a_resolver_that_answers_no_arp_holds_up_no_other() { - let mut net = Net::leased(&[SILENT, ANSWERS], Some(R)); - net.zone.push(("www.example", 0, Says::Address(ADDRESS))); - let id = net.resolve("www.example").unwrap(); - let ended = net.run(id, 25_000); - assert!(net.arp_asked.contains(&SILENT), "the premise: the first resolver was asked for its link address"); - assert_eq!(ended, Some(Ok(vec![ADDRESS])), "the second resolver heard {:?}", net.queried); - assert_eq!(net.ms(), WAIT_MS); - assert_eq!(net.queried.len(), 1); -} - -// The same, for a resolver no route leads to: [udp] refuses its query (`udp.no-route`), which is -// counted, holds no port, and is waited out like a query nobody answered. +// asked. No route leads to the first: [udp] refuses its query's socket that peer (`udp.no-route`), +// and the query is counted, holds no port, and is waited out like one nobody answered. #[test] fn a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out() { let mut net = Net::leased(&[UNROUTED, ANSWERS], None); @@ -479,9 +474,9 @@ fn the_lookup_past_the_cap_is_refused_until_an_answer_is_taken() { for held in 1..MAX_LOOKUPS { net.resolve("www.example").unwrap_or_else(|why| panic!("lookup {held} was refused: {why:?}")); } - assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(Refused::ResourceExhausted)); + assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(NotStarted::ResourceExhausted)); net.until(1_000); - assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(Refused::ResourceExhausted), "an answer nobody took holds its place"); + assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(NotStarted::ResourceExhausted), "an answer nobody took holds its place"); assert_eq!(net.lan.node.take_resolved(), [Resolved { id: first, result: Ok(vec![ADDRESS]) }]); net.resolve("www.example").expect("a taken answer's place is another's"); } @@ -491,17 +486,17 @@ fn the_lookup_past_the_cap_is_refused_until_an_answer_is_taken() { #[test] fn a_lookup_with_no_resolver_to_ask_is_refused_as_not_connected() { let mut lan = Lan::new(); - assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(Refused::NotConnected), "before a lease"); + assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(NotStarted::NotConnected), "before a lease"); let mut options = terms(3_600, Some(R)); options.retain(|(code, _)| *code != 6); lan.lease_on(&options); - assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(Refused::NotConnected), "a lease that names no resolver"); + assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(NotStarted::NotConnected), "a lease that names no resolver"); let mut lan = Lan::new(); lan.lease(600); lan.node.resolve(lan.now, name("www.example"), sequence([1, 2])).expect("the premise: a held lease's resolver is asked"); assert!(lan.run_until(Duration::from_secs(700), |lan| lan.node.lease().is_none()), "the lease runs out"); - assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(Refused::NotConnected), "after the lease"); + assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(NotStarted::NotConnected), "after the lease"); } // A lookup nobody waits for is let go at once: the ports its queries left from are free, and its @@ -521,7 +516,7 @@ fn a_lookup_let_go_frees_its_ports_and_its_place_at_once() { let ports = |net: &Net, asked: &str| -> Vec { net.queried.iter().filter(|query| query.name == asked).map(|query| query.udp.source_port).collect() }; let (gone, stays) = (ports(&net, &names[3]), ports(&net, &names[4])); assert_eq!((gone.len(), stays.len()), (2, 2)); - assert_eq!(net.resolve("www.example"), Err(Refused::ResourceExhausted), "the premise: every place is held"); + assert_eq!(net.resolve("www.example"), Err(NotStarted::ResourceExhausted), "the premise: every place is held"); net.lan.node.let_go(net.lan.now, ids[3]); for port in gone { @@ -576,7 +571,7 @@ fn a_query_with_no_port_to_leave_from_ends_its_lookup_by_name() { net.lan.node.udp_bind(ANY, None, || offset).unwrap(); } let id = net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0x8001, last])).expect("the one port left"); - assert_eq!(net.lan.node.resolve(net.lan.now, name("other.example"), sequence([0x8002, 0])), Err(Refused::ResourceExhausted)); + assert_eq!(net.lan.node.resolve(net.lan.now, name("other.example"), sequence([0x8002, 0])), Err(NotStarted::ResourceExhausted)); assert_eq!(net.run(id, 10 * WAIT_MS), Some(Err(Ended::NoPort))); assert_eq!(net.ms(), WAIT_MS); let left: Vec = net.queried.iter().map(|query| query.udp.source_port).collect(); @@ -635,3 +630,115 @@ fn a_lookup_is_not_carried_by_a_later_ones_schedule() { assert_eq!(net.run(second, 10 * WAIT_MS), Some(Err(Ended::Failed(Failure::TimedOut)))); assert_eq!(net.ms(), WAIT_MS / 2 + rounds * WAIT_MS, "the second lookup waited on the first's schedule"); } + +// RFC 5452 §9.1: a reply is matched on the address it was sent to as well, which is the one its +// query left from. The limited broadcast, the subnet's broadcast and a group the node has joined +// each reach [udp] at the query's port, which is the premise, and none is the query's answer. +#[test] +fn a_reply_is_read_only_at_the_address_its_query_left_from() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.lan.node.answer_as(net.lan.now, toyos_mdns::Host::new("toyos").unwrap()).unwrap(); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + let group = (Ipv4Addr::new(224, 0, 0, 251), [0x01, 0x00, 0x5e, 0x00, 0x00, 0xfb]); + for (destination, mac) in [(Ipv4Addr::BROADCAST, common::BROADCAST), (Ipv4Addr::new(192, 0, 2, 255), common::BROADCAST), group] { + let reached = net.lan.counted(Rule::Rx); + net.lan.deliver(&udp(mac, MAC_S, (ANSWERS, 53), (destination, asked.udp.source_port), &gives(&asked.udp.payload, FORGED))); + assert_eq!(net.lan.counted(Rule::Rx), reached + 1, "the premise: the datagram to {destination} reached [udp]"); + assert_eq!(net.lan.node.take_resolved(), NONE, "a reply sent to {destination} was read"); + } + net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); +} + +// `toyos-dns/src/tests.rs` holds this reply as `REAL_GOOGLE`: what a public resolver answered for +// `dns.google` to a query with id 0x1001, 60 octets, recorded and not written here. Its two +// addresses come out of the node in the reply's order. +#[test] +fn a_recorded_reply_of_a_public_resolver_answers_the_nodes_query() { + #[rustfmt::skip] + const RECORDED: [u8; 60] = [ + 0x10, 0x01, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x03, 0x64, 0x6e, 0x73, 0x06, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x00, 0x00, 0x01, 0x00, 0x01, 0xc0, 0x0c, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0x02, 0xcf, 0x00, 0x04, 0x08, 0x08, 0x04, 0x04, 0xc0, 0x0c, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0xcf, + 0x00, 0x04, 0x08, 0x08, 0x08, 0x08, + ]; + let mut net = Net::leased(&[ANSWERS], Some(R)); + let id = net.lan.node.resolve(net.lan.now, name("dns.google"), sequence([0x1001, 3])).unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + assert_eq!((asked.id, &asked.udp.payload[12..]), (0x1001, &RECORDED[12..28]), "the premise: the node's query carries the reply's id and question"); + net.resolver_says(asked.udp.source_port, &RECORDED); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![[8, 8, 4, 4], [8, 8, 8, 8]]) }]); +} + +// RFC 1122 §3.3.1.1 and §4.1.3.3: a datagram whose next hop never answers ARP is reported to the +// socket it left from. Each of a lookup's queries to such a resolver is counted as the network +// reported it, and waited out like one nobody answered. +#[test] +fn a_query_the_network_reports_unreachable_is_counted_and_waited_out() { + let mut net = Net::leased(&[SILENT], Some(R)); + let id = net.resolve("www.example").unwrap(); + let rounds = u64::try_from(ROUNDS).unwrap(); + assert_eq!(net.run(id, 10 * WAIT_MS), Some(Err(Ended::Failed(Failure::TimedOut)))); + assert_eq!(net.ms(), rounds * WAIT_MS); + assert!(net.queried.is_empty(), "the premise: no query reached the wire: {:?}", net.queried); + assert_eq!(net.lan.node.counters().get(Counter::QueryFailed), rounds); + assert_eq!(net.lan.node.counters().get(Counter::QueryUnsent), 0); +} + +/// A lookup in flight as the lease's first renewal leaves: the net, the lookup and the renewal's +/// transaction id. +fn renewing_under_a_lookup() -> (Net, LookupId, u32) { + let mut net = Net::leased(&[ANSWERS], Some(R)); + let sent = net.lan.udp().iter().filter(|udp| udp.port == 67).count(); + assert!(net.lan.run_until(Duration::from_secs(50_000), |lan| lan.udp().iter().filter(|udp| udp.port == 67).count() > sent), "the renewal leaves"); + let renewal = xid(&net.lan.udp().iter().rfind(|udp| udp.port == 67).expect("a renewal").payload); + net.seen = net.lan.sent.len(); + let id = net.resolve("www.example").unwrap(); + net.pass(); + assert_eq!(net.queried.len(), 1, "the premise: the lookup's first query left"); + (net, id, renewal) +} + +/// The options of a day's lease through the router that names `resolver`. +fn naming(resolver: Ipv4Addr) -> Vec<(u8, Vec)> { + let mut options = terms(86_400, Some(R)); + options.retain(|(code, _)| *code != 6); + options.push((6, resolver.octets().to_vec())); + options +} + +// The resolvers are the held lease's and come and go with it. A lookup asks the ones its lease +// named: a renewal that names the same ones leaves it be, and its answer still ends it. +#[test] +fn a_lookup_outlives_a_renewal_that_names_the_same_resolvers() { + let (mut net, id, renewal) = renewing_under_a_lookup(); + net.lan.deliver(&from_server(MAC, A, &message_of(ACK, renewal, &naming(ANSWERS)))); + assert_eq!(net.lan.node.take_resolved(), NONE); + let asked = net.queried[0].clone(); + net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS)); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]); +} + +// A renewal that names another resolver ends the lookup by name, at once: no further query goes +// to an address the held lease does not name, its answer is not read, and its port is free. +#[test] +fn a_lookup_ends_when_its_leases_resolvers_change() { + let (mut net, id, renewal) = renewing_under_a_lookup(); + net.lan.deliver(&from_server(MAC, A, &message_of(ACK, renewal, &naming(SILENT)))); + assert_eq!(net.lan.node.lease().expect("held").dns, [SILENT], "the premise: the renewed lease names another resolver"); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Err(Ended::LeaseChanged) }]); + let left = net.queried[0].udp.source_port; + assert!(!net.port_held(left), "port {left} of the ended lookup"); +} + +// RFC 2131 §3.1: a DHCPNAK takes the lease, and the lookup under it ends by the same name. +#[test] +fn a_lookup_ends_when_its_lease_is_lost() { + let (mut net, id, renewal) = renewing_under_a_lookup(); + net.lan.deliver(&from_server(common::BROADCAST, Ipv4Addr::BROADCAST, &message(NAK, renewal, Ipv4Addr::UNSPECIFIED, &[(54, R.octets().to_vec())]))); + assert_eq!(net.lan.node.lease(), None, "the premise: the lease is gone"); + assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Err(Ended::LeaseChanged) }]); +} diff --git a/userland/netstack/src/resolve.rs b/userland/netstack/src/resolve.rs index 190af55443f..526316c8787 100644 --- a/userland/netstack/src/resolve.rs +++ b/userland/netstack/src/resolve.rs @@ -145,7 +145,7 @@ impl u16> Resolver { if self.lookups.len() == MAX_LOOKUPS { return Err((client, Refused::Full)); } - let (lookup, step) = Lookup::start(name.clone(), &self.servers, self.ms(now), (self.draw)()) + let (lookup, step) = Lookup::start(name.clone(), &self.servers, self.ms(now), &mut self.draw) .expect("the server list was checked not empty"); let mut pending = Pending { client, name, lookup, queries: Vec::new() }; match act(&mut pending, step, socket_set, &mut self.draw) { @@ -185,7 +185,7 @@ impl u16> Resolver { unreachable!("netstack: recv hands back the whole datagram and truncates nothing") } }; - let step = pending.lookup.on_datagram(asked, from, port, &reply, now_ms, (self.draw)()); + let step = pending.lookup.on_datagram(asked, from, port, &reply, now_ms, &mut self.draw); done = act(pending, step, socket_set, &mut self.draw); // The step may have let this query go: its socket is read // again from wherever it now is, or every socket from the @@ -193,7 +193,7 @@ impl u16> Resolver { q = pending.queries.iter().position(|&(a, _)| a == asked).unwrap_or(0); } if done.is_none() { - let step = pending.lookup.on_time(now_ms, (self.draw)()); + let step = pending.lookup.on_time(now_ms, &mut self.draw); done = act(pending, step, socket_set, &mut self.draw); } match done { From ff4e4ce0dd456462e6bc5531c84b6e0e6d4457d8 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 20:47:40 +0200 Subject: [PATCH 3/3] Review round 2 of #774: two premises asserted, two exits say when The same-resolvers renewal test asserts the lease's expiry moved across the ACK; the unreachable test asserts [ip] refused exactly one query on a failed neighbour. The track's [ip] bullet exits before the move, and the unreachable bullet names the node.query-failed test and the time it then ends at. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 4 ++-- userland/netstack/node/tests/resolve.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index ca3647226b0..96e525b110d 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -29,10 +29,10 @@ What the node does not yet meet: - The machine's name is told to the node twice, as `toyos_dhcp::HostName` in `Node::new` and as `toyos_mdns::Host` in `Node::answer_as`: the first owns its bytes and the second borrows them. Exit: one type carries the label to both; due at the move if handing `answer_as` its `&'static str` takes a leak. - The node's name is not probed for or defended (RFC 6762 §8.1, §9), as `toyos-mdns` records of itself. It is not announced again when the link comes back, which §8.3 asks and netd does today, where link-up resets its DHCP client and the address is new again: here the lease outlives the link. Exit: `toyos-mdns` does both, on the node's `link`. - An answer to a query from a 169.254/16 source leaves in a frame to the router: `toyos-mdns` takes a link-local source for one on the link, and [ip]'s route lookup has no way to 169.254/16 on a leased interface but the gateway, where RFC 3927 §2.6.2 sends it on the link and §7 never to a router. `an_answer_to_a_link_local_asker_leaves_by_the_router` holds the present state. Exit: [ip] sends to 169.254/16 on the link, and that test finds the answer in a frame to the asker's own link address. -- A query that does not reach its resolver is waited out for `toyos_dns::WAIT_MS` like one nobody answered, whether [udp] refused it for want of a route (`node.query-unsent`) or the network reported it back, its resolver unreachable or its port refused (`node.query-failed`): `toyos_dns::Lookup` has no word for either. Exit, before the move: `Lookup` is told and asks the next resolver at once, and `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms. +- A query that does not reach its resolver is waited out for `toyos_dns::WAIT_MS` like one nobody answered, whether [udp] refused it for want of a route (`node.query-unsent`) or the network reported it back, its resolver unreachable or its port refused (`node.query-failed`): `toyos_dns::Lookup` has no word for either. Exit, before the move: `Lookup` is told and asks the next resolver at once, `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms, and `a_query_the_network_reports_unreachable_is_counted_and_waited_out` finds its lookup ended at 3 s, [ip]'s first report of its one resolver, and not at `ROUNDS` × `WAIT_MS`. - Lookups started together at a resolver whose link address is not yet known lose all but the newest `PENDING_PER_NEIGHBOUR` queries to [ip]'s queue for an unresolved next hop (`nb.pending-overflow`, RFC 4861 §7.2.2), and each lost one costs its lookup a `WAIT_MS`; smoltcp kept each query in its own socket until the neighbour answered. `a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later` holds it as it stands. It is a regression against what ships and would ship at the move. Exit, before the move: that test finds every query of the burst on the wire once the resolver answers ARP. - `toyos_dns::Lookup::on_datagram` still takes a reply's source address and port, which the node's connected sockets have already matched and the node hands it from its own record of the query: the two parameters are read only by netd's resolver on smoltcp. Exit, at the move: they go with that resolver. -- No test of `toyos-net-ip` holds that a next hop which never answers ARP holds up no datagram to another, which the node's resolver rests on when the lease's first resolver is down (`issues/netstack-resolver-asks-a-dead-primary-first-on-every-lookup.md`, its second cost). Exit: a scenario in `toyos-net-ip/tests/nud.rs` sends to a silent neighbour and to an answering one and finds the second datagram on the wire at once. +- No test of `toyos-net-ip` holds that a next hop which never answers ARP holds up no datagram to another, which the node's resolver rests on when the lease's first resolver is down (`issues/netstack-resolver-asks-a-dead-primary-first-on-every-lookup.md`, its second cost). Exit, before the move: a scenario in `toyos-net-ip/tests/nud.rs` sends to a silent neighbour and to an answering one and finds the second datagram on the wire at once. - `node.address-refused` has no test: `toyos-dhcp` accepts no address or prefix [ip] refuses, by the same `toyos-net-wire` checks in both, so the refusal cannot be reached from the wire. Exit: the client hands [ip] a type that carries the check, and the counter goes. 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/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs index 69262491d25..01fbfc20871 100644 --- a/userland/netstack/node/tests/resolve.rs +++ b/userland/netstack/node/tests/resolve.rs @@ -684,6 +684,11 @@ fn a_query_the_network_reports_unreachable_is_counted_and_waited_out() { assert_eq!(net.run(id, 10 * WAIT_MS), Some(Err(Ended::Failed(Failure::TimedOut)))); assert_eq!(net.ms(), rounds * WAIT_MS); assert!(net.queried.is_empty(), "the premise: no query reached the wire: {:?}", net.queried); + // The first two queries wait in [ip] until the neighbour fails, at three of its requests; + // the third leaves after that and is refused at once, because [ip] holds a failed neighbour + // down for longer than the second it would take to be asked again. + let refused_at_once = net.lan.node.shard().ip().counters().get(toyos_net_ip::Counter::NbFailedRefused); + assert_eq!(refused_at_once, 1, "the premise: [ip] refused one query on a failed neighbour"); assert_eq!(net.lan.node.counters().get(Counter::QueryFailed), rounds); assert_eq!(net.lan.node.counters().get(Counter::QueryUnsent), 0); } @@ -715,7 +720,10 @@ fn naming(resolver: Ipv4Addr) -> Vec<(u8, Vec)> { #[test] fn a_lookup_outlives_a_renewal_that_names_the_same_resolvers() { let (mut net, id, renewal) = renewing_under_a_lookup(); + let expiry = |net: &Net| net.lan.node.lease().and_then(|lease| lease.timers).map(|timers| timers.expiry); + let before = expiry(&net); net.lan.deliver(&from_server(MAC, A, &message_of(ACK, renewal, &naming(ANSWERS)))); + assert_ne!(expiry(&net), before, "the premise: the renewal was taken, and the lease runs from it"); assert_eq!(net.lan.node.take_resolved(), NONE); let asked = net.queried[0].clone(); net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS));