Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/7080-windows-network-interfaces.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
272 changes: 265 additions & 7 deletions crates/perry-runtime/src/os_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.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");
Expand All @@ -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()
}
Expand Down Expand Up @@ -71,11 +106,7 @@ fn collect_link_macs() -> HashMap<String, String> {
// 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::<Vec<_>>()
.join(":");
let mac = format_mac_address(bytes);
let name = unsafe { std::ffi::CStr::from_ptr(ifa.ifa_name) }
.to_string_lossy()
.into_owned();
Expand Down Expand Up @@ -177,7 +208,149 @@ fn collect_network_interfaces() -> HashMap<String, Vec<OsNetworkAddress>> {
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<String, Vec<OsNetworkAddress>> {
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<String, Vec<OsNetworkAddress>> = 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(&current.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::<SOCKADDR>() as i32
{
continue;
}

let family = unsafe { (*socket_address.lpSockaddr).sa_family };
match family {
AF_INET if socket_address.iSockaddrLength >= size_of::<SOCKADDR_IN>() as i32 => {
let socket = unsafe { &*socket_address.lpSockaddr.cast::<SOCKADDR_IN>() };
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::<SOCKADDR_IN6>() as i32 => {
let socket = unsafe { &*socket_address.lpSockaddr.cast::<SOCKADDR_IN6>() };
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<String, Vec<OsNetworkAddress>> {
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::<IP_ADAPTER_ADDRESSES_LH>(),
&mut buffer_bytes,
)
};

if result == ERROR_SUCCESS {
return unsafe {
collect_windows_adapters(buffer.as_mut_ptr().cast::<IP_ADAPTER_ADDRESSES_LH>())
};
}
if result != ERROR_BUFFER_OVERFLOW || buffer_bytes == 0 {
break;
}
}
HashMap::new()
}

#[cfg(not(any(unix, windows)))]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fn collect_network_interfaces() -> HashMap<String, Vec<OsNetworkAddress>> {
HashMap::new()
}
Expand Down Expand Up @@ -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::<Ipv6Addr>().unwrap()
);
assert_eq!(
ipv6_netmask_from_prefix(68),
"ffff:ffff:ffff:ffff:f000::".parse::<Ipv6Addr>().unwrap()
);
assert_eq!(
ipv6_netmask_from_prefix(128),
"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<Ipv6Addr>()
.unwrap()
);
assert_eq!(
ipv6_netmask_from_prefix(u8::MAX),
"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<Ipv6Addr>()
.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);
}
}
}
}
34 changes: 31 additions & 3 deletions test-parity/node-suite/os/collections/network-interfaces-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Loading