diff --git a/tools/cargo-zerocopy/src/main.rs b/tools/cargo-zerocopy/src/main.rs index 4db0e5f600..e5f1fbdcaf 100644 --- a/tools/cargo-zerocopy/src/main.rs +++ b/tools/cargo-zerocopy/src/main.rs @@ -27,10 +27,14 @@ use std::{ collections::{BTreeMap, HashSet}, env, fmt, io::{self, BufRead as _, Write as _}, + path::{Path, PathBuf}, process::{self, Command, Output, Stdio}, }; -use zc::metadata::ToolchainMetadata; +use zc::{ + execution::{EXECUTION_CONTEXT_ENV, MIRI_REPOSITORY_ROOT_CONTEXT}, + metadata::ToolchainMetadata, +}; // Cargo test executables inherit these variables from the delegated process. // `testutil::UiTestRunner` reuses the exact outer feature selection when it @@ -45,6 +49,7 @@ enum Error { MissingToolchainVersion, UnrecognizedToolchain(String), Ci(zc::cli::CliError), + InvalidExecutionContext(String), } impl fmt::Display for Error { @@ -55,6 +60,7 @@ impl fmt::Display for Error { Self::MissingToolchainVersion => write!(f, "No toolchain version specified after '--version'"), Self::UnrecognizedToolchain(name) => write!(f, "Unrecognized toolchain name: `{name}` (options are 'msrv', 'stable', and 'nightly')"), Self::Ci(error) => error.fmt(f), + Self::InvalidExecutionContext(message) => write!(f, "invalid internal execution context: {message}"), } } } @@ -338,17 +344,83 @@ fn rustup<'a>(args: impl IntoIterator, env: Option<(&str, &str)> // It's important to set `RUSTUP_TOOLCHAIN` to override any value set while // running this program. That variable overrides any `+` CLI // argument. - cmd.args(args).env("RUSTUP_TOOLCHAIN", ""); + cmd.args(args) + .env("RUSTUP_TOOLCHAIN", "") + // The executor protocol is consumed by this wrapper only; never leak + // it into rustup, Cargo, or metadata subprocesses. + .env_remove(EXECUTION_CONTEXT_ENV); if let Some((name, val)) = env { cmd.env(name, val); } cmd } +fn internal_execution_context() -> Result { + match env::var(EXECUTION_CONTEXT_ENV) { + Ok(value) => parse_execution_context(Some(&value)), + Err(env::VarError::NotPresent) => parse_execution_context(None), + Err(error) => Err(Error::InvalidExecutionContext(error.to_string())), + } +} + +fn parse_execution_context(value: Option<&str>) -> Result { + match value { + Some(value) if value == MIRI_REPOSITORY_ROOT_CONTEXT => Ok(true), + Some(value) => Err(Error::InvalidExecutionContext(format!("unrecognized value {value:?}"))), + None => Ok(false), + } +} + +fn validate_execution_context(active: bool, toolchain: &str, args: &[String]) -> Result<(), Error> { + if !active { + return Ok(()); + } + let expected = ["miri", "nextest", "run", "--manifest-path", "zerocopy/Cargo.toml"]; + if toolchain != "nightly" + || args + .get(..expected.len()) + .is_none_or(|actual| !actual.iter().map(String::as_str).eq(expected)) + { + return Err(Error::InvalidExecutionContext( + "the repository-root context requires the nightly Miri command with its repository manifest".to_owned(), + )); + } + Ok(()) +} + +fn repository_root() -> Result { + let cwd = + env::current_dir().map_err(|error| Error::InvalidExecutionContext(error.to_string()))?; + cwd.parent().map(PathBuf::from).ok_or_else(|| { + Error::InvalidExecutionContext("wrapper cwd has no repository parent".to_owned()) + }) +} + +fn default_target_dir(name: &str, repository_root: Option<&Path>) -> PathBuf { + if let Some(root) = repository_root { + root.join("zerocopy/target/by-toolchain").join(name) + } else { + PathBuf::from(format!("target/by-toolchain/{name}")) + } +} + +fn package_id_command(version: &str, package: &str, repository_root: Option<&Path>) -> Command { + let mut command = rustup(["run", version, "cargo", "pkgid"], None); + if let Some(root) = repository_root { + command + .args(["--manifest-path", "zerocopy/Cargo.toml"]) + .current_dir(root) + .env_remove(EXECUTION_CONTEXT_ENV); + } + command.arg("-p").arg(package); + command +} + fn delegate_cargo() -> Result<(), Error> { let mut args = env::args(); let this = args.next().unwrap(); let argument = args.next().ok_or(Error::NoArguments)?; + let execution_context = internal_execution_context()?; // Both repository wrappers deliberately invoke this binary from the // `zerocopy` directory. Keep the `..` repository root coordinated with @@ -356,6 +428,11 @@ fn delegate_cargo() -> Result<(), Error> { // command before reading crate toolchain metadata: typed CI validation is // a repository-tools operation and does not delegate to Cargo. if argument == "ci" { + if execution_context { + return Err(Error::InvalidExecutionContext( + "the repository-root context is only valid for the Miri Cargo command".to_owned(), + )); + } return zc::cli::run("..", args, io::stdout().lock()).map_err(Error::Ci); } @@ -363,11 +440,23 @@ fn delegate_cargo() -> Result<(), Error> { match argument.as_str() { "--version" => { + if execution_context { + return Err(Error::InvalidExecutionContext( + "the repository-root context is only valid for the Miri Cargo command" + .to_owned(), + )); + } let name = args.next().ok_or(Error::MissingToolchainVersion)?; println!("{}", versions.get(&name)?); Ok(()) } "+all" => { + if execution_context { + return Err(Error::InvalidExecutionContext( + "the repository-root context is only valid for the Miri Cargo command" + .to_owned(), + )); + } eprintln!("[cargo-zerocopy] warning: running the same command for each toolchain (msrv, stable, nightly)"); let args = args.collect::>(); @@ -383,6 +472,12 @@ fn delegate_cargo() -> Result<(), Error> { arg => { if let Some(name) = arg.strip_prefix('+') { let version = versions.get(name)?; + let args_vec = args.collect::>(); + validate_execution_context(execution_context, name, &args_vec)?; + // Resolve the executor-owned cwd before any installation or + // prompt. Once present, this option is the single source of + // truth for every root-relative Cargo setting below. + let repository_root = execution_context.then(repository_root).transpose()?; install_toolchain_or_exit(&versions, name)?; @@ -391,7 +486,6 @@ fn delegate_cargo() -> Result<(), Error> { targets.push(t); } - let args_vec = args.collect::>(); let feature_selection_args = capture_feature_selection_args(&args_vec); let mut i = 0; while i < args_vec.len() { @@ -443,17 +537,31 @@ fn delegate_cargo() -> Result<(), Error> { cmd.env("RUSTDOCFLAGS", &rustdocflags); set_ui_test_feature_args(&mut cmd, &feature_selection_args); + // Cargo must run from the repository root for Miri: this + // deliberately avoids discovering zerocopy/.cargo/config.toml, + // while retaining every wrapper-added flag and environment. + // The wrapper itself remains in the zerocopy directory. + if let Some(root) = &repository_root { + cmd.current_dir(root); + cmd.env_remove(EXECUTION_CONTEXT_ENV); + } + if env::var("CARGO_TARGET_DIR").is_ok() { eprintln!("[cargo-zerocopy] WARNING: `CARGO_TARGET_DIR` is set - this may cause `cargo-zerocopy` to behave unexpectedly"); } else { - cmd.env("CARGO_TARGET_DIR", format!("target/by-toolchain/{}", name)); + // The ordinary wrapper uses a path relative to its + // `zerocopy` cwd. Root context moves only the Cargo child, + // so keep the historical target location explicit. + cmd.env( + "CARGO_TARGET_DIR", + default_target_dir(name, repository_root.as_deref()), + ); } // Computes the fully-qualified package name of workspace package `p`. - let fqpn = |p| { - let output = rustup(["run", version, "cargo", "pkgid", "-p"], None) - .arg(p) - .output_or_exit(); + let fqpn = |p: &str| { + let output = + package_id_command(version, p, repository_root.as_deref()).output_or_exit(); String::from_utf8(output.stdout).unwrap().trim().to_string() }; @@ -462,36 +570,31 @@ fn delegate_cargo() -> Result<(), Error> { // this because unqualified package names are sometimes ambiguous // if a dev-dependency has taken a dependency on an earlier // version of zerocopy or zerocopy-derive. - loop { - let Some(arg) = args.next() else { - break; - }; + while let Some(arg) = args.next() { if arg == "-p" || arg == "--package" { cmd.arg(&arg); let Some(arg) = args.next() else { break; }; - cmd.arg(fqpn(arg)); - } else if arg.starts_with("-p") { + cmd.arg(fqpn(&arg)); + } else if let Some(package) = arg.strip_prefix("-p") { cmd.arg("-p"); - cmd.arg(fqpn(arg[2..].to_string())); + cmd.arg(fqpn(package)); } else if arg == "--" { cmd.arg("--"); cmd.args(args); break; - } else { - if arg == "--target" { - cmd.arg(&arg); - if let Some(target) = args.next() { - cmd.arg(&target); - cmd.env("ZEROCOPY_UI_TEST_TARGET", target); - } - } else if let Some(target) = arg.strip_prefix("--target=") { - cmd.arg(&arg); + } else if arg == "--target" { + cmd.arg(&arg); + if let Some(target) = args.next() { + cmd.arg(&target); cmd.env("ZEROCOPY_UI_TEST_TARGET", target); - } else { - cmd.arg(arg); } + } else if let Some(target) = arg.strip_prefix("--target=") { + cmd.arg(&arg); + cmd.env("ZEROCOPY_UI_TEST_TARGET", target); + } else { + cmd.arg(arg); } } @@ -505,11 +608,40 @@ fn delegate_cargo() -> Result<(), Error> { } } +fn print_usage() { + let name = env::args().next().unwrap(); + eprintln!("Usage:"); + eprintln!(" {} --version ", name); + eprintln!(" {} + [...]", name); + eprintln!(" {} +all [...]", name); + eprintln!(" {} ci audit", name); + eprintln!(" {} ci plan --event ", name); + eprintln!(" {} ci explain --event ", name); + eprintln!(" {} ci github-plan --event --github-output --artifact ", name); + eprintln!(" {} ci execute-build-cell --event --package \\", name); + eprintln!(" --toolchain --feature-profile --target "); + eprintln!(" {} ci execute-miri-cell --event --package \\", name); + eprintln!(" --toolchain --feature-profile --target \\"); + eprintln!(" --miri-model "); +} + +fn main() { + if let Err(e) = delegate_cargo() { + eprintln!("Error: {e}"); + print_usage(); + process::exit(1); + } +} + #[cfg(test)] mod tests { - use std::{ffi::OsStr, process::Command}; + use std::{ffi::OsStr, path::Path, process::Command}; - use super::{capture_feature_selection_args, set_ui_test_feature_args}; + use super::{ + capture_feature_selection_args, default_target_dir, package_id_command, + parse_execution_context, set_ui_test_feature_args, validate_execution_context, + EXECUTION_CONTEXT_ENV, MIRI_REPOSITORY_ROOT_CONTEXT, + }; fn strings(args: &[&str]) -> Vec { args.iter().map(|arg| (*arg).to_string()).collect() @@ -572,25 +704,76 @@ mod tests { assert_eq!(command.get_envs().count(), 1); assert_env(&command, "ZEROCOPY_UI_TEST_FEATURE_ARG_COUNT", "0"); } -} -fn print_usage() { - let name = env::args().next().unwrap(); + #[test] + fn execution_context_accepts_only_the_exact_nightly_miri_shape() { + let args = strings(&["miri", "nextest", "run", "--manifest-path", "zerocopy/Cargo.toml"]); + assert!(validate_execution_context(true, "nightly", &args).is_ok()); + assert!(validate_execution_context(false, "stable", &[]).is_ok()); + + for (toolchain, command) in [ + ("stable", args.clone()), + ("nightly", strings(&["test"])), + ("nightly", strings(&["miri", "nextest", "run"])), + ("nightly", strings(&["miri", "nextest", "run", "--manifest-path", "Cargo.toml"])), + ] { + assert!(validate_execution_context(true, toolchain, &command).is_err()); + } + } - eprintln!("Usage:"); - eprintln!(" {} --version ", name); - eprintln!(" {} + [...]", name); - eprintln!(" {} +all [...]", name); - eprintln!(" {} ci audit", name); - eprintln!(" {} ci plan --event ", name); - eprintln!(" {} ci explain --event ", name); - eprintln!(" {} ci github-plan --event --github-output --artifact ", name); -} + #[test] + fn execution_context_parser_is_exact_and_fail_closed() { + assert!(!parse_execution_context(None).unwrap()); + assert!(parse_execution_context(Some(MIRI_REPOSITORY_ROOT_CONTEXT)).unwrap()); + assert!(parse_execution_context(Some("miri-repository-root ")).is_err()); + assert!(parse_execution_context(Some("1")).is_err()); + } -fn main() { - if let Err(e) = delegate_cargo() { - eprintln!("Error: {e}"); - print_usage(); - process::exit(1); + #[test] + fn package_id_command_preserves_ordinary_mode_and_configures_root_mode() { + let ordinary = package_id_command("stable-version", "zerocopy", None); + assert_eq!( + ordinary.get_args().collect::>(), + [ + OsStr::new("run"), + OsStr::new("stable-version"), + OsStr::new("cargo"), + OsStr::new("pkgid"), + OsStr::new("-p"), + OsStr::new("zerocopy"), + ] + ); + assert!(ordinary.get_current_dir().is_none()); + + let root = Path::new("/repo"); + let root_mode = package_id_command("nightly-version", "zerocopy", Some(root)); + assert_eq!(root_mode.get_current_dir(), Some(root)); + assert_eq!( + root_mode.get_args().collect::>(), + [ + OsStr::new("run"), + OsStr::new("nightly-version"), + OsStr::new("cargo"), + OsStr::new("pkgid"), + OsStr::new("--manifest-path"), + OsStr::new("zerocopy/Cargo.toml"), + OsStr::new("-p"), + OsStr::new("zerocopy"), + ] + ); + let context = + root_mode.get_envs().find(|(key, _)| *key == OsStr::new(EXECUTION_CONTEXT_ENV)); + assert_eq!(context, Some((OsStr::new(EXECUTION_CONTEXT_ENV), None))); + assert_eq!(MIRI_REPOSITORY_ROOT_CONTEXT, "miri-repository-root"); + } + + #[test] + fn target_directory_preserves_relative_default_and_root_location() { + let root = Path::new("/repo"); + assert_eq!(default_target_dir("nightly", None), Path::new("target/by-toolchain/nightly")); + assert_eq!( + default_target_dir("nightly", Some(root)), + Path::new("/repo/zerocopy/target/by-toolchain/nightly") + ); } } diff --git a/tools/zc/src/ci.rs b/tools/zc/src/ci.rs index 01be53cc98..0a305ddf17 100644 --- a/tools/zc/src/ci.rs +++ b/tools/zc/src/ci.rs @@ -42,6 +42,7 @@ pub const POLICY_PATH: &str = "ci/zc.toml"; /// All repository-owned inputs accepted for CI planning. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CiInputs { + repository_root: PathBuf, policy: Policy, repository: RepositoryInventory, workflow_jobs: ReviewedWorkflowJobs, @@ -103,7 +104,7 @@ impl CiInputs { reject_duplicate_baseline_inputs(&baseline_files)?; let legacy = LegacyBaselines::read_open(&paths, baseline_files.files()) .map_err(LoadCiError::Baseline)?; - let inputs = Self { policy, repository, workflow_jobs, legacy }; + let inputs = Self { repository_root, policy, repository, workflow_jobs, legacy }; // Keep the pure parity proof inside this checked boundary. Returning a // `CiInputs` without this call would make correctness depend on every // planner and CLI entry point remembering a second validation pass. @@ -130,6 +131,14 @@ impl CiInputs { pub fn legacy(&self) -> &LegacyBaselines { &self.legacy } + + /// Returns the canonical checkout root which supplied every checked input. + /// + /// Execution uses this crate-private authority instead of accepting an + /// unrelated caller-supplied path after validation has completed. + pub(crate) fn repository_root(&self) -> &Path { + &self.repository_root + } } /// The complete open-handle counterpart to [`LegacyBaselinePaths`]. diff --git a/tools/zc/src/cli.rs b/tools/zc/src/cli.rs index 7c15cf4e41..72ebfa309f 100644 --- a/tools/zc/src/cli.rs +++ b/tools/zc/src/cli.rs @@ -30,6 +30,10 @@ use thiserror::Error; use crate::{ ci::{CiInputs, LoadCiError}, + execution::{ + execute_build_cell, execute_miri_cell, BuildCellSelector, CellExecutionError, + CellExecutionReport, MiriCellSelector, + }, github::{GitHubProjection, ProjectionError, ProjectionWriteError}, plan::{ BuildPlanCell, ExecutionMode, FeatureSelection, MiriPlanCell, Plan, PlanError, @@ -50,6 +54,7 @@ pub fn run( if let Command::GitHubPlan { github_output, artifact, .. } = &command { validate_publication_paths(github_output, artifact)?; } + let repository_root = repository_root.as_ref(); let inputs = CiInputs::load(repository_root).map_err(|error| CliError::LoadInputs(Box::new(error)))?; let result = match command { @@ -59,6 +64,14 @@ pub fn run( Command::GitHubPlan { event, github_output, artifact } => { write_github_plan(&inputs, &event, &github_output, &artifact, &mut output) } + Command::ExecuteBuildCell { selector } => print_execution_report( + "ordinary build", + &execute_build_cell(&inputs, &selector)?, + &mut output, + ), + Command::ExecuteMiriCell { selector } => { + print_execution_report("Miri", &execute_miri_cell(&inputs, &selector)?, &mut output) + } }; match result { // Rust reports a closed downstream pipe as an ordinary write error. @@ -77,6 +90,8 @@ enum Command { Plan { event: String }, Explain { event: String }, GitHubPlan { event: String, github_output: PathBuf, artifact: PathBuf }, + ExecuteBuildCell { selector: BuildCellSelector }, + ExecuteMiriCell { selector: MiriCellSelector }, } impl Command { @@ -100,11 +115,96 @@ impl Command { } } "github-plan" => parse_github_plan(args), + "execute-build-cell" => parse_execution_cell(args, false), + "execute-miri-cell" => parse_execution_cell(args, true), _ => Err(CliError::UnknownCommand { command }), } } } +fn parse_execution_cell( + args: impl IntoIterator, + miri: bool, +) -> Result { + let command = if miri { "execute-miri-cell" } else { "execute-build-cell" }; + let mut args = args.into_iter(); + let mut event = None; + let mut package = None; + let mut toolchain = None; + let mut feature_profile = None; + let mut target = None; + let mut miri_model = None; + + while let Some(argument) = args.next() { + let (name, inline_value) = argument + .split_once('=') + .map_or((argument.as_str(), None), |(name, value)| (name, Some(value))); + let destination = match name { + "--event" => &mut event, + "--package" => &mut package, + "--toolchain" => &mut toolchain, + "--feature-profile" => &mut feature_profile, + "--target" => &mut target, + "--miri-model" if miri => &mut miri_model, + _ => { + return Err(CliError::UnknownArgument { command: command.to_owned(), argument }); + } + }; + if destination.is_some() { + return Err(CliError::DuplicateOption { + command: command.to_owned(), + option: name.to_owned(), + }); + } + let value = match inline_value { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| CliError::MissingOptionValue { + command: command.to_owned(), + option: name.to_owned(), + })?; + if value.starts_with('-') { + return Err(CliError::MissingOptionValueBefore { + command: command.to_owned(), + option: name.to_owned(), + argument: value, + }); + } + value + } + }; + if value.is_empty() { + return Err(CliError::MissingOptionValue { + command: command.to_owned(), + option: name.to_owned(), + }); + } + *destination = Some(value); + } + + let event = required_option(command, "--event", event)?; + let package = required_option(command, "--package", package)?; + let toolchain = required_option(command, "--toolchain", toolchain)?; + let feature_profile = required_option(command, "--feature-profile", feature_profile)?; + let target = required_option(command, "--target", target)?; + if miri { + Ok(Command::ExecuteMiriCell { + selector: MiriCellSelector::new( + event, + package, + toolchain, + feature_profile, + target, + required_option(command, "--miri-model", miri_model)?, + ), + }) + } else { + Ok(Command::ExecuteBuildCell { + selector: BuildCellSelector::new(event, package, toolchain, feature_profile, target), + }) + } +} + fn parse_github_plan(args: impl IntoIterator) -> Result { let command = "github-plan"; let mut args = args.into_iter(); @@ -387,15 +487,36 @@ fn write_github_plan( Ok(()) } +fn print_execution_report( + kind: &str, + report: &CellExecutionReport, + output: &mut impl Write, +) -> Result<(), CliError> { + writeln!( + output, + "executed {kind} cell: {} local process step(s)", + report.executed_steps().len() + )?; + for step in report.executed_steps() { + writeln!(output, "executed: {step}")?; + } + for step in report.workflow_owned_steps() { + writeln!(output, "skipped workflow-owned step: {step}")?; + } + Ok(()) +} + /// A command-line syntax, input, planning, or output failure. #[derive(Debug, Error)] pub enum CliError { /// No command followed the literal `ci` argument. - #[error("missing CI command; expected `audit`, `plan`, `explain`, or `github-plan`")] + #[error( + "missing CI command; expected `audit`, `plan`, `explain`, `github-plan`, `execute-build-cell`, or `execute-miri-cell`" + )] MissingCommand, /// The command name is not part of the local CI interface. #[error( - "unknown CI command {command:?}; expected `audit`, `plan`, `explain`, or `github-plan`" + "unknown CI command {command:?}; expected `audit`, `plan`, `explain`, `github-plan`, `execute-build-cell`, or `execute-miri-cell`" )] UnknownCommand { /// The rejected command. @@ -435,8 +556,8 @@ pub enum CliError { /// The option which cannot serve as an event name. argument: String, }, - /// A plan or explanation command received an unsupported argument. - #[error("unknown argument {argument:?} for `ci {command}`; expected `--event EVENT`")] + /// A command received an unsupported argument. + #[error("unknown argument {argument:?} for `ci {command}`")] UnknownArgument { /// The command being parsed. command: String, @@ -508,6 +629,9 @@ pub enum CliError { /// A checked plan could not be constructed. #[error(transparent)] Plan(#[from] PlanError), + /// A selected plan cell could not be executed. + #[error(transparent)] + Execution(#[from] CellExecutionError), /// A checked plan could not be serialized for GitHub Actions. #[error(transparent)] Projection(#[from] ProjectionError), @@ -530,6 +654,7 @@ mod tests { }; use super::{run, CliError, Command}; + use crate::execution::{BuildCellSelector, MiriCellSelector}; fn strings(args: &[&str]) -> Vec { args.iter().map(|arg| (*arg).to_owned()).collect() @@ -578,6 +703,49 @@ mod tests { artifact: "plan.json".into(), } ); + assert_eq!( + Command::parse(strings(&[ + "execute-build-cell", + "--target=x86_64-unknown-linux-gnu", + "--package", + "zerocopy", + "--event=pull_request", + "--feature-profile=default", + "--toolchain=stable", + ])) + .unwrap(), + Command::ExecuteBuildCell { + selector: BuildCellSelector::new( + "pull_request", + "zerocopy", + "stable", + "default", + "x86_64-unknown-linux-gnu", + ), + } + ); + assert_eq!( + Command::parse(strings(&[ + "execute-miri-cell", + "--event=push", + "--package=zerocopy", + "--toolchain=nightly", + "--feature-profile=all", + "--target=aarch64-unknown-linux-gnu", + "--miri-model=tree", + ])) + .unwrap(), + Command::ExecuteMiriCell { + selector: MiriCellSelector::new( + "push", + "zerocopy", + "nightly", + "all", + "aarch64-unknown-linux-gnu", + "tree", + ), + } + ); } #[test] @@ -662,6 +830,44 @@ mod tests { Err(CliError::MissingOptionValue { command, option }) if command == "github-plan" && option == "--event" )); + assert!(matches!( + Command::parse(strings(&[ + "execute-build-cell", + "--event=pull_request", + "--package=zerocopy", + "--toolchain=stable", + "--feature-profile=default", + "--target=x86_64-unknown-linux-gnu", + "--miri-model=tree", + ])), + Err(CliError::UnknownArgument { command, argument }) + if command == "execute-build-cell" && argument == "--miri-model=tree" + )); + assert!(matches!( + Command::parse(strings(&[ + "execute-miri-cell", + "--event=push", + "--package=zerocopy", + "--toolchain=nightly", + "--feature-profile=default", + "--target=x86_64-unknown-linux-gnu", + ])), + Err(CliError::MissingOption { command, option }) + if command == "execute-miri-cell" && option == "--miri-model" + )); + assert!(matches!( + Command::parse(strings(&[ + "execute-build-cell", + "--event=pull_request", + "--package=zerocopy", + "--toolchain=stable", + "--feature-profile=default", + "--target=x86_64-unknown-linux-gnu", + "--target=i686-unknown-linux-gnu", + ])), + Err(CliError::DuplicateOption { command, option }) + if command == "execute-build-cell" && option == "--target" + )); } #[test] diff --git a/tools/zc/src/execution.rs b/tools/zc/src/execution.rs index 72a480117c..4f67a6006f 100644 --- a/tools/zc/src/execution.rs +++ b/tools/zc/src/execution.rs @@ -6,18 +6,19 @@ // This file may not be copied, modified, or distributed except according to // those terms. -//! Pure modeling and legacy-parity validation of unprivileged CI behavior. +//! Modeling, parity validation, and local execution of unprivileged CI work. //! //! [`Plan`](crate::plan::Plan) decides matrix membership. This module takes the //! next deliberately separate step: it expands each selected cell into the -//! ordinary Cargo or Miri operations which that cell means. It does not execute -//! a process, inspect workflow YAML, or make any decision about runners, -//! permissions, secrets, actions, environments, or publication. Those remain -//! visible workflow authority in `.github/workflows/ci.yml`. +//! ordinary Cargo or Miri operations which that cell means. The local executor +//! can run one explicitly selected cell, but it does not inspect workflow YAML +//! or make any decision about runners, permissions, secrets, actions, or +//! publication. Those remain visible workflow authority in +//! `.github/workflows/ci.yml`. //! -//! The operation builders below are intended to become the single semantic -//! source for a later executor. Until then, every place where their command -//! spelling remains duplicated in `ci.yml` is called out explicitly. The +//! The operation builders below are the single semantic source for both parity +//! checking and local execution. Every place where their command spelling or +//! setup remains duplicated in `ci.yml` is called out explicitly. The //! independent files under `ci/baselines/` are comparison evidence only: this //! module never reads a baseline row to construct proposed behavior. In //! particular, the legacy comparison covers the repository state named by the @@ -27,12 +28,20 @@ use std::{ collections::{BTreeMap, BTreeSet}, + env, error::Error, fmt, - path::Path, + fs::OpenOptions, + io::{self, Write}, + num::{NonZeroUsize, ParseIntError}, + path::{Path, PathBuf}, + process, str::FromStr, + thread, }; +use thiserror::Error as ThisError; + use crate::{ baseline::{ BaselineId, CommandBehavior, CommandBehaviors, CommandPayload, JsonValue, LegacyBaselines, @@ -41,19 +50,39 @@ use crate::{ WorkingDirectory, }, ci::CiInputs, - plan::{BuildPlanCell, EventClass, ExecutionMode, FeatureSelection, MiriPlanCell, Plan}, + plan::{ + BuildPlanCell, EventClass, ExecutionMode, FeatureSelection, MiriPlanCell, Plan, PlanError, + }, policy::{Policy, ToolchainSource}, }; const BUILD_JOB: &str = "build_test"; const MIRI_JOB: &str = "miri"; const MATRIX_WORKING_DIRECTORY: &str = "zerocopy"; - -// These environment values are workflow command behavior, not policy. Keep -// them coordinated with the top-level `env` block and the "Configure -// environment variables" step in `.github/workflows/ci.yml`. A future -// executor should consume these constants directly, at which point YAML no -// longer needs to reproduce them. +// Keep the platform-neutral command model and its frozen evidence in terms of +// the public Unix wrapper. Only the host boundary below translates that exact +// program to the equivalent Windows entry point. This avoids making every +// planner, selector, baseline, and fake-host test platform-dependent. +const CARGO_WRAPPER: &str = "./cargo.sh"; +const WINDOWS_CARGO_WRAPPER: &str = "./win-cargo.bat"; +const AARCH64_TARGET: &str = "aarch64-unknown-linux-gnu"; +const MIRI_THREAD_PLACEHOLDER: &str = "<2*nproc>"; +const NPROC_STEP: &str = "Determine Miri thread count"; +// This is the private half of the protocol implemented by cargo-zerocopy. +// Keep these literals synchronized: the executor sets them and the wrapper +// validates them before changing only Cargo subprocess cwd/discovery. +#[doc(hidden)] +pub const EXECUTION_CONTEXT_ENV: &str = "ZEROCOPY_INTERNAL_EXECUTION_CONTEXT"; +#[doc(hidden)] +pub const MIRI_REPOSITORY_ROOT_CONTEXT: &str = "miri-repository-root"; + +// These environment values are executor-owned command behavior, not policy. +// Until `.github/workflows/ci.yml` delegates matrix commands to this executor, +// the base values remain duplicated in its top-level `env` block and the +// nightly additions in its "Configure environment variables" step. The frozen +// command goldens prove that this model matches independently captured main; +// they do not inspect that temporary live-YAML duplication. Migrated workflow +// jobs must not reproduce these values in YAML. const BASE_RUSTFLAGS: &str = "-Dwarnings"; const BASE_RUSTDOCFLAGS: &str = "-Dwarnings --cfg=zerocopy_unstable_ptr"; const NIGHTLY_RUSTFLAGS: &str = "-Zrandomize-layout"; @@ -119,6 +148,743 @@ pub fn audit_execution(inputs: &CiInputs) -> Result<(), ExecutionAuditError> { compare_execution(inputs.legacy(), &proposed) } +/// The complete identity of one selected ordinary build-plan cell. +/// +/// Every field is required deliberately. A caller cannot accidentally run a +/// different profile or target merely because policy adds another cell later. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildCellSelector { + event: String, + package: String, + toolchain: String, + feature_profile: String, + target: String, +} + +impl BuildCellSelector { + /// Constructs an exact ordinary-cell selector. + pub fn new( + event: impl Into, + package: impl Into, + toolchain: impl Into, + feature_profile: impl Into, + target: impl Into, + ) -> Self { + Self { + event: event.into(), + package: package.into(), + toolchain: toolchain.into(), + feature_profile: feature_profile.into(), + target: target.into(), + } + } + + fn description(&self) -> String { + format!( + "event={:?}, package={:?}, toolchain={:?}, feature_profile={:?}, target={:?}", + self.event, self.package, self.toolchain, self.feature_profile, self.target, + ) + } +} + +/// The complete identity of one selected Miri-plan cell. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MiriCellSelector { + event: String, + package: String, + toolchain: String, + feature_profile: String, + target: String, + model: String, +} + +impl MiriCellSelector { + /// Constructs an exact Miri-cell selector. + pub fn new( + event: impl Into, + package: impl Into, + toolchain: impl Into, + feature_profile: impl Into, + target: impl Into, + model: impl Into, + ) -> Self { + Self { + event: event.into(), + package: package.into(), + toolchain: toolchain.into(), + feature_profile: feature_profile.into(), + target: target.into(), + model: model.into(), + } + } + + fn description(&self) -> String { + format!( + "event={:?}, package={:?}, toolchain={:?}, feature_profile={:?}, target={:?}, miri_model={:?}", + self.event, self.package, self.toolchain, self.feature_profile, self.target, self.model, + ) + } +} + +/// What one selected-cell invocation actually did locally. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CellExecutionReport { + executed_steps: Vec, + workflow_owned_steps: Vec, +} + +impl CellExecutionReport { + fn new() -> Self { + Self { executed_steps: Vec::new(), workflow_owned_steps: Vec::new() } + } + + /// Returns modeled process steps which completed successfully, in order. + pub fn executed_steps(&self) -> &[String] { + &self.executed_steps + } + + /// Returns selected steps which remain owned by GitHub Actions. + pub fn workflow_owned_steps(&self) -> &[String] { + &self.workflow_owned_steps + } +} + +/// A deterministic selection, model, process, or Miri-setup failure. +#[derive(Debug, ThisError)] +pub enum CellExecutionError { + /// The requested event could not be planned. + #[error(transparent)] + Plan(#[from] PlanError), + /// No selected cell has the complete requested identity. + #[error( + "no selected {kind} cell matches {selector}; the selector is unknown or excluded for this event" + )] + CellNotSelected { + /// The matrix kind being selected. + kind: &'static str, + /// The escaped, complete selector. + selector: String, + }, + /// More than one selected cell has an identity which should be unique. + #[error("{matches} selected {kind} cells match {selector}; refusing an ambiguous execution")] + AmbiguousCell { + /// The matrix kind being selected. + kind: &'static str, + /// The escaped, complete selector. + selector: String, + /// Number of matching cells observed. + matches: usize, + }, + /// Checked inputs could not be expanded into executable behavior. + #[error("cannot construct selected-cell execution: {message}")] + Model { + /// The model validation diagnostic. + message: String, + }, + /// A selected operation is represented by a payload this executor cannot run. + #[error("step {step:?} has unsupported modeled payload {payload}")] + UnsupportedPayload { + /// Human-readable modeled step name. + step: String, + /// Stable payload description. + payload: &'static str, + }, + /// A process could not be started. + #[error("failed to start step {step:?} with program {program:?}: {source}")] + StartProcess { + /// Human-readable modeled step name. + step: String, + /// Exact argv element used as the program. + program: String, + /// Operating-system process error. + #[source] + source: io::Error, + }, + /// A process completed unsuccessfully. + #[error("step {step:?} failed ({status})")] + ProcessFailed { + /// Human-readable modeled step name. + step: String, + /// Exit-code or signal description. + status: String, + }, + /// GNU `nproc` could not be started. + #[error("failed to start GNU nproc while determining the Miri thread count: {source}")] + StartNproc { + /// Operating-system process error. + #[source] + source: io::Error, + }, + /// GNU `nproc` completed unsuccessfully. + #[error("GNU nproc failed while determining the Miri thread count ({status})")] + NprocFailed { + /// Exit-code or signal description. + status: String, + }, + /// GNU `nproc` wrote stdout which was not UTF-8. + #[error("GNU nproc output is not UTF-8: {source}")] + NprocOutputNotUtf8 { + /// UTF-8 decoding error. + #[source] + source: std::str::Utf8Error, + }, + /// GNU `nproc` did not write exactly one newline-terminated value. + #[error("GNU nproc output must be exactly one nonempty line terminated by LF; got {output:?}")] + NprocOutputShape { + /// Decoded output, rendered escaped by the diagnostic. + output: String, + }, + /// GNU `nproc` did not write an unsigned decimal which fits `usize`. + #[error("GNU nproc output {value:?} is not a base-10 usize: {source}")] + NprocOutputParse { + /// Single output line, without its terminating newline. + value: String, + /// Integer parsing error. + #[source] + source: ParseIntError, + }, + /// The host unexpectedly reported no available processors. + #[error("available processor count was zero; the Miri thread count must be nonzero")] + ProcessorCountZero, + /// Doubling the host's processor count overflowed the integer type. + #[error("cannot double available processor count {available}: usize overflow")] + ThreadCountOverflow { + /// Processor count reported by the host query. + available: usize, + }, + /// The operating system could not report its available parallelism. + #[error("failed to query available processors: {source}")] + AvailableParallelism { + /// Operating-system query error. + #[source] + source: io::Error, + }, + /// A modeled argv template did not have exactly one dynamic element. + #[error( + "step {step:?} must contain dynamic argv element {placeholder:?} exactly once; found {occurrences}" + )] + DynamicPlaceholder { + /// Human-readable modeled step name. + step: String, + /// Exact placeholder being replaced. + placeholder: String, + /// Number of exact argv elements found. + occurrences: usize, + }, + /// A GitHub step summary could not be appended. + #[error("failed to append Miri thread count to {path:?}: {source}")] + AppendStepSummary { + /// `GITHUB_STEP_SUMMARY` destination. + path: PathBuf, + /// Underlying filesystem error. + #[source] + source: io::Error, + }, +} + +/// Executes the modeled commands for one exact selected ordinary build cell. +/// +/// The semver action is intentionally not executed: GitHub requires its +/// literal `uses` identity and security-relevant condition to remain in the +/// workflow. If the selected cell includes that action, the returned report +/// names it as workflow-owned instead of silently treating it as completed. +pub fn execute_build_cell( + inputs: &CiInputs, + selector: &BuildCellSelector, +) -> Result { + execute_build_cell_with(inputs, selector, &mut SystemExecutionHost) +} + +/// Executes the modeled command for one exact selected Miri cell. +pub fn execute_miri_cell( + inputs: &CiInputs, + selector: &MiriCellSelector, +) -> Result { + execute_miri_cell_with(inputs, selector, &mut SystemExecutionHost) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ProcessInvocation { + step: String, + argv: Vec, + working_directory: PathBuf, + environment: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProcessOutcome { + success: bool, + code: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CapturedProcessOutcome { + success: bool, + code: Option, + stdout: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HostPlatform { + Linux, + Windows, + Other, +} + +impl HostPlatform { + const fn current() -> Self { + if cfg!(target_os = "linux") { + Self::Linux + } else if cfg!(windows) { + Self::Windows + } else { + Self::Other + } + } +} + +/// The narrow host boundary keeps selection and execution tests incapable of +/// invoking Cargo or mutating the checkout. It also keeps argv as a vector all +/// the way to `std::process::Command`; no command joins checked values into +/// shell text. On Windows, the operating system necessarily dispatches the +/// reviewed batch wrapper, but each modeled argument still crosses the process +/// boundary separately. +trait ExecutionHost { + fn platform(&self) -> HostPlatform; + fn available_parallelism(&self) -> io::Result; + fn run(&mut self, invocation: &ProcessInvocation) -> io::Result; + fn run_capture(&mut self, invocation: &ProcessInvocation) + -> io::Result; + fn github_step_summary(&mut self) -> Option; + fn append(&mut self, path: &Path, bytes: &[u8]) -> io::Result<()>; +} + +struct SystemExecutionHost; + +fn system_command(invocation: &ProcessInvocation) -> io::Result { + system_command_for_platform(invocation, HostPlatform::current()) +} + +fn system_command_for_platform( + invocation: &ProcessInvocation, + platform: HostPlatform, +) -> io::Result { + let (program, arguments) = + invocation.argv.split_first().expect("validated modeled commands always have a program"); + // `cargo.sh` and `win-cargo.bat` implement the same reviewed repository + // interface. Keep the model canonical and translate only that exact + // executable here; unrelated programs such as `cargo` and `nproc` retain + // their ordinary host lookup semantics. + let program = match (platform, program.as_str()) { + (HostPlatform::Windows, CARGO_WRAPPER) => WINDOWS_CARGO_WRAPPER, + _ => program, + }; + let working_directory = if invocation.working_directory.is_absolute() { + invocation.working_directory.clone() + } else { + env::current_dir()?.join(&invocation.working_directory) + }; + let program_path = Path::new(program); + let executable = if program_path.is_absolute() || program_path.components().count() > 1 { + working_directory.join(program_path) + } else { + program_path.to_path_buf() + }; + let mut command = process::Command::new(executable); + command + .args(arguments) + .current_dir(working_directory) + // This private protocol is owned by the final Miri wrapper boundary. + // Never pass an ambient value to setup commands such as `cargo clean` + // or `nproc`; the final invocation adds its exact value explicitly. + .env_remove(EXECUTION_CONTEXT_ENV) + // Inherit unrelated runner state, but set every modeled variable + // directly. Shell assignment and word splitting are not part of the + // operation model. + .envs(&invocation.environment); + Ok(command) +} + +impl ExecutionHost for SystemExecutionHost { + fn platform(&self) -> HostPlatform { + HostPlatform::current() + } + fn available_parallelism(&self) -> io::Result { + thread::available_parallelism() + } + fn run(&mut self, invocation: &ProcessInvocation) -> io::Result { + let status = system_command(invocation)?.status()?; + Ok(ProcessOutcome { success: status.success(), code: status.code() }) + } + + fn run_capture( + &mut self, + invocation: &ProcessInvocation, + ) -> io::Result { + // Only stdout is machine-readable. Preserve the program's stderr on + // the runner so a start or status failure retains its native context. + let output = system_command(invocation)?.stderr(process::Stdio::inherit()).output()?; + Ok(CapturedProcessOutcome { + success: output.status.success(), + code: output.status.code(), + stdout: output.stdout, + }) + } + + fn github_step_summary(&mut self) -> Option { + env::var_os("GITHUB_STEP_SUMMARY").map(PathBuf::from) + } + + fn append(&mut self, path: &Path, bytes: &[u8]) -> io::Result<()> { + OpenOptions::new().create(true).append(true).open(path)?.write_all(bytes) + } +} + +fn execute_build_cell_with( + inputs: &CiInputs, + selector: &BuildCellSelector, + host: &mut impl ExecutionHost, +) -> Result { + let repository_root = inputs.repository_root(); + let plan = Plan::create(inputs, &selector.event)?; + let description = selector.description(); + let cell = unique_match( + "ordinary build", + &description, + plan.builds().iter().filter(|cell| build_cell_matches(cell, selector)), + )?; + let semantics = BuildCellSemantics::from_plan(cell, inputs.policy()) + .map_err(|message| CellExecutionError::Model { message })?; + let operations = build_operations( + inputs.policy(), + inputs.repository().zerocopy_docs_rs_rustdoc_args(), + &semantics, + ) + .map_err(|message| CellExecutionError::Model { message })?; + let mut report = CellExecutionReport::new(); + + for operation in operations { + validate_operation(&operation).map_err(|message| CellExecutionError::Model { message })?; + if !operation.applicable { + return Err(CellExecutionError::Model { + message: format!( + "selected operation {:?} is unexpectedly inapplicable", + operation.kind + ), + }); + } + match &operation.command.payload { + CommandPayload::Argv { argv, .. } => { + run_process(host, repository_root, &operation.command, argv)?; + report.executed_steps.push(operation.command.step.clone()); + } + CommandPayload::ActionInputs { .. } + if operation.kind == MatrixOperationKind::CargoSemverCheck => + { + // The action identity, condition, and permission boundary must + // stay literal in ci.yml. This explicit report is coupled to + // that audited adapter until GitHub supports dynamic `uses`. + report.workflow_owned_steps.push(operation.command.step.clone()); + } + CommandPayload::ActionInputs { .. } => { + return Err(CellExecutionError::UnsupportedPayload { + step: operation.command.step.clone(), + payload: "action inputs", + }); + } + CommandPayload::ArgvTemplate { .. } => { + return Err(CellExecutionError::UnsupportedPayload { + step: operation.command.step.clone(), + payload: "argv template in an ordinary build cell", + }); + } + } + } + Ok(report) +} + +fn execute_miri_cell_with( + inputs: &CiInputs, + selector: &MiriCellSelector, + host: &mut impl ExecutionHost, +) -> Result { + let repository_root = inputs.repository_root(); + let plan = Plan::create(inputs, &selector.event)?; + let description = selector.description(); + let cell = unique_match( + "Miri", + &description, + plan.miri().iter().filter(|cell| miri_cell_matches(cell, selector)), + )?; + let semantics = MiriCellSemantics::from_plan(cell, inputs.policy()) + .map_err(|message| CellExecutionError::Model { message })?; + let operation = + miri_operation(&semantics).map_err(|message| CellExecutionError::Model { message })?; + validate_operation(&operation).map_err(|message| CellExecutionError::Model { message })?; + if !operation.applicable { + return Err(CellExecutionError::Model { + message: "selected Miri operation is unexpectedly inapplicable".to_owned(), + }); + } + let CommandPayload::ArgvTemplate { argv, dynamic_value } = &operation.command.payload else { + return Err(CellExecutionError::UnsupportedPayload { + step: operation.command.step.clone(), + payload: "non-template Miri command", + }); + }; + validate_dynamic_placeholder(&operation.command.step, argv, dynamic_value)?; + let (final_command, final_argv_template) = miri_wrapper_invocation(&operation.command, argv)?; + + execute_miri_direct( + host, + repository_root, + &semantics, + &operation.command, + &final_command, + &final_argv_template, + dynamic_value, + ) +} + +fn execute_miri_direct( + host: &mut impl ExecutionHost, + repository_root: &Path, + semantics: &MiriCellSemantics, + setup_command: &CommandSpec, + final_command: &CommandSpec, + argv_template: &[String], + dynamic_value: &str, +) -> Result { + let mut report = CellExecutionReport::new(); + if semantics.target == AARCH64_TARGET { + // Keep this command coordinated with the workaround for rust-lang/miri + // #3125 in ci.yml. It is setup for the modeled Miri invocation, not an + // additional coverage decision. + let clean = CommandSpec { + job: MIRI_JOB.to_owned(), + step: "Clean aarch64 Miri target".to_owned(), + working_directory: WorkingDirectory::Relative(MATRIX_WORKING_DIRECTORY.to_owned()), + environment: setup_command.environment.clone(), + payload: CommandPayload::Argv { + argv: vec!["cargo".to_owned(), "clean".to_owned()], + dynamic_value: None, + }, + }; + let CommandPayload::Argv { argv, .. } = &clean.payload else { + unreachable!("the local aarch64 cleanup is a fixed argv command"); + }; + run_process(host, repository_root, &clean, argv)?; + report.executed_steps.push(clean.step); + } + + let threads = miri_thread_count(host, repository_root, &setup_command.environment)?; + report.executed_steps.push(NPROC_STEP.to_owned()); + let argv = substitute_dynamic( + &final_command.step, + argv_template, + dynamic_value, + &threads.to_string(), + )?; + + if let Some(path) = host.github_step_summary() { + let summary = format!("Running Miri tests with {threads} threads\n"); + host.append(&path, summary.as_bytes()) + .map_err(|source| CellExecutionError::AppendStepSummary { path, source })?; + } + run_process(host, repository_root, final_command, &argv)?; + report.executed_steps.push(final_command.step.clone()); + Ok(report) +} + +fn miri_wrapper_invocation( + command: &CommandSpec, + argv: &[String], +) -> Result<(CommandSpec, Vec), CellExecutionError> { + let prefix = [CARGO_WRAPPER, "+nightly", "miri", "nextest", "run"]; + if argv.get(..prefix.len()).map(|values| values.iter().map(String::as_str).collect::>()) + != Some(prefix.to_vec()) + { + return Err(CellExecutionError::Model { + message: format!( + "Miri command does not begin with the frozen wrapper prefix {prefix:?}" + ), + }); + } + if command.environment.contains_key(EXECUTION_CONTEXT_ENV) { + return Err(CellExecutionError::Model { + message: format!("Miri command must not preconfigure {EXECUTION_CONTEXT_ENV}"), + }); + } + let mut adapted = argv.to_vec(); + adapted.splice(5..5, ["--manifest-path".to_owned(), "zerocopy/Cargo.toml".to_owned()]); + let mut environment = command.environment.clone(); + environment.insert(EXECUTION_CONTEXT_ENV.to_owned(), MIRI_REPOSITORY_ROOT_CONTEXT.to_owned()); + // Keep the wrapper's argv and cwd unchanged. The wrapper consumes the + // private context and applies it only to its rustup/Cargo children, so all + // toolchain installation, cfgs, UI variables, target directory handling, + // and fully-qualified package resolution remain in one implementation. + let invocation = CommandSpec { environment, ..command.clone() }; + Ok((invocation, adapted)) +} + +fn miri_thread_count( + host: &mut impl ExecutionHost, + repository_root: &Path, + environment: &BTreeMap, +) -> Result { + if host.platform() != HostPlatform::Linux { + return host + .available_parallelism() + .map_err(|source| CellExecutionError::AvailableParallelism { source }) + .and_then(|available| checked_miri_thread_count(available.get())); + } + // Keep this direct GNU `nproc` invocation coordinated with the Miri step + // in ci.yml. The frozen workflow computes `2 * nproc`; invoking the same + // program without a shell preserves nproc's handling of OMP overrides, + // while Rust performs the checked multiplication instead of `bc`. + let invocation = ProcessInvocation { + step: NPROC_STEP.to_owned(), + argv: vec!["nproc".to_owned()], + working_directory: repository_root.join(MATRIX_WORKING_DIRECTORY), + environment: environment.clone(), + }; + let outcome = host + .run_capture(&invocation) + .map_err(|source| CellExecutionError::StartNproc { source })?; + if !outcome.success { + return Err(CellExecutionError::NprocFailed { status: process_status(outcome.code) }); + } + parse_nproc_thread_count(&outcome.stdout) +} + +fn parse_nproc_thread_count(stdout: &[u8]) -> Result { + let output = std::str::from_utf8(stdout) + .map_err(|source| CellExecutionError::NprocOutputNotUtf8 { source })?; + let Some(value) = output.strip_suffix('\n') else { + return Err(CellExecutionError::NprocOutputShape { output: output.to_owned() }); + }; + if value.is_empty() || value.contains(['\r', '\n']) { + return Err(CellExecutionError::NprocOutputShape { output: output.to_owned() }); + } + let available = value.parse::().map_err(|source| { + CellExecutionError::NprocOutputParse { value: value.to_owned(), source } + })?; + // GNU nproc emits canonical unsigned decimal. Reject spellings which + // Rust's integer parser might accept but the modeled program never emits. + if !value.bytes().all(|byte| byte.is_ascii_digit()) || available.to_string() != value { + return Err(CellExecutionError::NprocOutputShape { output: output.to_owned() }); + } + if available == 0 { + return Err(CellExecutionError::ProcessorCountZero); + } + checked_miri_thread_count(available) +} + +fn checked_miri_thread_count(available: usize) -> Result { + if available == 0 { + return Err(CellExecutionError::ProcessorCountZero); + } + available.checked_mul(2).ok_or(CellExecutionError::ThreadCountOverflow { available }) +} + +fn run_process( + host: &mut impl ExecutionHost, + repository_root: &Path, + command: &CommandSpec, + argv: &[String], +) -> Result<(), CellExecutionError> { + let working_directory = match &command.working_directory { + WorkingDirectory::RepositoryRoot => repository_root.to_path_buf(), + WorkingDirectory::Relative(path) => repository_root.join(path), + }; + let invocation = ProcessInvocation { + step: command.step.clone(), + argv: argv.to_vec(), + working_directory, + environment: command.environment.clone(), + }; + let program = invocation.argv.first().cloned().ok_or_else(|| CellExecutionError::Model { + message: format!("step {:?} has an empty argv", command.step), + })?; + let outcome = host.run(&invocation).map_err(|source| CellExecutionError::StartProcess { + step: command.step.clone(), + program, + source, + })?; + if !outcome.success { + return Err(CellExecutionError::ProcessFailed { + step: command.step.clone(), + status: process_status(outcome.code), + }); + } + Ok(()) +} + +fn process_status(code: Option) -> String { + code.map_or_else(|| "terminated by a signal".to_owned(), |code| format!("exit code {code}")) +} + +fn validate_dynamic_placeholder( + step: &str, + argv: &[String], + placeholder: &str, +) -> Result<(), CellExecutionError> { + let occurrences = argv.iter().filter(|argument| argument.as_str() == placeholder).count(); + if occurrences != 1 { + return Err(CellExecutionError::DynamicPlaceholder { + step: step.to_owned(), + placeholder: placeholder.to_owned(), + occurrences, + }); + } + Ok(()) +} + +fn substitute_dynamic( + step: &str, + argv: &[String], + placeholder: &str, + value: &str, +) -> Result, CellExecutionError> { + validate_dynamic_placeholder(step, argv, placeholder)?; + Ok(argv + .iter() + .map(|argument| if argument == placeholder { value.to_owned() } else { argument.clone() }) + .collect()) +} + +fn build_cell_matches(cell: &BuildPlanCell, selector: &BuildCellSelector) -> bool { + cell.package().id() == selector.package + && cell.toolchain().id() == selector.toolchain + && cell.features().profile() == selector.feature_profile + && cell.target().triple() == selector.target +} + +fn miri_cell_matches(cell: &MiriPlanCell, selector: &MiriCellSelector) -> bool { + cell.package().id() == selector.package + && cell.toolchain().id() == selector.toolchain + && cell.features().profile() == selector.feature_profile + && cell.target().triple() == selector.target + && cell.model().id() == selector.model +} + +fn unique_match<'a, T>( + kind: &'static str, + selector: &str, + matches: impl Iterator, +) -> Result<&'a T, CellExecutionError> { + let matches = matches.collect::>(); + match matches.as_slice() { + [] => Err(CellExecutionError::CellNotSelected { kind, selector: selector.to_owned() }), + [cell] => Ok(*cell), + cells => Err(CellExecutionError::AmbiguousCell { + kind, + selector: selector.to_owned(), + matches: cells.len(), + }), + } +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum MatrixOperationKind { CargoTest, @@ -618,7 +1384,7 @@ fn cargo_operation( golden: Option<&'static str>, ) -> MatrixOperation { let mut argv = - vec!["./cargo.sh".to_owned(), format!("+{}", cell.toolchain), cargo_subcommand.to_owned()]; + vec![CARGO_WRAPPER.to_owned(), format!("+{}", cell.toolchain), cargo_subcommand.to_owned()]; argv.extend(leading_arguments.iter().map(|argument| (*argument).to_owned())); argv.extend([ "--package".to_owned(), @@ -653,7 +1419,7 @@ fn cargo_operation( fn docs_operation(docs_rs_rustdoc_args: &[String], cell: &BuildCellSemantics) -> MatrixOperation { let kind = MatrixOperationKind::CargoDoc; let mut argv = vec![ - "./cargo.sh".to_owned(), + CARGO_WRAPPER.to_owned(), format!("+{}", cell.toolchain), "doc".to_owned(), "--no-deps".to_owned(), @@ -673,11 +1439,11 @@ fn docs_operation(docs_rs_rustdoc_args: &[String], cell: &BuildCellSemantics) -> let docs_rs_rustdoc_args = docs_rs_rustdoc_args.join(" "); // Cargo doc inherits the same ordinary matrix environment as every other - // command, then its workflow step replaces RUSTDOCFLAGS. Keep this - // complete map coordinated with the Cargo doc step in `ci.yml` and the - // representative nightly-docs command golden. Omitting inherited - // RUSTFLAGS or MIRIFLAGS would make this model silently differ from the - // command whose behavior it is intended to validate. + // command, then its step replaces RUSTDOCFLAGS. Keep this complete map + // coordinated with the Cargo doc step in ci.yml and the representative + // nightly-docs command golden. The golden used to omit inherited + // RUSTFLAGS and MIRIFLAGS; retaining that omission in executable behavior + // would make this typed executor silently differ from CI. let mut environment = ordinary_environment(cell.pinned_nightly); let rustdocflags = if cell.pinned_nightly { format!( @@ -784,9 +1550,13 @@ fn miri_operation(cell: &MiriCellSemantics) -> Result { return Err(format!("Miri cell uses non-nightly toolchain `{}`", cell.toolchain)); } let kind = MatrixOperationKind::MiriTest; - let dynamic = "<2*nproc>".to_owned(); + // Keep this placeholder coordinated with the frozen command golden and + // the direct GNU `nproc` invocation in `miri_thread_count`. The typed + // executor removes shell parsing and `bc`, but deliberately preserves + // nproc's processor-count semantics, including its OMP overrides. + let dynamic = MIRI_THREAD_PLACEHOLDER.to_owned(); let mut argv = vec![ - "./cargo.sh".to_owned(), + CARGO_WRAPPER.to_owned(), format!("+{}", cell.toolchain), "miri".to_owned(), "nextest".to_owned(), @@ -1476,12 +2246,25 @@ fn collect_difference( #[cfg(test)] mod tests { - use std::{path::Path, sync::OnceLock}; + use std::{ + collections::{BTreeMap, VecDeque}, + ffi::OsStr, + io, + num::NonZeroUsize, + path::{Path, PathBuf}, + sync::OnceLock, + }; use super::{ - audit_execution, compare_execution, derive_execution, BuildCellSemantics, EventClass, - ExecutionMode, FeatureSelection, MatrixOperation, MatrixOperationKind, ModelMutation, - MIRI_JOB, + audit_execution, checked_miri_thread_count, compare_execution, derive_execution, + execute_build_cell_with, execute_miri_cell_with, miri_thread_count, + miri_wrapper_invocation, parse_nproc_thread_count, substitute_dynamic, + system_command_for_platform, unique_match, BuildCellSelector, BuildCellSemantics, + CapturedProcessOutcome, CellExecutionError, CommandSpec, EventClass, ExecutionHost, + ExecutionMode, FeatureSelection, HostPlatform, MatrixOperation, MatrixOperationKind, + MiriCellSelector, ModelMutation, ProcessInvocation, ProcessOutcome, WorkingDirectory, + AARCH64_TARGET, CARGO_WRAPPER, EXECUTION_CONTEXT_ENV, MIRI_JOB, + MIRI_REPOSITORY_ROOT_CONTEXT, MIRI_THREAD_PLACEHOLDER, NPROC_STEP, WINDOWS_CARGO_WRAPPER, }; use crate::{baseline::CommandPayload, ci::CiInputs}; @@ -1502,6 +2285,589 @@ mod tests { derive_execution(inputs(), mutation).unwrap_err() } + #[derive(Debug, Default)] + struct FakeExecutionHost { + platform: Option, + parallelism: Option>, + invocations: Vec, + outcomes: VecDeque, + start_error: Option, + captured_invocations: Vec, + captured_outcomes: VecDeque, + capture_start_error: Option, + step_summary: Option, + appended: BTreeMap>, + append_error: Option, + } + + impl ExecutionHost for FakeExecutionHost { + fn platform(&self) -> HostPlatform { + self.platform.unwrap_or(HostPlatform::Linux) + } + fn available_parallelism(&self) -> io::Result { + self.parallelism + .unwrap_or_else(|| NonZeroUsize::new(4).ok_or(io::ErrorKind::Other)) + .map_err(io::Error::from) + } + fn run(&mut self, invocation: &ProcessInvocation) -> io::Result { + self.invocations.push(invocation.clone()); + if let Some(kind) = self.start_error.take() { + return Err(io::Error::from(kind)); + } + Ok(self.outcomes.pop_front().unwrap_or(ProcessOutcome { success: true, code: Some(0) })) + } + + fn run_capture( + &mut self, + invocation: &ProcessInvocation, + ) -> io::Result { + self.captured_invocations.push(invocation.clone()); + if let Some(kind) = self.capture_start_error.take() { + return Err(io::Error::from(kind)); + } + Ok(self.captured_outcomes.pop_front().unwrap_or(CapturedProcessOutcome { + success: true, + code: Some(0), + stdout: b"4\n".to_vec(), + })) + } + + fn github_step_summary(&mut self) -> Option { + self.step_summary.clone() + } + + fn append(&mut self, path: &Path, bytes: &[u8]) -> io::Result<()> { + if let Some(kind) = self.append_error.take() { + return Err(io::Error::from(kind)); + } + self.appended.entry(path.to_path_buf()).or_default().extend_from_slice(bytes); + Ok(()) + } + } + + fn test_root() -> PathBuf { + inputs().repository_root().to_path_buf() + } + + fn build_selector(event: &str, profile: &str, target: &str) -> BuildCellSelector { + BuildCellSelector::new(event, "zerocopy", "stable", profile, target) + } + + fn miri_selector(event: &str, target: &str, model: &str) -> MiriCellSelector { + MiriCellSelector::new(event, "zerocopy", "nightly", "default", target, model) + } + + #[test] + fn miri_keeps_wrapper_argv_and_cwd_while_setting_private_context() { + let command = CommandSpec { + job: MIRI_JOB.to_owned(), + step: "Miri".to_owned(), + working_directory: WorkingDirectory::Relative("zerocopy".to_owned()), + environment: BTreeMap::new(), + payload: CommandPayload::ArgvTemplate { + argv: vec![ + CARGO_WRAPPER.to_owned(), + "+nightly".to_owned(), + "miri".to_owned(), + "nextest".to_owned(), + "run".to_owned(), + ], + dynamic_value: MIRI_THREAD_PLACEHOLDER.to_owned(), + }, + }; + let argv = vec![ + CARGO_WRAPPER.to_owned(), + "+nightly".to_owned(), + "miri".to_owned(), + "nextest".to_owned(), + "run".to_owned(), + ]; + let (invocation, adapted) = miri_wrapper_invocation(&command, &argv).unwrap(); + assert_eq!(invocation.working_directory, WorkingDirectory::Relative("zerocopy".to_owned())); + assert_eq!(invocation.environment[EXECUTION_CONTEXT_ENV], MIRI_REPOSITORY_ROOT_CONTEXT); + let CommandPayload::ArgvTemplate { argv: actual, dynamic_value } = invocation.payload + else { + panic!("Miri must retain its template payload"); + }; + assert_eq!(actual, argv); + assert_eq!(dynamic_value, MIRI_THREAD_PLACEHOLDER); + assert_eq!(adapted[5..7], ["--manifest-path", "zerocopy/Cargo.toml"]); + } + + #[test] + fn miri_rejects_private_context_in_model_environment() { + let command = CommandSpec { + job: MIRI_JOB.to_owned(), + step: "Miri".to_owned(), + working_directory: WorkingDirectory::Relative("zerocopy".to_owned()), + environment: BTreeMap::from([(EXECUTION_CONTEXT_ENV.to_owned(), "wrong".to_owned())]), + payload: CommandPayload::ArgvTemplate { + argv: vec![ + CARGO_WRAPPER.to_owned(), + "+nightly".to_owned(), + "miri".to_owned(), + "nextest".to_owned(), + "run".to_owned(), + ], + dynamic_value: MIRI_THREAD_PLACEHOLDER.to_owned(), + }, + }; + let error = miri_wrapper_invocation( + &command, + &[ + CARGO_WRAPPER.to_owned(), + "+nightly".to_owned(), + "miri".to_owned(), + "nextest".to_owned(), + "run".to_owned(), + ], + ) + .unwrap_err(); + assert!( + matches!(error, CellExecutionError::Model { message } if message.contains(EXECUTION_CONTEXT_ENV)) + ); + } + + #[test] + fn invalid_miri_prefix_is_rejected_during_side_effect_free_preparation() { + let command = CommandSpec { + job: MIRI_JOB.to_owned(), + step: "Miri".to_owned(), + working_directory: WorkingDirectory::Relative("zerocopy".to_owned()), + environment: BTreeMap::new(), + payload: CommandPayload::ArgvTemplate { + argv: vec!["cargo".to_owned(), "miri".to_owned()], + dynamic_value: MIRI_THREAD_PLACEHOLDER.to_owned(), + }, + }; + let error = miri_wrapper_invocation(&command, &["cargo".to_owned(), "miri".to_owned()]) + .unwrap_err(); + assert!(matches!(error, CellExecutionError::Model { .. })); + } + + #[test] + fn execution_uses_checked_root_and_preserves_argv_and_environment_boundaries() { + let root = test_root(); + assert!(root.is_absolute(), "CiInputs must retain its canonical root"); + let mut host = FakeExecutionHost::default(); + let selector = build_selector("pull_request", "stable", "x86_64-unknown-linux-gnu"); + let report = execute_build_cell_with(inputs(), &selector, &mut host).unwrap(); + + assert_eq!(report.executed_steps, ["Test native target", "Cargo doc"]); + assert_eq!(report.workflow_owned_steps, ["Check semver compatibility"]); + assert_eq!(host.invocations.len(), 2); + assert_eq!( + host.invocations[0].argv, + [ + "./cargo.sh", + "+stable", + "test", + "--package", + "zerocopy", + "--target", + "x86_64-unknown-linux-gnu", + "--no-default-features", + "--features", + "__internal_use_only_features_that_work_on_stable", + "--verbose", + ] + ); + assert_eq!(host.invocations[0].working_directory, root.join("zerocopy")); + assert_eq!( + host.invocations[0].environment, + BTreeMap::from([ + ("RUSTDOCFLAGS".to_owned(), "-Dwarnings --cfg=zerocopy_unstable_ptr".to_owned(),), + ("RUSTFLAGS".to_owned(), "-Dwarnings".to_owned()), + ]) + ); + assert_eq!( + host.invocations[1].environment, + BTreeMap::from([ + ("RUSTDOCFLAGS".to_owned(), "-Dwarnings --cfg=zerocopy_unstable_ptr".to_owned()), + ("RUSTFLAGS".to_owned(), "-Dwarnings".to_owned()), + ]) + ); + } + + #[test] + fn windows_translates_only_the_repository_cargo_wrapper() { + let working_directory = test_root().join("zerocopy"); + let environment = BTreeMap::from([ + ("RUSTFLAGS".to_owned(), "-Dwarnings".to_owned()), + ("ZC_TEST_VALUE".to_owned(), "two words".to_owned()), + ]); + let invocation = ProcessInvocation { + step: "Test native target".to_owned(), + argv: vec![ + CARGO_WRAPPER.to_owned(), + "+stable".to_owned(), + "test".to_owned(), + "--features".to_owned(), + "feature-a,feature-b".to_owned(), + ], + working_directory: working_directory.clone(), + environment: environment.clone(), + }; + + let command = system_command_for_platform(&invocation, HostPlatform::Windows).unwrap(); + + assert_eq!(command.get_program(), working_directory.join(WINDOWS_CARGO_WRAPPER)); + assert_eq!( + command.get_args().collect::>(), + invocation.argv[1..].iter().map(AsRef::as_ref).collect::>() + ); + assert_eq!(command.get_current_dir(), Some(working_directory.as_path())); + assert_eq!( + command + .get_envs() + .filter_map(|(name, value)| { + value.map(|value| { + (name.to_str().unwrap().to_owned(), value.to_str().unwrap().to_owned()) + }) + }) + .collect::>(), + environment + ); + assert!(command + .get_envs() + .any(|(name, value)| name == OsStr::new(EXECUTION_CONTEXT_ENV) && value.is_none())); + + let mut final_invocation = invocation.clone(); + final_invocation + .environment + .insert(EXECUTION_CONTEXT_ENV.to_owned(), MIRI_REPOSITORY_ROOT_CONTEXT.to_owned()); + let final_command = + system_command_for_platform(&final_invocation, HostPlatform::Windows).unwrap(); + assert!(final_command.get_envs().any(|(name, value)| { + name == OsStr::new(EXECUTION_CONTEXT_ENV) + && value == Some(OsStr::new(MIRI_REPOSITORY_ROOT_CONTEXT)) + })); + + let mut unrelated = invocation; + unrelated.argv = vec!["cargo".to_owned(), "clean".to_owned()]; + let command = system_command_for_platform(&unrelated, HostPlatform::Windows).unwrap(); + assert_eq!(command.get_program(), "cargo"); + assert_eq!(command.get_args().collect::>(), ["clean"]); + } + + #[test] + fn nightly_docs_execute_with_the_complete_effective_environment() { + let mut host = FakeExecutionHost::default(); + let selector = BuildCellSelector::new( + "push", + "zerocopy", + "nightly", + "all", + "x86_64-unknown-linux-gnu", + ); + + let report = execute_build_cell_with(inputs(), &selector, &mut host).unwrap(); + + assert_eq!(report.executed_steps, ["Test native target", "Clippy tests", "Cargo doc"]); + let docs = + host.invocations.iter().find(|invocation| invocation.step == "Cargo doc").unwrap(); + assert_eq!( + docs.environment, + BTreeMap::from([ + ( + "MIRIFLAGS".to_owned(), + " -Zmiri-strict-provenance -Zmiri-backtrace=full".to_owned(), + ), + ( + "RUSTDOCFLAGS".to_owned(), + "-Z unstable-options --document-hidden-items --cfg doc_cfg --generate-link-to-definition --extend-css rustdoc/style.css -Dwarnings --cfg=zerocopy_unstable_ptr" + .to_owned(), + ), + ("RUSTFLAGS".to_owned(), "-Dwarnings -Zrandomize-layout".to_owned()), + ]) + ); + } + + #[test] + fn unknown_and_event_excluded_cells_fail_without_processes() { + let mut host = FakeExecutionHost::default(); + let unknown = BuildCellSelector::new( + "pull_request", + "unknown-package", + "stable", + "default", + "x86_64-unknown-linux-gnu", + ); + assert!(matches!( + execute_build_cell_with(inputs(), &unknown, &mut host), + Err(CellExecutionError::CellNotSelected { .. }) + )); + let excluded = build_selector("pull_request", "default", "aarch64-unknown-linux-gnu"); + assert!(matches!( + execute_build_cell_with(inputs(), &excluded, &mut host), + Err(CellExecutionError::CellNotSelected { .. }) + )); + let excluded_miri = miri_selector("pull_request", "x86_64-unknown-linux-gnu", "stacked"); + assert!(matches!( + execute_miri_cell_with(inputs(), &excluded_miri, &mut host), + Err(CellExecutionError::CellNotSelected { .. }) + )); + let unknown_event = build_selector("unknown-event", "default", "x86_64-unknown-linux-gnu"); + assert!(matches!( + execute_build_cell_with(inputs(), &unknown_event, &mut host), + Err(CellExecutionError::Plan(_)) + )); + assert!(host.invocations.is_empty()); + } + + #[test] + fn duplicate_matches_fail_closed() { + let values = [1, 2]; + assert!(matches!( + unique_match("test", "selector", values.iter()), + Err(CellExecutionError::AmbiguousCell { matches: 2, .. }) + )); + } + + #[test] + fn x86_miri_runs_nproc_then_wrapper_with_root_context_only_at_boundary() { + let root = test_root(); + let mut host = FakeExecutionHost::default(); + let selector = miri_selector("push", "x86_64-unknown-linux-gnu", "stacked"); + + let report = execute_miri_cell_with(inputs(), &selector, &mut host).unwrap(); + + assert_eq!(report.executed_steps, [NPROC_STEP, "Run tests under Miri"]); + assert_eq!(host.captured_invocations.len(), 1); + assert_eq!(host.captured_invocations[0].argv, ["nproc"]); + assert_eq!(host.captured_invocations[0].working_directory, root.join("zerocopy")); + assert_eq!(host.invocations.len(), 1); + let invocation = &host.invocations[0]; + assert_eq!(invocation.working_directory, root.join("zerocopy")); + assert_eq!( + invocation.argv, + [ + "./cargo.sh", + "+nightly", + "miri", + "nextest", + "run", + "--manifest-path", + "zerocopy/Cargo.toml", + "--locked", + "--ignore-default-filter", + "--test-threads", + "8", + "--package", + "zerocopy", + "--target", + "x86_64-unknown-linux-gnu", + ] + ); + assert!(!host.captured_invocations[0].environment.contains_key(EXECUTION_CONTEXT_ENV)); + assert!(!invocation.environment.is_empty()); + assert_eq!(invocation.environment[EXECUTION_CONTEXT_ENV], MIRI_REPOSITORY_ROOT_CONTEXT); + assert_eq!(invocation.environment["RUSTFLAGS"], "-Dwarnings -Zrandomize-layout"); + assert_eq!( + invocation.environment["RUSTDOCFLAGS"], + "-Dwarnings --cfg=zerocopy_unstable_ptr" + ); + assert_eq!( + invocation.environment["MIRIFLAGS"], + " -Zmiri-strict-provenance -Zmiri-backtrace=full " + ); + } + + #[test] + fn aarch64_miri_cleans_from_zerocopy_before_running_wrapper() { + let root = test_root(); + let mut host = FakeExecutionHost::default(); + let selector = miri_selector("push", AARCH64_TARGET, "tree"); + + let report = execute_miri_cell_with(inputs(), &selector, &mut host).unwrap(); + + assert_eq!( + report.executed_steps, + ["Clean aarch64 Miri target", NPROC_STEP, "Run tests under Miri"] + ); + assert_eq!(host.invocations.len(), 2); + assert_eq!(host.invocations[0].argv, ["cargo", "clean"]); + assert_eq!(host.invocations[0].working_directory, root.join("zerocopy")); + assert!(!host.invocations[0].environment.contains_key(EXECUTION_CONTEXT_ENV)); + assert_eq!(host.captured_invocations[0].argv, ["nproc"]); + assert!(!host.captured_invocations[0].environment.contains_key(EXECUTION_CONTEXT_ENV)); + assert_eq!(&host.invocations[1].argv[5..7], ["--manifest-path", "zerocopy/Cargo.toml"]); + assert_eq!(host.invocations[1].working_directory, root.join("zerocopy")); + assert_eq!( + host.invocations[1].environment[EXECUTION_CONTEXT_ENV], + MIRI_REPOSITORY_ROOT_CONTEXT + ); + } + + #[test] + fn parses_exact_gnu_nproc_output_and_checked_doubles_it() { + assert_eq!(parse_nproc_thread_count(b"1\n").unwrap(), 2); + assert_eq!(parse_nproc_thread_count(b"17\n").unwrap(), 34); + } + + #[test] + fn nproc_output_shape_parse_zero_and_overflow_failures_are_typed() { + assert!(matches!( + parse_nproc_thread_count(&[0xff, b'\n']), + Err(CellExecutionError::NprocOutputNotUtf8 { .. }) + )); + for output in [ + b"".as_slice(), + b"\n".as_slice(), + b"1".as_slice(), + b"1\r\n".as_slice(), + b"1\n2\n".as_slice(), + b"+1\n".as_slice(), + b"01\n".as_slice(), + ] { + assert!( + matches!( + parse_nproc_thread_count(output), + Err(CellExecutionError::NprocOutputShape { .. }) + ), + "unexpected result for {output:?}" + ); + } + assert!(matches!( + parse_nproc_thread_count(b"not-a-number\n"), + Err(CellExecutionError::NprocOutputParse { .. }) + )); + let too_large = format!("{}0\n", usize::MAX); + assert!(matches!( + parse_nproc_thread_count(too_large.as_bytes()), + Err(CellExecutionError::NprocOutputParse { .. }) + )); + assert!(matches!( + parse_nproc_thread_count(b"0\n"), + Err(CellExecutionError::ProcessorCountZero) + )); + let overflow = format!("{}\n", usize::MAX); + assert!(matches!( + parse_nproc_thread_count(overflow.as_bytes()), + Err(CellExecutionError::ThreadCountOverflow { available }) + if available == usize::MAX + )); + } + + #[test] + fn linux_thread_count_uses_exact_nproc_invocation() { + let root = test_root(); + let mut host = FakeExecutionHost::default(); + host.captured_outcomes.push_back(CapturedProcessOutcome { + success: true, + code: Some(0), + stdout: b"17\n".to_vec(), + }); + + assert_eq!(miri_thread_count(&mut host, &root, &BTreeMap::new()).unwrap(), 34); + assert_eq!(host.captured_invocations.len(), 1); + assert_eq!(host.captured_invocations[0].argv, ["nproc"]); + assert_eq!(host.captured_invocations[0].working_directory, root.join("zerocopy")); + } + + #[test] + fn non_linux_thread_count_uses_host_available_parallelism() { + for platform in [HostPlatform::Windows, HostPlatform::Other] { + let root = test_root(); + let mut host = FakeExecutionHost { + platform: Some(platform), + parallelism: Some(Ok(NonZeroUsize::new(3).unwrap())), + ..FakeExecutionHost::default() + }; + + assert_eq!(miri_thread_count(&mut host, &root, &BTreeMap::new()).unwrap(), 6); + assert!(host.captured_invocations.is_empty()); + } + } + + #[test] + fn non_linux_thread_count_reports_parallelism_query_failure() { + let root = test_root(); + let mut host = FakeExecutionHost { + platform: Some(HostPlatform::Other), + parallelism: Some(Err(io::ErrorKind::PermissionDenied)), + ..FakeExecutionHost::default() + }; + + assert!(matches!( + miri_thread_count(&mut host, &root, &BTreeMap::new()), + Err(CellExecutionError::AvailableParallelism { source }) + if source.kind() == io::ErrorKind::PermissionDenied + )); + } + + #[test] + fn linux_nproc_start_failure_is_typed() { + let root = test_root(); + let mut host = FakeExecutionHost { + capture_start_error: Some(io::ErrorKind::NotFound), + ..FakeExecutionHost::default() + }; + + assert!(matches!( + miri_thread_count(&mut host, &root, &BTreeMap::new()), + Err(CellExecutionError::StartNproc { source }) + if source.kind() == io::ErrorKind::NotFound + )); + assert_eq!(host.captured_invocations.len(), 1); + assert!(host.invocations.is_empty()); + } + + #[test] + fn linux_nproc_status_failure_is_typed() { + let root = test_root(); + let mut host = FakeExecutionHost::default(); + host.captured_outcomes.push_back(CapturedProcessOutcome { + success: false, + code: Some(23), + stdout: Vec::new(), + }); + + assert!(matches!( + miri_thread_count(&mut host, &root, &BTreeMap::new()), + Err(CellExecutionError::NprocFailed { status }) if status == "exit code 23" + )); + assert_eq!(host.captured_invocations.len(), 1); + assert!(host.invocations.is_empty()); + } + + #[test] + fn checked_thread_count_rejects_zero_and_overflow() { + assert!(matches!( + checked_miri_thread_count(0), + Err(CellExecutionError::ProcessorCountZero) + )); + assert!(matches!( + checked_miri_thread_count(usize::MAX), + Err(CellExecutionError::ThreadCountOverflow { available }) + if available == usize::MAX + )); + } + + #[test] + fn dynamic_substitution_requires_one_exact_argv_element() { + let missing = ["prefixsuffix".to_owned()]; + assert!(matches!( + substitute_dynamic("Miri", &missing, "", "4"), + Err(CellExecutionError::DynamicPlaceholder { occurrences: 0, .. }) + )); + let duplicate = ["".to_owned(), "".to_owned()]; + assert!(matches!( + substitute_dynamic("Miri", &duplicate, "", "4"), + Err(CellExecutionError::DynamicPlaceholder { occurrences: 2, .. }) + )); + assert_eq!( + substitute_dynamic( + "Miri", + &["--test-threads".to_owned(), "".to_owned()], + "", + "4", + ) + .unwrap(), + ["--test-threads", "4"] + ); + } + fn is_non_golden_native_test(class: EventClass, operation: &MatrixOperation) -> bool { class == EventClass::Full && operation.kind == MatrixOperationKind::CargoTest