From fcbac431e9986469e139677056a8de5506a7c8bc Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 22:33:02 +0200 Subject: [PATCH 1/6] The pipe ABI carries a datagram socket's broadcast permission, and the option read is gone toyos::net gains MsgType::UdpSetOption (15) on SocketOptionRequest, OPT_BROADCAST, and ERR_PERMISSION_DENIED with NetError::PermissionDenied: the word for a datagram refused for want of the permission, apart from invalid input. std's UdpSocket::set_broadcast sends the request and then keeps the value, shared by every duplicate since they name one netstack socket, and broadcast() answers it. libc defines SO_BROADCAST (6, Linux's number like its neighbours), sends it for a bound datagram socket, keeps it in the socket's entry for one not yet bound and hands it over at bind, and maps the new word to EACCES. netstack answers the request done for a datagram socket it holds, ERR_NOT_CONNECTED otherwise and ERR_INVALID_INPUT for an option it does not know; smoltcp sends to a broadcast address for every socket, so nothing is enforced until netd runs on the node. MsgType::TcpGetOption, tcp_get_option, SocketOptionResponse and netstack's handle_tcp_get_option are deleted. libc's getsockopt was the only caller (std answered nodelay() from the value its TcpStream holds already; the mio and socket2 forks call only tcp_set_option), and it now answers TCP_NODELAY and SO_BROADCAST from its socket's entry, so a stream its peer reset is answered where netstack no longer has the id. The three options getsockopt knows refuse a buffer shorter than an int instead of writing four bytes into it. Found on the way and filed: a TCP_NODELAY set before connect is dropped, and sendto on a datagram socket never bound writes to handle 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...uses-the-option-requests-a-host-answers.md | 13 +-- ...-drops-a-tcp-nodelay-set-before-connect.md | 21 +++++ ...s-from-a-datagram-socket-it-never-bound.md | 21 +++++ issues/toyos-has-its-own-network-stack.md | 2 +- sdk/std/sys/net/connection.rs | 12 ++- toyos/src/net.rs | 58 ++++++++++--- userland/libc/include/sys/socket.h | 1 + userland/libc/src/socket.rs | 86 +++++++++++++------ userland/netstack/src/main.rs | 14 ++- 9 files changed, 172 insertions(+), 56 deletions(-) create mode 100644 issues/libc-drops-a-tcp-nodelay-set-before-connect.md create mode 100644 issues/libc-sends-from-a-datagram-socket-it-never-bound.md diff --git a/issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md b/issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md index fd1f1df5948..d5dda726075 100644 --- a/issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md +++ b/issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md @@ -10,9 +10,8 @@ netstack lets a piped connection's socket and id go once the wire is finished and it has closed both of its pipe ends (`bridge_piped`, `userland/netstack/src/main.rs`). A peer's reset does both at once, so a client that still holds the stream names an id netstack no longer has, and -`handle_tcp_set_option` and `handle_tcp_get_option` answer it -`ERR_NOT_CONNECTED`. Before the socket left with its bridge both answered from -the kept socket. +`handle_tcp_set_option` answers it `ERR_NOT_CONNECTED`. Before the socket left +with its bridge it answered from the kept socket. What a host answers `std::net::TcpStream` on a stream whose peer reset it, after the read that reported the reset: @@ -21,10 +20,12 @@ after the read that reported the reset: |---|---|---| | macOS (Darwin 27.0.0), measured | `Err(InvalidInput)`, `EINVAL` | `Ok(false)` | | Linux, by the review of the change that made this, not measured | `Ok(())` | `Ok` | -| ToyOS, read from the code | `Err(NotConnected)` | `Err(NotConnected)` | +| ToyOS, read from the code | `Err(NotConnected)` | `Ok`, the value last set | -So the write is refused as macOS refuses it, under another kind, and the read -is refused where both hosts answer. Keeping the id until the client's close +So the write is refused as macOS refuses it, under another kind. The read asks +netstack nothing: std answers from the value its `TcpStream` holds +(`sdk/std/sys/net/connection.rs`) and libc's `getsockopt` from its socket's +entry (`userland/libc/src/socket.rs`). Keeping the id until the client's close request is not the fix: a client that dies sends none, and netstack, having closed its pipe ends, has nothing left that says the client is gone. diff --git a/issues/libc-drops-a-tcp-nodelay-set-before-connect.md b/issues/libc-drops-a-tcp-nodelay-set-before-connect.md new file mode 100644 index 00000000000..47016b93f21 --- /dev/null +++ b/issues/libc-drops-a-tcp-nodelay-set-before-connect.md @@ -0,0 +1,21 @@ +--- +status: open +kind: defect +opened: 2026-10-08 +--- + +# libc drops a TCP_NODELAY set before connect + +Read from the code, not run. `setsockopt` (`userland/libc/src/socket.rs`) +sends `TCP_NODELAY` to netstack only for a socket netstack holds, and a stream +socket is one only from `connect` or `accept`. Set on a socket not yet +connected, the option answers 0, is stored nowhere and is not sent when the +connection is made; `getsockopt` then answers 0 for it, which is true of what +netstack holds and not of what the program was told. Linux keeps the option +across `connect`. `SO_BROADCAST` on a datagram socket not yet bound is kept in +the socket's entry and sent at `bind`; this is the same case with no such +path. + +**Exit**: a `TCP_NODELAY` set before `connect` reaches netstack with the +connection or is refused by name, and a guest C case reads it back after +`connect`. diff --git a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md new file mode 100644 index 00000000000..9361655fcb8 --- /dev/null +++ b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md @@ -0,0 +1,21 @@ +--- +status: open +kind: defect +opened: 2026-10-08 +--- + +# libc sends from a datagram socket it never bound + +Read from the code, not run. netstack holds a datagram socket from `bind` +(`toyos::net::udp_bind`), which is also where libc's socket entry gets its two +pipe ends. `sendto` (`userland/libc/src/socket.rs`) does not bind a socket +that was never bound: it writes the datagram to the entry's `tx_fd`, which is +0 and names whatever handle 0 is in the process, and then asks netstack to +send from socket id 0. POSIX binds an unbound datagram socket to an ephemeral +port at its first send, and a C program that broadcasts, `socket`, +`setsockopt(SO_BROADCAST)`, `sendto`, does exactly this. std has no such +socket: `UdpSocket::bind` is its only constructor. + +**Exit**: the first `sendto` on an unbound datagram socket binds it to a port +netstack chooses, handing over a `SO_BROADCAST` already set as `bind` does, +and a guest C case sends from a socket it never bound. diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index e95e20e367f..27d4b8a7d77 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -25,7 +25,7 @@ What the node does not yet meet: - It counts a DHCP message [udp] refused as `node.dhcp-unsent`, whatever the rule; the `dhcp.renew-unroutable` scenario owed above is not written. Exit: that scenario names the counter, or the node counts the renewal apart. - With the link down the DHCP client keeps its timers: every 4 to 64 s the node has a deadline, builds a DISCOVER that [udp] refuses, and counts it in `dhcp.tx.discover` and `node.dhcp-unsent`, from `Node::new` on. Exit: `toyos-dhcp`'s client is told the link went down and waits for it, and the node's first DISCOVER is the one that leaves. - The word each of [udp]'s refusals is answered in on the pipe ABI is `Refused` in `userland/netstack/node/src/datagram.rs`, tested by `each_refusal_is_answered_in_the_pipes_word_for_it` against no reader's scenario; `udp.no-ephemeral-port` has a word and no test that reaches it. Exit: the scenario owed below exists, and the test names its id. -- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it: the pipe ABI has no request that permits it, and std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) answers `Ok` to a permission nothing grants. No program in the tree sends to a broadcast address or sets the option; the sources of the third-party C programs an image builds from a recipe have not been searched. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The move of netd onto the node does not land with this open. Exit, both before the move, as a stage of their own: that search, for `SO_BROADCAST` and a `sendto` to a broadcast address, is posted with its command and output; and the pipe ABI carries the permission from std's and libc's setters to `Udp::set_broadcast`, a send refused for want of it answered in a word that says permission and not input. +- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. Nothing enforces it yet: netd on smoltcp answers the request done and sends to a broadcast address for every socket, and the node has no call that takes the permission and answers the refusal invalid input. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The move of netd onto the node does not land with this open. Exit: the node hands the permission to `Udp::set_broadcast` and answers a send refused for want of it `ERR_PERMISSION_DENIED`, held by a node test; and behind the move a guest test, `udp_broadcast_needs_its_permission`, sends to the limited broadcast address before and after `set_broadcast(true)` and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red. - 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`. diff --git a/sdk/std/sys/net/connection.rs b/sdk/std/sys/net/connection.rs index 10f89ee4ec3..aad1cc4d99d 100644 --- a/sdk/std/sys/net/connection.rs +++ b/sdk/std/sys/net/connection.rs @@ -24,6 +24,7 @@ fn net_err_to_io(e: NetError) -> io::Error { NetError::NotConnected => io::ErrorKind::NotConnected, NetError::InvalidInput => io::ErrorKind::InvalidInput, NetError::NetstackNotFound => io::ErrorKind::NotConnected, + NetError::PermissionDenied => io::ErrorKind::PermissionDenied, _ => io::ErrorKind::Other, }; io::Error::new(kind, "netstack error") @@ -490,6 +491,8 @@ pub struct UdpSocket { rx_fd: OwnedFd, local: SocketAddr, peer: crate::sync::Mutex>, + /// What netstack holds for the socket every duplicate names. + broadcast: Arc, read_timeout_ms: AtomicU32, write_timeout_ms: AtomicU32, } @@ -515,6 +518,7 @@ impl UdpSocket { rx_fd: unsafe { OwnedFd::from_raw_fd(bound.rx.into_raw().0 as i32) }, local: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), bound.bound_port)), peer: crate::sync::Mutex::new(None), + broadcast: Arc::new(AtomicBool::new(false)), read_timeout_ms: AtomicU32::new(0), write_timeout_ms: AtomicU32::new(0), }) @@ -574,6 +578,7 @@ impl UdpSocket { rx_fd: unsafe { OwnedFd::from_raw_fd(new_rx_fd.0 as i32) }, local: self.local, peer: crate::sync::Mutex::new(*self.peer.lock().unwrap()), + broadcast: Arc::clone(&self.broadcast), read_timeout_ms: AtomicU32::new(self.read_timeout_ms.load(Relaxed)), write_timeout_ms: AtomicU32::new(self.write_timeout_ms.load(Relaxed)), }) @@ -599,12 +604,15 @@ impl UdpSocket { Ok(if ms == 0 { None } else { Some(Duration::from_millis(ms as u64)) }) } - pub fn set_broadcast(&self, _broadcast: bool) -> io::Result<()> { + pub fn set_broadcast(&self, broadcast: bool) -> io::Result<()> { + toyos::net::udp_set_option(self.socket_id(), toyos::net::OPT_BROADCAST, broadcast as u32) + .map_err(net_err_to_io)?; + self.broadcast.store(broadcast, Relaxed); Ok(()) } pub fn broadcast(&self) -> io::Result { - Ok(false) + Ok(self.broadcast.load(Relaxed)) } pub fn set_multicast_loop_v4(&self, _: bool) -> io::Result<()> { diff --git a/toyos/src/net.rs b/toyos/src/net.rs index 84b4d4e0a67..b9332b14a9c 100644 --- a/toyos/src/net.rs +++ b/toyos/src/net.rs @@ -19,7 +19,7 @@ pub enum MsgType { UdpClose = 11, DnsLookup = 12, TcpSetOption = 13, - TcpGetOption = 14, + UdpSetOption = 15, TcpConnectPiped = 20, TcpBindPiped = 21, TcpAcceptPiped = 22, @@ -36,7 +36,7 @@ impl MsgType { 11 => Some(Self::UdpClose), 12 => Some(Self::DnsLookup), 13 => Some(Self::TcpSetOption), - 14 => Some(Self::TcpGetOption), + 15 => Some(Self::UdpSetOption), 20 => Some(Self::TcpConnectPiped), 21 => Some(Self::TcpBindPiped), 22 => Some(Self::TcpAcceptPiped), @@ -74,9 +74,16 @@ pub const ERR_INVALID_INPUT: u32 = 6; /// so a capacity refusal on that code is indistinguishable from an ordinary /// failed connection — including to a test trying to find where the cap is. pub const ERR_RESOURCE_EXHAUSTED: u32 = 7; +/// The socket was not given the permission the request needs: a datagram to a +/// broadcast address from a socket whose [`OPT_BROADCAST`] is off. The request +/// was well formed, which is what keeps this apart from [`ERR_INVALID_INPUT`]. +pub const ERR_PERMISSION_DENIED: u32 = 8; pub const ERR_OTHER: u32 = 255; +/// A stream's Nagle algorithm off: [`tcp_set_option`]. pub const OPT_NODELAY: u32 = 1; +/// A datagram socket may send to a broadcast address: [`udp_set_option`]. +pub const OPT_BROADCAST: u32 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetError { @@ -90,6 +97,8 @@ pub enum NetError { /// netstack is at its own limit. Retryable against the same peer, unlike /// [`NetError::ConnectionRefused`] — see [`ERR_RESOURCE_EXHAUSTED`]. ResourceExhausted, + /// See [`ERR_PERMISSION_DENIED`]. + PermissionDenied, Protocol(u32), Io, } @@ -104,6 +113,7 @@ impl NetError { ERR_NOT_CONNECTED => NetError::NotConnected, ERR_INVALID_INPUT => NetError::InvalidInput, ERR_RESOURCE_EXHAUSTED => NetError::ResourceExhausted, + ERR_PERMISSION_DENIED => NetError::PermissionDenied, ERR_OTHER => NetError::Io, // An older client meets a newer netstack here rather than at a panic: // an unknown code is still an error, and still says which one. @@ -212,16 +222,14 @@ ipc_payload! { pub len: u16, } + /// [`MsgType::TcpSetOption`] and [`MsgType::UdpSetOption`]: the request's + /// type says which kind of socket `socket_id` names. pub struct SocketOptionRequest { pub socket_id: u32, pub option: u32, pub value: u32, } - pub struct SocketOptionResponse { - pub value: u32, - } - pub struct ErrorResponse { pub code: u32, } @@ -488,13 +496,6 @@ pub fn tcp_set_option(socket_id: TcpSocketId, option: u32, value: u32) -> Result .status() } -pub fn tcp_get_option(socket_id: TcpSocketId, option: u32) -> Result { - let resp: SocketOptionResponse = NetstackConn::connect()? - .request(MsgType::TcpGetOption, &SocketOptionRequest { socket_id: socket_id.0, option, value: 0 })? - .response()?; - Ok(resp.value) -} - // UDP client functions pub fn udp_bind(addr: [u8; 4], port: u16) -> Result { @@ -535,6 +536,12 @@ pub fn udp_close(socket_id: UdpSocketId) -> Result<(), NetError> { .status() } +pub fn udp_set_option(socket_id: UdpSocketId, option: u32, value: u32) -> Result<(), NetError> { + NetstackConn::connect()? + .request(MsgType::UdpSetOption, &SocketOptionRequest { socket_id: socket_id.0, option, value })? + .status() +} + pub fn dns_lookup(hostname: &str, results: &mut [[u8; 4]]) -> Result { let mut buf = [0u8; 256]; let n = NetstackConn::connect()? @@ -628,7 +635,32 @@ mod tests { NetError::from_error_code(ERR_CONNECTION_REFUSED), NetError::ConnectionRefused ); + assert_eq!(NetError::from_error_code(ERR_PERMISSION_DENIED), NetError::PermissionDenied); + assert_eq!(NetError::from_error_code(ERR_INVALID_INPUT), NetError::InvalidInput); assert_eq!(NetError::from_error_code(ERR_OTHER), NetError::Io); assert_eq!(NetError::from_error_code(4242), NetError::Protocol(4242)); } + + /// The option request as netstack decodes it: three words in the order + /// socket, option, value, and nothing shorter. + #[test] + fn an_option_request_is_three_words_on_the_wire() { + let wire = [0x44, 0x33, 0x22, 0x11, 2, 0, 0, 0, 1, 0, 0, 0]; + let request: SocketOptionRequest = crate::ipc::decode_payload(&wire).unwrap(); + assert_eq!(request.socket_id, 0x1122_3344); + assert_eq!(request.option, OPT_BROADCAST); + assert_eq!(request.value, 1); + assert!(matches!( + crate::ipc::decode_payload::(&wire[..11]), + Err(IpcError::Malformed) + )); + } + + /// A datagram socket's option has a request of its own, apart from a stream's. + #[test] + fn a_datagram_sockets_option_is_request_fifteen() { + assert_eq!(MsgType::UdpSetOption as u32, 15); + assert_eq!(MsgType::from_u32(15), Some(MsgType::UdpSetOption)); + assert_eq!(MsgType::from_u32(13), Some(MsgType::TcpSetOption)); + } } diff --git a/userland/libc/include/sys/socket.h b/userland/libc/include/sys/socket.h index dfda2863f05..a25ada6026f 100644 --- a/userland/libc/include/sys/socket.h +++ b/userland/libc/include/sys/socket.h @@ -23,6 +23,7 @@ typedef unsigned int socklen_t; #define SOL_SOCKET 1 #define SO_REUSEADDR 2 #define SO_ERROR 4 +#define SO_BROADCAST 6 #define SO_KEEPALIVE 9 #define SO_RCVTIMEO 20 #define SO_SNDTIMEO 21 diff --git a/userland/libc/src/socket.rs b/userland/libc/src/socket.rs index bdda0d2576b..f62e65840db 100644 --- a/userland/libc/src/socket.rs +++ b/userland/libc/src/socket.rs @@ -5,9 +5,9 @@ use alloc::vec::Vec; use core::ptr; use toyos_abi::RawHandle; use toyos_abi::syscall; -use toyos::net::{NetError, TcpSocketId, UdpSocketId, OPT_NODELAY}; +use toyos::net::{NetError, TcpSocketId, UdpSocketId, OPT_BROADCAST, OPT_NODELAY}; -use crate::errno::{EADDRINUSE, EAFNOSUPPORT, EBADF, ECONNREFUSED, ECONNRESET, EINVAL, EIO, ENOMEM, ENOTCONN, ETIMEDOUT}; +use crate::errno::{EACCES, EADDRINUSE, EAFNOSUPPORT, EBADF, ECONNREFUSED, ECONNRESET, EINVAL, EIO, ENOMEM, ENOTCONN, ETIMEDOUT}; // C types matching POSIX @@ -53,6 +53,7 @@ const IPPROTO_TCP: i32 = 6; const SOL_SOCKET: i32 = 1; const SO_ERROR: i32 = 4; +const SO_BROADCAST: i32 = 6; const TCP_NODELAY: i32 = 1; // Internal socket table @@ -76,6 +77,11 @@ struct SocketEntry { rx_fd: i32, // read end of rx pipe (netstack→client) tx_fd: i32, // write end of tx pipe (client→netstack) notify_fd: i32, // read end of listener notify pipe + // What netstack holds for the socket, which `getsockopt` answers from. A + // datagram socket netstack does not hold yet keeps `broadcast` for `bind` + // to hand over. + nodelay: bool, + broadcast: bool, } const MAX_SOCKETS: usize = 128; @@ -116,6 +122,7 @@ fn net_err_to_errno(e: NetError) -> i32 { NetError::AddrInUse => EADDRINUSE, NetError::NotConnected => ENOTCONN, NetError::InvalidInput => EINVAL, + NetError::PermissionDenied => EACCES, _ => EIO, } } @@ -174,6 +181,8 @@ pub unsafe extern "C" fn socket(domain: i32, sock_type: i32, _protocol: i32) -> rx_fd: 0, tx_fd: 0, notify_fd: 0, + nodelay: false, + broadcast: false, }; let fd = alloc_socket(entry); if fd < 0 { @@ -253,6 +262,14 @@ pub unsafe extern "C" fn bind(fd: i32, addr: *const Sockaddr, addrlen: SocklenT) Ok(b) => b, Err(e) => { set_errno(net_err_to_errno(e)); return -1; } }; + if entry.broadcast { + if let Err(e) = toyos::net::udp_set_option(bound.socket_id, OPT_BROADCAST, 1) { + // `bound`'s pipe ends close where it drops. + let _ = toyos::net::udp_close(bound.socket_id); + set_errno(net_err_to_errno(e)); + return -1; + } + } entry.netstack_id = bound.socket_id.0; entry.local_port = bound.bound_port; entry.bound = true; @@ -310,6 +327,8 @@ pub unsafe extern "C" fn accept( rx_fd: accepted.rx.into_raw().0 as i32, tx_fd: accepted.tx.into_raw().0 as i32, notify_fd: 0, + nodelay: false, + broadcast: false, }; let new_fd = alloc_socket(new_entry); if new_fd < 0 { @@ -538,26 +557,45 @@ pub unsafe extern "C" fn setsockopt( level: i32, optname: i32, optval: *const u8, - _optlen: SocklenT, + optlen: SocklenT, ) -> i32 { let slot = match sock_from_fd(fd) { Some(s) => s, None => { set_errno(EBADF); return -1; } }; - let entry = match slot.as_ref() { + let entry = match slot.as_mut() { Some(e) => e, None => { set_errno(EBADF); return -1; } }; - // TCP_NODELAY is the only option we actually send to netstack - if level == IPPROTO_TCP && optname == TCP_NODELAY && entry.netstack_id != 0 { - let val = if optval.is_null() { 0u32 } else { *(optval as *const i32) as u32 }; - if let Err(e) = toyos::net::tcp_set_option(TcpSocketId(entry.netstack_id), OPT_NODELAY, val) { - set_errno(net_err_to_errno(e)); - return -1; + match (level, optname) { + (IPPROTO_TCP, TCP_NODELAY) if entry.netstack_id != 0 => { + let val = if optval.is_null() { 0u32 } else { *(optval as *const i32) as u32 }; + if let Err(e) = toyos::net::tcp_set_option(TcpSocketId(entry.netstack_id), OPT_NODELAY, val) { + set_errno(net_err_to_errno(e)); + return -1; + } + entry.nodelay = val != 0; + } + (SOL_SOCKET, SO_BROADCAST) => { + if optval.is_null() || (optlen as usize) < core::mem::size_of::() { + set_errno(EINVAL); + return -1; + } + let on = (optval as *const i32).read_unaligned() != 0; + // Only a datagram is ever sent to a broadcast address, and netstack + // holds a datagram socket from its `bind`. + if let (SocketKind::Udp, true) = (entry.kind, entry.netstack_id != 0) { + if let Err(e) = toyos::net::udp_set_option(UdpSocketId(entry.netstack_id), OPT_BROADCAST, on as u32) { + set_errno(net_err_to_errno(e)); + return -1; + } + } + entry.broadcast = on; } + // All other options silently succeed (SO_REUSEADDR, SO_KEEPALIVE, etc.) + _ => {} } - // All other options silently succeed (SO_REUSEADDR, SO_KEEPALIVE, etc.) 0 } @@ -583,22 +621,18 @@ pub unsafe extern "C" fn getsockopt( return -1; } - if level == IPPROTO_TCP && optname == TCP_NODELAY && entry.netstack_id != 0 { - match toyos::net::tcp_get_option(TcpSocketId(entry.netstack_id), OPT_NODELAY) { - Ok(val) => { - *(optval as *mut i32) = val as i32; - *optlen = 4; - return 0; - } - Err(e) => { - set_errno(net_err_to_errno(e)); - return -1; - } + let held = match (level, optname) { + (IPPROTO_TCP, TCP_NODELAY) => Some(entry.nodelay as i32), + (SOL_SOCKET, SO_BROADCAST) => Some(entry.broadcast as i32), + (SOL_SOCKET, SO_ERROR) => Some(0), + _ => None, + }; + if let Some(value) = held { + if (*optlen as usize) < core::mem::size_of::() { + set_errno(EINVAL); + return -1; } - } - - if level == SOL_SOCKET && optname == SO_ERROR { - *(optval as *mut i32) = 0; + (optval as *mut i32).write_unaligned(value); *optlen = 4; return 0; } diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs index 0bd5fcc9fc9..0e35fef34be 100644 --- a/userland/netstack/src/main.rs +++ b/userland/netstack/src/main.rs @@ -837,7 +837,7 @@ impl Netstack { Some(MsgType::UdpClose) => self.handle_udp_close(&req, socket_set), Some(MsgType::DnsLookup) => self.handle_dns_lookup(req, socket_set), Some(MsgType::TcpSetOption) => self.handle_tcp_set_option(&req, socket_set), - Some(MsgType::TcpGetOption) => self.handle_tcp_get_option(&req, socket_set), + Some(MsgType::UdpSetOption) => self.handle_udp_set_option(&req), Some(MsgType::TcpConnectPiped) => self.handle_tcp_connect_piped(req, socket_set, iface), Some(MsgType::TcpBindPiped) => self.handle_tcp_bind_piped(&req, socket_set), Some(MsgType::TcpAcceptPiped) => self.handle_tcp_accept_piped(&req, socket_set), @@ -1125,21 +1125,19 @@ impl Netstack { } } - fn handle_tcp_get_option(&mut self, msg: &Request, socket_set: &mut SocketSet<'_>) { + /// smoltcp sends to a broadcast address for every socket, so the + /// permission has nothing here to switch. + fn handle_udp_set_option(&self, msg: &Request) { let Ok(req) = ipc::decode_payload::(msg.payload()) else { msg.client.error(ERR_INVALID_INPUT); return; }; - let Some(SocketKind::TcpStream(handle)) = self.sockets.get(&req.socket_id) else { + let Some(SocketKind::Udp(_)) = self.sockets.get(&req.socket_id) else { msg.client.error(ERR_NOT_CONNECTED); return; }; - let socket = socket_set.get_mut::(*handle); match req.option { - OPT_NODELAY => { - let val = if socket.nagle_enabled() { 0u32 } else { 1u32 }; - msg.client.result(&SocketOptionResponse { value: val }); - } + OPT_BROADCAST => msg.client.done(), _ => msg.client.error(ERR_INVALID_INPUT), } } From e55d6031090b78804d65fd3045838bb0a808edf4 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 22:50:49 +0200 Subject: [PATCH 2/6] libc reads an IPv4 address in host order: filed Found by a development probe of the option table: connect to an address built with htonl answered -1 where one built with inet_addr answered 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...ibc-reads-an-ipv4-address-in-host-order.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 issues/libc-reads-an-ipv4-address-in-host-order.md diff --git a/issues/libc-reads-an-ipv4-address-in-host-order.md b/issues/libc-reads-an-ipv4-address-in-host-order.md new file mode 100644 index 00000000000..5bed7718be9 --- /dev/null +++ b/issues/libc-reads-an-ipv4-address-in-host-order.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-10-08 +--- + +# libc reads an IPv4 address in host order + +`sockaddr_in`'s `s_addr` is in network byte order: its four bytes in memory +are the address's four octets. libc's `parse_sockaddr` +(`userland/libc/src/socket.rs`) takes them with `s_addr.to_be_bytes()`, which +on a little-endian machine reverses them, and `fill_sockaddr`, `getaddrinfo` +and `inet_addr` write `u32::from_be_bytes(ip)`, reversed the same way. So an +address libc made itself comes back right, and one a program made as POSIX +says does not: `inet_pton` copies the octets in order and `htonl` swaps, and +`connect` reads either backwards. + +Measured in a guest on `tests/netcase`, x86-64, with a host server listening: +`connect` to `{htonl(0x0a000202)}` answered -1, and to +`{inet_addr("10.0.2.2")}` answered 0. + +**Exit**: every address libc reads or writes is its octets in memory order, +and a guest C case connects to an address built with `htonl` and to one built +with `inet_pton`. From 716e618ee655039cf7b0d70ae82c9d484df1725a Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 8 Oct 2026 23:17:02 +0200 Subject: [PATCH 3/6] Round 1 of #783's review: std's broadcast under one lock, libc truncates a short getsockopt, the maps name every word std keeps a datagram socket's broadcast permission under one mutex held across the request and the store, so two duplicates setting opposite values cannot leave broadcast() disagreeing with what netstack holds. libc's getsockopt copies min(*optlen, 4) bytes of a known option and writes that length back, as POSIX's "silently truncated" says and as Darwin 27.0.0 answers for SO_BROADCAST and TCP_NODELAY at optlen 2, 1 and 0 (measured with one C program; Linux unread). setsockopt checks the value once for both options it knows: null or shorter than an int is EINVAL, which Darwin answers for both too. net_err_to_io and net_err_to_errno name every NetError and have no wildcard, so the next word does not compile until it is mapped. a_datagram_sockets_option_is_request_fifteen asserted what its enum says and is deleted. The track's broadcast exit says which half is owed before the move and which after, and names the C case that reads libc's setter. The three libc issues name their owner and stage; filed: the socket2 fork's SO_BROADCAST setter that reaches nobody, and TcpStream's nodelay held per duplicate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...-drops-a-tcp-nodelay-set-before-connect.md | 2 ++ ...ibc-reads-an-ipv4-address-in-host-order.md | 2 ++ ...s-from-a-datagram-socket-it-never-bound.md | 9 +++++- ...d-holds-a-streams-nodelay-per-duplicate.md | 26 +++++++++++++++ ...-forks-so-broadcast-setter-does-nothing.md | 24 ++++++++++++++ issues/toyos-has-its-own-network-stack.md | 2 +- sdk/std/sys/net/connection.rs | 14 ++++---- toyos/src/net.rs | 8 ----- userland/libc/src/socket.rs | 32 +++++++++---------- 9 files changed, 87 insertions(+), 32 deletions(-) create mode 100644 issues/std-holds-a-streams-nodelay-per-duplicate.md create mode 100644 issues/the-socket2-forks-so-broadcast-setter-does-nothing.md diff --git a/issues/libc-drops-a-tcp-nodelay-set-before-connect.md b/issues/libc-drops-a-tcp-nodelay-set-before-connect.md index 47016b93f21..cc081fbfd7b 100644 --- a/issues/libc-drops-a-tcp-nodelay-set-before-connect.md +++ b/issues/libc-drops-a-tcp-nodelay-set-before-connect.md @@ -19,3 +19,5 @@ path. **Exit**: a `TCP_NODELAY` set before `connect` reaches netstack with the connection or is refused by name, and a guest C case reads it back after `connect`. + +**Owner**: libc, in a libc stage of its own under `issues/toyos-has-its-own-network-stack.md`: it touches only `userland/libc` and guest C cases, and lands before the guest tests that follow the move of netd onto the node. diff --git a/issues/libc-reads-an-ipv4-address-in-host-order.md b/issues/libc-reads-an-ipv4-address-in-host-order.md index 5bed7718be9..92e8912ff0e 100644 --- a/issues/libc-reads-an-ipv4-address-in-host-order.md +++ b/issues/libc-reads-an-ipv4-address-in-host-order.md @@ -22,3 +22,5 @@ Measured in a guest on `tests/netcase`, x86-64, with a host server listening: **Exit**: every address libc reads or writes is its octets in memory order, and a guest C case connects to an address built with `htonl` and to one built with `inet_pton`. + +**Owner**: libc, in a libc stage of its own under `issues/toyos-has-its-own-network-stack.md`: it touches only `userland/libc` and guest C cases, and lands before the guest tests that follow the move of netd onto the node. diff --git a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md index 9361655fcb8..588bebb00c5 100644 --- a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md +++ b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md @@ -16,6 +16,13 @@ port at its first send, and a C program that broadcasts, `socket`, `setsockopt(SO_BROADCAST)`, `sendto`, does exactly this. std has no such socket: `UdpSocket::bind` is its only constructor. +That program does not compile either: `userland/libc/include/netinet/in.h` +defines no `INADDR_BROADCAST`. + **Exit**: the first `sendto` on an unbound datagram socket binds it to a port netstack chooses, handing over a `SO_BROADCAST` already set as `bind` does, -and a guest C case sends from a socket it never bound. +`netinet/in.h` defines `INADDR_BROADCAST`, and a guest C case sends from a +socket it never bound. + +**Owner**: libc, in a libc stage of its own under `issues/toyos-has-its-own-network-stack.md`: it touches only `userland/libc` and guest C cases, and lands before the guest tests that follow the move of netd onto the node. The C case the track's broadcast line names for the sequence without +`bind` waits on this. diff --git a/issues/std-holds-a-streams-nodelay-per-duplicate.md b/issues/std-holds-a-streams-nodelay-per-duplicate.md new file mode 100644 index 00000000000..f5ddbd738b2 --- /dev/null +++ b/issues/std-holds-a-streams-nodelay-per-duplicate.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-10-08 +--- + +# std holds a stream's nodelay per duplicate + +Read from the code, not run. `TcpStream::try_clone` +(`sdk/std/sys/net/connection.rs`, `duplicate`) shares the one netstack socket +between the stream and its duplicate and gives the duplicate its own copy of +`nodelay`. `set_nodelay` on either changes the socket both name and stores the +value only in the one it was called on, so `nodelay()` on the other answers +what was true before. A host's getter reads the kernel's value and cannot +differ. Two threads setting opposite values on one stream can also reach +netstack in one order and the store in the other. `UdpSocket`'s `broadcast` +is one value shared by every duplicate and locked across its request. + +**Exit**: `nodelay` is one value shared by every duplicate of a stream and +locked across the request that changes it, and a guest test sets it through a +duplicate and reads it from the original. + +**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`, with the +reset-stream option test of +`issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md`, +which reads the same value. diff --git a/issues/the-socket2-forks-so-broadcast-setter-does-nothing.md b/issues/the-socket2-forks-so-broadcast-setter-does-nothing.md new file mode 100644 index 00000000000..579c5a70e3c --- /dev/null +++ b/issues/the-socket2-forks-so-broadcast-setter-does-nothing.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-10-08 +--- + +# The socket2 fork's SO_BROADCAST setter does nothing + +Read from the fork at the commit the lock names (`1d90250`), not run. On +ToyOS, socket2's `setsockopt` (`src/sys/toyos.rs`) matches +`(SOL_SOCKET, SO_BROADCAST)` to an empty arm and answers `Ok`, and its +`getsockopt` answers 0 for it: a setter that says yes to a permission it hands +to nobody, where std's `UdpSocket::set_broadcast` and libc's `setsockopt` send +`toyos::net::udp_set_option`. No send follows it today: the backend holds no +datagram socket at all, so a program that reaches a datagram socket through +socket2 has none on ToyOS. + +**Exit**: the fork's ToyOS backend either carries a datagram socket whose +`SO_BROADCAST` reaches netstack and reads back, or refuses the option by name +on a socket it cannot send a datagram from; the lock names the fork commit +that does. + +**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`; the +change is a commit on the socket2 fork's `toyos` branch. diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 27d4b8a7d77..c42edd5ca9b 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -25,7 +25,7 @@ What the node does not yet meet: - It counts a DHCP message [udp] refused as `node.dhcp-unsent`, whatever the rule; the `dhcp.renew-unroutable` scenario owed above is not written. Exit: that scenario names the counter, or the node counts the renewal apart. - With the link down the DHCP client keeps its timers: every 4 to 64 s the node has a deadline, builds a DISCOVER that [udp] refuses, and counts it in `dhcp.tx.discover` and `node.dhcp-unsent`, from `Node::new` on. Exit: `toyos-dhcp`'s client is told the link went down and waits for it, and the node's first DISCOVER is the one that leaves. - The word each of [udp]'s refusals is answered in on the pipe ABI is `Refused` in `userland/netstack/node/src/datagram.rs`, tested by `each_refusal_is_answered_in_the_pipes_word_for_it` against no reader's scenario; `udp.no-ephemeral-port` has a word and no test that reaches it. Exit: the scenario owed below exists, and the test names its id. -- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. Nothing enforces it yet: netd on smoltcp answers the request done and sends to a broadcast address for every socket, and the node has no call that takes the permission and answers the refusal invalid input. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The move of netd onto the node does not land with this open. Exit: the node hands the permission to `Udp::set_broadcast` and answers a send refused for want of it `ERR_PERMISSION_DENIED`, held by a node test; and behind the move a guest test, `udp_broadcast_needs_its_permission`, sends to the limited broadcast address before and after `set_broadcast(true)` and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red. +- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. Nothing enforces it yet: netd on smoltcp answers the request done and sends to a broadcast address for every socket, and the node has no call that takes the permission and answers the refusal invalid input. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The socket2 fork's setter reaches nobody: `issues/the-socket2-forks-so-broadcast-setter-does-nothing.md`. Exit, before the move, and the move does not land without it: the node hands the permission to `Udp::set_broadcast` and answers a send refused for want of it `ERR_PERMISSION_DENIED`, held by a node test. Exit, after the move, which keeps this line open until both are green on netd on the node, since no test can read the refusal on smoltcp: a guest test, `udp_broadcast_needs_its_permission`, sends to the limited broadcast address before and after `set_broadcast(true)` and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red; and a guest C case reads libc's setter, `socket`, `setsockopt(SO_BROADCAST)`, `bind`, `sendto` to the limited broadcast address, refused `EACCES` without the option and sent with it, and with the option set before `bind`, which `bind` no longer handing the kept option over turns red. The sequence without `bind` waits on `issues/libc-sends-from-a-datagram-socket-it-never-bound.md`. - 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`. diff --git a/sdk/std/sys/net/connection.rs b/sdk/std/sys/net/connection.rs index aad1cc4d99d..c593ca3d3d6 100644 --- a/sdk/std/sys/net/connection.rs +++ b/sdk/std/sys/net/connection.rs @@ -25,7 +25,7 @@ fn net_err_to_io(e: NetError) -> io::Error { NetError::InvalidInput => io::ErrorKind::InvalidInput, NetError::NetstackNotFound => io::ErrorKind::NotConnected, NetError::PermissionDenied => io::ErrorKind::PermissionDenied, - _ => io::ErrorKind::Other, + NetError::ResourceExhausted | NetError::Protocol(_) | NetError::Io => io::ErrorKind::Other, }; io::Error::new(kind, "netstack error") } @@ -491,8 +491,9 @@ pub struct UdpSocket { rx_fd: OwnedFd, local: SocketAddr, peer: crate::sync::Mutex>, - /// What netstack holds for the socket every duplicate names. - broadcast: Arc, + /// What netstack holds for the socket every duplicate names, locked across + /// the request that changes it so the two cannot come to differ. + broadcast: Arc>, read_timeout_ms: AtomicU32, write_timeout_ms: AtomicU32, } @@ -518,7 +519,7 @@ impl UdpSocket { rx_fd: unsafe { OwnedFd::from_raw_fd(bound.rx.into_raw().0 as i32) }, local: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), bound.bound_port)), peer: crate::sync::Mutex::new(None), - broadcast: Arc::new(AtomicBool::new(false)), + broadcast: Arc::new(crate::sync::Mutex::new(false)), read_timeout_ms: AtomicU32::new(0), write_timeout_ms: AtomicU32::new(0), }) @@ -605,14 +606,15 @@ impl UdpSocket { } pub fn set_broadcast(&self, broadcast: bool) -> io::Result<()> { + let mut held = self.broadcast.lock().unwrap(); toyos::net::udp_set_option(self.socket_id(), toyos::net::OPT_BROADCAST, broadcast as u32) .map_err(net_err_to_io)?; - self.broadcast.store(broadcast, Relaxed); + *held = broadcast; Ok(()) } pub fn broadcast(&self) -> io::Result { - Ok(self.broadcast.load(Relaxed)) + Ok(*self.broadcast.lock().unwrap()) } pub fn set_multicast_loop_v4(&self, _: bool) -> io::Result<()> { diff --git a/toyos/src/net.rs b/toyos/src/net.rs index b9332b14a9c..b5d991d1533 100644 --- a/toyos/src/net.rs +++ b/toyos/src/net.rs @@ -655,12 +655,4 @@ mod tests { Err(IpcError::Malformed) )); } - - /// A datagram socket's option has a request of its own, apart from a stream's. - #[test] - fn a_datagram_sockets_option_is_request_fifteen() { - assert_eq!(MsgType::UdpSetOption as u32, 15); - assert_eq!(MsgType::from_u32(15), Some(MsgType::UdpSetOption)); - assert_eq!(MsgType::from_u32(13), Some(MsgType::TcpSetOption)); - } } diff --git a/userland/libc/src/socket.rs b/userland/libc/src/socket.rs index f62e65840db..8d3bfb44194 100644 --- a/userland/libc/src/socket.rs +++ b/userland/libc/src/socket.rs @@ -123,7 +123,7 @@ fn net_err_to_errno(e: NetError) -> i32 { NetError::NotConnected => ENOTCONN, NetError::InvalidInput => EINVAL, NetError::PermissionDenied => EACCES, - _ => EIO, + NetError::NetstackNotFound | NetError::ResourceExhausted | NetError::Protocol(_) | NetError::Io => EIO, } } @@ -568,21 +568,24 @@ pub unsafe extern "C" fn setsockopt( None => { set_errno(EBADF); return -1; } }; + // All other options silently succeed (SO_REUSEADDR, SO_KEEPALIVE, etc.) + if !matches!((level, optname), (IPPROTO_TCP, TCP_NODELAY) | (SOL_SOCKET, SO_BROADCAST)) { + return 0; + } + if optval.is_null() || (optlen as usize) < core::mem::size_of::() { + set_errno(EINVAL); + return -1; + } + let on = (optval as *const i32).read_unaligned() != 0; match (level, optname) { (IPPROTO_TCP, TCP_NODELAY) if entry.netstack_id != 0 => { - let val = if optval.is_null() { 0u32 } else { *(optval as *const i32) as u32 }; - if let Err(e) = toyos::net::tcp_set_option(TcpSocketId(entry.netstack_id), OPT_NODELAY, val) { + if let Err(e) = toyos::net::tcp_set_option(TcpSocketId(entry.netstack_id), OPT_NODELAY, on as u32) { set_errno(net_err_to_errno(e)); return -1; } - entry.nodelay = val != 0; + entry.nodelay = on; } (SOL_SOCKET, SO_BROADCAST) => { - if optval.is_null() || (optlen as usize) < core::mem::size_of::() { - set_errno(EINVAL); - return -1; - } - let on = (optval as *const i32).read_unaligned() != 0; // Only a datagram is ever sent to a broadcast address, and netstack // holds a datagram socket from its `bind`. if let (SocketKind::Udp, true) = (entry.kind, entry.netstack_id != 0) { @@ -593,7 +596,6 @@ pub unsafe extern "C" fn setsockopt( } entry.broadcast = on; } - // All other options silently succeed (SO_REUSEADDR, SO_KEEPALIVE, etc.) _ => {} } 0 @@ -628,12 +630,10 @@ pub unsafe extern "C" fn getsockopt( _ => None, }; if let Some(value) = held { - if (*optlen as usize) < core::mem::size_of::() { - set_errno(EINVAL); - return -1; - } - (optval as *mut i32).write_unaligned(value); - *optlen = 4; + // POSIX: a value longer than the buffer is silently truncated. + let len = (*optlen as usize).min(core::mem::size_of::()); + ptr::copy_nonoverlapping(value.to_ne_bytes().as_ptr(), optval, len); + *optlen = len as SocklenT; return 0; } From b6946ca80bebbdf90b64c9f7a2186de6a2ce2412 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 01:21:02 +0200 Subject: [PATCH 4/6] The never-bound send is measured: sendto answers -1 with EIO A development probe on tests/netcase ran sendto on a datagram socket that was never bound: the write to handle 0 was refused and the call answered EIO, the process going on. The issue says what was measured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- .../libc-sends-from-a-datagram-socket-it-never-bound.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md index 588bebb00c5..36d17274d73 100644 --- a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md +++ b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md @@ -6,12 +6,15 @@ opened: 2026-10-08 # libc sends from a datagram socket it never bound -Read from the code, not run. netstack holds a datagram socket from `bind` +netstack holds a datagram socket from `bind` (`toyos::net::udp_bind`), which is also where libc's socket entry gets its two pipe ends. `sendto` (`userland/libc/src/socket.rs`) does not bind a socket that was never bound: it writes the datagram to the entry's `tx_fd`, which is -0 and names whatever handle 0 is in the process, and then asks netstack to -send from socket id 0. POSIX binds an unbound datagram socket to an ephemeral +0 and names whatever handle 0 is in the process, and only then would ask +netstack to send from socket id 0. Measured in a guest on `tests/netcase`, +x86-64, from a job of test-runner's: that write is refused, `sendto` answers +-1 with `errno` 5 (`EIO`), and the process goes on; in a process whose handle +0 takes a write the datagram's bytes go to it. POSIX binds an unbound datagram socket to an ephemeral port at its first send, and a C program that broadcasts, `socket`, `setsockopt(SO_BROADCAST)`, `sendto`, does exactly this. std has no such socket: `UdpSocket::bind` is its only constructor. From 75a93ef6b2cd53b1871b25e4749e15fd0b377eb4 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 01:21:29 +0200 Subject: [PATCH 5/6] The never-bound issue claims only what was measured Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/libc-sends-from-a-datagram-socket-it-never-bound.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md index 36d17274d73..7b134e31b15 100644 --- a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md +++ b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md @@ -13,9 +13,9 @@ that was never bound: it writes the datagram to the entry's `tx_fd`, which is 0 and names whatever handle 0 is in the process, and only then would ask netstack to send from socket id 0. Measured in a guest on `tests/netcase`, x86-64, from a job of test-runner's: that write is refused, `sendto` answers --1 with `errno` 5 (`EIO`), and the process goes on; in a process whose handle -0 takes a write the datagram's bytes go to it. POSIX binds an unbound datagram socket to an ephemeral -port at its first send, and a C program that broadcasts, `socket`, +-1 with `errno` 5 (`EIO`), and the process goes on. What a process whose handle 0 +takes a write sees is unmeasured. POSIX binds an unbound datagram socket to an +ephemeral port at its first send, and a C program that broadcasts, `socket`, `setsockopt(SO_BROADCAST)`, `sendto`, does exactly this. std has no such socket: `UdpSocket::bind` is its only constructor. From 8bceea05457bec3b9129987906d5d332000604bd Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 01:59:26 +0200 Subject: [PATCH 6/6] Round 2 of #783's review: libc's socket option rule in a module the host tests against its own two calls The rule setsockopt and getsockopt share (which options a socket keeps a value for, what a short or null value is refused with, how many bytes a short buffer gets and the length written back) moves to userland/libc/src/sockopt.rs, which reads only what it is handed. toyos-libc-copies puts it and the host's setsockopt and getsockopt through one table: SO_BROADCAST and TCP_NODELAY, a value and a buffer of 8, 4, 2, 1 and 0 bytes and a null one of each, and a null length. libc takes Linux's answers, so three things change: a null value of an int's length or more is EFAULT where it was EINVAL, as is a null buffer or a null length handed to getsockopt, and a getter with a null buffer of length 0 answers 0. The length is judged before the pointer. Darwin differs on two shapes of row, which the test names and holds it to: it judges a setter's pointer before a length of 1 or 2, and answers a getter's null buffer with a read of length 0. getsockopt answers every option it keeps no value for through the same rule, 0 truncated to the buffer, where it wrote nothing into a buffer shorter than an int. The track's broadcast line names the node test that turns to the new word and the slice that makes it, and its guest test reads broadcast() on the socket and on a duplicate. The never-bound issue says what its log shows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...s-from-a-datagram-socket-it-never-bound.md | 7 +- issues/toyos-has-its-own-network-stack.md | 4 +- tests/libc-arch/src/lib.rs | 10 +- tests/libc-arch/src/socket_options.rs | 184 ++++++++++++++++++ userland/libc/src/errno.rs | 1 + userland/libc/src/lib.rs | 1 + userland/libc/src/socket.rs | 68 +++---- userland/libc/src/sockopt.rs | 73 +++++++ 8 files changed, 299 insertions(+), 49 deletions(-) create mode 100644 tests/libc-arch/src/socket_options.rs create mode 100644 userland/libc/src/sockopt.rs diff --git a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md index 7b134e31b15..602498724f8 100644 --- a/issues/libc-sends-from-a-datagram-socket-it-never-bound.md +++ b/issues/libc-sends-from-a-datagram-socket-it-never-bound.md @@ -12,9 +12,10 @@ pipe ends. `sendto` (`userland/libc/src/socket.rs`) does not bind a socket that was never bound: it writes the datagram to the entry's `tx_fd`, which is 0 and names whatever handle 0 is in the process, and only then would ask netstack to send from socket id 0. Measured in a guest on `tests/netcase`, -x86-64, from a job of test-runner's: that write is refused, `sendto` answers --1 with `errno` 5 (`EIO`), and the process goes on. What a process whose handle 0 -takes a write sees is unmeasured. POSIX binds an unbound datagram socket to an +x86-64, from a job of test-runner's: `sendto` answers -1 with `errno` 5 +(`EIO`), and the job exits 0. Which step refused is not shown: libc answers +`EIO` for a refused write and for several of `udp_send_to`'s refusals alike. +What a process whose handle 0 takes a write sees is unmeasured. POSIX binds an unbound datagram socket to an ephemeral port at its first send, and a C program that broadcasts, `socket`, `setsockopt(SO_BROADCAST)`, `sendto`, does exactly this. std has no such socket: `UdpSocket::bind` is its only constructor. diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 8b56fcaebd8..d784b5ab57c 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -17,7 +17,7 @@ netd runs smoltcp. Its replacement is ToyOS's own stack, built clean-room: reade - Then multi-core, netring (blocked on the owner's ABI ruling), TCP and IP hardening, IPv6, offloads, soak. - This track owns the T14's outbound rows, the machine reaching its router and the internet on the I219, judged from the stick: they arrive on this stack and not on smoltcp (owner: "no smoltcp."), and until they do no T14 row reads the wired card (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). -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. +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, the datagram sockets' broadcast permission; then the move. What the node does not yet meet: @@ -25,7 +25,7 @@ What the node does not yet meet: - It counts a DHCP message [udp] refused as `node.dhcp-unsent`, whatever the rule; the `dhcp.renew-unroutable` scenario owed above is not written. Exit: that scenario names the counter, or the node counts the renewal apart. - With the link down the DHCP client keeps its timers: every 4 to 64 s the node has a deadline, builds a DISCOVER that [udp] refuses, and counts it in `dhcp.tx.discover` and `node.dhcp-unsent`, from `Node::new` on. Exit: `toyos-dhcp`'s client is told the link went down and waits for it, and the node's first DISCOVER is the one that leaves. - The word each of [udp]'s refusals is answered in on the pipe ABI is `Refused` in `userland/netstack/node/src/datagram.rs`, tested by `each_refusal_is_answered_in_the_pipes_word_for_it` against no reader's scenario; `udp.no-ephemeral-port` has a word and no test that reaches it. Exit: the scenario owed below exists, and the test names its id. -- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. Nothing enforces it yet: netd on smoltcp answers the request done and sends to a broadcast address for every socket, and the node has no call that takes the permission and answers the refusal invalid input. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The socket2 fork's setter reaches nobody: `issues/the-socket2-forks-so-broadcast-setter-does-nothing.md`. Exit, before the move, and the move does not land without it: the node hands the permission to `Udp::set_broadcast` and answers a send refused for want of it `ERR_PERMISSION_DENIED`, held by a node test. Exit, after the move, which keeps this line open until both are green on netd on the node, since no test can read the refusal on smoltcp: a guest test, `udp_broadcast_needs_its_permission`, sends to the limited broadcast address before and after `set_broadcast(true)` and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red; and a guest C case reads libc's setter, `socket`, `setsockopt(SO_BROADCAST)`, `bind`, `sendto` to the limited broadcast address, refused `EACCES` without the option and sent with it, and with the option set before `bind`, which `bind` no longer handing the kept option over turns red. The sequence without `bind` waits on `issues/libc-sends-from-a-datagram-socket-it-never-bound.md`. +- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. Nothing enforces it yet: netd on smoltcp answers the request done and sends to a broadcast address for every socket, and the node has no call that takes the permission and answers the refusal invalid input. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The socket2 fork's setter reaches nobody: `issues/the-socket2-forks-so-broadcast-setter-does-nothing.md`. Exit, before the move, and the move does not land without it: a slice of its own on the node's datagram sockets gives the node a call that hands the permission to `Udp::set_broadcast` and gives `Refused` (`userland/netstack/node/src/datagram.rs`) the word for `ERR_PERMISSION_DENIED`, and in `each_refusal_is_answered_in_the_pipes_word_for_it` (`userland/netstack/node/tests/datagram.rs`) the two `BroadcastNotPermitted` sends turn from invalid input to that word and the same two sends leave once the socket holds the permission. Exit, after the move, which keeps this line open until both are green on netd on the node, since no test can read the refusal on smoltcp: a guest test, `udp_broadcast_needs_its_permission`, reads `broadcast()` false, then true after `set_broadcast(true)`, on the socket and on a `try_clone` of it, which `broadcast()` answering a constant or a duplicate holding a value of its own turns red, and sends to the limited broadcast address before and after the set and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red; and a guest C case reads libc's setter, `socket`, `setsockopt(SO_BROADCAST)`, `bind`, `sendto` to the limited broadcast address, refused `EACCES` without the option and sent with it, and with the option set before `bind`, which `bind` no longer handing the kept option over turns red. The sequence without `bind` waits on `issues/libc-sends-from-a-datagram-socket-it-never-bound.md`. - 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`. diff --git a/tests/libc-arch/src/lib.rs b/tests/libc-arch/src/lib.rs index c1a68464238..b3c608dd3a1 100644 --- a/tests/libc-arch/src/lib.rs +++ b/tests/libc-arch/src/lib.rs @@ -6,8 +6,9 @@ //! architecture checks its own module. The UTF-8 reader against //! `core::str::from_utf8`, the number reader against the host C library's and //! IEEE 754's rounding of hexadecimal input computed exactly, AArch64's -//! `long double` widening against compiler-builtins', and the errno codes -//! against `include/errno.h`. +//! `long double` widening against compiler-builtins', the errno codes against +//! `include/errno.h`, and the socket option rule against the host's +//! `setsockopt` and `getsockopt`. #[cfg(test)] extern crate alloc; @@ -40,6 +41,9 @@ mod pollreq; #[path = "../../../userland/libc/src/sigmask.rs"] mod sigmask; #[cfg(test)] +#[path = "../../../userland/libc/src/sockopt.rs"] +mod sockopt; +#[cfg(test)] #[path = "../../../userland/libc/src/strtonum.rs"] mod strtonum; #[cfg(test)] @@ -76,6 +80,8 @@ mod prototypes; #[cfg(test)] mod signal_masks; #[cfg(test)] +mod socket_options; +#[cfg(test)] mod strtonum_differential; #[cfg(test)] mod text_differential; diff --git a/tests/libc-arch/src/socket_options.rs b/tests/libc-arch/src/socket_options.rs new file mode 100644 index 00000000000..2ac86db4206 --- /dev/null +++ b/tests/libc-arch/src/socket_options.rs @@ -0,0 +1,184 @@ +//! libc's socket option rule against the host's own `setsockopt` and +//! `getsockopt`, one table through both: `SO_BROADCAST` on a datagram socket +//! and `TCP_NODELAY` on a stream socket, a value and a buffer of 8, 4, 2, 1 +//! and 0 bytes and a null one of each length, and a null length. Compared are +//! the return and its `errno`, the length written back, which bytes of the +//! buffer were written, and whether what they hold is non-zero: a host reads a +//! set option back as any non-zero `int`, Darwin as the option's flag bit. +//! +//! libc's numbers and answers are Linux's, so on Linux every row agrees. Darwin +//! answers the rows [`darwin`] names differently, and is held to those. + +use std::ffi::c_int; +use std::net::{TcpListener, UdpSocket}; +use std::os::fd::AsRawFd; +use std::ptr; + +use crate::header; +use crate::sockopt::{self, Kept, Refusal}; + +const SOCKET_H: &str = include_str!("../../../userland/libc/include/sys/socket.h"); +const IN_H: &str = include_str!("../../../userland/libc/include/netinet/in.h"); + +unsafe extern "C" { + fn setsockopt(fd: c_int, level: c_int, name: c_int, value: *const u8, len: u32) -> c_int; + fn getsockopt(fd: c_int, level: c_int, name: c_int, value: *mut u8, len: *mut u32) -> c_int; + #[cfg_attr(target_os = "macos", link_name = "__error")] + #[cfg_attr(target_os = "linux", link_name = "__errno_location")] + fn errno_location() -> *mut c_int; +} + +/// The host's `EFAULT` and `EINVAL`, which macOS and Linux number alike. +const EFAULT: c_int = 14; +const EINVAL: c_int = 22; + +const LENGTHS: [u32; 5] = [8, 4, 2, 1, 0]; +/// What a buffer holds before a read, which no answer's byte is. +const UNWRITTEN: u8 = 0xAA; + +/// The option as a C program built against libc's headers names it. +fn libc_numbers(option: Kept) -> (c_int, c_int) { + match option { + Kept::Broadcast => (header::int(SOCKET_H, "SOL_SOCKET"), header::int(SOCKET_H, "SO_BROADCAST")), + Kept::NoDelay => (header::int(SOCKET_H, "IPPROTO_TCP"), header::int(IN_H, "TCP_NODELAY")), + } +} + +/// The option as the host numbers it: on Linux, as libc does. +fn host_numbers(option: Kept) -> (c_int, c_int) { + if cfg!(target_os = "macos") { + match option { + Kept::Broadcast => (0xFFFF, 0x20), + Kept::NoDelay => (6, 1), + } + } else { + libc_numbers(option) + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Answer { + /// -1, and this `errno`. A refused read's length is not compared: POSIX + /// leaves it unspecified. + Refused(c_int), + /// A setter's 0. + Set, + /// A getter's 0: the length written back, which bytes were written, and + /// whether they hold a non-zero value. + Read { len: u32, written: [bool; 8], nonzero: bool }, +} + +fn refused(refusal: Refusal) -> Answer { + Answer::Refused(match refusal { + Refusal::Short => EINVAL, + Refusal::Fault => EFAULT, + }) +} + +fn host_refusal() -> Answer { + // SAFETY: the host's errno slot is this thread's. + Answer::Refused(unsafe { *errno_location() }) +} + +fn read(buf: &[u8; 8], len: u32) -> Answer { + let written = buf.map(|byte| byte != UNWRITTEN); + Answer::Read { len, written, nonzero: buf.iter().any(|&byte| byte != UNWRITTEN && byte != 0) } +} + +/// One socket's option as libc keeps it and as the host does. +struct Pair { + option: Kept, + fd: c_int, + kept: bool, +} + +impl Pair { + /// A setter handed `len` bytes at `value`, by libc's rule and by the host. + fn set(&mut self, value: *const u8, len: u32) -> (Answer, Answer) { + let (level, name) = libc_numbers(self.option); + assert_eq!(sockopt::kept(level, name), Some(self.option)); + // SAFETY: `value` is null or eight readable bytes, and `len` at most 8. + let ours = match unsafe { sockopt::switch(value, len) } { + Ok(on) => { + self.kept = on; + Answer::Set + } + Err(refusal) => refused(refusal), + }; + let (level, name) = host_numbers(self.option); + // SAFETY: as above. + let host = match unsafe { setsockopt(self.fd, level, name, value, len) } { + 0 => Answer::Set, + _ => host_refusal(), + }; + (ours, host) + } + + /// A getter handed `len` bytes at `value`, null when `null`, by libc's rule + /// and by the host. + fn get(&self, null: bool, len: u32) -> (Answer, Answer) { + let call = |get: &dyn Fn(*mut u8, *mut u32) -> Option| { + let (mut buf, mut len) = ([UNWRITTEN; 8], len); + let value = if null { ptr::null_mut() } else { buf.as_mut_ptr() }; + get(value, &mut len).unwrap_or_else(|| read(&buf, len)) + }; + // SAFETY: the buffer is null or eight writable bytes, and `len` at most 8. + let ours = call(&|value, len| unsafe { sockopt::answer(self.kept as i32, value, len) }.err().map(refused)); + let (level, name) = host_numbers(self.option); + // SAFETY: as above. + let host = call(&|value, len| (unsafe { getsockopt(self.fd, level, name, value, len) } != 0).then(host_refusal)); + (ours, host) + } +} + +/// What Darwin answers where it is not what Linux and libc do. It judges a +/// setter's pointer before its length unless the length is 0, and a getter's +/// null buffer holds no byte, which it reports as a read of length 0. +fn darwin(setter: bool, null: bool, len: u32) -> Option { + match (setter, null, len) { + (true, true, 1 | 2) => Some(Answer::Refused(EFAULT)), + (false, true, 1..) => Some(Answer::Read { len: 0, written: [false; 8], nonzero: false }), + _ => None, + } +} + +fn hold((ours, host): (Answer, Answer), setter: bool, null: bool, len: u32, row: &str) { + let expected = match darwin(setter, null, len) { + Some(theirs) if cfg!(target_os = "macos") => theirs, + _ => ours, + }; + assert_eq!(host, expected, "{row}: libc answers {ours:?}"); +} + +#[test] +fn an_options_value_crosses_as_the_hosts_does() { + let datagram = UdpSocket::bind("127.0.0.1:0").unwrap(); + let stream = TcpListener::bind("127.0.0.1:0").unwrap(); + for (option, fd) in [(Kept::Broadcast, datagram.as_raw_fd()), (Kept::NoDelay, stream.as_raw_fd())] { + let mut pair = Pair { option, fd, kept: false }; + for on in [1u8, 0, 1] { + let value = [on, 0, 0, 0, 0, 0, 0, 0]; + for len in LENGTHS { + for null in [false, true] { + let row = format!("{option:?} {on}, {len} bytes, null {null}"); + let at = if null { ptr::null() } else { value.as_ptr() }; + hold(pair.set(at, len), true, null, len, &format!("set {row}")); + hold(pair.get(null, len), false, null, len, &format!("get {row}")); + } + } + assert_eq!(pair.kept, on != 0, "{option:?}: the value of four bytes or more was kept"); + let (ours, host) = pair.get(false, 4); + assert_eq!(ours, Answer::Read { len: 4, written: [true, true, true, true, false, false, false, false], nonzero: on != 0 }); + assert_eq!(host, ours); + } + + let (level, name) = host_numbers(option); + let mut buf = [UNWRITTEN; 8]; + // SAFETY: a null length is the refusal asked for; the buffer is eight bytes. + let host = unsafe { getsockopt(fd, level, name, buf.as_mut_ptr(), ptr::null_mut()) }; + assert_eq!((host, host_refusal()), (-1, Answer::Refused(EFAULT)), "{option:?}: a null length"); + // SAFETY: as above. + let ours = unsafe { sockopt::answer(1, buf.as_mut_ptr(), ptr::null_mut()) }; + assert_eq!((ours, buf), (Err(Refusal::Fault), [UNWRITTEN; 8])); + } +} diff --git a/userland/libc/src/errno.rs b/userland/libc/src/errno.rs index ce9cd41f9ce..e07148fd199 100644 --- a/userland/libc/src/errno.rs +++ b/userland/libc/src/errno.rs @@ -26,6 +26,7 @@ pub(crate) const ECHILD: i32 = 10; pub(crate) const EAGAIN: i32 = 11; pub(crate) const ENOMEM: i32 = 12; pub(crate) const EACCES: i32 = 13; +pub(crate) const EFAULT: i32 = 14; pub(crate) const EBUSY: i32 = 16; pub(crate) const EEXIST: i32 = 17; pub(crate) const ENODEV: i32 = 19; diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs index 8ea47af3be4..594b72fc83b 100644 --- a/userland/libc/src/lib.rs +++ b/userland/libc/src/lib.rs @@ -25,6 +25,7 @@ mod pthread; mod refused; mod sigmask; mod socket; +mod sockopt; mod stdio; mod string; mod strtonum; diff --git a/userland/libc/src/socket.rs b/userland/libc/src/socket.rs index 8d3bfb44194..2cdc871b38e 100644 --- a/userland/libc/src/socket.rs +++ b/userland/libc/src/socket.rs @@ -7,7 +7,8 @@ use toyos_abi::RawHandle; use toyos_abi::syscall; use toyos::net::{NetError, TcpSocketId, UdpSocketId, OPT_BROADCAST, OPT_NODELAY}; -use crate::errno::{EACCES, EADDRINUSE, EAFNOSUPPORT, EBADF, ECONNREFUSED, ECONNRESET, EINVAL, EIO, ENOMEM, ENOTCONN, ETIMEDOUT}; +use crate::errno::{EACCES, EADDRINUSE, EAFNOSUPPORT, EBADF, ECONNREFUSED, ECONNRESET, EFAULT, EINVAL, EIO, ENOMEM, ENOTCONN, ETIMEDOUT}; +use crate::sockopt::{self, Kept}; // C types matching POSIX @@ -49,12 +50,6 @@ const AF_INET: i32 = 2; const AF_UNSPEC: i32 = 0; const SOCK_STREAM: i32 = 1; const SOCK_DGRAM: i32 = 2; -const IPPROTO_TCP: i32 = 6; - -const SOL_SOCKET: i32 = 1; -const SO_ERROR: i32 = 4; -const SO_BROADCAST: i32 = 6; -const TCP_NODELAY: i32 = 1; // Internal socket table @@ -551,6 +546,13 @@ pub unsafe extern "C" fn close_socket(fd: i32) -> bool { // setsockopt / getsockopt +fn option_errno(refusal: sockopt::Refusal) -> i32 { + match refusal { + sockopt::Refusal::Short => EINVAL, + sockopt::Refusal::Fault => EFAULT, + } +} + #[no_mangle] pub unsafe extern "C" fn setsockopt( fd: i32, @@ -569,23 +571,21 @@ pub unsafe extern "C" fn setsockopt( }; // All other options silently succeed (SO_REUSEADDR, SO_KEEPALIVE, etc.) - if !matches!((level, optname), (IPPROTO_TCP, TCP_NODELAY) | (SOL_SOCKET, SO_BROADCAST)) { - return 0; - } - if optval.is_null() || (optlen as usize) < core::mem::size_of::() { - set_errno(EINVAL); - return -1; - } - let on = (optval as *const i32).read_unaligned() != 0; - match (level, optname) { - (IPPROTO_TCP, TCP_NODELAY) if entry.netstack_id != 0 => { + let Some(option) = sockopt::kept(level, optname) else { return 0 }; + let on = match sockopt::switch(optval, optlen) { + Ok(on) => on, + Err(refusal) => { set_errno(option_errno(refusal)); return -1; } + }; + match option { + Kept::NoDelay if entry.netstack_id != 0 => { if let Err(e) = toyos::net::tcp_set_option(TcpSocketId(entry.netstack_id), OPT_NODELAY, on as u32) { set_errno(net_err_to_errno(e)); return -1; } entry.nodelay = on; } - (SOL_SOCKET, SO_BROADCAST) => { + Kept::NoDelay => {} + Kept::Broadcast => { // Only a datagram is ever sent to a broadcast address, and netstack // holds a datagram socket from its `bind`. if let (SocketKind::Udp, true) = (entry.kind, entry.netstack_id != 0) { @@ -596,7 +596,6 @@ pub unsafe extern "C" fn setsockopt( } entry.broadcast = on; } - _ => {} } 0 } @@ -618,31 +617,16 @@ pub unsafe extern "C" fn getsockopt( None => { set_errno(EBADF); return -1; } }; - if optval.is_null() || optlen.is_null() { - set_errno(EINVAL); - return -1; - } - - let held = match (level, optname) { - (IPPROTO_TCP, TCP_NODELAY) => Some(entry.nodelay as i32), - (SOL_SOCKET, SO_BROADCAST) => Some(entry.broadcast as i32), - (SOL_SOCKET, SO_ERROR) => Some(0), - _ => None, + let value = match sockopt::kept(level, optname) { + Some(Kept::NoDelay) => entry.nodelay as i32, + Some(Kept::Broadcast) => entry.broadcast as i32, + // Every other option reads 0, `SO_ERROR` among them. + None => 0, }; - if let Some(value) = held { - // POSIX: a value longer than the buffer is silently truncated. - let len = (*optlen as usize).min(core::mem::size_of::()); - ptr::copy_nonoverlapping(value.to_ne_bytes().as_ptr(), optval, len); - *optlen = len as SocklenT; - return 0; - } - - // Default: return 0 for unknown options - if *optlen >= 4 { - *(optval as *mut i32) = 0; - *optlen = 4; + match sockopt::answer(value, optval, optlen) { + Ok(()) => 0, + Err(refusal) => { set_errno(option_errno(refusal)); -1 } } - 0 } // getpeername / getsockname diff --git a/userland/libc/src/sockopt.rs b/userland/libc/src/sockopt.rs new file mode 100644 index 00000000000..0c67a100869 --- /dev/null +++ b/userland/libc/src/sockopt.rs @@ -0,0 +1,73 @@ +//! The socket options libc keeps a value for, and how an option's value +//! crosses `setsockopt` and `getsockopt`: each an `int`, refused and truncated +//! as Linux does, whose option numbers these are. It reads nothing but what it +//! is handed, so the host tests it (`toyos-libc-copies`) against the host's +//! own two calls. + +use core::mem::size_of; + +pub(crate) const IPPROTO_TCP: i32 = 6; +pub(crate) const SOL_SOCKET: i32 = 1; +pub(crate) const SO_BROADCAST: i32 = 6; +pub(crate) const TCP_NODELAY: i32 = 1; + +/// An option a socket keeps a value for. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Kept { + NoDelay, + Broadcast, +} + +/// The kept option `optname` names at `level`. +pub(crate) fn kept(level: i32, optname: i32) -> Option { + match (level, optname) { + (IPPROTO_TCP, TCP_NODELAY) => Some(Kept::NoDelay), + (SOL_SOCKET, SO_BROADCAST) => Some(Kept::Broadcast), + _ => None, + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Refusal { + /// A value shorter than an `int`: `EINVAL`. + Short, + /// A null pointer where bytes are read or written: `EFAULT`. + Fault, +} + +/// What a setter is told: whether the `int` at `optval` is non-zero. The +/// length is judged before the pointer is. +/// +/// # Safety +/// `optval` is null or `optlen` readable bytes. +pub(crate) unsafe fn switch(optval: *const u8, optlen: u32) -> Result { + if (optlen as usize) < size_of::() { + return Err(Refusal::Short); + } + if optval.is_null() { + return Err(Refusal::Fault); + } + Ok(optval.cast::().read_unaligned() != 0) +} + +/// Answers a getter `value`: as many of its bytes as `*optlen` holds, at most +/// an `int`'s, and that count written back. A buffer no byte goes into may be +/// null. +/// +/// # Safety +/// `optlen` is null or a writable length, and `optval` null or `*optlen` +/// writable bytes. +pub(crate) unsafe fn answer(value: i32, optval: *mut u8, optlen: *mut u32) -> Result<(), Refusal> { + if optlen.is_null() { + return Err(Refusal::Fault); + } + let len = (*optlen as usize).min(size_of::()); + if len != 0 { + if optval.is_null() { + return Err(Refusal::Fault); + } + core::ptr::copy_nonoverlapping(value.to_ne_bytes().as_ptr(), optval, len); + } + *optlen = len as u32; + Ok(()) +}