ferranet 0.2.0

A modern, async-first, zero-copy datalink-layer (L2) networking library
Documentation
//! Network interface types and enumeration.

use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;

use crate::error::{Error, Result};

/// A kernel interface index (the value behind `if_nametoindex`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct IfIndex(pub u32);

impl fmt::Display for IfIndex {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// A 48-bit IEEE 802 MAC address.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MacAddr(pub [u8; 6]);

impl MacAddr {
    /// The broadcast address `ff:ff:ff:ff:ff:ff`.
    pub const BROADCAST: MacAddr = MacAddr([0xff; 6]);

    /// The all-zero address `00:00:00:00:00:00`.
    pub const ZERO: MacAddr = MacAddr([0; 6]);

    /// Returns the raw 6-byte representation.
    #[must_use]
    pub const fn octets(&self) -> [u8; 6] {
        self.0
    }

    /// Returns `true` if this is the broadcast address.
    #[must_use]
    pub const fn is_broadcast(&self) -> bool {
        matches!(self.0, [0xff, 0xff, 0xff, 0xff, 0xff, 0xff])
    }

    /// Returns `true` if the multicast (group) bit is set.
    #[must_use]
    pub const fn is_multicast(&self) -> bool {
        self.0[0] & 0x01 != 0
    }
}

impl fmt::Display for MacAddr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let o = &self.0;
        write!(
            f,
            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
            o[0], o[1], o[2], o[3], o[4], o[5]
        )
    }
}

impl fmt::Debug for MacAddr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "MacAddr({self})")
    }
}

impl FromStr for MacAddr {
    type Err = ParseMacAddrError;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        let mut octets = [0u8; 6];
        let mut parts = s.split([':', '-']);
        for slot in &mut octets {
            let part = parts.next().ok_or(ParseMacAddrError)?;
            *slot = u8::from_str_radix(part, 16).map_err(|_| ParseMacAddrError)?;
        }
        if parts.next().is_some() {
            return Err(ParseMacAddrError);
        }
        Ok(MacAddr(octets))
    }
}

/// Error returned when a string cannot be parsed as a [`MacAddr`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParseMacAddrError;

impl fmt::Display for ParseMacAddrError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("invalid MAC address")
    }
}

impl std::error::Error for ParseMacAddrError {}

/// An IP address assigned to an interface, with its subnet mask.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct IfAddr {
    /// The assigned address.
    pub addr: IpAddr,
    /// The subnet mask, if the kernel reported one.
    pub netmask: Option<IpAddr>,
    /// The IPv6 scope id (`sin6_scope_id`): for link-local addresses this is the interface
    /// index, and is required to disambiguate the same `fe80::/10` address across interfaces.
    /// `None` for IPv4; `Some(0)` for global-scope IPv6.
    pub scope_id: Option<u32>,
}

impl IfAddr {
    /// The CIDR prefix length derived from the netmask (e.g. `24` for `255.255.255.0`).
    #[must_use]
    pub fn prefix_len(&self) -> Option<u8> {
        match self.netmask? {
            IpAddr::V4(m) => Some(u32::from(m).count_ones() as u8),
            IpAddr::V6(m) => Some(u128::from(m).count_ones() as u8),
        }
    }
}

/// A network interface and the subset of its attributes relevant to L2 work.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Interface {
    /// The interface name (e.g. `eth0`).
    pub name: String,
    /// The kernel interface index.
    pub index: IfIndex,
    /// The hardware (MAC) address, if the interface has one.
    pub mac: Option<MacAddr>,
    /// The IP addresses (v4 and v6) assigned to the interface.
    pub ips: Vec<IfAddr>,
    /// The raw interface flags (`IFF_*`) as reported by the kernel.
    pub flags: u32,
    /// The interface MTU in bytes.
    pub mtu: u32,
}

impl Interface {
    /// Returns `true` if the interface is administratively up (`IFF_UP`).
    #[must_use]
    pub const fn is_up(&self) -> bool {
        self.flags & (libc::IFF_UP as u32) != 0
    }

    /// Returns `true` if the interface is a loopback device (`IFF_LOOPBACK`).
    #[must_use]
    pub const fn is_loopback(&self) -> bool {
        self.flags & (libc::IFF_LOOPBACK as u32) != 0
    }

    /// Iterates the interface's assigned IP addresses (without netmasks).
    pub fn ip_addrs(&self) -> impl Iterator<Item = IpAddr> + '_ {
        self.ips.iter().map(|a| a.addr)
    }
}

/// Looks up the kernel index for an interface by name.
///
/// This is a thin wrapper over `if_nametoindex(3)`.
pub fn index_of(name: &str) -> Result<IfIndex> {
    let c_name = std::ffi::CString::new(name)
        .map_err(|_| Error::InterfaceNotFound(name.to_owned()))?;
    // SAFETY: `c_name` is a valid NUL-terminated C string for the duration of the call.
    let idx = unsafe { libc::if_nametoindex(c_name.as_ptr()) };
    if idx == 0 {
        // Only a genuinely absent device is "not found"; an EPERM (seccomp), EMFILE, etc. must
        // keep its errno rather than masquerade as a missing interface.
        let err = std::io::Error::last_os_error();
        match err.raw_os_error() {
            Some(libc::ENODEV | libc::ENXIO) | None => {
                Err(Error::InterfaceNotFound(name.to_owned()))
            }
            _ => Err(Error::Interfaces(err)),
        }
    } else {
        Ok(IfIndex(idx))
    }
}

/// Enumerates the system's network interfaces.
///
/// Implemented by reading `/sys/class/net`, so it requires no special privileges.
pub fn interfaces() -> Result<Vec<Interface>> {
    let mut addrs = interface_addrs()?;
    let mut out = Vec::new();
    for entry in fs::read_dir("/sys/class/net").map_err(Error::Interfaces)? {
        let entry = entry.map_err(Error::Interfaces)?;
        let name = entry.file_name().to_string_lossy().into_owned();
        let base = entry.path();

        let index = read_sysfs_u32(&base.join("ifindex")).map(IfIndex);
        let Ok(index) = index else { continue };

        let mac = fs::read_to_string(base.join("address"))
            .ok()
            .and_then(|s| s.trim().parse::<MacAddr>().ok())
            .filter(|m| *m != MacAddr::ZERO);

        let flags = read_sysfs_hex_u32(&base.join("flags")).unwrap_or(0);
        let mtu = read_sysfs_u32(&base.join("mtu")).unwrap_or(0);
        let ips = addrs.remove(&name).unwrap_or_default();

        out.push(Interface { name, index, mac, ips, flags, mtu });
    }
    out.sort_by_key(|i| i.index);
    Ok(out)
}

/// Collects every interface's IP addresses via `getifaddrs(3)`, keyed by interface name.
fn interface_addrs() -> Result<HashMap<String, Vec<IfAddr>>> {
    let mut map: HashMap<String, Vec<IfAddr>> = HashMap::new();
    let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
    // SAFETY: `getifaddrs` writes a linked-list head into `head`; on success the list must be
    // released with `freeifaddrs`, which we do below.
    if unsafe { libc::getifaddrs(&raw mut head) } != 0 {
        // Propagate rather than return an empty map: "every interface has zero IPs" is
        // indistinguishable from real data for the caller.
        return Err(Error::Interfaces(std::io::Error::last_os_error()));
    }

    let mut cur = head;
    while !cur.is_null() {
        // SAFETY: `cur` is a valid node in the list returned by getifaddrs.
        let ifa = unsafe { &*cur };
        cur = ifa.ifa_next;

        if ifa.ifa_name.is_null() || ifa.ifa_addr.is_null() {
            continue;
        }
        // SAFETY: `ifa_name` is a valid NUL-terminated C string owned by the list.
        let name = unsafe { std::ffi::CStr::from_ptr(ifa.ifa_name) }.to_string_lossy().into_owned();

        if let Some((addr, scope_id)) = sockaddr_to_ip(ifa.ifa_addr) {
            let netmask = sockaddr_to_ip(ifa.ifa_netmask).map(|(ip, _)| ip);
            map.entry(name).or_default().push(IfAddr { addr, netmask, scope_id });
        }
    }

    // SAFETY: `head` is the exact list returned by the matching getifaddrs call, freed once.
    unsafe { libc::freeifaddrs(head) };
    Ok(map)
}

/// Decodes a `sockaddr` pointer into an [`IpAddr`] plus the IPv6 scope id, for
/// `AF_INET`/`AF_INET6` only.
fn sockaddr_to_ip(sa: *const libc::sockaddr) -> Option<(IpAddr, Option<u32>)> {
    if sa.is_null() {
        return None;
    }
    // SAFETY: `sa` is non-null and points to a `sockaddr` whose `sa_family` is readable.
    let family = i32::from(unsafe { (*sa).sa_family });
    match family {
        libc::AF_INET => {
            // SAFETY: family is AF_INET, so `sa` points to a `sockaddr_in`.
            let sin = unsafe { &*sa.cast::<libc::sockaddr_in>() };
            Some((IpAddr::V4(Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr))), None))
        }
        libc::AF_INET6 => {
            // SAFETY: family is AF_INET6, so `sa` points to a `sockaddr_in6`.
            let sin6 = unsafe { &*sa.cast::<libc::sockaddr_in6>() };
            Some((IpAddr::V6(Ipv6Addr::from(sin6.sin6_addr.s6_addr)), Some(sin6.sin6_scope_id)))
        }
        _ => None,
    }
}

fn read_sysfs_u32(path: &std::path::Path) -> std::io::Result<u32> {
    let s = fs::read_to_string(path)?;
    s.trim()
        .parse::<u32>()
        .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed sysfs integer"))
}

fn read_sysfs_hex_u32(path: &std::path::Path) -> std::io::Result<u32> {
    let s = fs::read_to_string(path)?;
    let s = s.trim();
    let s = s.strip_prefix("0x").unwrap_or(s);
    u32::from_str_radix(s, 16).map_err(|_| {
        std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed sysfs hex integer")
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mac_parse_roundtrip() {
        let m: MacAddr = "01:23:45:67:89:ab".parse().unwrap();
        assert_eq!(m.octets(), [0x01, 0x23, 0x45, 0x67, 0x89, 0xab]);
        assert_eq!(m.to_string(), "01:23:45:67:89:ab");
    }

    #[test]
    fn mac_parse_accepts_dashes() {
        let m: MacAddr = "01-23-45-67-89-AB".parse().unwrap();
        assert_eq!(m.octets(), [0x01, 0x23, 0x45, 0x67, 0x89, 0xab]);
    }

    #[test]
    fn mac_parse_rejects_bad_input() {
        assert!("01:23:45:67:89".parse::<MacAddr>().is_err());
        assert!("01:23:45:67:89:ab:cd".parse::<MacAddr>().is_err());
        assert!("zz:23:45:67:89:ab".parse::<MacAddr>().is_err());
    }

    #[test]
    fn mac_flags() {
        assert!(MacAddr::BROADCAST.is_broadcast());
        assert!(MacAddr::BROADCAST.is_multicast());
        assert!(!MacAddr([0x00, 0, 0, 0, 0, 0]).is_multicast());
        assert!(MacAddr([0x01, 0, 0, 0, 0, 0]).is_multicast());
    }

    #[test]
    #[cfg_attr(miri, ignore = "uses getifaddrs/sysfs; not interpretable by Miri")]
    fn enumerate_has_loopback() {
        // /sys/class/net is always present on Linux and includes `lo`.
        let ifaces = interfaces().expect("enumerate interfaces");
        assert!(ifaces.iter().any(|i| i.name == "lo" && i.is_loopback()));
    }

    #[test]
    #[cfg_attr(miri, ignore = "uses getifaddrs/sysfs; not interpretable by Miri")]
    fn ipv6_link_local_addresses_carry_scope_id() {
        // Vacuously true on hosts with no fe80:: addresses. Where one exists, its scope id (the
        // interface index, for link-local) must be captured — without it the address is
        // ambiguous across interfaces, which is fatal for an L2 library.
        for iface in interfaces().expect("enumerate interfaces") {
            for a in &iface.ips {
                if let IpAddr::V6(v6) = a.addr {
                    if v6.is_unicast_link_local() {
                        assert_eq!(a.scope_id, Some(iface.index.0), "{}: {v6}", iface.name);
                    }
                }
            }
        }
    }

    #[test]
    #[cfg_attr(miri, ignore = "uses getifaddrs/sysfs; not interpretable by Miri")]
    fn loopback_has_ip_addresses() {
        let ifaces = interfaces().expect("enumerate interfaces");
        let lo = ifaces.iter().find(|i| i.name == "lo").expect("lo present");
        // Loopback always carries 127.0.0.1 (and usually ::1).
        assert!(
            lo.ip_addrs().any(|ip| ip == IpAddr::V4(Ipv4Addr::LOCALHOST)),
            "lo addresses: {:?}",
            lo.ips,
        );
    }

    #[test]
    fn prefix_len_from_netmask() {
        let a = IfAddr {
            addr: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)),
            netmask: Some(IpAddr::V4(Ipv4Addr::new(255, 255, 255, 0))),
            scope_id: None,
        };
        assert_eq!(a.prefix_len(), Some(24));

        let b = IfAddr { addr: IpAddr::V4(Ipv4Addr::LOCALHOST), netmask: None, scope_id: None };
        assert_eq!(b.prefix_len(), None);
    }
}