diff --git a/.github/workflows/compliance-tests.yml b/.github/workflows/compliance-tests.yml new file mode 100644 index 0000000..09932b4 --- /dev/null +++ b/.github/workflows/compliance-tests.yml @@ -0,0 +1,25 @@ +name: Compliance Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + compliance-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build compliance test image + run: docker build -f compliance-tests/Dockerfile -t net-tools-compliance . + + - name: Run compliance tests + run: | + docker run --rm \ + --privileged \ + --cap-add=NET_ADMIN \ + --cap-add=SYS_ADMIN \ + net-tools-compliance diff --git a/Cargo.toml b/Cargo.toml index b2372c7..5d60194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ thiserror = "2.0" name = "hostname" path = "bin/hostname.rs" +[[bin]] +name = "nameif" +path = "bin/nameif.rs" + [lints.rust] unsafe_op_in_unsafe_fn = { level = "deny" } diff --git a/README.md b/README.md index 525ab6b..37a8599 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # net-tools-rs Rust implementation of [net-tools](https://sourceforge.net/projects/net-tools/). + +The status of implemented commands is tracked in +https://github.com/rust-swifties/net-tools-rs/issues/3 + +## Testing + +Compliance tests verify that net-tools-rs commands behave identically to the original +net-tools implementation. See [compliance-tests/README.md](compliance-tests/README.md) for +details on running the tests. diff --git a/bin/nameif.rs b/bin/nameif.rs new file mode 100644 index 0000000..2b21154 --- /dev/null +++ b/bin/nameif.rs @@ -0,0 +1,3 @@ +fn main() { + net_tools_rs::nameif_main(); +} diff --git a/compliance-tests/Dockerfile b/compliance-tests/Dockerfile new file mode 100644 index 0000000..b7d90d1 --- /dev/null +++ b/compliance-tests/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1-trixie + +RUN apt-get update && apt-get install -y \ + net-tools \ + iproute2 + +WORKDIR /workspace +COPY . /workspace/ + +RUN cargo build --release + +RUN chmod +x compliance-tests/tests/*.sh +WORKDIR /workspace/compliance-tests + +CMD ["./run-tests.sh"] diff --git a/compliance-tests/README.md b/compliance-tests/README.md new file mode 100644 index 0000000..b05dc3d --- /dev/null +++ b/compliance-tests/README.md @@ -0,0 +1,52 @@ +# Compliance Tests + +This directory contains compliance tests that verify net-tools-rs commands behave identically to the original net-tools implementation. + +## Structure + +- `tests/` - Individual test scripts for each command +- `Dockerfile` - Test environment with both implementations installed +- `run-tests.sh` - Runner script to execute all tests + +## Running Tests + +### Using Docker (recommended) + +```bash +cd compliance-tests + +# Build the test image (build context is parent directory) +docker build -f Dockerfile -t net-tools-compliance .. + +# Run all tests +docker run --rm --privileged net-tools-compliance + +# Run specific test +docker run --rm --privileged net-tools-compliance ./tests/nameif_test.sh +``` + +### Local testing (requires root) + +```bash +cd ../ +cargo build --release + +# Run tests +sudo ./run-tests.sh +``` + +## Writing Tests + +Each test script should: +1. Set up test environment (create dummy interfaces, config files, etc.) +2. Run the original command and capture output/behavior +3. Reset environment +4. Run the Rust implementation and capture output/behavior +5. Compare results +6. Clean up + +Tests run in isolated network namespaces when possible to avoid interfering with the host system. + +## CI Integration + +These tests run in GitHub Actions using Docker containers with `--privileged` flag to allow network interface manipulation. diff --git a/compliance-tests/run-tests.sh b/compliance-tests/run-tests.sh new file mode 100755 index 0000000..c59c898 --- /dev/null +++ b/compliance-tests/run-tests.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TESTS_DIR="$SCRIPT_DIR/tests" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +TOTAL_FAILED=0 +TOTAL_PASSED=0 + +echo "========================================" +echo "Running net-tools compliance tests" +echo "========================================" +echo + +for test_script in "$TESTS_DIR"/*_test.sh; do + if [ -f "$test_script" ]; then + if ! bash "$test_script"; then + TOTAL_FAILED=$((TOTAL_FAILED + 1)) + fi + echo + fi +done + +if [ $TOTAL_FAILED -gt 0 ]; then + exit 1 +fi + +exit 0 diff --git a/compliance-tests/tests/nameif_test.sh b/compliance-tests/tests/nameif_test.sh new file mode 100755 index 0000000..0ab11de --- /dev/null +++ b/compliance-tests/tests/nameif_test.sh @@ -0,0 +1,172 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ORIGINAL_NAMEIF="${ORIGINAL_NAMEIF:-/sbin/nameif}" +RUST_NAMEIF="${RUST_NAMEIF:-/workspace/target/release/nameif}" + +FAILED=0 +PASSED=0 +FAILED_TESTS=() + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +fail() { + echo -e "${RED}FAILED${NC}" + FAILED=$((FAILED + 1)) + FAILED_TESTS+=("$1") +} + +pass() { + echo -e "${GREEN}ok${NC}" + PASSED=$((PASSED + 1)) +} + +cleanup() { + ip link delete dummy0 2>/dev/null || true + ip link delete testif0 2>/dev/null || true + ip link delete testif1 2>/dev/null || true + rm -f /tmp/test_mactab +} + +setup_interface() { + local name=$1 + local mac=$2 + ip link add "$name" type dummy + ip link set "$name" address "$mac" +} + +test_basic_rename() { + echo -n "test nameif::test_basic_rename ... " + cleanup + + setup_interface dummy0 "00:11:22:33:44:55" + + set +e + $ORIGINAL_NAMEIF testif0 00:11:22:33:44:55 2>/tmp/original_stderr + ORIG_EXIT=$? + set -e + ORIG_EXISTS=$(ip link show testif0 2>/dev/null && echo "yes" || echo "no") + + if [ "$ORIG_EXISTS" = "yes" ]; then + ip link set testif0 name dummy0 2>/dev/null || true + fi + + set +e + $RUST_NAMEIF testif0 00:11:22:33:44:55 2>/tmp/rust_stderr + RUST_EXIT=$? + set -e + RUST_EXISTS=$(ip link show testif0 2>/dev/null && echo "yes" || echo "no") + + if [ "$ORIG_EXIT" -eq "$RUST_EXIT" ] && [ "$ORIG_EXISTS" = "$RUST_EXISTS" ]; then + pass + else + fail "nameif::test_basic_rename" + fi + + cleanup +} + +test_config_file() { + echo -n "test nameif::test_config_file ... " + cleanup + + setup_interface dummy0 "aa:bb:cc:dd:ee:ff" + + cat > /tmp/test_mactab </tmp/original_stderr + ORIG_EXIT=$? + set -e + ORIG_EXISTS=$(ip link show testif1 2>/dev/null && echo "yes" || echo "no") + + if [ "$ORIG_EXISTS" = "yes" ]; then + ip link set testif1 name dummy0 2>/dev/null || true + fi + + set +e + $RUST_NAMEIF -c /tmp/test_mactab 2>/tmp/rust_stderr + RUST_EXIT=$? + set -e + RUST_EXISTS=$(ip link show testif1 2>/dev/null && echo "yes" || echo "no") + + if [ "$ORIG_EXIT" -eq "$RUST_EXIT" ] && [ "$ORIG_EXISTS" = "$RUST_EXISTS" ]; then + pass + else + fail "nameif::test_config_file" + fi + + cleanup +} + +test_missing_interface() { + echo -n "test nameif::test_missing_interface ... " + cleanup + + set +e + $ORIGINAL_NAMEIF testnonexist 00:00:00:00:00:00 2>/tmp/original_stderr + ORIG_EXIT=$? + + $RUST_NAMEIF testnonexist 00:00:00:00:00:00 2>/tmp/rust_stderr + RUST_EXIT=$? + set -e + + if [ "$ORIG_EXIT" -eq "$RUST_EXIT" ]; then + pass + else + fail "nameif::test_missing_interface" + fi + + cleanup +} + +test_invalid_mac() { + echo -n "test nameif::test_invalid_mac ... " + cleanup + + set +e + $ORIGINAL_NAMEIF testif0 "invalid:mac" 2>/tmp/original_stderr + ORIG_EXIT=$? + + $RUST_NAMEIF testif0 "invalid:mac" 2>/tmp/rust_stderr + RUST_EXIT=$? + set -e + + # Both should fail + if [ "$ORIG_EXIT" -ne 0 ] && [ "$RUST_EXIT" -ne 0 ]; then + pass + else + fail "nameif::test_invalid_mac" + fi + + cleanup +} + +echo "running nameif tests" +test_basic_rename +test_config_file +test_missing_interface +test_invalid_mac + +echo +if [ $FAILED -gt 0 ]; then + echo "failures:" + for test in "${FAILED_TESTS[@]}"; do + echo " $test" + done + echo +fi + +if [ $FAILED -eq 0 ]; then + echo -e "test result: ${GREEN}ok${NC}. $PASSED passed; $FAILED failed" + exit 0 +else + echo -e "test result: ${RED}FAILED${NC}. $PASSED passed; $FAILED failed" + exit 1 +fi diff --git a/src/lib.rs b/src/lib.rs index 211c0e0..3e84fa2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ pub mod error; pub mod hostname; +pub mod nameif; pub use error::{NetToolsError, Result}; pub use hostname::main as hostname_main; +pub use nameif::main as nameif_main; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const RELEASE: &str = concat!("net-tools-rs ", env!("CARGO_PKG_VERSION")); diff --git a/src/nameif/mod.rs b/src/nameif/mod.rs new file mode 100644 index 0000000..063b53b --- /dev/null +++ b/src/nameif/mod.rs @@ -0,0 +1,464 @@ +//! Rust implementation of the nameif command from net-tools + +use crate::{NetToolsError, RELEASE, Result}; +use clap::Parser; +use std::fs; +use std::net::UdpSocket; +use std::os::unix::io::AsRawFd; + +const IFNAMEIZ: usize = 16; // Linux ifname max len +const MAC_MAX_LEN: usize = 14; // sizeof(ifreq.ifr_hwaddr.sa_data) + +#[derive(Parser, Debug)] +#[command( + name = "nameif", + version = RELEASE, + about = "name network interfaces based on MAC addresses", + long_about = "Rust implementation of the nameif command.\n\n\ + nameif renames network interfaces based on MAC addresses. When no arguments \ + are given /etc/mactab is read. Each line of it contains an interface name \ + and a Ethernet MAC address. Comments are allowed starting with #. Otherwise \ + the interfaces specified on the command line are processed. nameif looks for \ + the interface with the given MAC address and renames it to the name given.\n\n\ + nameif should be run before the interface is up, otherwise it'll fail." +)] +struct Args { + /// Configuration file (default: /etc/mactab) + #[arg(short = 'c', long = "config-file", default_value = "/etc/mactab")] + config_file: String, + + /// Interface name and MAC address pairs (ifname macaddress) + #[arg(value_name = "IFNAME MACADDRESS")] + pairs: Vec, +} + +#[derive(Debug, Clone)] +struct InterfaceChange { + ifname: String, + mac: Vec, + mac_len: usize, + found: bool, +} + +pub fn main() { + let args = Args::parse(); + + let mut changes = match parse_cli_pairs(&args.pairs) { + Ok(c) => c, + Err(e) => { + eprintln!("nameif: {e}"); + std::process::exit(1); + } + }; + + if changes.is_empty() || args.config_file != "/etc/mactab" { + match read_config_file(&args.config_file) { + Ok(mut config_changes) => changes.append(&mut config_changes), + Err(e) => { + eprintln!("nameif: {e}"); + std::process::exit(1); + } + } + } + + let interfaces = match list_interfaces() { + Ok(i) => i, + Err(e) => { + eprintln!("nameif: {e}"); + std::process::exit(1); + } + }; + + for ifname in interfaces { + let mac = match get_interface_mac(&ifname) { + Ok(m) => m, + Err(_) => continue, + }; + + if let Some(change) = changes + .iter_mut() + .find(|ch| mac.len() >= ch.mac_len && ch.mac[..ch.mac_len] == mac[..ch.mac_len]) + { + change.found = true; + + if ifname != change.ifname + && let Err(e) = rename_interface(&ifname, &change.ifname) + { + eprintln!("nameif: {e}"); + std::process::exit(1); + } + } + } + + let mut has_missing = false; + for change in &changes { + if !change.found { + eprintln!("nameif: interface '{}' not found", change.ifname); + has_missing = true; + } + } + + if has_missing { + std::process::exit(1); + } +} + +/// Copy bytes from a &[u8] slice to a [i8] array +/// This is a safe alternative to pointer casting +fn copy_bytes_to_i8_array(dest: &mut [i8], src: &[u8]) { + let len = src.len().min(dest.len()); + for i in 0..len { + dest[i] = src[i] as i8; + } +} + +/// Parse MAC address from string format "00:11:22:33:44:55" to bytes +fn parse_mac(s: &str) -> Result> { + let mut mac = Vec::new(); + + for part in s.split(':') { + let byte = u8::from_str_radix(part, 16) + .map_err(|_| NetToolsError::InvalidArgument(format!("cannot parse MAC '{s}'")))?; + mac.push(byte); + + if mac.len() > MAC_MAX_LEN { + return Err(NetToolsError::Other(format!( + "MAC address '{s}' is larger than maximum allowed {MAC_MAX_LEN} bytes" + ))); + } + } + + if mac.is_empty() { + return Err(NetToolsError::InvalidArgument( + "empty MAC address".to_string(), + )); + } + + Ok(mac) +} + +/// Read and parse the config file +fn read_config_file(path: &str) -> Result> { + let contents = fs::read_to_string(path) + .map_err(|e| NetToolsError::Other(format!("opening configuration file {path}: {e}")))?; + + let mut changes = Vec::new(); + + for (line_num, line) in contents.lines().enumerate() { + let line_num = line_num + 1; + + // Resolve comments + let line = if let Some(pos) = line.find('#') { + &line[..pos] + } else { + line + }; + + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() != 2 { + return Err(NetToolsError::InvalidArgument(format!( + "invalid format at line {line_num}: expected 'ifname macaddress'" + ))); + } + + let ifname = parts[0]; + let mac_str = parts[1]; + + if ifname.len() >= IFNAMEIZ { + return Err(NetToolsError::NameTooLong(format!( + "interface name too long at line {line_num}" + ))); + } + + if ifname.contains(':') { + eprintln!( + "nameif: warning: alias device {} at line {} probably has no mac", + ifname, line_num + ); + } + + let mac = parse_mac(mac_str) + .map_err(|e| NetToolsError::InvalidArgument(format!("at line {line_num}: {e}")))?; + + let mac_len = mac.len(); + changes.push(InterfaceChange { + ifname: ifname.to_string(), + mac, + mac_len, + found: false, + }) + } + + Ok(changes) +} + +/// Parse command-line interface/MAC pairs +fn parse_cli_pairs(pairs: &[String]) -> Result> { + if !pairs.len().is_multiple_of(2) { + return Err(NetToolsError::InvalidArgument( + "interface name and MAC address must be provided in pairs".to_string(), + )); + } + + let mut changes = Vec::new(); + let mut i = 0; + while i < pairs.len() { + let ifname = &pairs[i]; + let mac_str = &pairs[i + 1]; + + if ifname.len() >= IFNAMEIZ { + return Err(NetToolsError::NameTooLong(format!( + "interface name '{}' is too long (max {} chars)", + ifname, + IFNAMEIZ - 1 + ))); + } + + let mac = parse_mac(mac_str)?; + let mac_len = mac.len(); + changes.push(InterfaceChange { + ifname: ifname.clone(), + mac, + mac_len, + found: false, + }); + + i += 2; + } + + Ok(changes) +} + +/// List all network interfaces from /proc/net/dev +fn list_interfaces() -> Result> { + let contents = fs::read_to_string("/proc/net/dev") + .map_err(|e| NetToolsError::Other(format!("open of /proc/net/dev: {e}")))?; + + let mut interfaces = Vec::new(); + + for (line_num, line) in contents.lines().enumerate() { + // Skip the header lines + if line_num < 2 { + continue; + } + + let line = line.trim(); + + // Interface name is before :, for example `wlp1s0:` + if let Some(pos) = line.find(':') { + let ifname = line[..pos].trim(); + + if ifname.len() >= IFNAMEIZ { + continue; + } + + interfaces.push(ifname.to_string()); + } + } + + Ok(interfaces) +} + +/// Get the MAC address of a network interface +fn get_interface_mac(ifname: &str) -> Result> { + let socket = UdpSocket::bind("0.0.0.0:0") + .map_err(|e| NetToolsError::Other(format!("socket creation failed: {e}")))?; + + let ifname_bytes = ifname.as_bytes(); + if ifname_bytes.len() >= libc::IF_NAMESIZE { + return Err(NetToolsError::NameTooLong(format!( + "interface name '{ifname}' too long" + ))); + } + + let mut ifreq = libc::ifreq { + ifr_name: [0; libc::IF_NAMESIZE], + ifr_ifru: libc::__c_anonymous_ifr_ifru { + ifru_addr: libc::sockaddr { + sa_family: 0, + sa_data: [0; 14], + }, + }, + }; + + copy_bytes_to_i8_array(&mut ifreq.ifr_name, ifname_bytes); + + // SAFETY: Valid fd and properly initialized ifreq struct for SIOCGIFHWADDR + let ret = unsafe { libc::ioctl(socket.as_raw_fd(), libc::SIOCGIFHWADDR, &mut ifreq) }; + + if ret < 0 { + return Err(NetToolsError::Other(format!( + "ioctl SIOCGIFHWADDR failed for interface '{ifname}'", + ))); + } + + // SAFETY: Reading ifru_hwaddr which was just populated by the successful ioctl + let mac = unsafe { + let sa_data = &ifreq.ifr_ifru.ifru_hwaddr.sa_data; + sa_data[..MAC_MAX_LEN].iter().map(|&b| b as u8).collect() + }; + + Ok(mac) +} + +/// Rename a network interface +fn rename_interface(old_name: &str, new_name: &str) -> Result<()> { + let socket = UdpSocket::bind("0.0.0.0:0") + .map_err(|e| NetToolsError::Other(format!("socket creation failed: {e}")))?; + + let old_bytes = old_name.as_bytes(); + if old_bytes.len() >= libc::IF_NAMESIZE { + return Err(NetToolsError::NameTooLong(format!( + "interface name '{old_name}' too long" + ))); + } + + let new_bytes = new_name.as_bytes(); + if new_bytes.len() >= libc::IF_NAMESIZE { + return Err(NetToolsError::NameTooLong(format!( + "interface name '{new_name}' too long" + ))); + } + + let mut ifreq = libc::ifreq { + ifr_name: [0; libc::IF_NAMESIZE], + ifr_ifru: libc::__c_anonymous_ifr_ifru { + ifru_newname: [0; libc::IF_NAMESIZE], + }, + }; + + copy_bytes_to_i8_array(&mut ifreq.ifr_name, old_bytes); + + // SAFETY: We initialized this union variant and are writing to it, not reading uninitialized data + let newname_slice = unsafe { &mut ifreq.ifr_ifru.ifru_newname }; + copy_bytes_to_i8_array(newname_slice, new_bytes); + + // SAFETY: Valid fd with properly initialized ifreq containing old and new interface names + let ret = unsafe { libc::ioctl(socket.as_raw_fd(), libc::SIOCSIFNAME, &ifreq) }; + + if ret < 0 { + let errno = std::io::Error::last_os_error(); + return Err(NetToolsError::Other(format!( + "cannot change name of {old_name} to {new_name}: {errno}" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_mac_valid() { + let mac = parse_mac("00:11:22:33:44:55").unwrap(); + assert_eq!(mac, vec![0x00, 0x11, 0x22, 0x33, 0x44, 0x55]); + } + + #[test] + fn test_parse_mac_lowercase() { + let mac = parse_mac("aa:bb:cc:dd:ee:ff").unwrap(); + assert_eq!(mac, vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + } + + #[test] + fn test_parse_mac_mixed_case() { + let mac = parse_mac("Aa:Bb:Cc:Dd:Ee:Ff").unwrap(); + assert_eq!(mac, vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + } + + #[test] + fn test_parse_mac_invalid() { + assert!(parse_mac("not:a:mac").is_err()); + assert!(parse_mac("00:11:22:33:44:GG").is_err()); + assert!(parse_mac("").is_err()); + } + + #[test] + fn test_parse_mac_single_byte() { + let mac = parse_mac("ff").unwrap(); + assert_eq!(mac, vec![0xff]); + } + + #[test] + fn test_parse_mac_two_bytes() { + let mac = parse_mac("00:11").unwrap(); + assert_eq!(mac, vec![0x00, 0x11]); + } + + #[test] + fn test_parse_cli_pairs_valid() { + let pairs = vec!["eth0".to_string(), "00:11:22:33:44:55".to_string()]; + let changes = parse_cli_pairs(&pairs).unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].ifname, "eth0"); + assert_eq!(changes[0].mac, vec![0x00, 0x11, 0x22, 0x33, 0x44, 0x55]); + assert!(!changes[0].found); + } + + #[test] + fn test_parse_cli_pairs_multiple() { + let pairs = vec![ + "eth0".to_string(), + "00:11:22:33:44:55".to_string(), + "eth1".to_string(), + "aa:bb:cc:dd:ee:ff".to_string(), + ]; + let changes = parse_cli_pairs(&pairs).unwrap(); + assert_eq!(changes.len(), 2); + assert_eq!(changes[0].ifname, "eth0"); + assert_eq!(changes[1].ifname, "eth1"); + } + + #[test] + fn test_parse_cli_pairs_odd_number() { + let pairs = vec!["eth0".to_string()]; + assert!(parse_cli_pairs(&pairs).is_err()); + } + + #[test] + fn test_parse_cli_pairs_empty() { + let pairs = vec![]; + let changes = parse_cli_pairs(&pairs).unwrap(); + assert_eq!(changes.len(), 0); + } + + #[test] + fn test_parse_cli_pairs_name_too_long() { + let long_name = "a".repeat(20); + let pairs = vec![long_name, "00:11:22:33:44:55".to_string()]; + assert!(parse_cli_pairs(&pairs).is_err()); + } + + #[test] + fn test_parse_cli_pairs_invalid_mac() { + let pairs = vec!["eth0".to_string(), "invalid".to_string()]; + assert!(parse_cli_pairs(&pairs).is_err()); + } + + #[test] + fn test_list_interfaces() { + let interfaces = list_interfaces().unwrap(); + assert!(!interfaces.is_empty()); + assert!(interfaces.iter().any(|i| i == "lo")); + } + + #[test] + fn test_get_interface_mac_loopback() { + // We don't assert success because loopback might not have a MAC + // but we at least test that the function runs + let _ = get_interface_mac("lo"); + } + + #[test] + fn test_get_interface_mac_nonexistent() { + let result = get_interface_mac("this_interface_does_not_exist_12345"); + assert!(result.is_err()); + } +}