From 9e385a2f9d90d47f19681ac0521401ad30bed0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 18:51:56 +0200 Subject: [PATCH 1/2] fix(runtime): enumerate Windows network interfaces --- crates/perry-runtime/Cargo.toml | 4 + crates/perry-runtime/src/os_network.rs | 272 +++++++++++++++++- .../collections/network-interfaces-shape.ts | 34 ++- 3 files changed, 300 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index f7538d4bf5..4750345bca 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -337,6 +337,10 @@ libmimalloc-sys = { version = "0.1", features = ["extended"], optional = true } windows-sys = { version = "0.61", features = [ "Win32_System_Console", "Win32_Foundation", + # `GetAdaptersAddresses` backs `os.networkInterfaces()` on Windows. + "Win32_NetworkManagement_IpHelper", + "Win32_NetworkManagement_Ndis", + "Win32_Networking_WinSock", "Win32_System_LibraryLoader", # `Win32_System_Threading` powers the Windows arm of `process.kill` # (`OpenProcess` / `TerminateProcess` / `GetExitCodeProcess`, mirroring diff --git a/crates/perry-runtime/src/os_network.rs b/crates/perry-runtime/src/os_network.rs index d5ba6e69e7..a045d49391 100644 --- a/crates/perry-runtime/src/os_network.rs +++ b/crates/perry-runtime/src/os_network.rs @@ -31,6 +31,41 @@ fn fallback_mac_address() -> String { "00:00:00:00:00:00".to_string() } +fn format_mac_address(bytes: &[u8]) -> String { + if bytes.len() != 6 { + return fallback_mac_address(); + } + bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(":") +} + +#[cfg(any(windows, test))] +fn ipv4_netmask_from_prefix(prefix: u8) -> Ipv4Addr { + let prefix = prefix.min(32); + let bits = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Ipv4Addr::from(bits) +} + +#[cfg(any(windows, test))] +fn ipv6_netmask_from_prefix(prefix: u8) -> Ipv6Addr { + let prefix = prefix.min(128); + let mut octets = [0u8; 16]; + let full_bytes = usize::from(prefix / 8); + octets[..full_bytes].fill(u8::MAX); + let remaining_bits = prefix % 8; + if remaining_bits != 0 { + octets[full_bytes] = u8::MAX << (8 - remaining_bits); + } + Ipv6Addr::from(octets) +} + #[cfg(any(target_os = "linux", target_os = "android"))] fn interface_mac_address(name: &str) -> String { let path = format!("/sys/class/net/{name}/address"); @@ -41,7 +76,7 @@ fn interface_mac_address(name: &str) -> String { .unwrap_or_else(fallback_mac_address) } -#[cfg(not(any(target_os = "linux", target_os = "android")))] +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] fn interface_mac_address(_name: &str) -> String { fallback_mac_address() } @@ -71,11 +106,7 @@ fn collect_link_macs() -> HashMap { // MAC bytes follow the interface name inside `sdl_data`. let data = unsafe { (sdl.sdl_data.as_ptr() as *const u8).add(nlen) }; let bytes = unsafe { std::slice::from_raw_parts(data, alen) }; - let mac = bytes - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":"); + let mac = format_mac_address(bytes); let name = unsafe { std::ffi::CStr::from_ptr(ifa.ifa_name) } .to_string_lossy() .into_owned(); @@ -177,7 +208,149 @@ fn collect_network_interfaces() -> HashMap> { interfaces } -#[cfg(not(unix))] +#[cfg(windows)] +unsafe fn windows_wide_string(ptr: *const u16) -> String { + if ptr.is_null() { + return String::new(); + } + + let mut len = 0; + while unsafe { *ptr.add(len) } != 0 { + len += 1; + } + String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(ptr, len) }) +} + +#[cfg(windows)] +unsafe fn collect_windows_adapters( + mut adapter: *mut windows_sys::Win32::NetworkManagement::IpHelper::IP_ADAPTER_ADDRESSES_LH, +) -> HashMap> { + use windows_sys::Win32::NetworkManagement::IpHelper::IF_TYPE_SOFTWARE_LOOPBACK; + use windows_sys::Win32::NetworkManagement::Ndis::IfOperStatusUp; + use windows_sys::Win32::Networking::WinSock::{ + AF_INET, AF_INET6, SOCKADDR, SOCKADDR_IN, SOCKADDR_IN6, + }; + + let mut interfaces: HashMap> = HashMap::new(); + while !adapter.is_null() { + let current = unsafe { &*adapter }; + adapter = current.Next; + if current.OperStatus != IfOperStatusUp || current.FirstUnicastAddress.is_null() { + continue; + } + + let name = unsafe { windows_wide_string(current.FriendlyName) }; + if name.is_empty() { + continue; + } + let mac = if current.PhysicalAddressLength == 6 { + format_mac_address(¤t.PhysicalAddress[..6]) + } else { + fallback_mac_address() + }; + let internal = current.IfType == IF_TYPE_SOFTWARE_LOOPBACK; + + let mut unicast = current.FirstUnicastAddress; + while !unicast.is_null() { + let address_entry = unsafe { &*unicast }; + unicast = address_entry.Next; + let socket_address = address_entry.Address; + if socket_address.lpSockaddr.is_null() + || socket_address.iSockaddrLength < size_of::() as i32 + { + continue; + } + + let family = unsafe { (*socket_address.lpSockaddr).sa_family }; + match family { + AF_INET if socket_address.iSockaddrLength >= size_of::() as i32 => { + let socket = unsafe { &*socket_address.lpSockaddr.cast::() }; + let bytes = unsafe { socket.sin_addr.S_un.S_un_b }; + let address = Ipv4Addr::new(bytes.s_b1, bytes.s_b2, bytes.s_b3, bytes.s_b4); + let prefix = address_entry.OnLinkPrefixLength.min(32); + let netmask = ipv4_netmask_from_prefix(prefix); + interfaces + .entry(name.clone()) + .or_default() + .push(OsNetworkAddress { + address: address.to_string(), + netmask: netmask.to_string(), + family: "IPv4", + mac: mac.clone(), + internal, + cidr: format!("{address}/{prefix}"), + scopeid: None, + }); + } + AF_INET6 if socket_address.iSockaddrLength >= size_of::() as i32 => { + let socket = unsafe { &*socket_address.lpSockaddr.cast::() }; + let address = Ipv6Addr::from(unsafe { socket.sin6_addr.u.Byte }); + let prefix = address_entry.OnLinkPrefixLength.min(128); + let netmask = ipv6_netmask_from_prefix(prefix); + interfaces + .entry(name.clone()) + .or_default() + .push(OsNetworkAddress { + address: address.to_string(), + netmask: netmask.to_string(), + family: "IPv6", + mac: mac.clone(), + internal, + cidr: format!("{address}/{prefix}"), + scopeid: Some(unsafe { socket.Anonymous.sin6_scope_id }), + }); + } + _ => {} + } + } + } + interfaces +} + +#[cfg(windows)] +fn collect_network_interfaces() -> HashMap> { + use windows_sys::Win32::Foundation::{ERROR_BUFFER_OVERFLOW, ERROR_SUCCESS}; + use windows_sys::Win32::NetworkManagement::IpHelper::{ + GetAdaptersAddresses, GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, + GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES_LH, + }; + use windows_sys::Win32::Networking::WinSock::AF_UNSPEC; + + const INITIAL_BUFFER_BYTES: u32 = 16 * 1024; + const MAX_ATTEMPTS: usize = 4; + + let flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER; + let mut buffer_bytes = INITIAL_BUFFER_BYTES; + + for _ in 0..MAX_ATTEMPTS { + // A byte Vec is not guaranteed to satisfy the adapter structure's + // alignment. u64 storage supplies the required eight-byte alignment on + // every Windows target while still giving the API a byte-sized buffer. + let word_count = (buffer_bytes as usize).saturating_add(7) / 8; + let mut buffer = vec![0u64; word_count]; + let result = unsafe { + GetAdaptersAddresses( + u32::from(AF_UNSPEC), + flags, + std::ptr::null(), + buffer.as_mut_ptr().cast::(), + &mut buffer_bytes, + ) + }; + + if result == ERROR_SUCCESS { + return unsafe { + collect_windows_adapters(buffer.as_mut_ptr().cast::()) + }; + } + if result != ERROR_BUFFER_OVERFLOW || buffer_bytes == 0 { + break; + } + } + HashMap::new() +} + +#[cfg(not(any(unix, windows)))] fn collect_network_interfaces() -> HashMap> { HashMap::new() } @@ -287,3 +460,88 @@ pub extern "C" fn js_os_network_interfaces() -> *mut ObjectHeader { result_handle.get_raw_mut_ptr() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_mac_addresses_like_node() { + assert_eq!( + format_mac_address(&[0x00, 0x1a, 0x2b, 0xc3, 0xde, 0xff]), + "00:1a:2b:c3:de:ff" + ); + assert_eq!(format_mac_address(&[]), "00:00:00:00:00:00"); + assert_eq!( + format_mac_address(&[0x00, 0x1a, 0x2b, 0xc3, 0xde]), + "00:00:00:00:00:00" + ); + } + + #[test] + fn derives_ipv4_netmasks_from_windows_prefixes() { + assert_eq!(ipv4_netmask_from_prefix(0), Ipv4Addr::new(0, 0, 0, 0)); + assert_eq!( + ipv4_netmask_from_prefix(24), + Ipv4Addr::new(255, 255, 255, 0) + ); + assert_eq!( + ipv4_netmask_from_prefix(32), + Ipv4Addr::new(255, 255, 255, 255) + ); + assert_eq!( + ipv4_netmask_from_prefix(u8::MAX), + Ipv4Addr::new(255, 255, 255, 255) + ); + } + + #[test] + fn derives_ipv6_netmasks_from_windows_prefixes() { + assert_eq!(ipv6_netmask_from_prefix(0), Ipv6Addr::UNSPECIFIED); + assert_eq!( + ipv6_netmask_from_prefix(64), + "ffff:ffff:ffff:ffff::".parse::().unwrap() + ); + assert_eq!( + ipv6_netmask_from_prefix(68), + "ffff:ffff:ffff:ffff:f000::".parse::().unwrap() + ); + assert_eq!( + ipv6_netmask_from_prefix(128), + "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" + .parse::() + .unwrap() + ); + assert_eq!( + ipv6_netmask_from_prefix(u8::MAX), + "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" + .parse::() + .unwrap() + ); + } + + #[cfg(windows)] + #[test] + fn windows_reports_live_network_interfaces() { + let interfaces = collect_network_interfaces(); + assert!( + !interfaces.is_empty(), + "GetAdaptersAddresses should report at least the loopback adapter" + ); + + for (name, addresses) in interfaces { + assert!(!name.is_empty()); + assert!(!addresses.is_empty()); + for entry in addresses { + let prefix = match entry.family { + "IPv4" => prefix_len_ipv4(entry.netmask.parse().unwrap()), + "IPv6" => prefix_len_ipv6(entry.netmask.parse().unwrap()), + family => panic!("unexpected address family {family}"), + }; + assert_eq!(entry.cidr, format!("{}/{prefix}", entry.address)); + assert_eq!(entry.scopeid.is_some(), entry.family == "IPv6"); + assert_eq!(entry.mac.len(), 17); + } + } + } +} diff --git a/test-parity/node-suite/os/collections/network-interfaces-shape.ts b/test-parity/node-suite/os/collections/network-interfaces-shape.ts index 06655df717..d43a45ac8d 100644 --- a/test-parity/node-suite/os/collections/network-interfaces-shape.ts +++ b/test-parity/node-suite/os/collections/network-interfaces-shape.ts @@ -2,10 +2,38 @@ import os from "node:os"; const nets = os.networkInterfaces(); const names = Object.keys(nets).sort(); -console.log("names type:", Array.isArray(names), names.length >= 0); +const addresses = names.flatMap((name) => nets[name] || []); +console.log("names type:", Array.isArray(names), names.length > 0); +console.log("addresses present:", addresses.length > 0); +console.log( + "address fields:", + addresses.every((entry) => + typeof entry.address === "string" && + typeof entry.netmask === "string" && + (entry.family === "IPv4" || entry.family === "IPv6") && + /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(entry.mac) && + typeof entry.internal === "boolean" && + entry.cidr.startsWith(`${entry.address}/`) + ), +); +console.log( + "scope shape:", + addresses.every((entry) => + entry.family === "IPv6" + ? typeof entry.scopeid === "number" + : !("scopeid" in entry) + ), +); for (const name of names.slice(0, 2)) { const list = nets[name] || []; - console.log("iface:", typeof name, Array.isArray(list), list.length >= 0); + console.log("iface:", typeof name, Array.isArray(list), list.length > 0); const first: any = list[0]; - if (first) console.log("addr shape:", typeof first.address, typeof first.family, typeof first.internal); + if (first) { + console.log( + "addr shape:", + typeof first.address, + typeof first.family, + typeof first.internal, + ); + } } From d74bdd18aa95c90465e7c4d21e695baffe84f7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 18:52:32 +0200 Subject: [PATCH 2/2] docs: add changelog for PR 7080 --- changelog.d/7080-windows-network-interfaces.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/7080-windows-network-interfaces.md diff --git a/changelog.d/7080-windows-network-interfaces.md b/changelog.d/7080-windows-network-interfaces.md new file mode 100644 index 0000000000..8c3de3fa79 --- /dev/null +++ b/changelog.d/7080-windows-network-interfaces.md @@ -0,0 +1,3 @@ +Fixed `os.networkInterfaces()` returning an empty object on Windows. Perry now +enumerates live Windows adapters with Node-compatible names, addresses, +netmasks, MAC addresses, loopback flags, CIDRs, and IPv6 scope IDs.