From c46e59feb410200a46825980b0d3ce83b0522c8b Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:06:30 +0900 Subject: [PATCH 01/13] fix: correct default gateway selection and route parsing --- src/interface/mod.rs | 8 - src/os/bsd/route/mod.rs | 245 ++++++++++++++++------------- src/os/darwin/route.rs | 337 +++++++++++++++++++--------------------- src/os/linux/procfs.rs | 218 ++++++++++++++++++-------- src/route/mod.rs | 55 +++++-- 5 files changed, 485 insertions(+), 378 deletions(-) diff --git a/src/interface/mod.rs b/src/interface/mod.rs index 2152940..c7d1141 100644 --- a/src/interface/mod.rs +++ b/src/interface/mod.rs @@ -57,14 +57,6 @@ pub(crate) fn pick_default_iface_index(ifaces: &[Interface], local_ip: IpAddr) - subnet_candidate } -#[cfg(feature = "gateway")] -pub(crate) fn iface_has_ip(iface: &Interface, local_ip: IpAddr) -> bool { - match local_ip { - IpAddr::V4(ipv4) => iface.ipv4.iter().any(|x| x.addr() == ipv4), - IpAddr::V6(ipv6) => iface.ipv6.iter().any(|x| x.addr() == ipv6), - } -} - #[cfg(feature = "gateway")] pub(crate) fn resolve_default_interface(mut ifaces: Vec) -> Result { use crate::net::ip::get_local_ipaddr; diff --git a/src/os/bsd/route/mod.rs b/src/os/bsd/route/mod.rs index 2949855..489abd6 100644 --- a/src/os/bsd/route/mod.rs +++ b/src/os/bsd/route/mod.rs @@ -183,65 +183,36 @@ fn normalize_gateway(ip: IpAddr) -> IpAddr { } } -/// Parse an IP address from a `sockaddr` -fn ip_from_sockaddr(sa: &libc::sockaddr) -> Option { - unsafe { - match sa.sa_family as libc::c_int { - x if x == libc::AF_INET => { - let want = core::mem::size_of::(); - if (sa.sa_len as usize) < want { - return None; - } - let sin = &*(sa as *const _ as *const libc::sockaddr_in); - let n = u32::from_be(sin.sin_addr.s_addr as u32); - Some(IpAddr::V4(Ipv4Addr::from(n))) - } - x if x == libc::AF_INET6 => { - let want = core::mem::size_of::(); - if (sa.sa_len as usize) < want { - return None; - } - let sin6 = &*(sa as *const _ as *const libc::sockaddr_in6); - // `s6_addr` is raw big-endian bytes; `Ipv6Addr::from([u8;16])` expects octets. - Some(IpAddr::V6(Ipv6Addr::from(sin6.sin6_addr.s6_addr))) - } - _ => None, - } +/// Parse a bounded routing-socket address without requiring aligned storage. +fn ip_from_sockaddr(sa: &[u8]) -> Option { + match i32::from(*sa.get(1)?) { + libc::AF_INET if sa.len() >= mem::size_of::() => Some(IpAddr::V4( + Ipv4Addr::from(<[u8; 4]>::try_from(sa.get(4..8)?).ok()?), + )), + libc::AF_INET6 if sa.len() >= mem::size_of::() => Some(IpAddr::V6( + Ipv6Addr::from(<[u8; 16]>::try_from(sa.get(8..24)?).ok()?), + )), + _ => None, } } -fn masklen_from_sockaddr(dst: IpAddr, mask_sa: &libc::sockaddr) -> u8 { - unsafe { - let sa_len = mask_sa.sa_len as usize; - if sa_len == 0 { - return 0; - } - - match dst { - IpAddr::V4(_) => { - const OFF: usize = 4; - if sa_len <= OFF { - return 0; - } - let n = (sa_len - OFF).min(4); - - let base = (mask_sa as *const _ as *const u8).add(OFF); - let mut bytes = [0u8; 4]; - core::ptr::copy_nonoverlapping(base, bytes.as_mut_ptr(), n); - u32::from_be_bytes(bytes).leading_ones() as u8 +fn masklen_from_sockaddr(dst: IpAddr, mask: &[u8]) -> u8 { + // Routing sockets omit trailing zero bytes from masks, including all bytes + // except the length byte for a default-route mask. + match dst { + IpAddr::V4(_) => { + let mut bytes = [0u8; 4]; + for (out, value) in bytes.iter_mut().zip(mask.get(4..).unwrap_or_default()) { + *out = *value; } - IpAddr::V6(_) => { - const OFF: usize = 8; - if sa_len <= OFF { - return 0; - } - let n = (sa_len - OFF).min(16); - - let base = (mask_sa as *const _ as *const u8).add(OFF); - let mut bytes = [0u8; 16]; - core::ptr::copy_nonoverlapping(base, bytes.as_mut_ptr(), n); - u128::from_be_bytes(bytes).leading_ones() as u8 + u32::from_be_bytes(bytes).leading_ones() as u8 + } + IpAddr::V6(_) => { + let mut bytes = [0u8; 16]; + for (out, value) in bytes.iter_mut().zip(mask.get(8..).unwrap_or_default()) { + *out = *value; } + u128::from_be_bytes(bytes).leading_ones() as u8 } } } @@ -288,9 +259,8 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option }) }; - while off + core::mem::size_of::() <= msg.len() { - let sa = unsafe { &*(msg[off..].as_ptr() as *const libc::sockaddr) }; - let sa_len = sa.sa_len as usize; + while off + 2 <= msg.len() { + let sa_len = msg[off] as usize; if sa_len == 0 { off += roundup(0); @@ -301,7 +271,11 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option continue; } - match sa.sa_family as c_int { + let sa = &msg[off..off + sa_len]; + if sa.len() < 2 { + return None; + } + match sa[1] as c_int { x if x == libc::AF_INET => { ip = ip_from_sockaddr(sa); if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { @@ -310,8 +284,8 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option } x if x == libc::AF_INET6 => { if let Some(IpAddr::V6(v6)) = ip_from_sockaddr(sa) { - let sin6 = unsafe { &*(sa as *const _ as *const libc::sockaddr_in6) }; - let (v6, normalized_scope) = normalize_scoped_v6(v6, sin6.sin6_scope_id); + let scope = u32::from_ne_bytes(sa.get(24..28)?.try_into().ok()?); + let (v6, normalized_scope) = normalize_scoped_v6(v6, scope); scope_id = normalized_scope; ip = Some(IpAddr::V6(v6)); if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { @@ -368,53 +342,30 @@ struct RawRoute { } fn parse_one_route(hdr: &rt_msghdr, addr_block: &[u8]) -> Option { - let mut addrs: [Option<*const libc::sockaddr>; RTAX_MAX] = [None; RTAX_MAX]; + let mut addrs: [Option<&[u8]>; RTAX_MAX] = [None; RTAX_MAX]; let mut off = 0usize; - - for idx in 0..RTAX_MAX { + for (idx, entry) in addrs.iter_mut().enumerate() { if (hdr.rtm_addrs & (1 << idx)) != 0 { - if off + mem::size_of::() > addr_block.len() { - return None; - } - let sa = unsafe { &*(addr_block[off..].as_ptr() as *const libc::sockaddr) }; - addrs[idx] = Some(sa as *const libc::sockaddr); - - let sa_len = sa.sa_len as usize; - let step = roundup(if sa_len == 0 { 0 } else { sa_len }); - if off + step > addr_block.len() { - return None; - } + let len = usize::from(*addr_block.get(off)?); + let step = roundup(len); + let bytes = addr_block.get(off..off + step)?; + *entry = Some(&bytes[..len]); off += step; } } - let dptr = addrs[RTAX_DST]? as *const libc::sockaddr; - let dst_sa = unsafe { &*dptr }; - let dst_ip = ip_from_sockaddr(dst_sa)?; - let mut prefix: u8 = match dst_ip { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, - }; - if let Some(mptr) = addrs[RTAX_NETMASK] { - let m_sa = unsafe { &*mptr }; - prefix = if m_sa.sa_len == 0 { - 0 - } else { - masklen_from_sockaddr(dst_ip, m_sa) - }; - } else if (hdr.rtm_flags & libc::RTF_HOST) != 0 { - prefix = match dst_ip { - IpAddr::V4(_) => 32, - _ => 128, - }; - } - - let gateway = if let Some(gptr) = addrs[RTAX_GATEWAY] { - let g_sa = unsafe { &*gptr }; - ip_from_sockaddr(g_sa).map(normalize_gateway) + let dst_ip = ip_from_sockaddr(addrs[RTAX_DST]?)?; + let prefix = if let Some(mask) = addrs[RTAX_NETMASK] { + masklen_from_sockaddr(dst_ip, mask) } else { - None + match dst_ip { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + } }; + let gateway = addrs[RTAX_GATEWAY] + .and_then(ip_from_sockaddr) + .map(normalize_gateway); Some(RawRoute { dst: dst_ip, @@ -464,7 +415,7 @@ fn get_neighbor_entries(address_family: c_int) -> io::Result> let mut off = 0usize; while off + mem::size_of::() <= buf.len() { - let hdr = unsafe { &*(buf[off..].as_ptr() as *const rt_msghdr) }; + let hdr = unsafe { std::ptr::read_unaligned(buf[off..].as_ptr().cast::()) }; let msglen = hdr.rtm_msglen as usize; if msglen == 0 || off + msglen > buf.len() { break; @@ -478,7 +429,7 @@ fn get_neighbor_entries(address_family: c_int) -> io::Result> return Err(code_to_error(hdr.rtm_errno)); } - let hdrlen = message_header_len(hdr); + let hdrlen = message_header_len(&hdr); if hdrlen < mem::size_of::() || off + hdrlen > off + msglen { break; } @@ -495,7 +446,7 @@ fn get_neighbor_entries(address_family: c_int) -> io::Result> #[derive(Default)] struct NeighborTables { - ipv4: HashMap, + ipv4: HashMap<(u32, Ipv4Addr), MacAddr>, ipv6: HashMap<(u32, Ipv6Addr), MacAddr>, } @@ -505,7 +456,7 @@ fn get_neighbor_tables() -> NeighborTables { // Fetch each family independently so failure of one table does not discard the other. for entry in get_neighbor_entries(libc::AF_INET).unwrap_or_default() { if let IpAddr::V4(ip) = entry.ip { - tables.ipv4.insert(ip, entry.mac); + tables.ipv4.insert((entry.ifindex, ip), entry.mac); } } for entry in get_neighbor_entries(libc::AF_INET6).unwrap_or_default() { @@ -547,7 +498,7 @@ fn list_routes() -> io::Result> { let mut off = 0usize; while off + mem::size_of::() <= buf.len() { - let hdr = unsafe { &*(buf[off..].as_ptr() as *const rt_msghdr) }; + let hdr = unsafe { std::ptr::read_unaligned(buf[off..].as_ptr().cast::()) }; let msglen = hdr.rtm_msglen as usize; if msglen == 0 || off + msglen > buf.len() { break; @@ -561,12 +512,12 @@ fn list_routes() -> io::Result> { return Err(code_to_error(hdr.rtm_errno)); } - let hdrlen = message_header_len(hdr); + let hdrlen = message_header_len(&hdr); if hdrlen < mem::size_of::() || off + hdrlen > off + msglen { break; } let addr_block = &buf[off + hdrlen..off + msglen]; - if let Some(rr) = parse_one_route(hdr, addr_block) { + if let Some(rr) = parse_one_route(&hdr, addr_block) { out.push(rr); } @@ -629,7 +580,7 @@ fn build_gateway_map( match gw { IpAddr::V4(v4) => { - if let Some(mac) = neighbor_tables.ipv4.get(&v4).copied() { + if let Some(mac) = neighbor_tables.ipv4.get(&(r.ifindex, v4)).copied() { entry.ipv4_mac = Some(mac); } push_v4(&mut entry.v4, v4); @@ -706,6 +657,45 @@ mod tests { } } + #[test] + fn rejects_truncated_ip_sockaddrs() { + for (family, length) in [(libc::AF_INET, 16usize), (libc::AF_INET6, 28usize)] { + for truncated_length in 2..length { + let mut bytes = vec![0; truncated_length]; + bytes[0] = truncated_length as u8; + bytes[1] = family as u8; + assert_eq!( + ip_from_sockaddr(&bytes), + None, + "family={family}, length={truncated_length}" + ); + } + let mut bytes = vec![0; length]; + bytes[0] = length as u8; + bytes[1] = family as u8; + assert!(ip_from_sockaddr(&bytes).is_some()); + } + } + + #[test] + fn parses_default_route_with_short_zero_mask() { + let mut header: rt_msghdr = unsafe { mem::zeroed() }; + header.rtm_addrs = (1 << RTAX_DST) | (1 << RTAX_GATEWAY) | (1 << RTAX_NETMASK); + header.rtm_index = 2; + let mut bytes = vec![0; roundup(16) * 2 + roundup(0)]; + bytes[0] = 16; + bytes[1] = libc::AF_INET as u8; + let gateway_offset = roundup(16); + bytes[gateway_offset] = 16; + bytes[gateway_offset + 1] = libc::AF_INET as u8; + bytes[gateway_offset + 4..gateway_offset + 8].copy_from_slice(&[192, 0, 2, 1]); + let route = parse_one_route(&header, &bytes).unwrap(); + assert_eq!(route.prefix, 0); + assert_eq!(route.gateway, Some("192.0.2.1".parse().unwrap())); + bytes.pop(); + assert!(parse_one_route(&header, &bytes).is_none()); + } + #[test] fn parses_ipv6_ndp_entry() { let ip: Ipv6Addr = "fe80::1".parse().unwrap(); @@ -736,6 +726,45 @@ mod tests { assert_eq!(entry.ifindex, TEST_IFINDEX); } + #[test] + fn keeps_ipv4_neighbors_separate_by_interface() { + let ip = Ipv4Addr::new(192, 168, 1, 1); + let first_mac = MacAddr::from_octets([0, 1, 2, 3, 4, 5]); + let second_mac = MacAddr::from_octets([6, 7, 8, 9, 10, 11]); + let mut tables = NeighborTables::default(); + tables.ipv4.insert((2, ip), first_mac); + tables.ipv4.insert((3, ip), second_mac); + let gateways = build_gateway_map( + vec![ + default_route(IpAddr::V4(ip), 2), + default_route(IpAddr::V4(ip), 3), + default_route(IpAddr::V4(ip), 4), + ], + &tables, + ); + assert_eq!(gateways[&2].mac_addr, first_mac); + assert_eq!(gateways[&3].mac_addr, second_mac); + assert_eq!(gateways[&4].mac_addr, MacAddr::zero()); + } + + #[test] + fn parses_short_nonzero_masks() { + assert_eq!( + masklen_from_sockaddr( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + &[7, 0, 0, 0, 255, 255, 255] + ), + 24 + ); + assert_eq!( + masklen_from_sockaddr( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + &[10, 0, 0, 0, 0, 0, 0, 0, 255, 255] + ), + 16 + ); + } + #[test] fn keeps_link_local_neighbors_separate_by_interface() { let ip: Ipv6Addr = "fe80::1".parse().unwrap(); @@ -792,7 +821,7 @@ mod tests { let ipv4_mac = MacAddr::from_octets(TEST_MAC); let ipv6_mac = MacAddr::from_octets([0, 1, 2, 3, 4, 5]); let mut tables = NeighborTables::default(); - tables.ipv4.insert(ipv4, ipv4_mac); + tables.ipv4.insert((TEST_IFINDEX, ipv4), ipv4_mac); tables.ipv6.insert((TEST_IFINDEX, ipv6), ipv6_mac); let gateways = build_gateway_map( diff --git a/src/os/darwin/route.rs b/src/os/darwin/route.rs index dfae1d6..d011bc5 100644 --- a/src/os/darwin/route.rs +++ b/src/os/darwin/route.rs @@ -1,6 +1,6 @@ #![allow(non_camel_case_types)] -use libc::{c_char, c_int, c_uchar, pid_t, size_t}; +use libc::{c_int, c_uchar, pid_t, size_t}; use std::{ collections::HashMap, ffi::c_void, @@ -29,7 +29,6 @@ const RTF_LLINFO: c_int = 1024; #[allow(dead_code)] const RTF_UP: c_int = 0x0001; const RTF_GATEWAY: c_int = 0x0002; -const RTF_HOST: c_int = 0x0004; #[allow(dead_code)] const RTF_REJECT: c_int = 0x0008; #[allow(dead_code)] @@ -45,50 +44,6 @@ const RTAX_MAX: usize = 8; // sockaddr alignment const SA_ALIGN: usize = 4; -#[repr(C)] -#[derive(Copy, Clone)] -struct sockaddr { - sa_len: c_uchar, - sa_family: c_uchar, - sa_data: [c_char; 14], -} -#[repr(C)] -#[derive(Copy, Clone)] -struct in_addr { - s_addr: u32, -} -#[repr(C)] -#[derive(Copy, Clone)] -struct in6_addr { - __u6_addr: in6_addr_bind, -} -#[repr(C)] -#[derive(Copy, Clone)] -union in6_addr_bind { - __u6_addr8: [u8; 16], - __u6_addr16: [u16; 8], - __u6_addr32: [u32; 4], -} -#[repr(C)] -#[derive(Copy, Clone)] -struct sockaddr_in { - sin_len: c_uchar, - sin_family: c_uchar, - sin_port: u16, - sin_addr: in_addr, - sin_zero: [c_char; 8], -} -#[repr(C)] -#[derive(Copy, Clone)] -struct sockaddr_in6 { - sin6_len: c_uchar, - sin6_family: c_uchar, - sin6_port: u16, - sin6_flowinfo: u32, - sin6_addr: in6_addr, - sin6_scope_id: u32, -} - #[repr(C)] #[derive(Debug, Copy, Clone)] struct rt_metrics { @@ -241,49 +196,36 @@ fn normalize_gateway(ip: IpAddr) -> IpAddr { } } -/// Parse an IP address from a `sockaddr` -fn ip_from_sockaddr(sa: &sockaddr) -> Option { - unsafe { - match sa.sa_family as c_int { - AF_INET => { - let want = core::mem::size_of::(); - if (sa.sa_len as usize) < want { - return None; - } - let sin = &*(sa as *const _ as *const libc::sockaddr_in); - let n = u32::from_be(sin.sin_addr.s_addr as u32); - Some(IpAddr::V4(Ipv4Addr::from(n))) - } - AF_INET6 => { - // Require the full `sockaddr_in6` to be present. - let want = core::mem::size_of::(); - if (sa.sa_len as usize) < want { - // prevent reading a truncated variable-length sockaddr - return None; - } - let sin6 = &*(sa as *const _ as *const libc::sockaddr_in6); - // `s6_addr` is raw big-endian bytes; `Ipv6Addr::from([u8;16])` expects octets. - let addr_bytes = (*sin6).sin6_addr.s6_addr; - Some(IpAddr::V6(Ipv6Addr::from(addr_bytes))) - } - _ => None, - } +/// Parse a bounded routing-socket address without requiring aligned storage. +fn ip_from_sockaddr(sa: &[u8]) -> Option { + match i32::from(*sa.get(1)?) { + libc::AF_INET if sa.len() >= mem::size_of::() => Some(IpAddr::V4( + Ipv4Addr::from(<[u8; 4]>::try_from(sa.get(4..8)?).ok()?), + )), + libc::AF_INET6 if sa.len() >= mem::size_of::() => Some(IpAddr::V6( + Ipv6Addr::from(<[u8; 16]>::try_from(sa.get(8..24)?).ok()?), + )), + _ => None, } } -fn masklen_from_sockaddr(dst: IpAddr, mask_sa: &sockaddr) -> u8 { - unsafe { - match dst { - IpAddr::V4(_) => { - let m: &sockaddr_in = &*(mask_sa as *const _ as *const sockaddr_in); - u32::from_be(m.sin_addr.s_addr).leading_ones() as u8 +fn masklen_from_sockaddr(dst: IpAddr, mask: &[u8]) -> u8 { + // Routing sockets omit trailing zero bytes from masks, including all bytes + // except the length byte for a default-route mask. + match dst { + IpAddr::V4(_) => { + let mut bytes = [0u8; 4]; + for (out, value) in bytes.iter_mut().zip(mask.get(4..).unwrap_or_default()) { + *out = *value; } - IpAddr::V6(_) => { - let m: &sockaddr_in6 = &*(mask_sa as *const _ as *const sockaddr_in6); - let b = m.sin6_addr.__u6_addr.__u6_addr8; - let v = u128::from_be_bytes(b); - v.leading_ones() as u8 + u32::from_be_bytes(bytes).leading_ones() as u8 + } + IpAddr::V6(_) => { + let mut bytes = [0u8; 16]; + for (out, value) in bytes.iter_mut().zip(mask.get(8..).unwrap_or_default()) { + *out = *value; } + u128::from_be_bytes(bytes).leading_ones() as u8 } } } @@ -292,7 +234,7 @@ fn code_to_error(err: i32) -> io::Error { let kind = match err { 17 => io::ErrorKind::AlreadyExists, // EEXIST 3 => io::ErrorKind::NotFound, // ESRCH - 3436 => io::ErrorKind::OutOfMemory, // ENOBUFS + libc::ENOBUFS => io::ErrorKind::OutOfMemory, _ => io::ErrorKind::Other, }; @@ -330,10 +272,8 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option }; // Walk `sockaddr` records while there is room for a header. - while off + core::mem::size_of::() <= msg.len() { - // Read the sockaddr header - let sa = unsafe { &*(msg[off..].as_ptr() as *const sockaddr) }; - let sa_len = sa.sa_len as usize; + while off + 2 <= msg.len() { + let sa_len = msg[off] as usize; // `sa_len == 0` can appear as "no address" (alignment-only slot). // Advance by the platform's alignment unit (4 on BSD/Darwin). @@ -347,7 +287,11 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option continue; } - match sa.sa_family as c_int { + let sa = &msg[off..off + sa_len]; + if sa.len() < 2 { + return None; + } + match sa[1] as c_int { AF_INET => { // Target IPv4 of ARP. `sockaddr_in` and `sockaddr_inarp` share the initial layout, // so `sin_addr` sits at the same position. @@ -358,8 +302,7 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option } AF_INET6 => { if let Some(parsed_ip @ IpAddr::V6(_)) = ip_from_sockaddr(sa) { - let sin6 = unsafe { &*(sa as *const _ as *const libc::sockaddr_in6) }; - scope_id = sin6.sin6_scope_id; + scope_id = u32::from_ne_bytes(sa.get(24..28)?.try_into().ok()?); ip = Some(parsed_ip); if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { return Some(entry); @@ -367,44 +310,29 @@ fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option } } AF_LINK => { - if sa_len < core::mem::size_of::() { + if sa_len < mem::size_of::() { off += roundup(sa_len); continue; } - // Extract LLADDR from `sockaddr_dl`. - let sdl = unsafe { &*(sa as *const _ as *const libc::sockaddr_dl) }; - let nlen = sdl.sdl_nlen as usize; - let alen = sdl.sdl_alen as usize; - let total = sdl.sdl_len as usize; - link_ifindex = sdl.sdl_index as u32; - - // Validate against the *actual* struct length (`sdl_len`), and also - // make sure the caller-provided `sa_len` is at least that long. - if total >= core::mem::size_of::() - && alen >= 6 - && sa_len >= total - { - let base = sa as *const _ as *const u8; - let data_base = &sdl.sdl_data as *const _ as *const u8; - let data_off = unsafe { data_base.offset_from(base) } as usize; - - // LLADDR is at `sdl_data + sdl_nlen`. - if data_off + nlen + alen <= total { - let mac_ptr = unsafe { data_base.add(nlen) }; - let m = MacAddr::from_octets(unsafe { - [ - *mac_ptr.add(0), - *mac_ptr.add(1), - *mac_ptr.add(2), - *mac_ptr.add(3), - *mac_ptr.add(4), - *mac_ptr.add(5), - ] - }); - mac = Some(m); - if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { - return Some(entry); - } + // Read the variable-length link address within its declared bounds. + let sdl = &msg[off..off + sa_len]; + let nlen = sdl[5] as usize; + let alen = sdl[6] as usize; + link_ifindex = u16::from_ne_bytes([sdl[2], sdl[3]]) as u32; + + let mac_start = 8 + nlen; + if alen >= 6 && mac_start + alen <= sdl.len() { + let m = MacAddr::from_octets([ + sdl[mac_start], + sdl[mac_start + 1], + sdl[mac_start + 2], + sdl[mac_start + 3], + sdl[mac_start + 4], + sdl[mac_start + 5], + ]); + mac = Some(m); + if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { + return Some(entry); } } } @@ -430,55 +358,30 @@ struct RawRoute { } fn parse_one_route(hdr: &rt_msghdr, addr_block: &[u8]) -> Option { - let mut addrs: [Option<*const sockaddr>; RTAX_MAX] = [None; RTAX_MAX]; + let mut addrs: [Option<&[u8]>; RTAX_MAX] = [None; RTAX_MAX]; let mut off = 0usize; - - for idx in 0..RTAX_MAX { + for (idx, entry) in addrs.iter_mut().enumerate() { if (hdr.rtm_addrs & (1 << idx)) != 0 { - if off + mem::size_of::() > addr_block.len() { - return None; - } - let sa = unsafe { &*(addr_block[off..].as_ptr() as *const sockaddr) }; - addrs[idx] = Some(sa as *const sockaddr); - - let sa_len = sa.sa_len as usize; - let step = roundup(if sa_len == 0 { 0 } else { sa_len }); - if off + step > addr_block.len() { - return None; - } + let len = usize::from(*addr_block.get(off)?); + let step = roundup(len); + let bytes = addr_block.get(off..off + step)?; + *entry = Some(&bytes[..len]); off += step; } } - let dptr = addrs[RTAX_DST]? as *const sockaddr; - let dst_sa = unsafe { &*dptr }; - let dst_ip = ip_from_sockaddr(dst_sa)?; - let mut prefix: u8 = match dst_ip { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, - }; - if let Some(mptr) = addrs[RTAX_NETMASK] { - let m_sa = unsafe { &*mptr }; - // sa_len==0 is possible for default route - prefix = if m_sa.sa_len == 0 { - 0 - } else { - masklen_from_sockaddr(dst_ip, m_sa) - }; - } else if (hdr.rtm_flags & RTF_HOST) != 0 { - prefix = match dst_ip { - IpAddr::V4(_) => 32, - _ => 128, - }; - } - - // gateway - let gateway = if let Some(gptr) = addrs[RTAX_GATEWAY] { - let g_sa = unsafe { &*gptr }; - ip_from_sockaddr(g_sa).map(normalize_gateway) + let dst_ip = ip_from_sockaddr(addrs[RTAX_DST]?)?; + let prefix = if let Some(mask) = addrs[RTAX_NETMASK] { + masklen_from_sockaddr(dst_ip, mask) } else { - None + match dst_ip { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + } }; + let gateway = addrs[RTAX_GATEWAY] + .and_then(ip_from_sockaddr) + .map(normalize_gateway); Some(RawRoute { dst: dst_ip, @@ -506,10 +409,10 @@ fn get_neighbor_entries(address_family: c_int) -> io::Result> let mut off = 0usize; // Each record starts with `rt_msghdr` followed by a variable-length sockaddr block. while off + mem::size_of::() <= buf.len() { - // Header view (no copy). - let hdr = unsafe { &*(buf[off..].as_ptr() as *const rt_msghdr) }; + // Copy the header without requiring the byte buffer to be aligned. + let hdr = unsafe { std::ptr::read_unaligned(buf[off..].as_ptr().cast::()) }; let msglen = hdr.rtm_msglen as usize; - if msglen == 0 || off + msglen > buf.len() { + if msglen < mem::size_of::() || off + msglen > buf.len() { break; } @@ -536,7 +439,7 @@ fn get_neighbor_entries(address_family: c_int) -> io::Result> #[derive(Default)] struct NeighborTables { - ipv4: HashMap, + ipv4: HashMap<(u32, Ipv4Addr), MacAddr>, ipv6: HashMap<(u32, Ipv6Addr), MacAddr>, } @@ -547,7 +450,7 @@ fn get_neighbor_tables() -> NeighborTables { // and an ARP failure cannot prevent IPv6-only gateways from being resolved. for entry in get_neighbor_entries(AF_INET).unwrap_or_default() { if let IpAddr::V4(ip) = entry.ip { - tables.ipv4.insert(ip, entry.mac); + tables.ipv4.insert((entry.ifindex, ip), entry.mac); } } for entry in get_neighbor_entries(AF_INET6).unwrap_or_default() { @@ -577,9 +480,9 @@ fn list_routes() -> io::Result> { let mut off = 0usize; while off + mem::size_of::() <= buf.len() { - let hdr = unsafe { &*(buf[off..].as_ptr() as *const rt_msghdr) }; + let hdr = unsafe { std::ptr::read_unaligned(buf[off..].as_ptr().cast::()) }; let msglen = hdr.rtm_msglen as usize; - if msglen == 0 || off + msglen > buf.len() { + if msglen < mem::size_of::() || off + msglen > buf.len() { break; } // Version mismatch: skip the record but keep reading. @@ -597,7 +500,7 @@ fn list_routes() -> io::Result> { } let addr_block = &buf[off + mem::size_of::()..off + msglen]; - if let Some(rr) = parse_one_route(hdr, addr_block) { + if let Some(rr) = parse_one_route(&hdr, addr_block) { out.push(rr); } @@ -660,7 +563,7 @@ fn build_gateway_map( match gw { IpAddr::V4(v4) => { - if let Some(mac) = neighbor_tables.ipv4.get(&v4).copied() { + if let Some(mac) = neighbor_tables.ipv4.get(&(r.ifindex, v4)).copied() { entry.ipv4_mac = Some(mac); } push_v4(&mut entry.v4, v4); @@ -741,6 +644,45 @@ mod tests { } } + #[test] + fn rejects_truncated_ip_sockaddrs() { + for (family, length) in [(libc::AF_INET, 16usize), (libc::AF_INET6, 28usize)] { + for truncated_length in 2..length { + let mut bytes = vec![0; truncated_length]; + bytes[0] = truncated_length as u8; + bytes[1] = family as u8; + assert_eq!( + ip_from_sockaddr(&bytes), + None, + "family={family}, length={truncated_length}" + ); + } + let mut bytes = vec![0; length]; + bytes[0] = length as u8; + bytes[1] = family as u8; + assert!(ip_from_sockaddr(&bytes).is_some()); + } + } + + #[test] + fn parses_default_route_with_short_zero_mask() { + let mut header: rt_msghdr = unsafe { mem::zeroed() }; + header.rtm_addrs = (1 << RTAX_DST) | (1 << RTAX_GATEWAY) | (1 << RTAX_NETMASK); + header.rtm_index = 2; + let mut bytes = vec![0; roundup(16) * 2 + roundup(0)]; + bytes[0] = 16; + bytes[1] = libc::AF_INET as u8; + let gateway_offset = roundup(16); + bytes[gateway_offset] = 16; + bytes[gateway_offset + 1] = libc::AF_INET as u8; + bytes[gateway_offset + 4..gateway_offset + 8].copy_from_slice(&[192, 0, 2, 1]); + let route = parse_one_route(&header, &bytes).unwrap(); + assert_eq!(route.prefix, 0); + assert_eq!(route.gateway, Some("192.0.2.1".parse().unwrap())); + bytes.pop(); + assert!(parse_one_route(&header, &bytes).is_none()); + } + #[test] fn parses_ipv4_arp_entry() { let ip = Ipv4Addr::new(192, 168, 10, 1); @@ -813,6 +755,45 @@ mod tests { assert_eq!(message_to_neighbor(&truncated_link, TEST_IFINDEX), None); } + #[test] + fn keeps_ipv4_neighbors_separate_by_interface() { + let ip = Ipv4Addr::new(192, 168, 1, 1); + let first_mac = MacAddr::from_octets([0, 1, 2, 3, 4, 5]); + let second_mac = MacAddr::from_octets([6, 7, 8, 9, 10, 11]); + let mut tables = NeighborTables::default(); + tables.ipv4.insert((2, ip), first_mac); + tables.ipv4.insert((3, ip), second_mac); + let gateways = build_gateway_map( + vec![ + default_route(IpAddr::V4(ip), 2), + default_route(IpAddr::V4(ip), 3), + default_route(IpAddr::V4(ip), 4), + ], + &tables, + ); + assert_eq!(gateways[&2].mac_addr, first_mac); + assert_eq!(gateways[&3].mac_addr, second_mac); + assert_eq!(gateways[&4].mac_addr, MacAddr::zero()); + } + + #[test] + fn parses_short_nonzero_masks() { + assert_eq!( + masklen_from_sockaddr( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + &[7, 0, 0, 0, 255, 255, 255] + ), + 24 + ); + assert_eq!( + masklen_from_sockaddr( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + &[10, 0, 0, 0, 0, 0, 0, 0, 255, 255] + ), + 16 + ); + } + #[test] fn keeps_link_local_neighbors_separate_by_interface() { let ip: Ipv6Addr = "fe80::1".parse().unwrap(); @@ -869,7 +850,7 @@ mod tests { let ipv4_mac = MacAddr::from_octets(TEST_MAC); let ipv6_mac = MacAddr::from_octets([0, 1, 2, 3, 4, 5]); let mut tables = NeighborTables::default(); - tables.ipv4.insert(ipv4, ipv4_mac); + tables.ipv4.insert((TEST_IFINDEX, ipv4), ipv4_mac); tables.ipv6.insert((TEST_IFINDEX, ipv6), ipv6_mac); let gateways = build_gateway_map( diff --git a/src/os/linux/procfs.rs b/src/os/linux/procfs.rs index 7ae51d5..dca9c2d 100644 --- a/src/os/linux/procfs.rs +++ b/src/os/linux/procfs.rs @@ -3,106 +3,120 @@ use crate::net::mac::MacAddr; use std::collections::HashMap; use std::fs::read_to_string; use std::net::{Ipv4Addr, Ipv6Addr}; -use std::str::FromStr; const PATH_PROC_NET_ROUTE: &str = "/proc/net/route"; const PATH_PROC_NET_IPV6_ROUTE: &str = "/proc/net/ipv6_route"; const PATH_PROC_NET_ARP: &str = "/proc/net/arp"; -fn convert_hex_ipv4(hex_ip: &str) -> Ipv4Addr { +fn convert_hex_ipv4(hex_ip: &str) -> Option { if hex_ip.len() != 8 { - return Ipv4Addr::UNSPECIFIED; + return None; } - let o1: u8 = u8::from_str_radix(&hex_ip[6..8], 0x10).unwrap_or(0); - let o2: u8 = u8::from_str_radix(&hex_ip[4..6], 0x10).unwrap_or(0); - let o3: u8 = u8::from_str_radix(&hex_ip[2..4], 0x10).unwrap_or(0); - let o4: u8 = u8::from_str_radix(&hex_ip[0..2], 0x10).unwrap_or(0); - Ipv4Addr::new(o1, o2, o3, o4) + // /proc/net/route prints the native-endian integer backing the IPv4 address. + u32::from_str_radix(hex_ip, 16) + .ok() + .map(|value| Ipv4Addr::from(value.to_ne_bytes())) } -fn convert_hex_ipv6(hex_ip: &str) -> Ipv6Addr { +fn convert_hex_ipv6(hex_ip: &str) -> Option { if hex_ip.len() != 32 { - return Ipv6Addr::UNSPECIFIED; + return None; } - let h1: u16 = u16::from_str_radix(&hex_ip[0..4], 0x10).unwrap_or(0); - let h2: u16 = u16::from_str_radix(&hex_ip[4..8], 0x10).unwrap_or(0); - let h3: u16 = u16::from_str_radix(&hex_ip[8..12], 0x10).unwrap_or(0); - let h4: u16 = u16::from_str_radix(&hex_ip[12..16], 0x10).unwrap_or(0); - let h5: u16 = u16::from_str_radix(&hex_ip[16..20], 0x10).unwrap_or(0); - let h6: u16 = u16::from_str_radix(&hex_ip[20..24], 0x10).unwrap_or(0); - let h7: u16 = u16::from_str_radix(&hex_ip[24..28], 0x10).unwrap_or(0); - let h8: u16 = u16::from_str_radix(&hex_ip[28..32], 0x10).unwrap_or(0); - Ipv6Addr::new(h1, h2, h3, h4, h5, h6, h7, h8) + u128::from_str_radix(hex_ip, 16).ok().map(Ipv6Addr::from) } -fn get_arp_map() -> HashMap { - let mut arp_map: HashMap = HashMap::new(); - if let Ok(arp_text) = read_to_string(PATH_PROC_NET_ARP) { - for row in arp_text.lines() { - let mut fields = row.split_whitespace(); - let Some(ip_addr) = fields.next() else { - continue; - }; - let _hw_type = fields.next(); - let _flags = fields.next(); - let Some(mac_addr) = fields.next() else { - continue; - }; - if let Ok(ipv4_addr) = Ipv4Addr::from_str(ip_addr) { - arp_map.insert(ipv4_addr, MacAddr::from_hex_format(mac_addr)); - } +fn get_arp_map() -> HashMap<(String, Ipv4Addr), MacAddr> { + read_to_string(PATH_PROC_NET_ARP) + .map(|text| parse_arp_map(&text)) + .unwrap_or_default() +} + +fn parse_arp_map(text: &str) -> HashMap<(String, Ipv4Addr), MacAddr> { + let mut map = HashMap::new(); + for row in text.lines() { + let fields: Vec<_> = row.split_whitespace().collect(); + if fields.len() < 6 { + continue; + } + let Ok(flags) = u32::from_str_radix(fields[2].trim_start_matches("0x"), 16) else { + continue; + }; + if flags & 2 == 0 { + continue; + } + if let Ok(ip) = fields[0].parse() { + map.insert( + (fields[5].to_owned(), ip), + MacAddr::from_hex_format(fields[3]), + ); } } - arp_map + map } fn get_ipv4_gateway_map() -> HashMap { - let mut ipv4_gateway_map: HashMap = HashMap::new(); - if let Ok(route_text) = read_to_string(PATH_PROC_NET_ROUTE) { - for row in route_text.lines() { - let mut fields = row.split_whitespace(); - let Some(if_name) = fields.next() else { - continue; - }; - let _destination = fields.next(); - let Some(gateway) = fields.next() else { - continue; - }; - if gateway != "00000000" { - ipv4_gateway_map.insert(if_name.to_owned(), convert_hex_ipv4(gateway)); - } + read_to_string(PATH_PROC_NET_ROUTE) + .map(|text| parse_ipv4_gateway_map(&text)) + .unwrap_or_default() +} + +fn parse_ipv4_gateway_map(text: &str) -> HashMap { + let mut gateways = HashMap::new(); + for row in text.lines() { + let fields: Vec<_> = row.split_whitespace().collect(); + if fields.len() < 8 || fields[1] != "00000000" || fields[7] != "00000000" { + continue; + } + let Ok(flags) = u32::from_str_radix(fields[3], 16) else { + continue; + }; + // Require an active gateway route, excluding rejected routes. + if flags & 3 != 3 || flags & 0x200 != 0 { + continue; + } + if let Some(gateway) = convert_hex_ipv4(fields[2]).filter(|ip| !ip.is_unspecified()) { + gateways.insert(fields[0].to_owned(), gateway); } } - ipv4_gateway_map + gateways } fn get_ipv6_gateway_map() -> HashMap { - let mut ipv6_gateway_map: HashMap = HashMap::new(); - if let Ok(route_text) = read_to_string(PATH_PROC_NET_IPV6_ROUTE) { - for row in route_text.lines() { - let fields: Vec<&str> = row.split_whitespace().collect(); - if fields.len() < 10 { - continue; - } - // default route has zero destination and zero prefix length - if fields[0] == "00000000000000000000000000000000" - && fields[1] == "00" - && fields[4] != "00000000000000000000000000000000" - { - ipv6_gateway_map.insert(fields[9].to_owned(), convert_hex_ipv6(fields[4])); - } + read_to_string(PATH_PROC_NET_IPV6_ROUTE) + .map(|text| parse_ipv6_gateway_map(&text)) + .unwrap_or_default() +} + +fn parse_ipv6_gateway_map(text: &str) -> HashMap { + let mut gateways = HashMap::new(); + for row in text.lines() { + let fields: Vec<_> = row.split_whitespace().collect(); + if fields.len() < 10 || fields[0] != "00000000000000000000000000000000" || fields[1] != "00" + { + continue; + } + let Ok(flags) = u32::from_str_radix(fields[8], 16) else { + continue; + }; + // Apply the same active gateway and reject checks as the IPv4 table. + if flags & 3 != 3 || flags & 0x200 != 0 { + continue; + } + if let Some(gateway) = convert_hex_ipv6(fields[4]).filter(|ip| !ip.is_unspecified()) { + gateways.insert(fields[9].to_owned(), gateway); } } - ipv6_gateway_map + gateways } pub fn get_gateway_map() -> HashMap { let mut gateway_map: HashMap = HashMap::new(); - let arp_map: HashMap = get_arp_map(); + let mut arp_map = get_arp_map(); for (if_name, ipv4_addr) in get_ipv4_gateway_map() { + let mac = arp_map.remove(&(if_name.clone(), ipv4_addr)); let gateway = gateway_map.entry(if_name).or_insert(NetworkDevice::new()); - if let Some(mac_addr) = arp_map.get(&ipv4_addr) { - gateway.mac_addr = *mac_addr; + if let Some(mac_addr) = mac { + gateway.mac_addr = mac_addr; } gateway.ipv4.push(ipv4_addr); } @@ -112,3 +126,69 @@ pub fn get_gateway_map() -> HashMap { } gateway_map } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arp_entries_are_scoped_to_their_interface() { + let map = parse_arp_map( + "192.168.1.1 0x1 0x2 00:01:02:03:04:05 * eth0\n192.168.1.1 0x1 0x2 06:07:08:09:0a:0b * eth1\n192.168.1.1 0x1 0x0 00:00:00:00:00:00 * eth2", + ); + let ip = "192.168.1.1".parse().unwrap(); + assert_eq!(map.len(), 2); + assert_eq!( + map[&("eth0".into(), ip)], + MacAddr::from_octets([0, 1, 2, 3, 4, 5]) + ); + assert_eq!( + map[&("eth1".into(), ip)], + MacAddr::from_octets([6, 7, 8, 9, 10, 11]) + ); + } + + #[test] + fn ipv6_only_accepts_active_default_gateway_routes() { + let zero = "00000000000000000000000000000000"; + let gateway = "fe800000000000000000000000000001"; + let rejected_gateway = "fe800000000000000000000000000002"; + let text = format!( + "{zero} 00 {zero} 00 {gateway} 00000064 00000000 00000000 00000003 eth0\n {zero} 00 {zero} 00 {rejected_gateway} 00000064 00000000 00000000 00000203 eth0\n {zero} 00 {zero} 00 {gateway} 00000064 00000000 00000000 00000002 eth1\n {zero} 40 {zero} 00 {gateway} 00000064 00000000 00000000 00000003 eth2\n {zero} 00 {zero} 00 {zero} 00000064 00000000 00000000 00000003 eth3\n {zero} 00 {zero} 00 invalid 00000064 00000000 00000000 00000003 eth4" + ); + assert_eq!( + parse_ipv6_gateway_map(&text), + HashMap::from([("eth0".into(), "fe80::1".parse().unwrap())]) + ); + } + + #[test] + fn ipv6_hex_parser_rejects_invalid_input() { + assert_eq!( + convert_hex_ipv6("fe800000000000000000000000000001"), + Some("fe80::1".parse().unwrap()) + ); + assert_eq!(convert_hex_ipv6(&"z".repeat(32)), None); + assert_eq!(convert_hex_ipv6(&"\u{3042}".repeat(10)), None); + } + + #[test] + fn only_accepts_active_default_gateway_routes() { + let gateway = Ipv4Addr::new(192, 0, 2, 1); + let hex = format!("{:08X}", u32::from_ne_bytes(gateway.octets())); + let text = format!( + "Iface Destination Gateway Flags RefCnt Use Metric Mask +eth0 00000000 {hex} 0003 0 0 100 00000000 +eth0 0000000A 0100000A 0003 0 0 0 000000FF +eth1 00000000 {hex} 0002 0 0 0 00000000 +eth2 00000000 {hex} 0203 0 0 0 00000000 +eth3 00000000 00000000 0003 0 0 0 00000000 +eth4 00000000 invalid! 0003 0 0 0 00000000 +" + ); + assert_eq!( + parse_ipv4_gateway_map(&text), + HashMap::from([("eth0".into(), gateway)]) + ); + } +} diff --git a/src/route/mod.rs b/src/route/mod.rs index 64c8a70..f2508d9 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -1,23 +1,48 @@ use crate::interface::interface::Interface; use crate::net::device::NetworkDevice; -use std::net::IpAddr; /// Returns the default gateway associated with the active default interface. /// -/// Returns an error when the local IP address cannot be determined, when no matching interface -/// is found, or when the platform does not provide gateway information for the default route. +/// Uses the same interface selection as [`crate::get_default_interface`]. Returns an error +/// when no default interface can be determined or its gateway information is unavailable. pub fn get_default_gateway() -> Result { - let local_ip: IpAddr = match crate::net::ip::get_local_ipaddr() { - Some(local_ip) => local_ip, - None => return Err(String::from("Local IP address not found")), - }; - let interfaces: Vec = crate::interface::get_interfaces(); - for iface in interfaces { - if crate::interface::iface_has_ip(&iface, local_ip) { - if let Some(gateway) = iface.gateway { - return Ok(gateway); - } - } + resolve_default_gateway(crate::interface::get_interfaces()) +} + +fn resolve_default_gateway(interfaces: Vec) -> Result { + crate::interface::resolve_default_interface(interfaces)? + .gateway + .ok_or_else(|| String::from("Default Gateway not found")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uses_platform_default_without_requiring_a_local_ip() { + let mut default = Interface::dummy(); + default.index = 2; + default.default = true; + let mut gateway = NetworkDevice::new(); + gateway.ipv4.push("192.0.2.1".parse().unwrap()); + default.gateway = Some(gateway.clone()); + assert_eq!( + resolve_default_gateway(vec![Interface::dummy(), default]), + Ok(gateway) + ); + } + + #[test] + fn does_not_use_another_interfaces_gateway() { + let mut other = Interface::dummy(); + other.gateway = Some(NetworkDevice::new()); + let mut default = Interface::dummy(); + default.index = 2; + default.default = true; + assert_eq!( + resolve_default_gateway(vec![other, default]), + Err("Default Gateway not found".into()) + ); } - Err(String::from("Default Gateway not found")) } From e316249e7151d3a0df05c5b2f39683d147d9b651 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:07:21 +0900 Subject: [PATCH 02/13] fix: prefer local addresses and 64-bit netlink statistics --- src/os/android/netlink.rs | 49 +--------------- src/os/linux/mod.rs | 1 + src/os/linux/netlink.rs | 24 +------- src/os/linux/netlink_data.rs | 109 +++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 src/os/linux/netlink_data.rs diff --git a/src/os/android/netlink.rs b/src/os/android/netlink.rs index b19bddc..b32c0c8 100644 --- a/src/os/android/netlink.rs +++ b/src/os/android/netlink.rs @@ -1,14 +1,15 @@ use crate::interface::types::InterfaceType; +use crate::os::linux::netlink_data::ip_from_addr; +use crate::os::linux::netlink_data::stats_from_link; use crate::stats::counters::InterfaceStats; use netlink_packet_core::NetlinkPayload; use netlink_packet_route::{ RouteNetlinkMessage, - address::{AddressAttribute, AddressFlags, AddressMessage}, + address::AddressMessage, link::{LinkAttribute, LinkMessage}, }; use netlink_sys::{Socket, protocols::NETLINK_ROUTE}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::time::SystemTime; use std::{collections::HashMap, io}; #[cfg(feature = "gateway")] @@ -123,27 +124,6 @@ fn name_from_link(link: &LinkMessage) -> Option { None } -fn ip_from_addr(addr: &AddressMessage) -> Option<(IpAddr, u8, u32)> { - let pfx = addr.header.prefix_len; - let mut ip_out = None; - let mut flags: Option = None; - for nla in &addr.attributes { - match nla { - AddressAttribute::Local(ip) | AddressAttribute::Address(ip) => { - ip_out = Some(*ip); - } - AddressAttribute::Flags(f) => { - flags = Some(*f); - } - _ => {} - } - } - let addr_flags = flags - .map(|f| f.bits()) - .unwrap_or(addr.header.flags.bits() as u32); - ip_out.map(|ip| (ip, pfx, addr_flags)) -} - #[cfg(feature = "gateway")] fn route_addr_to_ip(a: &RouteAddress) -> Option { match a { @@ -230,29 +210,6 @@ fn if_type_from_link(link: &LinkMessage, _name: &str) -> InterfaceType { InterfaceType::try_from(arphrd).unwrap_or(InterfaceType::UnknownWithValue(arphrd)) } -fn stats_from_link(link: &LinkMessage) -> Option { - for nla in &link.attributes { - match nla { - LinkAttribute::Stats64(s) => { - return Some(InterfaceStats { - rx_bytes: s.rx_bytes, - tx_bytes: s.tx_bytes, - timestamp: Some(SystemTime::now()), - }); - } - LinkAttribute::Stats(s) => { - return Some(InterfaceStats { - rx_bytes: s.rx_bytes as u64, - tx_bytes: s.tx_bytes as u64, - timestamp: Some(SystemTime::now()), - }); - } - _ => {} - } - } - None -} - pub fn get_flags_by_name(name: &str) -> io::Result> { let links = dump_links()?; for l in links { diff --git a/src/os/linux/mod.rs b/src/os/linux/mod.rs index 88b63df..3d7d12e 100644 --- a/src/os/linux/mod.rs +++ b/src/os/linux/mod.rs @@ -11,6 +11,7 @@ pub mod ipv6_addr_flags; pub mod mtu; #[cfg(not(target_os = "android"))] pub mod netlink; +pub(crate) mod netlink_data; pub(crate) mod netlink_io; #[cfg(not(target_os = "android"))] #[cfg(feature = "gateway")] diff --git a/src/os/linux/netlink.rs b/src/os/linux/netlink.rs index 1034b9b..997c99b 100644 --- a/src/os/linux/netlink.rs +++ b/src/os/linux/netlink.rs @@ -1,7 +1,8 @@ +use crate::os::linux::netlink_data::ip_from_addr; use netlink_packet_core::NetlinkPayload; use netlink_packet_route::{ RouteNetlinkMessage, - address::{AddressAttribute, AddressFlags, AddressMessage}, + address::AddressMessage, link::{LinkAttribute, LinkMessage}, }; use netlink_sys::{Socket, protocols::NETLINK_ROUTE}; @@ -120,27 +121,6 @@ fn name_from_link(link: &LinkMessage) -> Option { None } -fn ip_from_addr(addr: &AddressMessage) -> Option<(IpAddr, u8, u32)> { - let pfx = addr.header.prefix_len; - let mut ip_out = None; - let mut flags: Option = None; - for nla in &addr.attributes { - match nla { - AddressAttribute::Local(ip) | AddressAttribute::Address(ip) => { - ip_out = Some(*ip); - } - AddressAttribute::Flags(f) => { - flags = Some(*f); - } - _ => {} - } - } - let addr_flags = flags - .map(|f| f.bits()) - .unwrap_or(addr.header.flags.bits() as u32); - ip_out.map(|ip| (ip, pfx, addr_flags)) -} - #[cfg(feature = "gateway")] fn route_addr_to_ip(a: &RouteAddress) -> Option { match a { diff --git a/src/os/linux/netlink_data.rs b/src/os/linux/netlink_data.rs new file mode 100644 index 0000000..a035e12 --- /dev/null +++ b/src/os/linux/netlink_data.rs @@ -0,0 +1,109 @@ +use netlink_packet_route::address::{AddressAttribute, AddressMessage}; +use std::net::IpAddr; + +pub(crate) fn ip_from_addr(addr: &AddressMessage) -> Option<(IpAddr, u8, u32)> { + let mut local = None; + let mut address = None; + let mut flags = u32::from(addr.header.flags.bits()); + for attribute in &addr.attributes { + match attribute { + AddressAttribute::Local(ip) => local = Some(*ip), + AddressAttribute::Address(ip) => address = Some(*ip), + AddressAttribute::Flags(value) => flags = value.bits(), + _ => {} + } + } + // IFA_ADDRESS is the peer address on point-to-point links. IFA_LOCAL must win + // regardless of attribute order, while IPv6 commonly supplies only IFA_ADDRESS. + local + .or(address) + .map(|ip| (ip, addr.header.prefix_len, flags)) +} + +#[cfg(any(target_os = "android", test))] +pub(crate) fn stats_from_link( + link: &netlink_packet_route::link::LinkMessage, +) -> Option { + use netlink_packet_route::link::LinkAttribute; + let mut fallback = None; + let counters = link + .attributes + .iter() + .find_map(|attribute| match attribute { + LinkAttribute::Stats64(stats) => Some((stats.rx_bytes, stats.tx_bytes)), + LinkAttribute::Stats(stats) => { + fallback = Some((u64::from(stats.rx_bytes), u64::from(stats.tx_bytes))); + None + } + _ => None, + }) + .or(fallback)?; + Some(crate::stats::counters::InterfaceStats { + rx_bytes: counters.0, + tx_bytes: counters.1, + timestamp: Some(std::time::SystemTime::now()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use netlink_packet_route::{ + address::AddressFlags, + link::{LinkAttribute, LinkMessage, Stats, Stats64}, + }; + + #[test] + fn local_address_wins_in_both_attribute_orders() { + let local = "192.0.2.1".parse().unwrap(); + let peer = "192.0.2.2".parse().unwrap(); + let mut message = AddressMessage::default(); + message.header.prefix_len = 32; + message.attributes = vec![ + AddressAttribute::Local(local), + AddressAttribute::Address(peer), + ]; + assert_eq!(ip_from_addr(&message), Some((local, 32, 0))); + message.attributes.reverse(); + assert_eq!(ip_from_addr(&message), Some((local, 32, 0))); + } + + #[test] + fn falls_back_to_address_and_preserves_extended_flags() { + let ip = "2001:db8::1".parse().unwrap(); + let mut message = AddressMessage::default(); + message.header.prefix_len = 64; + message.attributes = vec![ + AddressAttribute::Address(ip), + AddressAttribute::Flags(AddressFlags::from_bits_retain(0x200)), + ]; + assert_eq!(ip_from_addr(&message), Some((ip, 64, 0x200))); + message.attributes.pop(); + assert_eq!(ip_from_addr(&message), Some((ip, 64, 0))); + message.attributes.clear(); + assert_eq!(ip_from_addr(&message), None); + } + + #[test] + fn prefers_64_bit_statistics_in_both_attribute_orders() { + let mut short = Stats::default(); + short.rx_bytes = 1; + short.tx_bytes = 2; + let mut wide = Stats64::default(); + wide.rx_bytes = u64::from(u32::MAX) + 2; + wide.tx_bytes = u64::from(u32::MAX) + 3; + let mut link = LinkMessage::default(); + link.attributes = vec![LinkAttribute::Stats(short), LinkAttribute::Stats64(wide)]; + for _ in 0..2 { + let stats = stats_from_link(&link).unwrap(); + assert_eq!(stats.rx_bytes, u64::from(u32::MAX) + 2); + assert_eq!(stats.tx_bytes, u64::from(u32::MAX) + 3); + link.attributes.reverse(); + } + link.attributes.pop(); + let stats = stats_from_link(&link).unwrap(); + assert_eq!((stats.rx_bytes, stats.tx_bytes), (1, 2)); + link.attributes.clear(); + assert_eq!(stats_from_link(&link), None); + } +} From 7efc3a585b3e380a687e874012f9f3a2cd7f10af Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:07:44 +0900 Subject: [PATCH 03/13] fix(windows): align API buffers and avoid false default interfaces --- src/os/windows/interface.rs | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/os/windows/interface.rs b/src/os/windows/interface.rs index 4041c5a..7b1843a 100644 --- a/src/os/windows/interface.rs +++ b/src/os/windows/interface.rs @@ -25,7 +25,6 @@ use std::ffi::CStr; use crate::net::device::NetworkDevice; #[cfg(feature = "gateway")] use crate::net::ip::get_local_ipaddr; -#[cfg(feature = "gateway")] use std::mem::MaybeUninit; #[cfg(feature = "gateway")] use std::net::Ipv4Addr; @@ -49,7 +48,7 @@ fn get_mac_through_arp(src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> Option { let src_ip_int = u32::from_ne_bytes(src_ip.octets()); let dst_ip_int = u32::from_ne_bytes(dst_ip.octets()); let mut out_buf_len = 6; - let mut target_mac_addr = MaybeUninit::<[u8; 6]>::uninit(); + let mut target_mac_addr = [0u32; 2]; let res = unsafe { SendARP( dst_ip_int, @@ -59,9 +58,11 @@ fn get_mac_through_arp(src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> Option { ) }; if res == NO_ERROR && out_buf_len == 6 { - Some(MacAddr::from_octets(unsafe { - target_mac_addr.assume_init() - })) + let first = target_mac_addr[0].to_ne_bytes(); + let second = target_mac_addr[1].to_ne_bytes(); + Some(MacAddr::from_octets([ + first[0], first[1], first[2], first[3], second[0], second[1], + ])) } else { None } @@ -218,16 +219,16 @@ unsafe fn from_wide_string(ptr: *const u16) -> String { // Reference: https://docs.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-getadaptersaddresses pub fn interfaces() -> Vec { #[cfg(feature = "gateway")] - let local_ip: IpAddr = match get_local_ipaddr() { - Some(local_ip) => local_ip, - None => IpAddr::V4(Ipv4Addr::LOCALHOST), - }; + let local_ip = get_local_ipaddr(); // "The recommended method of calling the GetAdaptersAddresses function is to pre-allocate a 15KB working buffer pointed to by the AdapterAddresses parameter." // (c) https://learn.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-getadaptersaddresses - let mut mem = Vec::::with_capacity(15000); + // Keep the allocation aligned for the adapter structures and their linked data. + let element_size = std::mem::size_of::(); + let mut mem = + vec![MaybeUninit::::uninit(); 15000usize.div_ceil(element_size)]; let mut retries = 3; loop { - let mut dwsize = mem.capacity() as u32; + let mut dwsize = (mem.len() * element_size) as u32; let ret_val = unsafe { GetAdaptersAddresses( AF_UNSPEC as u32, @@ -238,14 +239,12 @@ pub fn interfaces() -> Vec { ) }; match ret_val { - NO_ERROR => { - unsafe { - mem.set_len(dwsize as usize); - } - break; - } + NO_ERROR => break, ERROR_BUFFER_OVERFLOW if retries > 0 => { - mem.reserve(dwsize as usize); + mem.resize_with( + (dwsize as usize).div_ceil(element_size), + MaybeUninit::uninit, + ); retries -= 1; } _ => { @@ -380,8 +379,9 @@ pub fn interfaces() -> Vec { .collect(); #[cfg(feature = "gateway")] let default: bool = match local_ip { - IpAddr::V4(local_ipv4) => ipv4_vec.iter().any(|x| x.addr() == local_ipv4), - IpAddr::V6(local_ipv6) => ipv6_vec.iter().any(|x| x.addr() == local_ipv6), + Some(IpAddr::V4(local_ipv4)) => ipv4_vec.iter().any(|x| x.addr() == local_ipv4), + Some(IpAddr::V6(local_ipv6)) => ipv6_vec.iter().any(|x| x.addr() == local_ipv6), + None => false, }; let stats: Option = super::stats::get_stats_from_index(index); let interface: Interface = Interface { From 37423965c72d3eea773e4ce7290e59b0ace0f02b Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:08:01 +0900 Subject: [PATCH 04/13] fix: update IPv6 global address classification --- src/net/ip.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/net/ip.rs b/src/net/ip.rs index 033a2d8..959d8f6 100644 --- a/src/net/ip.rs +++ b/src/net/ip.rs @@ -54,7 +54,8 @@ fn try_ipv6() -> Option { /// that would be used to reach a non-routable destination. No application data is sent. /// /// The returned address is useful for identifying the interface currently preferred for the -/// default route. +/// default route. More-specific routes to the probe destinations (10.254.254.254 or +/// fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff) can select a different interface. /// /// Returns `None` when socket creation, address selection, or local address inspection fails. #[cfg(feature = "gateway")] @@ -105,8 +106,8 @@ pub(crate) fn is_global_ipv6(ipv6_addr: &Ipv6Addr) -> bool { || matches!(ipv6_addr.segments(), [0, 0, 0, 0, 0, 0xffff, _, _]) // IPv4-IPv6 Translat. (`64:ff9b:1::/48`) || matches!(ipv6_addr.segments(), [0x64, 0xff9b, 1, _, _, _, _, _]) - // Discard-Only Address Block (`100::/64`) - || matches!(ipv6_addr.segments(), [0x100, 0, 0, 0, _, _, _, _]) + // Discard-Only Address Block (`100::/64`) and Dummy IPv6 Prefix (`100:0:0:1::/64`) + || matches!(ipv6_addr.segments(), [0x100, 0, 0, 0..=1, _, _, _, _]) // IETF Protocol Assignments (`2001::/23`) || (matches!(ipv6_addr.segments(), [0x2001, b, _, _, _, _, _, _] if b < 0x200) && !( @@ -114,6 +115,8 @@ pub(crate) fn is_global_ipv6(ipv6_addr: &Ipv6Addr) -> bool { u128::from_be_bytes(ipv6_addr.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0001 // Traversal Using Relays around NAT Anycast (`2001:1::2`) || u128::from_be_bytes(ipv6_addr.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0002 + // DNS-SD Service Registration Protocol Anycast (`2001:1::3`) + || u128::from_be_bytes(ipv6_addr.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0003 // AMT (`2001:3::/32`) || matches!(ipv6_addr.segments(), [0x2001, 3, _, _, _, _, _, _]) // AS112-v6 (`2001:4:112::/48`) @@ -125,6 +128,8 @@ pub(crate) fn is_global_ipv6(ipv6_addr: &Ipv6Addr) -> bool { // 6to4 (`2002::/16`) - it's not explicitly documented as globally reachable, // IANA says N/A. || matches!(ipv6_addr.segments(), [0x2002, _, _, _, _, _, _, _]) + // Segment Routing (SRv6) SIDs (`5f00::/16`) + || ipv6_addr.segments()[0] == 0x5f00 || is_documentation_ipv6(ipv6_addr) || ipv6_addr.is_unique_local() || ipv6_addr.is_unicast_link_local()) @@ -193,6 +198,29 @@ mod tests { assert!(!is_global_ipv6(&doc)); } + #[test] + fn special_purpose_ipv6_boundaries() { + for address in [ + "100:0:0:1::", + "100:0:0:1:ffff:ffff:ffff:ffff", + "5f00::", + "5f00:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "2001:1::4", + ] { + assert!(!is_global_ipv6(&address.parse().unwrap()), "{address}"); + } + for address in [ + "100:0:0:2::", + "5eff:ffff::", + "5f01::", + "2001:1::1", + "2001:1::2", + "2001:1::3", + ] { + assert!(is_global_ipv6(&address.parse().unwrap()), "{address}"); + } + } + #[test] fn test_is_global_ip() { let ip_v4 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); From 6418ced0fbe48548944c63f662391a1973f0f358 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:08:32 +0900 Subject: [PATCH 05/13] fix(illumos): clear unavailable statistics when refreshing interfaces --- src/stats/counters.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/stats/counters.rs b/src/stats/counters.rs index bba8f2c..9e04368 100644 --- a/src/stats/counters.rs +++ b/src/stats/counters.rs @@ -220,9 +220,6 @@ pub(crate) fn get_stats_from_index(index: u32) -> Option { } } -// On illumos, none of the branches below touch `iface` yet (no kstat integration), so it's -// otherwise unused there. -#[cfg_attr(target_os = "illumos", allow(unused_variables))] pub(crate) fn update_interface_stats(iface: &mut Interface) -> std::io::Result<()> { #[cfg(any(target_os = "linux", target_os = "android"))] { @@ -241,6 +238,10 @@ pub(crate) fn update_interface_stats(iface: &mut Interface) -> std::io::Result<( { iface.stats = get_stats_from_index(iface.index); } + #[cfg(target_os = "illumos")] + { + iface.stats = None; + } Ok(()) } From 89e20de9f61794f62934e94e3a844e0f63ee3c4c Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:09:55 +0900 Subject: [PATCH 06/13] docs: correct feature behavior and interface metadata descriptions --- README.md | 6 +++--- src/interface/interface.rs | 9 ++++++--- src/lib.rs | 9 ++++++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 28e1e39..deead8a 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ For more details, see [examples][examples-url] or [doc][doc-url]. - `gateway` (default) - Enables default interface and default gateway helpers. - `apple-system-configuration-extra` (default) - - Enables deeper Apple metadata enrichment using `SystemConfiguration` APIs. - - On macOS, this adds metadata such as interface display names and DHCP hints. iOS does not offer these APIs (`API_UNAVAILABLE(ios)`), so the feature has no effect there; iOS reads DNS resolvers through libresolv. + - Enables additional `objc2-system-configuration` dependency bindings on macOS. + - Currently, this flag does not change metadata collection: macOS reads interface names and DHCP hints from SystemConfiguration plist files, with a basic SystemConfiguration API fallback, regardless of this flag. It has no effect on iOS. - `android-extra` (default) - Enables deeper Android metadata enrichment using Android platform APIs through JNI bindings. - On Android, this can add metadata such as traffic stats, DNS servers, DHCP hints, and Wi-Fi link speed when the app provides the required Android context and permissions. @@ -57,7 +57,7 @@ For more details, see [examples][examples-url] or [doc][doc-url]. - Enables macOS Wi-Fi transmit-speed collection through CoreWLAN. - This feature is disabled by default because CoreWLAN metadata collection may perform synchronous IPC during interface enumeration. -To opt out of the additional Apple metadata enrichment while keeping gateway helpers: +To disable default feature flags while keeping gateway helpers (macOS metadata collection remains enabled): ```toml [dependencies] diff --git a/src/interface/interface.rs b/src/interface/interface.rs index 5e2c339..750c9d3 100644 --- a/src/interface/interface.rs +++ b/src/interface/interface.rs @@ -80,11 +80,12 @@ pub struct Interface { pub transmit_speed: Option, /// Reported receive link speed in bits per second. /// - /// This field follows the same availability rules as `Interface::transmit_speed`. + /// Availability is platform- and driver-dependent. On macOS, CoreWLAN supplies + /// only transmit speed, so enabling `apple-wifi-extra` does not add receive speed. pub receive_speed: Option, /// Whether this interface was configured to auto-negotiate the link speed. /// - /// It may `None` if reading this information has not been implemented for a specific OS. + /// It may be `None` if reading this information has not been implemented for a specific OS. pub auto_negotiate: Option, /// Whether this interface is configured to use DHCP for IPv4. /// @@ -113,7 +114,9 @@ pub struct Interface { pub gateway: Option, /// DNS resolver addresses associated with this interface. /// - /// This field is available only with the `gateway` feature. + /// This field is available only with the `gateway` feature. On platforms without + /// per-interface resolver information, system resolvers are attached to the default + /// interface and may include local stub resolver addresses. #[cfg(feature = "gateway")] pub dns_servers: Vec, /// Maximum transmission unit in bytes, when available. diff --git a/src/lib.rs b/src/lib.rs index 92fb928..846a378 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,14 @@ //! Main entry points: //! - [`get_interfaces`] returns a snapshot of all visible interfaces. //! - [`Interface`] represents one interface and its collected metadata. -//! - [`get_default_interface`] and [`get_default_gateway`] are available with the `gateway` feature (default). +#![cfg_attr( + feature = "gateway", + doc = "- [`get_default_interface`] and [`get_default_gateway`] are available with the `gateway` feature (default)." +)] +#![cfg_attr( + not(feature = "gateway"), + doc = "- `get_default_interface` and `get_default_gateway` are available with the `gateway` feature (default)." +)] //! pub mod interface; pub mod net; From ce47f6a63dd23a78585bf29b1770acf2af05d7dd Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:10:15 +0900 Subject: [PATCH 07/13] fix(scripts): isolate cross-build caches by target --- scripts/build-all.ps1 | 29 ++++++++++++++++++++--------- scripts/build-all.sh | 6 +++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/scripts/build-all.ps1 b/scripts/build-all.ps1 index 7c5581b..a0f82fc 100644 --- a/scripts/build-all.ps1 +++ b/scripts/build-all.ps1 @@ -7,14 +7,25 @@ $targets = @( "x86_64-linux-android" ) -# cross build -foreach ($target in $targets) { - Write-Host "==> Building for $target..." - $result = & cross build --target $target - Write-Host "✅ Success: $target" - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ Build failed for $target" - exit 1 +# Build scripts and proc macros run inside the container. Keep their artifacts +# separate because cross images can have incompatible host glibc versions. +$originalTargetDir = $env:CARGO_TARGET_DIR +$targetDir = if ([string]::IsNullOrEmpty($originalTargetDir)) { "target" } else { $originalTargetDir } +$buildRoot = Join-Path $targetDir "build" + +try { + foreach ($target in $targets) { + $env:CARGO_TARGET_DIR = Join-Path $buildRoot $target + Write-Host "==> Building for $target..." + & cross build --target $target + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Build failed for $target" + exit 1 + } + Write-Host "✅ Success: $target" } + Write-Host "✅ All builds succeeded." +} finally { + # Restore the caller's environment even when a build fails. + $env:CARGO_TARGET_DIR = $originalTargetDir } -Write-Host "✅ All builds succeeded." diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 8ef7b24..5eb84a2 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -10,10 +10,14 @@ TARGETS=( x86_64-linux-android ) +# Build scripts and proc macros run inside the container. Keep their artifacts +# separate because cross images can have incompatible host glibc versions. +BUILD_ROOT="${CARGO_TARGET_DIR:-target}/build" + # cross build for target in "${TARGETS[@]}"; do echo "==> Building for $target..." - if cross build --target "$target"; then + if CARGO_TARGET_DIR="$BUILD_ROOT/$target" cross build --target "$target"; then echo "✅ Success: $target" else echo "❌ Failed: $target" From e0c9ceaf828db29f669ca00ea359bb01f7fe33a6 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 15:10:28 +0900 Subject: [PATCH 08/13] ci: test feature configurations and validate documentation links --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d197f4b..13b2221 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,22 @@ jobs: - name: Run cargo test run: cargo test + - name: Test with all features + run: cargo test --all-features + + - name: Test without default features + run: cargo test --no-default-features + + - name: Check documentation links with all features + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --no-deps --all-features + + - name: Check documentation links without default features + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --no-deps --no-default-features + - name: Run cargo check with all features run: cargo check --all-features From 441e43c25eb8d233db7fb0034e60f6c2c8705fbf Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:10:34 +0900 Subject: [PATCH 09/13] fix(bsd): correct IPv6 address flag ioctl buffer layout --- src/os/bsd/ipv6_addr_flags.rs | 46 +++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/os/bsd/ipv6_addr_flags.rs b/src/os/bsd/ipv6_addr_flags.rs index a2e6852..166f146 100644 --- a/src/os/bsd/ipv6_addr_flags.rs +++ b/src/os/bsd/ipv6_addr_flags.rs @@ -9,14 +9,24 @@ const IN6_IFF_DUPLICATED: u32 = 0x04; const IN6_IFF_DEPRECATED: u32 = 0x10; const IN6_IFF_TEMPORARY: u32 = 0x80; -// `libc` does not expose `in6_ifreq` on FreeBSD/OpenBSD/NetBSD. +// Match on FreeBSD, OpenBSD, and NetBSD. The kernel +// copies the entire 288-byte request, even when only the address/flags are used. #[repr(C)] struct In6Ifreq { ifr_name: [u8; libc::IFNAMSIZ], - ifr_addr: libc::sockaddr_in6, - ifr_flags: libc::c_int, + ifr_ifru: In6IfreqUnion, } +#[repr(C)] +union In6IfreqUnion { + addr: libc::sockaddr_in6, + flags6: libc::c_int, + // icmp6_ifstat, the largest union member, contains 34 uint64_t counters. + icmp6_stats: [u64; 34], +} + +const _: [(); 288] = [(); std::mem::size_of::()]; + pub(crate) fn get_ipv6_addr_flags(ifname: &str, addr: &Ipv6Addr) -> Ipv6AddrFlags { unsafe { let fd = libc::socket(libc::AF_INET6, libc::SOCK_DGRAM, 0); @@ -34,9 +44,12 @@ pub(crate) fn get_ipv6_addr_flags(ifname: &str, addr: &Ipv6Addr) -> Ipv6AddrFlag copy_len, ); - req.ifr_addr.sin6_family = libc::AF_INET6 as libc::sa_family_t; - req.ifr_addr.sin6_len = std::mem::size_of::() as u8; - req.ifr_addr.sin6_addr.s6_addr = addr.octets(); + req.ifr_ifru.addr.sin6_family = libc::AF_INET6 as libc::sa_family_t; + req.ifr_ifru.addr.sin6_len = std::mem::size_of::() as u8; + req.ifr_ifru.addr.sin6_addr.s6_addr = addr.octets(); + if addr.is_unicast_link_local() { + req.ifr_ifru.addr.sin6_scope_id = libc::if_nametoindex(req.ifr_name.as_ptr().cast()); + } let ret = libc::ioctl(fd, SIOCGIFAFLAG_IN6, &mut req); libc::close(fd); @@ -45,7 +58,7 @@ pub(crate) fn get_ipv6_addr_flags(ifname: &str, addr: &Ipv6Addr) -> Ipv6AddrFlag return Ipv6AddrFlags::default(); } - let raw = req.ifr_flags as u32; + let raw = req.ifr_ifru.flags6 as u32; Ipv6AddrFlags { deprecated: raw & IN6_IFF_DEPRECATED != 0, @@ -56,3 +69,22 @@ pub(crate) fn get_ipv6_addr_flags(ifname: &str, addr: &Ipv6Addr) -> Ipv6AddrFlag } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_matches_bsd_kernel_abi() { + // Verified against the native headers on FreeBSD 14.4, OpenBSD 7.9, + // and NetBSD 10.1. ioctl copies this size in both directions. + assert_eq!(std::mem::size_of::(), 288); + assert_eq!(std::mem::offset_of!(In6Ifreq, ifr_ifru), 16); + assert_eq!((SIOCGIFAFLAG_IN6 >> 16) & 0x1fff, 288); + let mut req: In6Ifreq = unsafe { std::mem::zeroed() }; + req.ifr_ifru.flags6 = IN6_IFF_TEMPORARY as libc::c_int; + let flags_ptr = &raw const req.ifr_ifru.flags6; + let addr_ptr = &raw const req.ifr_ifru.addr; + assert_eq!(flags_ptr.cast::(), addr_ptr.cast::()); + } +} From f9142c4b513339c73286062d071acf4ff8f5299f Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:10:54 +0900 Subject: [PATCH 10/13] fix(bsd): handle compact netmasks and embedded IPv6 scope IDs --- src/os/unix/interface.rs | 2 +- src/os/unix/sockaddr.rs | 99 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/os/unix/interface.rs b/src/os/unix/interface.rs index c83d8a5..136ef27 100644 --- a/src/os/unix/interface.rs +++ b/src/os/unix/interface.rs @@ -90,7 +90,7 @@ fn unix_interfaces_inner( None => (None, None, None), }; let netmask: Option = - unsafe { netmask_ip_autolen(addr_ref.ifa_netmask as *const libc::sockaddr) }; + unsafe { netmask_ip_autolen(addr_ref.ifa_netmask as *const libc::sockaddr, ip) }; let stats: Option = get_stats(Some(addr_ref), &name); let mut ini_ipv4: Option = None; let mut ini_ipv6: Option = None; diff --git a/src/os/unix/sockaddr.rs b/src/os/unix/sockaddr.rs index 64df3cc..bbe4e37 100644 --- a/src/os/unix/sockaddr.rs +++ b/src/os/unix/sockaddr.rs @@ -56,6 +56,8 @@ impl<'a> SockaddrRef<'a> { } SockaddrRef::V6(sin6) => { let bytes = (*sin6).sin6_addr.s6_addr; + #[cfg(any(target_os = "netbsd", target_os = "openbsd"))] + let bytes = normalize_kame_scope(bytes, sin6.sin6_scope_id).0; IpAddr::V6(Ipv6Addr::from(bytes)) } } @@ -67,6 +69,8 @@ impl<'a> SockaddrRef<'a> { SockaddrRef::V4(_) => None, SockaddrRef::V6(sin6) => { let scope = (*sin6).sin6_scope_id as u32; + #[cfg(any(target_os = "netbsd", target_os = "openbsd"))] + let scope = normalize_kame_scope(sin6.sin6_addr.s6_addr, scope).1; Some(scope) } } @@ -141,6 +145,73 @@ impl<'a> SockaddrRef<'a> { } } +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] +fn normalize_kame_scope(mut bytes: [u8; 16], scope: u32) -> ([u8; 16], u32) { + // getifaddrs can return the interface index inside a link-local address. + // Expose the normal address and carry the zone only in ipv6_scope_ids. + if Ipv6Addr::from(bytes).is_unicast_link_local() { + let embedded_scope = u32::from(u16::from_be_bytes([bytes[2], bytes[3]])); + bytes[2] = 0; + bytes[3] = 0; + (bytes, if scope == 0 { embedded_scope } else { scope }) + } else { + (bytes, scope) + } +} + +#[cfg(all(test, any(target_os = "netbsd", target_os = "openbsd")))] +mod scope_tests { + use super::*; + + #[test] + fn parses_compact_unspecified_family_netmasks() { + for (mask, expected) in [ + (&[5u8, 0, 0, 0, 255][..], "255.0.0.0"), + (&[7u8, 0, 0, 0, 255, 255, 255][..], "255.255.255.0"), + ] { + let address = Some("192.0.2.1".parse().unwrap()); + let parsed = unsafe { netmask_ip_autolen(mask.as_ptr().cast(), address) }; + assert_eq!(parsed, Some(expected.parse().unwrap())); + assert_eq!( + unsafe { netmask_ip_autolen(mask.as_ptr().cast(), None) }, + None + ); + } + let mask = [10u8, 0, 0, 0, 0, 0, 0, 0, 255, 255]; + let parsed = unsafe { + netmask_ip_autolen(mask.as_ptr().cast(), Some("2001:db8::1".parse().unwrap())) + }; + assert_eq!(parsed, Some("ffff::".parse().unwrap())); + } + + #[test] + fn normalizes_link_local_addresses_and_preserves_scope() { + let mut addr: libc::sockaddr_in6 = unsafe { mem::zeroed() }; + addr.sin6_addr.s6_addr = "fe80:1234::1".parse::().unwrap().octets(); + assert_eq!( + SockaddrRef::V6(&addr).to_ip(), + "fe80::1".parse::().unwrap() + ); + assert_eq!(SockaddrRef::V6(&addr).to_ipv6_scope(), Some(0x1234)); + addr.sin6_scope_id = 7; + assert_eq!(SockaddrRef::V6(&addr).to_ipv6_scope(), Some(7)); + addr.sin6_addr.s6_addr = "fe80::1".parse::().unwrap().octets(); + assert_eq!(SockaddrRef::V6(&addr).to_ipv6_scope(), Some(7)); + } + + #[test] + fn preserves_global_addresses_and_netmasks() { + let mut addr: libc::sockaddr_in6 = unsafe { mem::zeroed() }; + for ip in ["2001:db8::1", "::1", "ffff:ffff:ffff:ffff::"] { + let ip = ip.parse::().unwrap(); + addr.sin6_addr.s6_addr = ip.octets(); + assert_eq!(SockaddrRef::V6(&addr).to_ip(), IpAddr::V6(ip)); + assert_eq!(SockaddrRef::V6(&addr).netmask_ip(), IpAddr::V6(ip)); + assert_eq!(SockaddrRef::V6(&addr).to_ipv6_scope(), Some(0)); + } + } +} + #[inline] fn mask_to_prefix_u32(m: u32) -> Option { if m == 0 { @@ -428,7 +499,10 @@ fn guess_len_from_family(family: libc::c_int) -> Option { // than the sa_len-based one below. #[cfg(any(target_os = "linux", target_os = "android", target_os = "illumos"))] #[inline] -pub(crate) unsafe fn netmask_ip_autolen(sa: *const libc::sockaddr) -> Option { +pub(crate) unsafe fn netmask_ip_autolen( + sa: *const libc::sockaddr, + _address: Option, +) -> Option { if sa.is_null() { return None; } @@ -456,21 +530,30 @@ pub(crate) unsafe fn netmask_ip_autolen(sa: *const libc::sockaddr) -> Option Option { +pub(crate) unsafe fn netmask_ip_autolen( + sa: *const libc::sockaddr, + address: Option, +) -> Option { if sa.is_null() { return None; } // BSD/Darwin carry the effective length in the first byte (sa_len). - let sa_dr = unsafe { *sa }; - let sa_len = sa_dr.sa_len as usize; - if sa_len == 0 { - // Zero-length sockaddr (e.g., default route). Treat as no mask. + let base = sa.cast::(); + let sa_len = unsafe { *base } as usize; + if sa_len < 2 { return None; } - let fam = sa_dr.sa_family as libc::c_int; - let base = sa as *const u8; + let mut fam = unsafe { *base.add(1) } as libc::c_int; + // OpenBSD returns compact IPv4 netmasks with AF_UNSPEC. Their family + // must be taken from the associated interface address, not guessed by length. + if fam == libc::AF_UNSPEC { + fam = match address? { + IpAddr::V4(_) => libc::AF_INET, + IpAddr::V6(_) => libc::AF_INET6, + }; + } match fam { libc::AF_INET => { From 8cf4cdf673a00f65d6a40edb7f627d2c5c169821 Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:11:22 +0900 Subject: [PATCH 11/13] fix(bsd): derive media ioctl numbers from native request sizes --- src/os/unix/link_speed.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/os/unix/link_speed.rs b/src/os/unix/link_speed.rs index 91a2023..7a5dda6 100644 --- a/src/os/unix/link_speed.rs +++ b/src/os/unix/link_speed.rs @@ -133,7 +133,25 @@ const SIOCGIFXMEDIA: u32 = 0xc028698b; // https://github.com/openbsd/src/blob/master/sys/sys/sockio.h#L70 // https://github.com/NetBSD/src/blob/trunk/sys/sys/sockio.h#L94 // #define SIOCGIFMEDIA _IOWR('i', 56, struct ifmediareq) -const SIOCGIFXMEDIA: u64 = 0xc02c6938; +const SIOCGIFXMEDIA: libc::c_ulong = + 0xc0006938 | ((std::mem::size_of::() as libc::c_ulong) << 16); + +#[cfg(all(test, target_pointer_width = "64"))] +mod abi_tests { + #[cfg(target_os = "openbsd")] + #[test] + fn openbsd_media_request_matches_native_headers() { + assert_eq!(std::mem::size_of::(), 64); + assert_eq!(super::SIOCGIFXMEDIA, 0xc0406938); + } + + #[cfg(target_os = "netbsd")] + #[test] + fn netbsd_media_request_matches_native_headers() { + assert_eq!(std::mem::size_of::(), 48); + assert_eq!(super::SIOCGIFXMEDIA, 0xc0306938); + } +} // The following constants are the same across all BSD variants and Apple targets. From 55c2b0df3e1c788ad5d690efeca4c25bd477366a Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:31:03 +0900 Subject: [PATCH 12/13] docs: update tested OS versions and remove redundant entries --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index deead8a..54dbdaa 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ and was later rebranded to `netdev` by the author for future expansion, clearer ## Tested on - Linux - Ubuntu - - 24.04 + - 24.04.5 - 22.04 - 21.10 - 20.04 @@ -107,20 +107,20 @@ and was later rebranded to `netdev` by the author for future expansion, clearer - 14.7.6 - macOS (Intel) - 13.4.1 - - 11.6 - Windows - - 11 25H2 26200.8037 + - 11 25H2 26200.9711 - 11 24H2 26100.6584 - - 11 23H2 22631.4602 - - 11 Pro 22H2 22621.3155 - - 11 22H2 22621.3155 - - 10 21H2 19044.1586 - FreeBSD - - 14 + - 14.4 +- OpenBSD + - 7.9 +- NetBSD + - 10.1 - Android (arm64) - - 16.0 + - 17 + - 16 - Android (x86_64) - - 16.0 + - 16 - iOS + - 26.6.2 - 18.6.2 - - 18.1.1 From 7065a3076f857b758d14a316489ff5665ff3fc6a Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 25 Oct 2026 23:17:27 +0900 Subject: [PATCH 13/13] chore: bump version to 0.47.0 --- Cargo.toml | 2 +- README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a215473..4a2544b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "netdev" -version = "0.46.3" +version = "0.47.0" authors = ["shellrow "] edition = "2024" description = "Cross-platform library for enumerating network interfaces with metadata." diff --git a/README.md b/README.md index 54dbdaa..da78c03 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ See the [Interface][doc-interface-url] struct documentation for more details. Add `netdev` to your `Cargo.toml`: ```toml [dependencies] -netdev = "0.46" +netdev = "0.47" ``` For more details, see [examples][examples-url] or [doc][doc-url]. @@ -61,7 +61,7 @@ To disable default feature flags while keeping gateway helpers (macOS metadata c ```toml [dependencies] -netdev = { version = "0.46", default-features = false, features = ["gateway"] } +netdev = { version = "0.47", default-features = false, features = ["gateway"] } ``` ## Apple behavior @@ -74,7 +74,7 @@ macOS Wi-Fi transmit-speed collection is disabled by default. To enable it, opt ```toml [dependencies] -netdev = { version = "0.46", features = ["apple-wifi-extra"] } +netdev = { version = "0.47", features = ["apple-wifi-extra"] } ``` This uses CoreWLAN, whose metadata lookup may perform synchronous IPC while interfaces are