nlink 0.13.0

Async netlink library for Linux network configuration
Documentation
//! Namespace process spawning integration tests.
//!
//! Tests for spawning processes inside network namespaces.

use std::{fs, path::PathBuf, process::Command};

use nlink::{
    Result,
    netlink::{link::DummyLink, namespace, namespace::NamespaceSpec},
};

use crate::common::TestNamespace;

#[tokio::test]
async fn test_spawn_ip_link_show() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-ip")?;

    let mut cmd = Command::new("ip");
    cmd.arg("link");
    let output = namespace::spawn_output(ns.name(), cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    // Every namespace has a loopback interface
    assert!(stdout.contains("lo"), "should contain loopback interface");

    Ok(())
}

#[tokio::test]
async fn test_spawn_sees_namespace_interface() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-if")?;
    let conn = ns.connection()?;

    // Create a dummy interface in the namespace
    conn.add_link(DummyLink::new("test0")).await?;
    conn.set_link_up("test0").await?;

    // Spawn ip link show in the namespace and verify the interface is visible
    let mut cmd = Command::new("ip");
    cmd.arg("link");
    let output = namespace::spawn_output(ns.name(), cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert!(stdout.contains("test0"), "should see test0 in namespace");

    Ok(())
}

#[tokio::test]
async fn test_spawn_background_process() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-bg")?;

    // Spawn a sleep process in the namespace
    let mut cmd = Command::new("sleep");
    cmd.arg("60");
    let mut child = namespace::spawn(ns.name(), cmd)?;

    // Verify it's running
    let try_wait = child.try_wait().map_err(nlink::Error::Io)?;
    assert!(try_wait.is_none(), "process should still be running");

    // Kill it
    child.kill().map_err(nlink::Error::Io)?;
    child.wait().map_err(nlink::Error::Io)?;

    Ok(())
}

#[tokio::test]
async fn test_spawn_captures_stdout() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-out")?;

    let mut cmd = Command::new("echo");
    cmd.arg("hello from namespace");
    let output = namespace::spawn_output(ns.name(), cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(stdout.trim(), "hello from namespace");

    Ok(())
}

#[tokio::test]
async fn test_spawn_nonexistent_namespace() -> Result<()> {
    require_root!();

    let result = namespace::spawn("definitely_does_not_exist_12345", Command::new("true"));
    assert!(result.is_err());

    Ok(())
}

#[tokio::test]
async fn test_spawn_nonexistent_binary() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-nobin")?;

    let result = namespace::spawn(ns.name(), Command::new("nonexistent_binary_12345"));
    assert!(result.is_err());

    Ok(())
}

#[tokio::test]
async fn test_spawn_parent_namespace_unaffected() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-parent")?;
    let conn = ns.connection()?;

    // Create an interface only in the namespace
    conn.add_link(DummyLink::new("nsonly0")).await?;

    // Spawn a process in the namespace
    let mut cmd = Command::new("ip");
    cmd.arg("link");
    let output = namespace::spawn_output(ns.name(), cmd)?;
    let ns_stdout = String::from_utf8_lossy(&output.stdout);
    assert!(ns_stdout.contains("nsonly0"));

    // Verify the parent namespace doesn't have this interface
    let parent_output = Command::new("ip")
        .arg("link")
        .output()
        .map_err(nlink::Error::Io)?;
    let parent_stdout = String::from_utf8_lossy(&parent_output.stdout);
    assert!(
        !parent_stdout.contains("nsonly0"),
        "parent namespace should not have nsonly0"
    );

    Ok(())
}

#[tokio::test]
async fn test_namespace_spec_spawn() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-spec")?;

    let spec = NamespaceSpec::Named(ns.name());
    let mut cmd = Command::new("echo");
    cmd.arg("via spec");
    let output = spec.spawn_output(cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(stdout.trim(), "via spec");

    Ok(())
}

// ============================================================================
// spawn_with_etc tests
// ============================================================================

/// Helper to set up /etc/netns/<name>/ with custom files.
/// Returns the directory path for cleanup.
fn setup_etc_netns(ns_name: &str, files: &[(&str, &str)]) -> PathBuf {
    let dir = PathBuf::from("/etc/netns").join(ns_name);
    fs::create_dir_all(&dir).expect("create /etc/netns/<name>/");
    for (filename, content) in files {
        fs::write(dir.join(filename), content).expect("write etc file");
    }
    dir
}

fn cleanup_etc_netns(ns_name: &str) {
    let dir = PathBuf::from("/etc/netns").join(ns_name);
    let _ = fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn test_spawn_with_etc_hosts() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-etc-h")?;
    let custom_hosts = "127.0.0.1 custom-host.lab\n";
    setup_etc_netns(ns.name(), &[("hosts", custom_hosts)]);

    let mut cmd = Command::new("cat");
    cmd.arg("/etc/hosts");
    let output = namespace::spawn_output_with_etc(ns.name(), cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    cleanup_etc_netns(ns.name());

    assert!(
        stdout.contains("custom-host.lab"),
        "spawned process should see custom /etc/hosts, got: {stdout}"
    );

    Ok(())
}

#[tokio::test]
async fn test_spawn_with_etc_no_dir() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-etc-nd")?;
    // Don't create /etc/netns/<name>/ — should work as a no-op

    let mut cmd = Command::new("echo");
    cmd.arg("ok");
    let output = namespace::spawn_output_with_etc(ns.name(), cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(stdout.trim(), "ok");

    Ok(())
}

#[tokio::test]
async fn test_spawn_with_etc_host_unaffected() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-etc-ha")?;
    let custom_hosts = "127.0.0.1 only-in-namespace\n";
    setup_etc_netns(ns.name(), &[("hosts", custom_hosts)]);

    // Spawn with overlay
    let mut cmd = Command::new("cat");
    cmd.arg("/etc/hosts");
    let _ = namespace::spawn_output_with_etc(ns.name(), cmd)?;

    // Verify host's /etc/hosts is NOT modified
    let host_hosts = fs::read_to_string("/etc/hosts").expect("read host /etc/hosts");

    cleanup_etc_netns(ns.name());

    assert!(
        !host_hosts.contains("only-in-namespace"),
        "host /etc/hosts should NOT contain namespace-specific entry"
    );

    Ok(())
}

#[tokio::test]
async fn test_spawn_with_etc_sys_remount() -> Result<()> {
    require_root!();

    let ns = TestNamespace::new("spawn-etc-sys")?;
    // Need at least one /etc overlay file to trigger mount namespace setup
    setup_etc_netns(ns.name(), &[("hosts", "127.0.0.1 localhost\n")]);

    let mut cmd = Command::new("ls");
    cmd.arg("/sys/class/net/");
    let output = namespace::spawn_output_with_etc(ns.name(), cmd)?;
    let stdout = String::from_utf8_lossy(&output.stdout);

    cleanup_etc_netns(ns.name());

    // A fresh namespace only has loopback
    assert!(
        stdout.contains("lo"),
        "should see loopback in /sys/class/net/"
    );
    // Should NOT see host interfaces (eth0, enp*, wl*, etc.)
    // We can't assert the exact names, but we can check there's no multi-word output
    // beyond "lo". Just verify lo is present — the sysfs remount is working if we get here.

    Ok(())
}