From 26d144c09817079d04a2eb59ead2e2f17fbfdd79 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 22:19:52 +0200 Subject: [PATCH 1/4] toyos-net-ip: 169.254/16 is on the link whatever address the interface holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 3927 §2.6.2 has a host resolve a 169.254/16 destination and send to it directly on the link, with a link-local or a routable address alike, and §7 forbids handing such a datagram to a router. The route lookup had only the connected prefixes and then the gateway, so a leased interface sent a link-local asker's mDNS answer to the router; the node's test `an_answer_to_a_link_local_asker_leaves_by_the_router` asserted that frame on purpose. `Interface::on_link` is the one statement of "reachable without a gateway" and now holds 169.254/16 on any interface with a usable address. The lookup sends there after the connected prefixes and before the gateway; ARP reception, which reads the same predicate, takes a link-local sender as a neighbour, without which the request [ip] now sends could never be answered; and a gateway there is on the link. 169.254.255.255 is resolved as a host on an interface whose own prefix is not 169.254/16: the RFC excludes it from the rule, no permission to broadcast was asked for it, and it still reaches no router. The node's test is renamed and finds the request for the asker itself and then the answer in a frame to the asker's own link address. A next hop that never answers ARP holds up no datagram to another (RFC 4861 §7.2.2): the queue was already INCOMPLETE's own, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` now holds it so. The track loses its line for the first, says what is left of RFC 3927 §2.6.2 (a link-local source, which no interface can hold today) and that the four new tests carry no scenario id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 3 +- toyos-net-ip/src/iface.rs | 5 +- toyos-net-ip/src/route.rs | 12 +++-- toyos-net-ip/tests/addr.rs | 64 +++++++++++++++++++++++ toyos-net-ip/tests/nbr.rs | 21 ++++++++ toyos-net-ip/tests/nud.rs | 44 ++++++++++++++++ userland/netstack/node/tests/name.rs | 25 +++++---- 7 files changed, 158 insertions(+), 16 deletions(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index e95e20e367f..025e70a776d 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -29,7 +29,6 @@ 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, at the move and not after it: the datagram calls end in that pass, or netd drains it once per turn of its 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; 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. - `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. @@ -41,10 +40,12 @@ What stage 3 departs from its specifications: - US-43 expects `ip.not-for-us` for a datagram to an unjoined group in a frame to that group's MAC; the frame filter refuses it first, as `eth.not-for-us` (ETH-23). Exit: the scenario names the frame filter. - DH-64 expects an announce request from the client on link-up with a lease held; `toyos-net-ip` announces the held address itself on link-up, by the owner's ruling to announce rather than probe again, so the client asks nothing. Exit: the scenario drops the request, or [ip] stops announcing on its own. - The readers' UDP specification lets every datagram a closed socket had accepted leave; `toyos-net-udp` holds at most one socket's queue of them together and refuses the rest `udp.tx-discarded-on-close`, because the readers' stage 4 design holds nothing in the shard without a bound. Exit: `s_udp_us_053_accepted_datagrams_outlive_close` finds `udp.tx-discarded-on-close` at 0, every datagram a closed socket accepted leaving. +- Four tests of [ip] name an RFC section and no scenario, because they were written from the RFCs without the specifications: the three `rfc_3927_2_6_2_…` in `toyos-net-ip/tests/addr.rs` and `nbr.rs`, 169.254/16 on the link whatever address is held, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` in `tests/nud.rs`. Exit: the readers' IP specification names a scenario for each, and each test carries its id. What stage 3 does not yet meet: - Its only oracles are the readers' own: the specifications' scenarios and byte vectors, and the tests' own RFC 1071 sum. No behaviour of `toyos-net-ip`, `toyos-net-udp` or `toyos-dhcp` is checked against anything the readers did not write. Exit: exchanges captured from slirp and the T14 (ARP, DHCP, ICMP, IGMP) replay through them at stage 5 and match. +- A datagram from a 169.254/16 source to a routable destination is handed to the router, where RFC 3927 §2.6.2 has the host resolve the destination itself and send to it on the link, and §7 never to a router: [ip] sends on the link by the destination alone (`rfc_3927_2_6_2_a_link_local_destination_is_on_the_link`). No interface holds such an address today: `toyos-dhcp` refuses one a server offers and nothing configures one by itself, so only `Ip::add_address`'s caller can make the case. Exit: the stage that gives an interface a link-local address lands with a `toyos-net-ip` test that finds a datagram from it to a routable destination in a frame to that destination's own link address. What stage 4 has not yet built: the segment-script runner, the resets owed when an address is lost, each routed by its destination alone, or the remaining [net] scenarios at frame level: NET-02, NET-05 and NET-07–16 still run on `toyos-net-tcp`'s own two-node network, which goes with the last of them. Exit: each lands, or the specification moves it to a later stage. diff --git a/toyos-net-ip/src/iface.rs b/toyos-net-ip/src/iface.rs index 148ac72e39c..b3d126daf4f 100644 --- a/toyos-net-ip/src/iface.rs +++ b/toyos-net-ip/src/iface.rs @@ -63,9 +63,10 @@ impl Interface { self.addresses.iter().any(|a| a.cidr.addr() == addr) } - /// Inside the prefix of a usable address: reachable without a gateway. + /// Reachable without a gateway: inside the prefix of a usable address, or in 169.254/16, + /// which is this link's whatever address the interface holds (RFC 3927 §2.6.2). pub fn on_link(&self, addr: Ipv4Addr) -> bool { - self.usable().any(|a| a.cidr.contains(addr)) + self.usable().any(|a| addr.is_link_local() || a.cidr.contains(addr)) } /// A usable address whose prefix holds `toward`, else the first usable one. diff --git a/toyos-net-ip/src/route.rs b/toyos-net-ip/src/route.rs index 79f1f91a0ee..1437347d71d 100644 --- a/toyos-net-ip/src/route.rs +++ b/toyos-net-ip/src/route.rs @@ -1,7 +1,8 @@ //! The host routing table: the connected prefixes of usable addresses and an ordered list of -//! on-link gateways per interface; longest prefix first, then the active gateway, and no -//! forwarding. A lookup considers only interfaces that are up, and with a bound source only the -//! interface holding it (RFC 1122 §3.3.4.2, the strong model). +//! on-link gateways per interface; longest prefix first, then 169.254/16 on the link itself (RFC +//! 3927 §2.6.2), then the active gateway, and no forwarding. A lookup considers only interfaces +//! that are up, and with a bound source only the interface holding it (RFC 1122 §3.3.4.2, the +//! strong model). use core::net::Ipv4Addr; @@ -92,6 +93,11 @@ pub(crate) fn lookup(ifaces: &[Interface], destination: Ipv4Addr, source: Source let next_hop = if cidr.broadcast() == Some(destination) { NextHop::Broadcast } else { NextHop::Neighbour(destination) }; return Ok(Route { iface: IfIndex(index), next_hop, source: pick_source(i, destination)? }); } + // On the link with no prefix holding it is 169.254/16: resolved and sent to directly, and + // never handed to a router (RFC 3927 §2.6.2, §7). + if let Some((index, i)) = candidates().find(|(_, i)| i.on_link(destination)) { + return Ok(Route { iface: IfIndex(index), next_hop: NextHop::Neighbour(destination), source: pick_source(i, destination)? }); + } let (index, i, gateway) = candidates().find_map(|(index, i)| i.active.map(|g| (index, i, g))).ok_or(Counter::RouteNone)?; Ok(Route { iface: IfIndex(index), next_hop: NextHop::Neighbour(gateway), source: pick_source(i, gateway)? }) } diff --git a/toyos-net-ip/tests/addr.rs b/toyos-net-ip/tests/addr.rs index e518c9e6f9b..7bd731305f7 100644 --- a/toyos-net-ip/tests/addr.rs +++ b/toyos-net-ip/tests/addr.rs @@ -415,3 +415,67 @@ fn s_ip_rte_017_lookups_send_nothing() { } assert!(h.run(600_000).is_empty()); } + +const LINK_LOCAL: Ipv4Addr = Ipv4Addr::new(169, 254, 3, 4); +const MAC_L: MacAddr = MacAddr([2, 0, 0, 0, 0, 0x4c]); + +// RFC 3927 §2.6.2: "If the destination address is in the 169.254/16 prefix (excluding the address +// 169.254.255.255, which is the IPv4 Link-Local subnet broadcast address), then the sender MUST +// ARP for the destination address and then send the packet directly to the destination on the +// same physical link. This MUST be done whether the interface is configured with a Link-Local +// or a routable IPv4 address." §7: "An IPv4 packet whose source and/or destination address is in +// the 169.254/16 prefix MUST NOT be sent to any router for forwarding". +#[test] +fn rfc_3927_2_6_2_a_link_local_destination_is_on_the_link() { + let mut h = H::fixture_i(); + let on_the_link = Route { iface: h.if0, next_hop: NextHop::Neighbour(LINK_LOCAL), source: A }; + assert_eq!(route(&mut h, LINK_LOCAL), Ok(on_the_link), "with a routable address and a router"); + assert_eq!(route(&mut h, REMOTE).map(|r| r.next_hop), Ok(NextHop::Neighbour(R)), "the router is still the way off the link"); + // 169.254.255.255 is that prefix's broadcast, not one of ours: no frame to every host leaves + // for it, and no router is handed it either. + let edge = ip4(169, 254, 255, 255); + assert_eq!(route(&mut h, edge).map(|r| r.next_hop), Ok(NextHop::Neighbour(edge))); + h.ip.set_gateways(h.clock(), h.if0, &[]).unwrap(); + assert_eq!(route(&mut h, LINK_LOCAL), Ok(on_the_link), "and with no router at all"); + // What is on the link may be the router: a datagram neither from nor to 169.254/16 is its to + // forward. + let router = ip4(169, 254, 0, 1); + h.ip.set_gateways(h.clock(), h.if0, &[router]).unwrap(); + assert_eq!(route(&mut h, REMOTE), Ok(Route { iface: h.if0, next_hop: NextHop::Neighbour(router), source: A })); + assert_eq!(route(&mut h, LINK_LOCAL), Ok(on_the_link)); + + let mut h = H::bare(); + assert_eq!(route(&mut h, LINK_LOCAL), Err(Counter::RouteNoSourceAddress), "an interface with no address sends nothing"); + + // The interface is the lowest one up, or the one a bound source names. + let mut h = H::raw(); + let (if0, if1) = (h.if0, h.add_if1()); + h.assign(if0, A, 24, 0); + h.assign(if1, ip4(198, 51, 100, 1), 24, 5_000); + h.settle(); + assert_eq!(route(&mut h, LINK_LOCAL).map(|r| (r.iface, r.source)), Ok((if0, A))); + let bound = h.ip.route(LINK_LOCAL, Source::Bound(ip4(198, 51, 100, 1)), None); + assert_eq!(bound, Ok(Route { iface: if1, next_hop: NextHop::Neighbour(LINK_LOCAL), source: ip4(198, 51, 100, 1) })); + h.ip.link_down(h.clock(), if0).unwrap(); + assert_eq!(route(&mut h, LINK_LOCAL).map(|r| r.iface), Ok(if1)); +} + +// The same rule on the wire: the request names the destination itself, its reply is a +// neighbour's, and no frame is the router's. +#[test] +fn rfc_3927_2_6_2_a_datagram_to_a_link_local_host_is_resolved_and_sent_to_it() { + let mut h = H::fixture_i_with(R, MAC_R); + assert_eq!(h.udp_to(LINK_LOCAL), Ok(None)); + let asked = h.out(); + assert_eq!(asked.len(), 1); + let request = asked[0].arp().unwrap(); + assert_eq!((asked[0].to(), request.sender_ip, request.target_ip), (MacAddr::BROADCAST, A, LINK_LOCAL)); + h.at(5); + h.reply_from(LINK_LOCAL, MAC_L); + assert!(h.is_reachable(LINK_LOCAL)); + assert_eq!(h.count(Counter::ArpSenderOffLink), 0); + let out = h.out(); + assert_eq!(out.len(), 1); + assert_eq!((out[0].to(), out[0].ip().unwrap().destination()), (MAC_L, LINK_LOCAL)); + assert!(matches!(h.udp_to(LINK_LOCAL), Ok(Some(frame)) if destination_of(&frame) == MAC_L)); +} diff --git a/toyos-net-ip/tests/nbr.rs b/toyos-net-ip/tests/nbr.rs index 1f23c4f6c12..df9c1d76357 100644 --- a/toyos-net-ip/tests/nbr.rs +++ b/toyos-net-ip/tests/nbr.rs @@ -153,6 +153,27 @@ fn s_ip_nbr_012_an_off_link_sender_is_answered_not_cached() { assert_eq!(h.count(Counter::ArpSenderOffLink), 1); } +// RFC 3927 §2.6.2: a host in 169.254/16 is on this link whatever address the interface holds, so +// its request is a neighbour's: answered, and cached to be verified before it is trusted. Before +// the interface has an address nothing is on its link. +#[test] +fn rfc_3927_2_6_2_a_link_local_sender_is_a_neighbour() { + let asker = ip4(169, 254, 3, 4); + let mut h = H::fixture_i(); + h.frame(&request(MAC_B, asker, A)); + let out = h.out(); + assert_eq!(out.len(), 1); + assert_eq!((out[0].to(), out[0].arp().unwrap().target_ip), (MAC_B, asker)); + assert!(h.is_stale(asker)); + assert_eq!(h.mac_of(asker), Some(MAC_B)); + assert_eq!(h.count(Counter::ArpSenderOffLink), 0); + + let mut h = H::bare(); + h.frame(&request(MAC_B, asker, A)); + assert!(h.out().is_empty() && h.state(asker).is_none()); + assert_eq!(h.count(Counter::ArpSenderOffLink), 1); +} + #[test] fn s_ip_nbr_013_a_probe_is_answered_and_not_cached() { let mut h = H::fixture_i(); diff --git a/toyos-net-ip/tests/nud.rs b/toyos-net-ip/tests/nud.rs index 8232e9804bc..a760b8811b1 100644 --- a/toyos-net-ip/tests/nud.rs +++ b/toyos-net-ip/tests/nud.rs @@ -886,3 +886,47 @@ fn s_ip_nud_031_a_request_queued_before_the_lifetime_is_not_idle() { assert_eq!(h.out_with(1).iter().map(|o| o.frame.clone()).collect::>(), [hex(V_ARP_REQ_R)]); assert_eq!(h.ip.next_deadline(), Some(H::instant(end + 3_000)), "3 s after the hand-off"); } + +// RFC 4861 §7.2.2: "While waiting for address resolution to complete, the sender MUST, for each +// neighbor, retain a small queue of packets waiting for address resolution to complete. ... When +// a queue overflows, the new arrival SHOULD replace the oldest entry." The queue is the next +// hop's: a router that never answers holds every datagram routed by it, drops only its own, and +// holds up none to a host on the link. +#[test] +fn rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other() { + let per = limits::nud::PENDING_PER_NEIGHBOUR as u8; + let mut h = H::fixture_i(); + let behind_r = |n: u8| Ipv4Addr::new(198, 51, 100, 1 + n); + for n in 0..=per { + assert_eq!(h.send(A, behind_r(n), 5001, 5001, &[b'r', n]), Ok(None)); + } + assert_eq!(h.count(Counter::NbPendingOverflow), 1, "one more than R's queue holds"); + for n in 0..per { + assert_eq!(h.send(A, B, 5001, 5001, &[b'b', n]), Ok(None)); + } + assert_eq!(h.count(Counter::NbPendingOverflow), 1, "B's datagrams wait in B's queue, and R's full one drops none of them"); + let queued = |h: &H, addr| match h.state(addr) { + Some(Nud::Incomplete(s)) => Some(s.pending.queued()), + _ => None, + }; + assert_eq!((queued(&h, R), queued(&h, B)), (Some(usize::from(per)), Some(usize::from(per)))); + let asked = h.out(); + assert!(asked.len() == 2 && asked[0].requests(R) && asked[1].requests(B), "one request each, and no datagram"); + + h.at(5); + h.frame(&hex(V_ARP_REPLY)); + let released: Vec<(MacAddr, Vec)> = h.out().iter().map(|o| (o.to(), payload(o))).collect(); + let all_of_bs: Vec<(MacAddr, Vec)> = (0..per).map(|n| (MAC_B, vec![b'b', n])).collect(); + assert_eq!(released, all_of_bs, "B answered: every datagram to B leaves, in order, while R is silent"); + assert_eq!(queued(&h, R), Some(usize::from(per)), "and R's wait on"); + assert!(matches!(h.send(A, B, 5001, 5001, b"now"), Ok(Some(frame)) if destination_of(&frame) == MAC_B)); + + let later = h.run(3_000); + assert!(later.iter().all(|o| o.requests(R)), "nothing but R's own requests leaves as R fails"); + assert!(matches!(h.state(R), Some(Nud::Failed))); + assert_eq!(h.count(Counter::NbPendingDropped), u64::from(per)); + let told: Vec = h.events.iter().filter(|e| matches!(e, Event::Unreachable(_))).copied().collect(); + let behind: Vec = (1..=per).map(|n| Event::Unreachable(flow_to(behind_r(n)))).collect(); + assert_eq!(told, behind, "only the flows routed by R are told, the oldest having given its place"); + assert!(matches!(h.send(A, B, 5001, 5001, b"still"), Ok(Some(frame)) if destination_of(&frame) == MAC_B)); +} diff --git a/userland/netstack/node/tests/name.rs b/userland/netstack/node/tests/name.rs index a7a245248f2..d59ee978c86 100644 --- a/userland/netstack/node/tests/name.rs +++ b/userland/netstack/node/tests/name.rs @@ -11,7 +11,7 @@ mod lan; use std::net::Ipv4Addr; use std::time::Duration; -use common::{sum, terms, A, MAC_B, MAC_R, R}; +use common::{arp, sum, terms, A, MAC, MAC_B, MAC_R, R}; use lan::{udp, Lan, Seen, Udp, B, OFF_LINK}; use toyos_mdns::Host; use toyos_net_node::{Counter, Refused}; @@ -216,19 +216,24 @@ fn a_second_query_inside_a_second_waits_for_the_second_to_end() { assert_eq!(lan.datagrams().len(), from + 2, "one multicast answered both"); } -// Present state, and wrong: RFC 3927 §2.6.2 has a host send to 169.254/16 directly on the link -// whatever address it holds, and §7 never through a router. The responder takes a link-local -// source for one on this link (RFC 6762 §11), and [ip], with no route for 169.254/16 on a leased -// interface, hands the answer to the router. The track records it; its exit turns `to` here into -// the asker's own link address. +// RFC 3927 §2.6.2: "If the destination address is in the 169.254/16 prefix ... then the sender +// MUST ARP for the destination address and then send the packet directly to the destination on +// the same physical link. This MUST be done whether the interface is configured with a +// Link-Local or a routable IPv4 address." §7: such a packet is never sent to a router. The +// responder takes a link-local source for one on this link (RFC 6762 §11); the node holds a +// routable lease and a router, asks the link for the asker itself, and answers it there. #[test] -fn an_answer_to_a_link_local_asker_leaves_by_the_router() { +fn an_answer_to_a_link_local_asker_goes_to_its_own_link_address() { let link_local = Ipv4Addr::new(169, 254, 3, 4); let mut lan = settled(); - let from = lan.datagrams().len(); + let (from, frames) = (lan.datagrams().len(), lan.sent.len()); lan.deliver(&to_group(MAC_B, (link_local, 53_000), &ours(5, CLASS_IN))); - let by_the_router = Udp { to: MAC_R, source: A, source_port: MDNS, destination: link_local, port: 53_000, ttl: 255, payload: legacy_response(5) }; - assert_eq!(since(&lan, from), [by_the_router]); + let asked: Vec<&Seen> = lan.sent[frames..].iter().map(|(_, seen)| seen).collect(); + assert_eq!(asked, [&Seen::Arp { request: true, sender: A, target: link_local }], "the asker is asked for, and nothing is the router's"); + + lan.deliver(&arp(MAC, false, MAC_B, link_local, A)); + let on_the_link = Udp { to: MAC_B, source: A, source_port: MDNS, destination: link_local, port: 53_000, ttl: 255, payload: legacy_response(5) }; + assert_eq!(since(&lan, from), [on_the_link]); } // A lease held before the name is told is as new to the responder as one that comes after: From 6b14a7f4d21cdd60d94bbce5d4b9ec66f1252586 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 22:36:30 +0200 Subject: [PATCH 2/4] Review round 1 of #779: two predicates, the prefix's edges are no host, a gateway is in a prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One predicate answered two questions. The route and ARP ask what is reached without a gateway; the gateways ask what may be a router. RFC 3927 widened only the first, so `Interface::on_link` is again the prefix of a usable address, read by `set_gateways` and `withdraw_off_link` as on main, and `Interface::is_neighbour` is that or a host of 169.254/16, read by the route lookup and by ARP reception. A router at 169.254.0.1 on a routable interface is refused `route.gateway-off-link` again. 169.254.0.0 and 169.254.255.255 are a host nowhere: as an ARP sender each is `arp.invalid-sender-address`, as our own prefixes' edges are, and as a destination on an interface whose prefix is another each is `route.none`, neither asked for on the link nor handed to the router. At the reviewed head a reply claiming 169.254.255.255 would have confirmed the entry our own request made. The asker's ARP reply in the tests comes to the link broadcast, as RFC 3927 §2.5 has a link-local sender send it. "Never to a router" cites §2.6.2's own sentence and §2.7, not §7. The track's line for a link-local source says both cases and is held by a host test; its line for the scenario ids says what the readers' routing, reachability and ARP scenarios hold and do not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 4 +- toyos-net-ip/src/arp.rs | 8 +-- toyos-net-ip/src/iface.rs | 16 ++++- toyos-net-ip/src/route.rs | 7 ++- toyos-net-ip/tests/addr.rs | 71 ++++++++++++++++++----- toyos-net-ip/tests/nbr.rs | 18 +++++- userland/netstack/node/tests/name.rs | 12 ++-- 7 files changed, 104 insertions(+), 32 deletions(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 6aa4e31ca2d..7f658c3eab1 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -43,12 +43,12 @@ What stage 3 departs from its specifications: - US-43 expects `ip.not-for-us` for a datagram to an unjoined group in a frame to that group's MAC; the frame filter refuses it first, as `eth.not-for-us` (ETH-23). Exit: the scenario names the frame filter. - DH-64 expects an announce request from the client on link-up with a lease held; `toyos-net-ip` announces the held address itself on link-up, by the owner's ruling to announce rather than probe again, so the client asks nothing. Exit: the scenario drops the request, or [ip] stops announcing on its own. - The readers' UDP specification lets every datagram a closed socket had accepted leave; `toyos-net-udp` holds at most one socket's queue of them together and refuses the rest `udp.tx-discarded-on-close`, because the readers' stage 4 design holds nothing in the shard without a bound. Exit: `s_udp_us_053_accepted_datagrams_outlive_close` finds `udp.tx-discarded-on-close` at 0, every datagram a closed socket accepted leaving. -- Four tests of [ip] name an RFC section and no scenario, because they were written from the RFCs without the specifications: the three `rfc_3927_2_6_2_…` in `toyos-net-ip/tests/addr.rs` and `nbr.rs`, 169.254/16 on the link whatever address is held, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` in `tests/nud.rs`. Exit: the readers' IP specification names a scenario for each, and each test carries its id. +- The readers' IP specification has no scenario for 169.254/16 on an interface that holds a routable address: its routing scenarios are RTE-01 to RTE-17 and its ARP reception scenarios NBR-01 to NBR-18, none names that prefix, and NBR-12's `arp.sender-off-link` is expected of a sender in no prefix at all, which its 198.51.100.9 still is. It has none either for two next hops of which one never answers: NUD-04 overflows one neighbour's queue and NUD-05 fills the interface's, and in neither does a second neighbour answer. The tests that hold the two rules therefore name their RFC section and carry no id: the `rfc_3927_…` tests in `toyos-net-ip/tests/addr.rs` and `nbr.rs`, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` in `tests/nud.rs`. Exit: the readers add a scenario for each, and each test carries its id. What stage 3 does not yet meet: - Its only oracles are the readers' own: the specifications' scenarios and byte vectors, and the tests' own RFC 1071 sum. No behaviour of `toyos-net-ip`, `toyos-net-udp` or `toyos-dhcp` is checked against anything the readers did not write. Exit: exchanges captured from slirp and the T14 (ARP, DHCP, ICMP, IGMP) replay through them at stage 5 and match. -- A datagram from a 169.254/16 source to a routable destination is handed to the router, where RFC 3927 §2.6.2 has the host resolve the destination itself and send to it on the link, and §7 never to a router: [ip] sends on the link by the destination alone (`rfc_3927_2_6_2_a_link_local_destination_is_on_the_link`). No interface holds such an address today: `toyos-dhcp` refuses one a server offers and nothing configures one by itself, so only `Ip::add_address`'s caller can make the case. Exit: the stage that gives an interface a link-local address lands with a `toyos-net-ip` test that finds a datagram from it to a routable destination in a frame to that destination's own link address. +- A datagram from a 169.254/16 source to a destination outside that prefix is handed to the router where the interface has one and refused `route.none` where it has none; RFC 3927 §2.6.2 has the host resolve the destination itself and send to it on the link, and with §2.7 never to a router. [ip] sends on the link by the destination alone. `rfc_3927_2_6_2_a_link_local_source_still_sends_by_the_router_or_nowhere` holds both cases as they stand. An interface that held such an address would also owe §2.5, every ARP packet of its own sent to the link's broadcast address, replies included. No interface holds one today, since `toyos-dhcp` refuses one a server offers and nothing configures one by itself, and no stage of this track is planned that gives an interface one. Exit: [ip] refuses a 169.254/16 address at `Ip::add_address` and that test finds the refusal, or the stage that configures one turns that test to the destination's own link address in both cases and adds §2.5's. What stage 4 has not yet built: the segment-script runner, the resets owed when an address is lost, each routed by its destination alone, or the remaining [net] scenarios at frame level: NET-02, NET-05 and NET-07–16 still run on `toyos-net-tcp`'s own two-node network, which goes with the last of them. Exit: each lands, or the specification moves it to a later stage. diff --git a/toyos-net-ip/src/arp.rs b/toyos-net-ip/src/arp.rs index 4c7236053b8..08a6093b839 100644 --- a/toyos-net-ip/src/arp.rs +++ b/toyos-net-ip/src/arp.rs @@ -1,6 +1,6 @@ //! ARP reception: the sender's MAC and address are checked, then the packet classified, in //! `receive`'s order. Only our own -//! need to send, and a request for one of our addresses from an on-link host, create an entry; +//! need to send, and a request for one of our addresses from a neighbour, create an entry; //! any other packet can confirm, assert a MAC into STALE, or be ignored, and a MAC change is //! always logged. @@ -9,7 +9,7 @@ use toyos_net_wire::arp::{Arp, Operation}; use toyos_net_wire::ethernet::MacClass; use crate::counters::Counter; -use crate::iface::{Cx, Interface}; +use crate::iface::{link_local_edge, Cx, Interface}; use crate::{acd, egress, nud}; pub(crate) fn receive(i: &mut Interface, cx: &mut Cx<'_>, arp: &Arp) { @@ -24,7 +24,7 @@ pub(crate) fn receive(i: &mut Interface, cx: &mut Cx<'_>, arp: &Arp) { if mac == i.mac.get() { return cx.log.count(Counter::ArpOwnSender); } - if i.usable().any(|a| a.cidr.is_edge(sender)) { + if link_local_edge(sender) || i.usable().any(|a| a.cidr.is_edge(sender)) { return cx.log.count(Counter::ArpInvalidSenderAddress); } if !probe && i.owns(sender) { @@ -36,7 +36,7 @@ pub(crate) fn receive(i: &mut Interface, cx: &mut Cx<'_>, arp: &Arp) { let for_us = arp.operation == Operation::Request && i.is_usable(target); let mut noticed = for_us; if !probe { - if !i.on_link(sender) { + if !i.is_neighbour(sender) { cx.log.count(Counter::ArpSenderOffLink); noticed = true; } else if i.neighbours.contains_key(&sender) { diff --git a/toyos-net-ip/src/iface.rs b/toyos-net-ip/src/iface.rs index b3d126daf4f..4a6f3d038c8 100644 --- a/toyos-net-ip/src/iface.rs +++ b/toyos-net-ip/src/iface.rs @@ -50,6 +50,11 @@ impl Cx<'_> { } } +/// The network or the broadcast address of 169.254/16: no host's, on any interface. +pub(crate) const fn link_local_edge(addr: Ipv4Addr) -> bool { + matches!(addr.octets(), [169, 254, 0, 0] | [169, 254, 255, 255]) +} + impl Interface { pub fn usable(&self) -> impl Iterator + '_ { self.addresses.iter().filter(|a| a.usable()) @@ -63,10 +68,15 @@ impl Interface { self.addresses.iter().any(|a| a.cidr.addr() == addr) } - /// Reachable without a gateway: inside the prefix of a usable address, or in 169.254/16, - /// which is this link's whatever address the interface holds (RFC 3927 §2.6.2). + /// Inside the prefix of a usable address: reachable without a gateway. pub fn on_link(&self, addr: Ipv4Addr) -> bool { - self.usable().any(|a| addr.is_link_local() || a.cidr.contains(addr)) + self.usable().any(|a| a.cidr.contains(addr)) + } + + /// Reached without a gateway: on the link, or a host of 169.254/16, which is this link's + /// whatever address the interface holds (RFC 3927 §2.6.2). + pub fn is_neighbour(&self, addr: Ipv4Addr) -> bool { + self.on_link(addr) || (addr.is_link_local() && !link_local_edge(addr) && self.usable().next().is_some()) } /// A usable address whose prefix holds `toward`, else the first usable one. diff --git a/toyos-net-ip/src/route.rs b/toyos-net-ip/src/route.rs index 1437347d71d..fc05654905b 100644 --- a/toyos-net-ip/src/route.rs +++ b/toyos-net-ip/src/route.rs @@ -93,9 +93,10 @@ pub(crate) fn lookup(ifaces: &[Interface], destination: Ipv4Addr, source: Source let next_hop = if cidr.broadcast() == Some(destination) { NextHop::Broadcast } else { NextHop::Neighbour(destination) }; return Ok(Route { iface: IfIndex(index), next_hop, source: pick_source(i, destination)? }); } - // On the link with no prefix holding it is 169.254/16: resolved and sent to directly, and - // never handed to a router (RFC 3927 §2.6.2, §7). - if let Some((index, i)) = candidates().find(|(_, i)| i.on_link(destination)) { + // RFC 3927 §2.6.2, §2.7: a host of 169.254/16 is resolved and sent to on the link itself, and + // nothing to that prefix is a router's: not its two edges either, which are no host. + if destination.is_link_local() { + let (index, i) = candidates().find(|(_, i)| i.is_neighbour(destination)).ok_or(Counter::RouteNone)?; return Ok(Route { iface: IfIndex(index), next_hop: NextHop::Neighbour(destination), source: pick_source(i, destination)? }); } let (index, i, gateway) = candidates().find_map(|(index, i)| i.active.map(|g| (index, i, g))).ok_or(Counter::RouteNone)?; diff --git a/toyos-net-ip/tests/addr.rs b/toyos-net-ip/tests/addr.rs index 7bd731305f7..233dbd8866c 100644 --- a/toyos-net-ip/tests/addr.rs +++ b/toyos-net-ip/tests/addr.rs @@ -418,31 +418,23 @@ fn s_ip_rte_017_lookups_send_nothing() { const LINK_LOCAL: Ipv4Addr = Ipv4Addr::new(169, 254, 3, 4); const MAC_L: MacAddr = MacAddr([2, 0, 0, 0, 0, 0x4c]); +/// The network and the broadcast address of 169.254/16. +const LINK_LOCAL_EDGES: [Ipv4Addr; 2] = [Ipv4Addr::new(169, 254, 0, 0), Ipv4Addr::new(169, 254, 255, 255)]; // RFC 3927 §2.6.2: "If the destination address is in the 169.254/16 prefix (excluding the address // 169.254.255.255, which is the IPv4 Link-Local subnet broadcast address), then the sender MUST // ARP for the destination address and then send the packet directly to the destination on the // same physical link. This MUST be done whether the interface is configured with a Link-Local -// or a routable IPv4 address." §7: "An IPv4 packet whose source and/or destination address is in -// the 169.254/16 prefix MUST NOT be sent to any router for forwarding". +// or a routable IPv4 address." And: "The host MUST NOT send a packet with an IPv4 Link-Local +// destination address to any router for forwarding." §2.7 says the same of every such packet. #[test] fn rfc_3927_2_6_2_a_link_local_destination_is_on_the_link() { let mut h = H::fixture_i(); let on_the_link = Route { iface: h.if0, next_hop: NextHop::Neighbour(LINK_LOCAL), source: A }; assert_eq!(route(&mut h, LINK_LOCAL), Ok(on_the_link), "with a routable address and a router"); assert_eq!(route(&mut h, REMOTE).map(|r| r.next_hop), Ok(NextHop::Neighbour(R)), "the router is still the way off the link"); - // 169.254.255.255 is that prefix's broadcast, not one of ours: no frame to every host leaves - // for it, and no router is handed it either. - let edge = ip4(169, 254, 255, 255); - assert_eq!(route(&mut h, edge).map(|r| r.next_hop), Ok(NextHop::Neighbour(edge))); h.ip.set_gateways(h.clock(), h.if0, &[]).unwrap(); assert_eq!(route(&mut h, LINK_LOCAL), Ok(on_the_link), "and with no router at all"); - // What is on the link may be the router: a datagram neither from nor to 169.254/16 is its to - // forward. - let router = ip4(169, 254, 0, 1); - h.ip.set_gateways(h.clock(), h.if0, &[router]).unwrap(); - assert_eq!(route(&mut h, REMOTE), Ok(Route { iface: h.if0, next_hop: NextHop::Neighbour(router), source: A })); - assert_eq!(route(&mut h, LINK_LOCAL), Ok(on_the_link)); let mut h = H::bare(); assert_eq!(route(&mut h, LINK_LOCAL), Err(Counter::RouteNoSourceAddress), "an interface with no address sends nothing"); @@ -461,7 +453,8 @@ fn rfc_3927_2_6_2_a_link_local_destination_is_on_the_link() { } // The same rule on the wire: the request names the destination itself, its reply is a -// neighbour's, and no frame is the router's. +// neighbour's, and no frame is the router's. RFC 3927 §2.5: a host sends every ARP packet whose +// sender is link-local, a reply too, to the link's broadcast address. #[test] fn rfc_3927_2_6_2_a_datagram_to_a_link_local_host_is_resolved_and_sent_to_it() { let mut h = H::fixture_i_with(R, MAC_R); @@ -471,7 +464,7 @@ fn rfc_3927_2_6_2_a_datagram_to_a_link_local_host_is_resolved_and_sent_to_it() { let request = asked[0].arp().unwrap(); assert_eq!((asked[0].to(), request.sender_ip, request.target_ip), (MacAddr::BROADCAST, A, LINK_LOCAL)); h.at(5); - h.reply_from(LINK_LOCAL, MAC_L); + h.frame(ð(MacAddr::BROADCAST, MAC_L, 0x0806, &arp_packet(2, MAC_L, LINK_LOCAL, MAC_A, A))); assert!(h.is_reachable(LINK_LOCAL)); assert_eq!(h.count(Counter::ArpSenderOffLink), 0); let out = h.out(); @@ -479,3 +472,53 @@ fn rfc_3927_2_6_2_a_datagram_to_a_link_local_host_is_resolved_and_sent_to_it() { assert_eq!((out[0].to(), out[0].ip().unwrap().destination()), (MAC_L, LINK_LOCAL)); assert!(matches!(h.udp_to(LINK_LOCAL), Ok(Some(frame)) if destination_of(&frame) == MAC_L)); } + +// RFC 3927 §2.6.2 excludes 169.254.255.255, "the IPv4 Link-Local subnet broadcast address", from +// what is resolved, and 169.254.0.0 is that prefix's network address: neither is a host. On an +// interface whose own prefix is another they are nothing it may send to: no request asks for +// them, and by §2.7 no router is handed them, though the router is resolved and a frame to it +// could leave at once. +#[test] +fn rfc_3927_2_6_2_the_edges_of_the_link_local_prefix_are_no_destination() { + let mut h = H::fixture_i_with(R, MAC_R); + for edge in LINK_LOCAL_EDGES { + assert_eq!(route(&mut h, edge), Err(Counter::RouteNone), "{edge}"); + assert_eq!(h.udp_to(edge), Err(Counter::RouteNone), "{edge}"); + assert!(h.state(edge).is_none(), "{edge}"); + } + assert!(h.out().is_empty(), "no request, and no frame to the router"); + assert!(h.events.contains(&toyos_net_ip::Event::Unreachable(toyos_net_ip::Flow { + source: A, + source_port: toyos_net_wire::Port::new(5001).unwrap(), + destination: LINK_LOCAL_EDGES[1], + destination_port: toyos_net_wire::Port::new(5001).unwrap(), + }))); +} + +// RFC 2132 §3.5: the router option lists "routers on the client's subnet". A router is inside a +// prefix the interface holds: what RFC 3927 puts on the link is a neighbour, and no router. +#[test] +fn rfc_3927_a_link_local_address_is_no_gateway_of_a_routable_interface() { + let mut h = H::fixture_i(); + assert_eq!(h.ip.set_gateways(h.clock(), h.if0, &[ip4(169, 254, 0, 1)]), Err(Counter::RouteGatewayOffLink)); + assert_eq!(h.ip.gateways(h.if0), Some(&[R][..])); +} + +// Present state, and wrong: RFC 3927 §2.6.2 has a host that sends from a link-local source to a +// destination outside 169.254/16 "ARP for the destination address and then send the packet ... +// directly to its destination on the same physical link", and "MUST NOT send the packet to any +// router for forwarding". [ip] decides by the destination alone: with a gateway the datagram is +// the router's, and with none it is refused. The track records it; its exit turns both into the +// destination itself. +#[test] +fn rfc_3927_2_6_2_a_link_local_source_still_sends_by_the_router_or_nowhere() { + let ours = ip4(169, 254, 7, 7); + let router = ip4(169, 254, 0, 1); + let mut h = H::raw(); + let if0 = h.if0; + h.assign(if0, ours, 16, 0); + h.settle(); + assert_eq!(route(&mut h, REMOTE), Err(Counter::RouteNone)); + h.ip.set_gateways(h.clock(), if0, &[router]).unwrap(); + assert_eq!(route(&mut h, REMOTE), Ok(Route { iface: if0, next_hop: NextHop::Neighbour(router), source: ours })); +} diff --git a/toyos-net-ip/tests/nbr.rs b/toyos-net-ip/tests/nbr.rs index df9c1d76357..bfed86d03c9 100644 --- a/toyos-net-ip/tests/nbr.rs +++ b/toyos-net-ip/tests/nbr.rs @@ -155,7 +155,8 @@ fn s_ip_nbr_012_an_off_link_sender_is_answered_not_cached() { // RFC 3927 §2.6.2: a host in 169.254/16 is on this link whatever address the interface holds, so // its request is a neighbour's: answered, and cached to be verified before it is trusted. Before -// the interface has an address nothing is on its link. +// the interface has an address nothing is on its link. §2.5 has that host broadcast its request, +// as every request here is. #[test] fn rfc_3927_2_6_2_a_link_local_sender_is_a_neighbour() { let asker = ip4(169, 254, 3, 4); @@ -174,6 +175,21 @@ fn rfc_3927_2_6_2_a_link_local_sender_is_a_neighbour() { assert_eq!(h.count(Counter::ArpSenderOffLink), 1); } +// 169.254.0.0 and 169.254.255.255 are the network and the broadcast address of that prefix +// (RFC 3927 §2.6.2 names the second), and no host's on any interface: as a sender, in a request +// for our address or in a reply to a request of ours, each is refused as our own prefixes' edges +// are, answered nothing and cached nowhere. +#[test] +fn rfc_3927_the_edges_of_the_link_local_prefix_are_no_senders() { + for edge in [ip4(169, 254, 0, 0), ip4(169, 254, 255, 255)] { + let mut h = H::fixture_i(); + h.frame(&request(MAC_B, edge, A)); + h.frame(ð(MAC_A, MAC_B, 0x0806, &arp_packet(2, MAC_B, edge, MAC_A, A))); + assert_eq!(h.count(Counter::ArpInvalidSenderAddress), 2, "{edge}"); + assert!(h.out().is_empty() && h.state(edge).is_none(), "{edge}"); + } +} + #[test] fn s_ip_nbr_013_a_probe_is_answered_and_not_cached() { let mut h = H::fixture_i(); diff --git a/userland/netstack/node/tests/name.rs b/userland/netstack/node/tests/name.rs index d59ee978c86..96e0681c9ba 100644 --- a/userland/netstack/node/tests/name.rs +++ b/userland/netstack/node/tests/name.rs @@ -11,7 +11,7 @@ mod lan; use std::net::Ipv4Addr; use std::time::Duration; -use common::{arp, sum, terms, A, MAC, MAC_B, MAC_R, R}; +use common::{arp, sum, terms, A, BROADCAST, MAC_B, MAC_R, R}; use lan::{udp, Lan, Seen, Udp, B, OFF_LINK}; use toyos_mdns::Host; use toyos_net_node::{Counter, Refused}; @@ -219,9 +219,11 @@ fn a_second_query_inside_a_second_waits_for_the_second_to_end() { // RFC 3927 §2.6.2: "If the destination address is in the 169.254/16 prefix ... then the sender // MUST ARP for the destination address and then send the packet directly to the destination on // the same physical link. This MUST be done whether the interface is configured with a -// Link-Local or a routable IPv4 address." §7: such a packet is never sent to a router. The -// responder takes a link-local source for one on this link (RFC 6762 §11); the node holds a -// routable lease and a router, asks the link for the asker itself, and answers it there. +// Link-Local or a routable IPv4 address." And: "The host MUST NOT send a packet with an IPv4 +// Link-Local destination address to any router for forwarding" (§2.7 too). The responder takes +// a link-local source for one on this link (RFC 6762 §11); the node holds a routable lease and +// a router, asks the link for the asker itself, and answers it there. §2.5: the asker's ARP +// reply, its sender link-local, comes in a frame to the link's broadcast address. #[test] fn an_answer_to_a_link_local_asker_goes_to_its_own_link_address() { let link_local = Ipv4Addr::new(169, 254, 3, 4); @@ -231,7 +233,7 @@ fn an_answer_to_a_link_local_asker_goes_to_its_own_link_address() { let asked: Vec<&Seen> = lan.sent[frames..].iter().map(|(_, seen)| seen).collect(); assert_eq!(asked, [&Seen::Arp { request: true, sender: A, target: link_local }], "the asker is asked for, and nothing is the router's"); - lan.deliver(&arp(MAC, false, MAC_B, link_local, A)); + lan.deliver(&arp(BROADCAST, false, MAC_B, link_local, A)); let on_the_link = Udp { to: MAC_B, source: A, source_port: MDNS, destination: link_local, port: 53_000, ttl: 255, payload: legacy_response(5) }; assert_eq!(since(&lan, from), [on_the_link]); } From b83237cabd126350990587b06b1a62587ae070ad Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 22:48:55 +0200 Subject: [PATCH 3/4] Review round 2 of #779: an edge of 169.254/16 is no IPv4 source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A datagram whose source is 169.254.255.255 or 169.254.0.0 was admitted on an interface whose own prefix is another, since the input policy read only the held prefixes' edges. RFC 1122 §3.2.1.3 has a host discard a datagram from a broadcast address, RFC 3927 §2.6.2 names 169.254.255.255 that for every host, and the network address goes by the tree's own rule for one. Both are now `ip.invalid-source` on every interface, by the predicate ARP reception and the route already read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- toyos-net-ip/src/input.rs | 4 ++-- toyos-net-ip/tests/addr.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/toyos-net-ip/src/input.rs b/toyos-net-ip/src/input.rs index d2769ea10d0..bb5749451d1 100644 --- a/toyos-net-ip/src/input.rs +++ b/toyos-net-ip/src/input.rs @@ -14,7 +14,7 @@ use toyos_net_wire::udp::UdpDatagram; use toyos_net_wire::Instant; use crate::counters::Counter; -use crate::iface::Interface; +use crate::iface::{link_local_edge, Interface}; use crate::{arp, igmp, Arrival, Cast, Delivery, IfIndex, Ip, Peer}; const IPV6: u16 = 0x86DD; @@ -84,7 +84,7 @@ fn admit(ifaces: &[Interface], i: &Interface, link: MacClass, packet: &Ipv4Packe return Err(Counter::IpNotForUs); }; let unspecified_igmp = source == Ipv4Addr::UNSPECIFIED && packet.protocol() == Protocol::Igmp; - if !unspecified_igmp && (!is_host(source) || i.usable().any(|a| a.cidr.is_edge(source))) { + if !unspecified_igmp && (!is_host(source) || link_local_edge(source) || i.usable().any(|a| a.cidr.is_edge(source))) { return Err(Counter::IpInvalidSource); } if ifaces.iter().any(|j| j.owns(source)) { diff --git a/toyos-net-ip/tests/addr.rs b/toyos-net-ip/tests/addr.rs index 233dbd8866c..a937698afa1 100644 --- a/toyos-net-ip/tests/addr.rs +++ b/toyos-net-ip/tests/addr.rs @@ -495,6 +495,21 @@ fn rfc_3927_2_6_2_the_edges_of_the_link_local_prefix_are_no_destination() { }))); } +// RFC 1122 §3.2.1.3: a datagram whose source is a broadcast address is silently discarded, and +// RFC 3927 §2.6.2 names 169.254.255.255 the link-local prefix's broadcast address for every +// host, whatever it holds; 169.254.0.0 is that prefix's network address, and no host's either. +#[test] +fn rfc_3927_the_edges_of_the_link_local_prefix_are_no_source() { + for edge in LINK_LOCAL_EDGES { + let mut h = H::fixture_i(); + assert_eq!(cast_of(&h.datagram(&udp(edge, A, 5000, 5001, b"hi"))), None, "{edge}"); + assert_eq!(h.count(Counter::IpInvalidSource), 1, "{edge}"); + assert!(h.out().is_empty(), "{edge}: and nothing answers it"); + } + let mut h = H::fixture_i(); + assert_eq!(cast_of(&h.datagram(&udp(LINK_LOCAL, A, 5000, 5001, b"hi"))), Some(Cast::Unicast), "a host of the prefix is a source"); +} + // RFC 2132 §3.5: the router option lists "routers on the client's subnet". A router is inside a // prefix the interface holds: what RFC 3927 puts on the link is a neighbour, and no router. #[test] From 95b889b1541df2dc5eae1b62bd6e59952db2104d Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 23:00:52 +0200 Subject: [PATCH 4/4] The track's departure line measures the input scenarios too IN-01 to IN-09 name no 169.254 and no link-local source, by the round 3 review's reading of them; the refusal of a source at an edge of 169.254/16 is an addition to IN-05's list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 7f658c3eab1..03e83a2490a 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -43,7 +43,7 @@ What stage 3 departs from its specifications: - US-43 expects `ip.not-for-us` for a datagram to an unjoined group in a frame to that group's MAC; the frame filter refuses it first, as `eth.not-for-us` (ETH-23). Exit: the scenario names the frame filter. - DH-64 expects an announce request from the client on link-up with a lease held; `toyos-net-ip` announces the held address itself on link-up, by the owner's ruling to announce rather than probe again, so the client asks nothing. Exit: the scenario drops the request, or [ip] stops announcing on its own. - The readers' UDP specification lets every datagram a closed socket had accepted leave; `toyos-net-udp` holds at most one socket's queue of them together and refuses the rest `udp.tx-discarded-on-close`, because the readers' stage 4 design holds nothing in the shard without a bound. Exit: `s_udp_us_053_accepted_datagrams_outlive_close` finds `udp.tx-discarded-on-close` at 0, every datagram a closed socket accepted leaving. -- The readers' IP specification has no scenario for 169.254/16 on an interface that holds a routable address: its routing scenarios are RTE-01 to RTE-17 and its ARP reception scenarios NBR-01 to NBR-18, none names that prefix, and NBR-12's `arp.sender-off-link` is expected of a sender in no prefix at all, which its 198.51.100.9 still is. It has none either for two next hops of which one never answers: NUD-04 overflows one neighbour's queue and NUD-05 fills the interface's, and in neither does a second neighbour answer. The tests that hold the two rules therefore name their RFC section and carry no id: the `rfc_3927_…` tests in `toyos-net-ip/tests/addr.rs` and `nbr.rs`, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` in `tests/nud.rs`. Exit: the readers add a scenario for each, and each test carries its id. +- The readers' IP specification has no scenario for 169.254/16 on an interface that holds a routable address: its routing scenarios are RTE-01 to RTE-17 and its ARP reception scenarios NBR-01 to NBR-18, none names that prefix, and NBR-12's `arp.sender-off-link` is expected of a sender in no prefix at all, which its 198.51.100.9 still is. Its input scenarios are IN-01 to IN-09, of which none names 169.254 or a link-local source: IN-05, the nearest, refuses a source that is no host or an edge of a held prefix, and [ip]'s refusal of a source at an edge of 169.254/16 on every interface, `ip.invalid-source`, is an addition to that step's list. It has none either for two next hops of which one never answers: NUD-04 overflows one neighbour's queue and NUD-05 fills the interface's, and in neither does a second neighbour answer. The tests that hold the two rules therefore name their RFC section and carry no id: the `rfc_3927_…` tests in `toyos-net-ip/tests/addr.rs` and `nbr.rs`, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` in `tests/nud.rs`. Exit: the readers add a scenario for each, and each test carries its id. What stage 3 does not yet meet: