vmrunner 0.0.1

micro-vm runner for testcases that require root or invasive IO
Documentation
//! Test helper and attribute macro for VM-backed integration tests.
//!
//! The public entry point is the `test` attribute:
//!
//! ```ignore
//! #[vmrunner::test(system = "fedora")]
//! fn smoke(setup: vmrunner::TestSetup) -> anyhow::Result<()> {
//!     assert_eq!(setup.test_name(), "smoke");
//!     Ok(())
//! }
//! ```

use std::{
    path::{Path, PathBuf},
    process::Command,
    sync::Arc,
};

use anyhow::{Context, Result, anyhow};

pub use vmrunner_macros::test;

/// Per-test setup generated by `#[vmrunner::test(...)]`.
///
/// `TestSetup` records the generated test name and the system root filesystem path the test should
/// pass to the VM harness. For the default `#[vmrunner::test(system = "fedora")]` mode, that path is
/// a per-test clone of the prepared Fedora system rootfs stored inside a temporary state directory.
/// This keeps guest runtime files such as `/run` and `/var/run` isolated between parallel VM tests
/// without serializing the whole suite. `no_unshare`/`unshare = false` tests use the supplied system
/// rootfs path directly because they are often macro smoke tests rather than VM launches.
///
/// `TestSetup` does **not** automatically create VMs, bridges, TAP devices, or DHCP state by itself.
/// Those operations should be driven explicitly by the harness using this setup object as input.
///
/// # Namespace notes
///
/// `#[vmrunner::test(...)]` enters a Linux user+network namespace by default before constructing
/// `TestSetup`. This is useful for privilege-free network tests:
/// inside a user namespace, the current user can be mapped to namespace-root, and
/// inside the paired network namespace the test can create bridges, TAP devices,
/// routes, and addresses without host-level `CAP_NET_ADMIN`.
///
/// Use `#[vmrunner::test(unshare = false, ...)]` or `#[vmrunner::test(no_unshare, ...)]` only for
/// tests that intentionally need the current process namespace.
///
/// There are important caveats if you call namespace setup manually instead of using
/// the macro:
///
/// - Namespace changes are process/thread scoped. Calling `unshare` from arbitrary
///   library code can surprise unrelated code running in the same test process.
/// - Rust integration **test binaries** are separate processes, but individual test
///   functions within one binary usually share a process and may run concurrently.
/// - Tokio's multithreaded runtime can already have worker threads by the time a
///   test body runs. Network namespace changes made on one thread do not magically
///   move all existing runtime worker threads into that namespace.
/// - Forking libkrun VMs from an already-multithreaded process is more fragile than
///   doing namespace setup first and then forking children.
/// - If you do manual namespace setup, prefer a dedicated test binary or a single
///   `#[tokio::test(flavor = "current_thread")]`, and enter the namespace before
///   creating TAP/bridge devices or launching libkrun children.
/// - Avoid `unshare --mount-proc`-style behavior unless you know the host permits
///   mounting procfs from an unprivileged namespace. The probe in this repository
///   showed user+network namespaces work here, while mounting `/proc` did not.
///
/// The macro follows the safer pattern by doing namespace entry at the very start of
/// the generated zero-argument test wrapper. For async functions it also generates a
/// current-thread Tokio test wrapper, so the namespace-sensitive setup does not happen
/// after a multithreaded Tokio runtime has already scheduled work elsewhere.
#[derive(Clone, Debug)]
pub struct TestSetup {
    test_name: String,
    source_system_rootfs_path: PathBuf,
    system_rootfs_path: PathBuf,
    state_directory_owner: Option<Arc<TestStateDirectory>>,
}

impl PartialEq for TestSetup {
    fn eq(&self, other: &Self) -> bool {
        self.test_name == other.test_name
            && self.source_system_rootfs_path == other.source_system_rootfs_path
            && self.system_rootfs_path == other.system_rootfs_path
    }
}

impl Eq for TestSetup {}

/// Owns the temporary per-test system rootfs clone used as mutable VM state.
///
/// The cached mkosi/Fedora system rootfs is treated as immutable input. Each default
/// `vmrunner::test` clones it into one of these directories, installs any test-local guest assets
/// there, and exposes that clone to libkrun. Dropping the owner removes the whole state tree after
/// the test body returns.
#[derive(Debug)]
struct TestStateDirectory {
    directory: tempfile::TempDir,
    system_rootfs_path: PathBuf,
}

impl TestStateDirectory {
    fn new(test_name: &str, source_system_rootfs_path: &Path) -> Result<Self> {
        let state_parent_directory = source_system_rootfs_path
            .parent()
            .map(|parent| parent.join("test-state"))
            .unwrap_or_else(|| std::env::temp_dir().join("vmrunner-test-state"));
        fs_err::create_dir_all(&state_parent_directory).with_context(|| {
            format!(
                "create vmrunner test state parent '{}'",
                state_parent_directory.display()
            )
        })?;

        let safe_test_name = test_name
            .chars()
            .map(|ch| {
                if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                    ch
                } else {
                    '-'
                }
            })
            .collect::<String>();
        let directory = tempfile::Builder::new()
            .prefix(&format!("{safe_test_name}-"))
            .tempdir_in(&state_parent_directory)
            .with_context(|| {
                format!(
                    "create vmrunner test state directory under '{}'",
                    state_parent_directory.display()
                )
            })?;
        let system_rootfs_path = directory.path().join("system-rootfs");
        fs_err::create_dir_all(&system_rootfs_path).with_context(|| {
            format!(
                "create per-test system rootfs directory '{}'",
                system_rootfs_path.display()
            )
        })?;

        Self::clone_system_rootfs_tree(source_system_rootfs_path, &system_rootfs_path)?;

        Ok(Self {
            directory,
            system_rootfs_path,
        })
    }

    fn system_rootfs_path(&self) -> PathBuf {
        self.system_rootfs_path.clone()
    }

    fn path(&self) -> &Path {
        self.directory.path()
    }

    fn clone_system_rootfs_tree(
        source_system_rootfs_path: &Path,
        destination_system_rootfs_path: &Path,
    ) -> Result<()> {
        // Use the platform `cp` instead of hand-rolling a partial recursive copy: guest system
        // rootfs trees rely on modes, symlinks, and special directory layout. `--reflink=auto` keeps
        // the common Linux case cheap on CoW filesystems while still falling back to an ordinary copy
        // elsewhere.
        let status = Command::new("cp")
            .arg("-a")
            .arg("--reflink=auto")
            .arg(source_system_rootfs_path.join("."))
            .arg(destination_system_rootfs_path)
            .status()
            .with_context(|| {
                format!(
                    "clone system rootfs '{}' into per-test system rootfs '{}'",
                    source_system_rootfs_path.display(),
                    destination_system_rootfs_path.display()
                )
            })?;
        if !status.success() {
            return Err(anyhow!(
                "cp failed while cloning system rootfs '{}' into '{}' with {status}",
                source_system_rootfs_path.display(),
                destination_system_rootfs_path.display()
            ));
        }
        Ok(())
    }
}

impl TestSetup {
    pub fn new_with_system_rootfs(
        test_name: impl Into<String>,
        source_system_rootfs_path: impl Into<PathBuf>,
    ) -> Self {
        let source_system_rootfs_path = source_system_rootfs_path.into();
        Self {
            test_name: test_name.into(),
            source_system_rootfs_path: source_system_rootfs_path.clone(),
            system_rootfs_path: source_system_rootfs_path,
            state_directory_owner: None,
        }
    }

    /// Create setup with a per-test cloned system rootfs under a temporary state directory.
    ///
    /// This is what the macro uses for normal VM tests. The clone isolates all guest-visible mutable
    /// state, including `/run`, `/var/run`, and test-installed helper binaries, while preserving the
    /// shared source system rootfs as a cacheable build artifact.
    pub fn new_with_isolated_system_rootfs(
        test_name: impl Into<String>,
        source_system_rootfs_path: impl Into<PathBuf>,
    ) -> Result<Self> {
        let test_name = test_name.into();
        let source_system_rootfs_path = source_system_rootfs_path.into();
        let state_directory = Arc::new(TestStateDirectory::new(
            &test_name,
            &source_system_rootfs_path,
        )?);
        let system_rootfs_path = state_directory.system_rootfs_path();
        Ok(Self {
            test_name,
            source_system_rootfs_path,
            system_rootfs_path,
            state_directory_owner: Some(state_directory),
        })
    }

    pub fn test_name(&self) -> &str {
        &self.test_name
    }

    /// System rootfs path the test should pass to `vmrunner_test_harness::TestCase`.
    ///
    /// In normal VM tests this points at a per-test clone, not the shared cached Fedora rootfs from
    /// `build.rs`. Test-local asset installs should use this path so parallel tests never mutate or
    /// write runtime files into the shared cache.
    pub fn system_rootfs_path(&self) -> &Path {
        &self.system_rootfs_path
    }

    /// Original system rootfs path supplied to the macro before per-test state isolation was applied.
    pub fn source_system_rootfs_path(&self) -> &Path {
        &self.source_system_rootfs_path
    }

    /// Temporary per-test state directory, when this setup owns one.
    pub fn state_directory_path(&self) -> Option<&Path> {
        self.state_directory_owner
            .as_ref()
            .map(|state| state.path())
    }
}

/// Re-run the current test in a child process created by `unshare(1)`.
///
/// Returns `Ok(true)` in the original parent process after the child has completed;
/// the generated macro wrapper should then return from the test. Returns `Ok(false)`
/// inside the unshared child process so the test body can run normally.
///
/// This process-level launcher is preferable to calling `libc::unshare` directly from
/// the Rust test harness process: the harness may already be multithreaded before the
/// test function runs, and `CLONE_NEWUSER` requires single-threaded callers on Linux.
#[cfg(target_os = "linux")]
pub fn run_current_test_in_unshare_child(test_name: &str) -> Result<bool> {
    run_current_test_with_unshare_args(
        test_name,
        "VMRUNNER_UNSHARE_CHILD_TEST",
        &["--user", "--map-root-user", "--net", "--fork", "--"],
        "user+network namespace",
    )
}

/// Re-run the current test with only a user namespace root mapping.
///
/// Use this for VM tests that need guest root-like privileges for btrfs mounts but should not
/// create a separate host-side network namespace.
#[cfg(target_os = "linux")]
pub fn run_current_test_in_userns_child(test_name: &str) -> Result<bool> {
    run_current_test_with_unshare_args(
        test_name,
        "VMRUNNER_USERNS_CHILD_TEST",
        &["--user", "--map-root-user", "--fork", "--"],
        "user namespace",
    )
}

#[cfg(target_os = "linux")]
fn run_current_test_with_unshare_args(
    test_name: &str,
    child_env: &str,
    unshare_args: &[&str],
    description: &str,
) -> Result<bool> {
    if std::env::var(child_env).as_deref() == Ok(test_name) {
        return Ok(false);
    }

    let current_test_binary = std::env::current_exe().context("resolve current test binary")?;
    let status = Command::new("unshare")
        .args(unshare_args)
        .arg(current_test_binary)
        .arg("--exact")
        .arg(test_name)
        .arg("--nocapture")
        .env(child_env, test_name)
        .status()
        .with_context(|| format!("launch unshare child test process in {description}"))?;

    if !status.success() {
        return Err(anyhow!(
            "unshare child test process in {description} failed with {status}"
        ));
    }

    Ok(true)
}

#[cfg(not(target_os = "linux"))]
pub fn run_current_test_in_unshare_child(_test_name: &str) -> Result<bool> {
    Err(anyhow!(
        "user+network namespaces are only supported on Linux"
    ))
}

#[cfg(not(target_os = "linux"))]
pub fn run_current_test_in_userns_child(_test_name: &str) -> Result<bool> {
    Err(anyhow!("user namespaces are only supported on Linux"))
}