nextest-runner 0.35.0

Core runner logic for cargo nextest.
Documentation
// Copyright (c) The nextest Contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

use crate::{
    cargo_config::EnvironmentMap,
    double_spawn::{DoubleSpawnContext, DoubleSpawnInfo},
    helpers::dylib_path_envvar,
    target_runner::TargetRunner,
};
use camino::Utf8PathBuf;
use guppy::graph::PackageMetadata;
use once_cell::sync::Lazy;
use std::{
    collections::{BTreeSet, HashMap},
    ffi::{OsStr, OsString},
};

#[derive(Clone, Debug)]
pub(crate) struct LocalExecuteContext<'a> {
    pub(crate) double_spawn: &'a DoubleSpawnInfo,
    pub(crate) runner: &'a TargetRunner,
    pub(crate) dylib_path: &'a OsStr,
    pub(crate) env: &'a EnvironmentMap,
}

/// Represents a to-be-run test command for a test binary with a certain set of arguments.
pub(crate) struct TestCommand {
    /// The command to be run.
    command: std::process::Command,
    /// Double-spawn context.
    double_spawn: Option<DoubleSpawnContext>,
}

impl TestCommand {
    /// Creates a new test command.
    pub(crate) fn new(
        ctx: &LocalExecuteContext<'_>,
        program: String,
        args: &[&str],
        cwd: &Utf8PathBuf,
        package: &PackageMetadata<'_>,
        non_test_binaries: &BTreeSet<(String, Utf8PathBuf)>,
    ) -> Self {
        // This is a workaround for a macOS SIP issue:
        // https://github.com/nextest-rs/nextest/pull/84
        //
        // Basically, if SIP is enabled, macOS removes any environment variables that start with
        // "LD_" or "DYLD_" when spawning system-protected processes. This unfortunately includes
        // processes like bash -- this means that if nextest invokes a shell script, paths might
        // end up getting sanitized.
        //
        // This is particularly relevant for target runners, which are often shell scripts.
        //
        // To work around this, re-export any variables that begin with LD_ or DYLD_ as "NEXTEST_LD_"
        // or "NEXTEST_DYLD_". Do this on all platforms for uniformity.
        //
        // Nextest never changes these environment variables within its own process, so caching them is
        // valid.
        fn is_sip_sanitized(var: &str) -> bool {
            // Look for variables starting with LD_ or DYLD_.
            // https://briandfoy.github.io/macos-s-system-integrity-protection-sanitizes-your-environment/
            var.starts_with("LD_") || var.starts_with("DYLD_")
        }

        static LD_DYLD_ENV_VARS: Lazy<HashMap<String, OsString>> = Lazy::new(|| {
            std::env::vars_os()
                .filter_map(|(k, v)| match k.into_string() {
                    Ok(k) => is_sip_sanitized(&k).then_some((k, v)),
                    Err(_) => None,
                })
                .collect()
        });

        let mut cmd = if let Some(current_exe) = ctx.double_spawn.current_exe() {
            let mut cmd = std::process::Command::new(current_exe);
            cmd.args([DoubleSpawnInfo::SUBCOMMAND_NAME, "--", program.as_str()]);
            cmd.arg(&shell_words::join(args));
            cmd
        } else {
            let mut cmd = std::process::Command::new(program);
            cmd.args(args);
            cmd
        };

        // NB: we will always override user-provided environment variables with the
        // `CARGO_*` and `NEXTEST_*` variables set directly on `cmd` below.
        ctx.env.apply_env(&mut cmd);

        cmd.current_dir(cwd)
            // This environment variable is set to indicate that tests are being run under nextest.
            .env("NEXTEST", "1")
            // This environment variable is set to indicate that each test is being run in its own process.
            .env("NEXTEST_EXECUTION_MODE", "process-per-test")
            // These environment variables are set at runtime by cargo test:
            // https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
            .env(
                "CARGO_MANIFEST_DIR",
                // CARGO_MANIFEST_DIR is set to the *new* cwd after path mapping.
                cwd,
            )
            .env(
                "__NEXTEST_ORIGINAL_CARGO_MANIFEST_DIR",
                // This is a test-only environment variable set to the *old* cwd. Not part of the
                // public API.
                package.manifest_path().parent().unwrap(),
            )
            .env("CARGO_PKG_VERSION", format!("{}", package.version()))
            .env(
                "CARGO_PKG_VERSION_MAJOR",
                format!("{}", package.version().major),
            )
            .env(
                "CARGO_PKG_VERSION_MINOR",
                format!("{}", package.version().minor),
            )
            .env(
                "CARGO_PKG_VERSION_PATCH",
                format!("{}", package.version().patch),
            )
            .env(
                "CARGO_PKG_VERSION_PRE",
                format!("{}", package.version().pre),
            )
            .env("CARGO_PKG_AUTHORS", package.authors().join(":"))
            .env("CARGO_PKG_NAME", package.name())
            .env(
                "CARGO_PKG_DESCRIPTION",
                package.description().unwrap_or_default(),
            )
            .env("CARGO_PKG_HOMEPAGE", package.homepage().unwrap_or_default())
            .env("CARGO_PKG_LICENSE", package.license().unwrap_or_default())
            .env(
                "CARGO_PKG_LICENSE_FILE",
                package.license_file().unwrap_or_else(|| "".as_ref()),
            )
            .env(
                "CARGO_PKG_REPOSITORY",
                package.repository().unwrap_or_default(),
            )
            .env(
                "CARGO_PKG_RUST_VERSION",
                package.rust_version().map_or(String::new(), |v| {
                    // A Rust version e.g. "1.58" has v.to_string() generated as "^1.58".
                    // Remove the prefix ^.
                    let s = v.to_string();
                    match s.strip_prefix('^') {
                        Some(suffix) => suffix.to_owned(),
                        None => s,
                    }
                }),
            )
            .env(dylib_path_envvar(), ctx.dylib_path);

        for (k, v) in &*LD_DYLD_ENV_VARS {
            if k != dylib_path_envvar() {
                cmd.env("NEXTEST_".to_owned() + k, v);
            }
        }
        // Also add the dylib path envvar under the NEXTEST_ prefix.
        if is_sip_sanitized(dylib_path_envvar()) {
            cmd.env("NEXTEST_".to_owned() + dylib_path_envvar(), ctx.dylib_path);
        }

        // Expose paths to non-test binaries at runtime so that relocated paths work.
        // These paths aren't exposed by Cargo at runtime, so use a NEXTEST_BIN_EXE prefix.
        for (name, path) in non_test_binaries {
            cmd.env(format!("NEXTEST_BIN_EXE_{name}"), path);
        }

        let double_spawn = ctx.double_spawn.spawn_context();

        Self {
            command: cmd,
            double_spawn,
        }
    }

    #[inline]
    pub(crate) fn command_mut(&mut self) -> &mut std::process::Command {
        &mut self.command
    }

    pub(crate) fn spawn(self) -> std::io::Result<tokio::process::Child> {
        let mut command = tokio::process::Command::from(self.command);
        let res = command.spawn();
        if let Some(ctx) = self.double_spawn {
            ctx.finish();
        }
        res
    }
}