diff --git a/ci/README.md b/ci/README.md index eae2d7dfb0..2558ae4576 100644 --- a/ci/README.md +++ b/ci/README.md @@ -29,7 +29,7 @@ Nix-backed build path; its temporary v1 example matrix is described at the end. | [`inventory.rs`](../tools/zc/src/inventory.rs) and [`metadata.rs`](../tools/zc/src/metadata.rs) | Live facts read from Cargo and checked repository files. Inventory resolves packages, feature closure, Cargo targets, compiler versions, and docs.rs arguments, then verifies every policy reference. It also checks the deliberately narrow `build.rs` metadata text contract and the two-way invariant that every `package.metadata.build-rs` version key has one typed build-rs toolchain descriptor and every such descriptor has a manifest key. Policy separately requires each descriptor to have nonempty scopes. | | [`ci.rs`](../tools/zc/src/ci.rs) | The all-or-nothing checked input boundary. `CiInputs::load` canonicalizes contained inputs and admits no plan until policy, Cargo inventory, workflow jobs, frozen baselines, and execution parity all pass. Its workflow-boundary checks audit exact planner publication, build and Miri consumers, required-check aggregation, and the complete standalone semver job. | | [`plan.rs`](../tools/zc/src/plan.rs) | Pure, deterministic ordinary-build, Miri, and semver membership. A plan contains complete selectors and execution meaning, but no command strings, permissions, runner labels, actions, secrets, or publication choices. Semver candidates are derived from the matching ordinary-build slice so reduced-event eligibility cannot drift. | -| [`execution.rs`](../tools/zc/src/execution.rs) | The exact argv vectors and environment for ordinary build and Miri cells, the exact modeled action inputs and environment for semver cells, ordinary target behavior, Miri setup and model flags, and parity with frozen command and logical-work evidence. It executes only a build or Miri selector that resolves uniquely in a newly checked plan; the literal workflow executes semver. At the legacy-evidence boundary, a narrow adapter restores semver's historical `build_test` job label and removes the post-baseline target-specific cache prefix. | +| [`execution.rs`](../tools/zc/src/execution.rs) | The exact argv vectors and environment for ordinary build and Miri cells, the exact modeled action inputs and environment for semver cells, ordinary target behavior, Miri setup and model flags, and parity with frozen command and logical-work evidence. It executes only a build or Miri selector that resolves uniquely in a newly checked plan; the literal workflow executes semver. At the legacy-evidence boundary, a narrow adapter restores semver's historical `build_test` job label and removes the post-baseline target-specific cache prefix. A separate current-state audit derives every required native build from checked plans, Cargo target kinds, and enabled integration targets, because those corrective operations postdate the immutable baseline. | | [`github.rs`](../tools/zc/src/github.rs) | Versioned, deterministic transport from one plan explanation to three compact GitHub matrices, two optional-job gates, and a detailed review artifact. It enforces per-matrix and combined UTF-16 job-output limits; it does not carry workflow authority. | | [`workflow_protocol.rs`](../tools/zc/src/workflow_protocol.rs) | Shared reviewed spellings used at CLI, projection, and workflow boundaries: commands, outputs, jobs, selectors, step and display names, the trusted host shell, and runner/Docker bridges. This private module prevents the Rust implementation and exact source audits from drifting internally; it does not grant workflow authority or choose coverage. | | [`ci/baselines`](baselines/README.md) | Independent evidence of the behavior that the typed implementation replaced: reduced/full matrix membership, logical and standalone work, and representative commands. These files are expected review data, not planner output. Because they describe one immutable source commit, they intentionally retain rows for historical operations that have since been retired. | @@ -49,7 +49,9 @@ Nix-backed build path; its temporary v1 example matrix is described at the end. 3. The execution model expands representative reduced and full behavior and must match the frozen matrix, logical-work, standalone-work, and command evidence exactly. This includes modeled semver action inputs for every - planned target. `ci audit` also plans every configured event. + planned target. It separately exact-compares every required native build + with an expected command derived from checked plans and Cargo inventory. + `ci audit` also plans every configured event. 4. `Plan` or `PlanExplanation` selects cells for one exact event. Planning is pure after input validation. 5. `GitHubProjection` creates three matrix outputs, two optional-job gates, and @@ -171,6 +173,44 @@ contains the historical hosted and pre-push operations from its source commit; removing those rows would weaken replacement-parity evidence, not remove a live obligation. +### Native build and test coverage + +Native ordinary-library cells use one `cargo test` command instead of a +separate build and test only when Cargo inventory reports an integration +target enabled by that cell's feature selection. Cargo then compiles the +ordinary library artifact as an integration-test dependency. If no enabled +integration target exists, the executor automatically retains a separate +build; unit tests alone do not prove the normal library artifact. The root +manifest may not define a test profile or a non-unwind dev panic strategy, and +CI rejects equivalent Cargo configuration, command-line selectors, and +environment overrides. Local execution remains permissive so personal Cargo +configuration still works. + +Proc macros are different on the oldest supported Cargo: their test build does +not produce the dev-profile artifact. `zerocopy-derive` also enables `syn`'s +`visit` feature only through a dev-dependency, so its test graph is not its +production graph. `execution.rs` therefore derives a separate native build for +every proc-macro package from Cargo's audited target kind, regardless of its +integration tests. These builds and the ordinary-library fallback were added +after the frozen baseline. A separate current-state audit constructs their +complete expected commands directly from the reduced and full plans and +exact-compares them with the live model; never add them to the immutable legacy +TSVs. + +Dev-dependency feature unification is not inherently limited to proc macros. +For every consolidated profile and native target, the `zc` test suite asks +`cargo tree` for its production and test views and requires every shared +package to retain its reported production feature set. Cargo documents these +views as approximations, not exact compilation plans, so this is a smoke test +and review reminder rather than a proof. Multiple feature sets reported for +one package fail as ambiguous instead of being merged. The repository-wide +`check_tools` job owns this centralized check so matrix runners do not repeat +those resolutions. If a dependency change makes the views diverge, retain a +separate native build or deliberately strengthen the check before updating +the reminder. Keep it coordinated with `execution::build_operations` and +`ci/check_tools.sh`; profile guards do not prove dependency-feature +equivalence. + ### Compilation targets, events, and workflow jobs - Declare a compilation target once in `[[targets]]`, including its ordinary @@ -225,8 +265,10 @@ obligation. (currently 900,000) bounds GitHub's UTF-16 estimate for the five output records together. Increasing one limit does not relax either of the others. -After an intentional behavior change, update the files under `ci/baselines` -using evidence independent of the new planner. Follow +After an intentional change to behavior owned by frozen evidence, update the +files under `ci/baselines` using evidence independent of the new planner. Do +not put a post-baseline correction into those immutable records; give it a +separate current-state audit like the required native builds above. Follow [`ci/baselines/README.md`](baselines/README.md): retain source identity, compare exact sets rather than only counts, preserve canonical sorting, and review the logical and command effects. Do not generate expected rows from the code they diff --git a/ci/zc.toml b/ci/zc.toml index 41403056b9..66ecf7b44f 100644 --- a/ci/zc.toml +++ b/ci/zc.toml @@ -60,9 +60,14 @@ id = "zerocopy-derive" manifest = "zerocopy/zerocopy-derive/Cargo.toml" profiles = ["default"] -# `mode` coordinates with the ordinary executor: native targets run tests, -# cross targets compile tests and build the library, and the current thumb -# target checks only the library because its test dependencies do not compile. +# `mode` coordinates with `execution::build_operations`: native targets run +# tests. Proc macros always retain a separate production build; ordinary +# package/profile pairs do too unless their audited feature selection enables +# an integration target. Cross targets compile tests and build the library, +# while the current thumb target checks only the library because its test +# dependencies do not compile. Keep the native distinction coordinated with +# the `cargo tree` smoke test in `execution.rs` and the explanation beside +# `syn` in `zerocopy-derive/Cargo.toml`. # `pr_eligible` is the latency-reduced subset. The merge queue and other full # events still use every target selected by a toolchain scope. Miri and semver # eligibility are explicit so a new target cannot silently escape either kind diff --git a/tools/cargo-zerocopy/src/main.rs b/tools/cargo-zerocopy/src/main.rs index f278d8a461..d3a2746970 100644 --- a/tools/cargo-zerocopy/src/main.rs +++ b/tools/cargo-zerocopy/src/main.rs @@ -420,6 +420,70 @@ fn package_id_command(version: &str, package: &str, repository_root: Option<&Pat command } +fn configure_delegated_cargo_command( + command: &mut Command, + rustdocflags: &str, + feature_selection_args: &[String], + repository_root: Option<&Path>, + default_target_dir: Option, + args: Vec, + mut fully_qualified_package: impl FnMut(&str) -> String, +) { + command.env("RUSTDOCFLAGS", rustdocflags); + set_ui_test_feature_args(command, 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 { + command.current_dir(root); + command.env_remove(EXECUTION_CONTEXT_ENV); + } + + if let Some(default_target_dir) = default_target_dir { + // 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. + command.env("CARGO_TARGET_DIR", default_target_dir); + } + + let mut args = args.into_iter(); + // Replace `-p`, `-p ` and `--package )`. We do 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. Every other argv element crosses this final wrapper + // boundary unchanged. + while let Some(arg) = args.next() { + if arg == "-p" || arg == "--package" { + command.arg(&arg); + let Some(arg) = args.next() else { + break; + }; + command.arg(fully_qualified_package(&arg)); + } else if let Some(package) = arg.strip_prefix("-p") { + command.arg("-p"); + command.arg(fully_qualified_package(package)); + } else if arg == "--" { + command.arg("--"); + command.args(args); + break; + } else if arg == "--target" { + command.arg(&arg); + if let Some(target) = args.next() { + command.arg(&target); + command.env("ZEROCOPY_UI_TEST_TARGET", target); + } + } else if let Some(target) = arg.strip_prefix("--target=") { + command.arg(&arg); + command.env("ZEROCOPY_UI_TEST_TARGET", target); + } else { + command.arg(arg); + } + } +} + fn delegate_cargo() -> Result<(), Error> { let mut args = env::args(); let this = args.next().unwrap(); @@ -515,8 +579,6 @@ fn delegate_cargo() -> Result<(), Error> { install_targets_or_exit(version, &targets)?; - let mut args = args_vec.into_iter(); - let env_rustflags = env::vars() .filter_map(|(k, v)| if k == "RUSTFLAGS" { Some(v) } else { None }) .next() @@ -537,30 +599,24 @@ fn delegate_cargo() -> Result<(), Error> { // Rustdoc needs the wrapper's cfgs and the caller's RUSTFLAGS // in addition to any rustdoc-specific flags supplied through // RUSTDOCFLAGS. + // + // A typed CI cell reaches this final Cargo construction only + // after `execution::preflight_ci_cargo_environment` rejects + // Cargo configuration, test-profile overrides, and a dev + // panic override. Ordinary-library native cells rely on the + // resulting `cargo test` preserving their production build; + // proc-macro cells receive a separate modeled `cargo build`. + // Keep this command transparent to modeled argv and do not + // add `--profile`, `--config`, `CARGO_PROFILE_TEST_*`, or + // `CARGO_PROFILE_DEV_PANIC` here. The helper's unit test makes + // that final-boundary assumption load-bearing. let mut cmd = rustup(["run", version, "cargo"], Some(("RUSTFLAGS", &rustflags))); - 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() { + let cargo_target_dir_is_set = env::var("CARGO_TARGET_DIR").is_ok(); + if cargo_target_dir_is_set { eprintln!("[cargo-zerocopy] WARNING: `CARGO_TARGET_DIR` is set - this may cause `cargo-zerocopy` to behave unexpectedly"); - } else { - // 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()), - ); } + let configured_target_dir = (!cargo_target_dir_is_set) + .then(|| default_target_dir(name, repository_root.as_deref())); // Computes the fully-qualified package name of workspace package `p`. let fqpn = |p: &str| { @@ -568,39 +624,15 @@ fn delegate_cargo() -> Result<(), Error> { package_id_command(version, p, repository_root.as_deref()).output_or_exit(); String::from_utf8(output.stdout).unwrap().trim().to_string() }; - - // Replace `-p`, `-p ` and `--package )`. We do - // 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. - 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 let Some(package) = arg.strip_prefix("-p") { - cmd.arg("-p"); - 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); - cmd.env("ZEROCOPY_UI_TEST_TARGET", target); - } else { - cmd.arg(arg); - } - } + configure_delegated_cargo_command( + &mut cmd, + &rustdocflags, + &feature_selection_args, + repository_root.as_deref(), + configured_target_dir, + args_vec, + fqpn, + ); cmd.execute(); @@ -642,9 +674,9 @@ mod tests { use std::{ffi::OsStr, path::Path, process::Command}; 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, + capture_feature_selection_args, configure_delegated_cargo_command, default_target_dir, + package_id_command, parse_execution_context, rustup, set_ui_test_feature_args, + validate_execution_context, EXECUTION_CONTEXT_ENV, MIRI_REPOSITORY_ROOT_CONTEXT, }; fn strings(args: &[&str]) -> Vec { @@ -709,6 +741,57 @@ mod tests { assert_env(&command, "ZEROCOPY_UI_TEST_FEATURE_ARG_COUNT", "0"); } + #[test] + fn final_cargo_delegation_preserves_native_ci_args_and_profile_boundary() { + let modeled = strings(&[ + "test", + "--package", + "zerocopy", + "--target", + "x86_64-unknown-linux-gnu", + "--no-default-features", + "--features", + "__internal_use_only_features_that_work_on_stable", + "--verbose", + ]); + let features = capture_feature_selection_args(&modeled); + let mut command = + rustup(["run", "stable-version", "cargo"], Some(("RUSTFLAGS", "modeled rustflags"))); + + configure_delegated_cargo_command( + &mut command, + "modeled rustdocflags", + &features, + None, + Some(default_target_dir("stable", None)), + modeled.clone(), + str::to_owned, + ); + + let delegated = command.get_args().skip(3).collect::>(); + assert_eq!( + delegated, + modeled.iter().map(OsStr::new).collect::>(), + "the final wrapper may rewrite package values, but must not add, remove, or reorder flags" + ); + assert!(!delegated.iter().any(|argument| { + let argument = argument.to_string_lossy(); + argument == "--release" + || argument == "-r" + || argument == "--profile" + || argument.starts_with("--profile=") + || argument == "--config" + || argument.starts_with("--config=") + })); + assert!(!command.get_envs().any(|(name, _)| { + let name = name.to_string_lossy(); + name.eq_ignore_ascii_case("CARGO_PROFILE_DEV_PANIC") + || name + .get(.."CARGO_PROFILE_TEST_".len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case("CARGO_PROFILE_TEST_")) + })); + } + #[test] fn execution_context_accepts_only_the_exact_nightly_miri_shape() { let args = strings(&["miri", "nextest", "run", "--manifest-path", "zerocopy/Cargo.toml"]); diff --git a/tools/zc/src/ci.rs b/tools/zc/src/ci.rs index 4131c6a943..52ae8c5adb 100644 --- a/tools/zc/src/ci.rs +++ b/tools/zc/src/ci.rs @@ -373,7 +373,7 @@ pub enum LoadCiError { /// The frozen legacy evidence was unreadable or noncanonical. #[error(transparent)] Baseline(BaselineError), - /// Typed execution behavior differed from frozen legacy evidence. + /// Typed execution behavior differed from legacy or current-state evidence. #[error(transparent)] Execution(ExecutionAuditError), } diff --git a/tools/zc/src/execution.rs b/tools/zc/src/execution.rs index d752fc1e51..4d59368562 100644 --- a/tools/zc/src/execution.rs +++ b/tools/zc/src/execution.rs @@ -26,15 +26,16 @@ //! construct proposed behavior. In //! particular, the legacy comparison covers the repository state named by the //! baseline manifest. It is not an inventory of control-plane validation jobs -//! added after that frozen source commit. Live workflow jobs and pre-push -//! checks remain protected by their separate, present-day audits and tests. +//! added after that frozen source commit. Live post-baseline behavior remains +//! protected by separate current-state audits and tests. use std::{ collections::{BTreeMap, BTreeSet}, env, error::Error, + ffi::{OsStr, OsString}, fmt, - fs::OpenOptions, + fs::{self, OpenOptions}, io::{self, Write}, num::{NonZeroUsize, ParseIntError}, path::{Path, PathBuf}, @@ -92,9 +93,16 @@ const BASE_RUSTFLAGS: &str = "-Dwarnings"; const BASE_RUSTDOCFLAGS: &str = "-Dwarnings --cfg=zerocopy_unstable_ptr"; const NIGHTLY_RUSTFLAGS: &str = "-Zrandomize-layout"; const NIGHTLY_MIRIFLAGS: &str = "-Zmiri-strict-provenance -Zmiri-backtrace=full"; +const CARGO_TEST_PROFILE_ENV_PREFIX: &str = "CARGO_PROFILE_TEST_"; +const CARGO_DEV_PANIC_ENVIRONMENT: &str = "CARGO_PROFILE_DEV_PANIC"; +// `.github/workflows/ci.yml` forwards both markers into the Docker executor, +// and `planned_adapter::matrix` audits that literal bridge. Keep that workflow +// contract coordinated with this detection: losing both markers would turn a +// CI invocation into the deliberately permissive local behavior below. +const CI_ENVIRONMENT_MARKERS: [&str; 2] = ["CI", "GITHUB_ACTIONS"]; /// A deterministic failure proving that proposed execution behavior differs -/// from independently captured legacy evidence. +/// from its frozen legacy evidence or current-state required-build contract. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExecutionAuditError { violations: Vec, @@ -133,8 +141,12 @@ impl fmt::Display for ExecutionAuditError { impl Error for ExecutionAuditError {} -/// Expands all selected cells and compares command behavior, logical work, -/// and standalone work with the exact frozen legacy sets. +/// Expands all selected cells and checks their two independent evidence sets. +/// +/// Historical command behavior, logical work, and standalone work must match +/// the exact frozen legacy sets. Native builds added after that source commit +/// must match the separate current-state contract derived from checked plans +/// and Cargo inventory. /// /// This function is pure after [`CiInputs`] has loaded: it performs no file or /// process I/O. Callers should make it part of the checked input boundary @@ -372,6 +384,58 @@ pub enum CellExecutionError { #[source] source: io::Error, }, + /// CI could not determine which runner-local Cargo home Cargo would use. + #[error( + "CI Cargo preflight requires `CARGO_HOME` or its `{fallback}/.cargo` platform fallback to be set" + )] + CargoHomeUnavailable { + /// Platform-specific home variable Cargo would otherwise consult. + fallback: &'static str, + }, + /// CI found a Cargo home spelling whose resolution is intentionally not + /// guessed by the executor. + #[error( + "CI Cargo preflight requires `{variable}` to select a nonempty absolute path; got {value:?}" + )] + InvalidCargoHome { + /// Environment variable which supplied the invalid path. + variable: &'static str, + /// Cargo home path selected or derived from the runner value. + value: PathBuf, + }, + /// A runner-local Cargo configuration could change modeled CI commands. + #[error( + "CI Cargo preflight rejects runner-local Cargo configuration `{path}`; CI behavior must come from checked repository inputs" + )] + RunnerCargoConfiguration { + /// Configuration path Cargo would merge with repository configuration. + path: PathBuf, + }, + /// The runner did not permit a Cargo configuration path to be inspected. + #[error("failed to inspect runner-local Cargo configuration `{path}`: {source}")] + InspectRunnerCargoConfiguration { + /// Candidate global Cargo configuration path. + path: PathBuf, + /// Underlying file-system error. + #[source] + source: io::Error, + }, + /// Cargo would apply an ambient override to its test profile. + #[error( + "CI Cargo preflight rejects ambient test-profile override(s): {variables}; consolidated ordinary-library CI requires the built-in test profile" + )] + AmbientCargoTestProfile { + /// Sorted environment variable names carrying overrides. + variables: String, + }, + /// Cargo would apply an ambient override to its dev panic strategy. + #[error( + "CI Cargo preflight rejects ambient dev-panic override(s): {variables}; consolidated CI requires the unwind panic strategy" + )] + AmbientCargoDevPanic { + /// Sorted environment variable names carrying overrides. + variables: String, + }, } /// Executes the modeled commands for one exact selected ordinary build cell. @@ -442,6 +506,8 @@ impl HostPlatform { /// boundary separately. trait ExecutionHost { fn platform(&self) -> HostPlatform; + fn environment_variables(&self) -> Vec<(OsString, OsString)>; + fn path_entry_exists(&self, path: &Path) -> io::Result; fn available_parallelism(&self) -> io::Result; fn run(&mut self, invocation: &ProcessInvocation) -> io::Result; fn run_capture(&mut self, invocation: &ProcessInvocation) @@ -500,6 +566,16 @@ impl ExecutionHost for SystemExecutionHost { fn platform(&self) -> HostPlatform { HostPlatform::current() } + fn environment_variables(&self) -> Vec<(OsString, OsString)> { + env::vars_os().collect() + } + fn path_entry_exists(&self, path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(source), + } + } fn available_parallelism(&self) -> io::Result { thread::available_parallelism() } @@ -531,12 +607,179 @@ impl ExecutionHost for SystemExecutionHost { } } +/// Rejects runner-owned inputs which can break native build/test +/// consolidation, but only when the executor is running in CI. +/// +/// Across the supported Cargo versions, one test command covers an ordinary +/// library build unless Cargo selects a distinct test profile or the dev +/// profile requests a non-unwind panic strategy which tests cannot preserve. +/// The checked manifest and repository configuration are audited by +/// `metadata::ToolchainMetadata` and `inventory::RepositoryInventory` before +/// this boundary. This preflight covers the remaining runner-owned inputs: +/// configuration in ancestors above the repository, Cargo's global +/// configuration, and ambient profile variables. Typed argv validation +/// separately prevents a checked command from adding `--profile` or +/// `--config`. +/// +/// Local execution deliberately skips this check when neither CI marker is +/// present. A contributor can therefore reproduce a selected cell without +/// abandoning ordinary personal Cargo configuration; only CI promises the +/// exact consolidated semantics. +fn preflight_ci_cargo_environment( + host: &impl ExecutionHost, + repository_root: &Path, +) -> Result<(), CellExecutionError> { + let platform = host.platform(); + let environment = host.environment_variables(); + if !CI_ENVIRONMENT_MARKERS + .iter() + .any(|marker| environment_value(&environment, platform, marker).is_some()) + { + return Ok(()); + } + + let profile_overrides = environment + .iter() + .filter(|(name, _)| { + environment_name_starts_with(platform, name, CARGO_TEST_PROFILE_ENV_PREFIX) + }) + .map(|(name, _)| name.to_string_lossy().into_owned()) + .collect::>(); + if !profile_overrides.is_empty() { + return Err(CellExecutionError::AmbientCargoTestProfile { + variables: profile_overrides.into_iter().collect::>().join(", "), + }); + } + + let dev_panic_overrides = environment + .iter() + .filter(|(name, _)| environment_name_equals(platform, name, CARGO_DEV_PANIC_ENVIRONMENT)) + .map(|(name, _)| name.to_string_lossy().into_owned()) + .collect::>(); + if !dev_panic_overrides.is_empty() { + return Err(CellExecutionError::AmbientCargoDevPanic { + variables: dev_panic_overrides.into_iter().collect::>().join(", "), + }); + } + + let fallback = cargo_home_fallback(platform); + let (variable, cargo_home) = + if let Some(value) = environment_value(&environment, platform, "CARGO_HOME") { + ("CARGO_HOME", PathBuf::from(value)) + } else if let Some(value) = environment_value(&environment, platform, fallback) { + (fallback, PathBuf::from(value).join(".cargo")) + } else { + return Err(CellExecutionError::CargoHomeUnavailable { fallback }); + }; + if cargo_home.as_os_str().is_empty() || !absolute_path_for_platform(platform, &cargo_home) { + return Err(CellExecutionError::InvalidCargoHome { variable, value: cargo_home }); + } + + // Cargo begins at its delegated cwd and walks every ancestor before it + // reads Cargo home. Ordinary cells begin in `repository_root/zerocopy`; + // Miri moves its Cargo child to `repository_root`. Inventory audits the + // in-repository portions of both searches, including the one reviewed + // `zerocopy/.cargo/config.toml`, so begin strictly above the root here. + // The remaining ancestor set is identical for both cwd choices. + // + // Cargo recognizes both config names and gives the extensionless spelling + // precedence when both exist. Inspect them in that order from nearest to + // farthest, then inspect Cargo home. De-duplicate paths because Cargo home + // commonly equals an ancestor's `.cargo` directory. Reject any directory + // entry, including a broken symlink or directory, rather than parsing one + // while Cargo reads another. Keep this boundary coordinated with + // `inventory::validate_cargo_source_configuration`, which owns only paths + // at or below `repository_root`. + for path in cargo_configuration_candidates(repository_root, &cargo_home) { + let exists = host.path_entry_exists(&path).map_err(|source| { + CellExecutionError::InspectRunnerCargoConfiguration { path: path.clone(), source } + })?; + if exists { + return Err(CellExecutionError::RunnerCargoConfiguration { path }); + } + } + Ok(()) +} + +fn cargo_home_fallback(platform: HostPlatform) -> &'static str { + match platform { + HostPlatform::Windows => "USERPROFILE", + HostPlatform::Linux | HostPlatform::Other => "HOME", + } +} + +fn absolute_path_for_platform(platform: HostPlatform, path: &Path) -> bool { + match platform { + HostPlatform::Linux | HostPlatform::Other => path.is_absolute(), + // `Path` follows the build host's syntax, while fake-host tests model + // Windows from Unix too. Recognize the two absolute Windows forms + // explicitly so those tests exercise path inspection rather than + // stopping at a host-dependent `Path::is_absolute` result. + HostPlatform::Windows => { + let path = path.as_os_str().to_string_lossy(); + let bytes = path.as_bytes(); + (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) + || path.starts_with("\\\\") + || path.starts_with("//") + } + } +} + +fn cargo_configuration_candidates(repository_root: &Path, cargo_home: &Path) -> Vec { + let mut paths = Vec::new(); + let mut seen = BTreeSet::new(); + let mut add_directory = |directory: PathBuf| { + for path in [directory.join("config"), directory.join("config.toml")] { + if seen.insert(path.clone()) { + paths.push(path); + } + } + }; + for ancestor in repository_root.ancestors().skip(1) { + add_directory(ancestor.join(".cargo")); + } + add_directory(cargo_home.to_path_buf()); + paths +} + +fn environment_value<'a>( + environment: &'a [(OsString, OsString)], + platform: HostPlatform, + expected: &str, +) -> Option<&'a OsStr> { + environment + .iter() + .find(|(name, _)| environment_name_equals(platform, name, expected)) + .map(|(_, value)| value.as_os_str()) +} + +fn environment_name_equals(platform: HostPlatform, actual: &OsStr, expected: &str) -> bool { + match platform { + HostPlatform::Windows => actual.to_string_lossy().eq_ignore_ascii_case(expected), + HostPlatform::Linux | HostPlatform::Other => actual == OsStr::new(expected), + } +} + +fn environment_name_starts_with(platform: HostPlatform, actual: &OsStr, expected: &str) -> bool { + match platform { + HostPlatform::Windows => actual + .to_string_lossy() + .get(..expected.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(expected)), + HostPlatform::Linux | HostPlatform::Other => actual.to_string_lossy().starts_with(expected), + } +} + fn execute_build_cell_with( inputs: &CiInputs, selector: &BuildCellSelector, host: &mut impl ExecutionHost, ) -> Result { let repository_root = inputs.repository_root(); + preflight_ci_cargo_environment(host, repository_root)?; let plan = Plan::create(inputs, &selector.event)?; let description = selector.description(); let cell = unique_match( @@ -544,7 +787,7 @@ fn execute_build_cell_with( &description, plan.builds().iter().filter(|cell| build_cell_matches(cell, selector)), )?; - let semantics = BuildCellSemantics::from_plan(cell, inputs.policy()) + let semantics = BuildCellSemantics::from_plan(cell, inputs) .map_err(|message| CellExecutionError::Model { message })?; let operations = build_operations(inputs.repository().zerocopy_docs_rs_rustdoc_args(), &semantics) @@ -553,6 +796,8 @@ fn execute_build_cell_with( for operation in operations { validate_operation(&operation).map_err(|message| CellExecutionError::Model { message })?; + validate_exact_native_operation(&operation, &semantics) + .map_err(|message| CellExecutionError::Model { message })?; if !operation.applicable { return Err(CellExecutionError::Model { message: format!( @@ -589,6 +834,7 @@ fn execute_miri_cell_with( host: &mut impl ExecutionHost, ) -> Result { let repository_root = inputs.repository_root(); + preflight_ci_cargo_environment(host, repository_root)?; let plan = Plan::create(inputs, &selector.event)?; let description = selector.description(); let cell = unique_match( @@ -868,6 +1114,7 @@ fn unique_match<'a, T>( #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum MatrixOperationKind { CargoTest, + CargoBuildNative, CargoCheckTests, CargoBuild, CargoCheckLibrary, @@ -882,6 +1129,7 @@ impl MatrixOperationKind { fn id(self) -> &'static str { match self { Self::CargoTest => "cargo-test", + Self::CargoBuildNative => "cargo-build", Self::CargoCheckTests => "cargo-check-tests", Self::CargoBuild => "cargo-build", Self::CargoCheckLibrary => "cargo-check-library", @@ -896,6 +1144,7 @@ impl MatrixOperationKind { fn step(self) -> &'static str { match self { Self::CargoTest => "Test native target", + Self::CargoBuildNative => "Build native target", Self::CargoCheckTests | Self::CargoBuild => "Check cross target", Self::CargoCheckLibrary => "Check thumb library", Self::CargoClippyTests => "Clippy tests", @@ -920,6 +1169,25 @@ impl MatrixOperationKind { _ => ObligationCondition::Always, } } + + fn evidence(self) -> ExecutionEvidence { + match self { + Self::CargoBuildNative => ExecutionEvidence::CurrentRequiredNativeBuild, + _ => ExecutionEvidence::FrozenLegacy, + } + } + + fn cargo_subcommand(self) -> Option<&'static str> { + match self { + Self::CargoTest => Some("test"), + Self::CargoBuildNative | Self::CargoBuild => Some("build"), + Self::CargoCheckTests | Self::CargoCheckLibrary => Some("check"), + Self::CargoClippyTests | Self::CargoClippyLibrary => Some("clippy"), + Self::CargoDoc => Some("doc"), + Self::MiriTest => Some("miri"), + Self::CargoSemverCheck => None, + } + } } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -932,7 +1200,7 @@ struct LogicalKey { miri_model: Option, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] struct LogicalSpec { key: LogicalKey, condition: ObligationCondition, @@ -940,7 +1208,7 @@ struct LogicalSpec { step: String, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] struct CommandSpec { job: String, step: String, @@ -976,7 +1244,7 @@ impl CommandSpec { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] struct MatrixOperation { kind: MatrixOperationKind, logical: LogicalSpec, @@ -985,6 +1253,35 @@ struct MatrixOperation { applicable: bool, } +/// Identifies which independent audit owns an operation. +/// +/// Most operations reconstruct the immutable source named by +/// `ci/baselines/manifest.tsv`. Required native builds correct an omission +/// discovered later, so a separate current-state audit owns them instead of +/// rewriting historical evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExecutionEvidence { + FrozenLegacy, + CurrentRequiredNativeBuild, +} + +/// The one library artifact kind Cargo assigns to a policy package. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PackageLibraryKind { + Ordinary, + ProcMacro, +} + +/// Whether one native `cargo test` proves the production library build. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NativeBuildStrategy { + /// An enabled integration target compiles the ordinary library as a normal + /// dependency, independently of its unit-test harness. + TestCoversBuild, + /// The selected test graph does not prove the production artifact. + SeparateBuild, +} + #[derive(Clone, Debug)] struct BuildCellSemantics { package: String, @@ -994,22 +1291,119 @@ struct BuildCellSemantics { features: FeatureSelection, target: String, mode: ExecutionMode, + native_build_strategy: NativeBuildStrategy, } impl BuildCellSemantics { - fn from_plan(cell: &BuildPlanCell, policy: &Policy) -> Result { + fn from_plan(cell: &BuildPlanCell, inputs: &CiInputs) -> Result { Ok(Self { package: cell.package().id().to_owned(), toolchain: cell.toolchain().id().to_owned(), - pinned_nightly: is_pinned_nightly(policy, cell.toolchain().id())?, + pinned_nightly: is_pinned_nightly(inputs.policy(), cell.toolchain().id())?, feature_profile: cell.features().profile().to_owned(), features: cell.features().selection().clone(), target: cell.target().triple().to_owned(), mode: cell.target().mode(), + native_build_strategy: native_build_strategy( + inputs, + cell.package().id(), + cell.features().selection(), + )?, }) } } +fn package_library_kind(inputs: &CiInputs, package: &str) -> Result { + let package = inputs + .repository() + .policy_packages() + .get(package) + .ok_or_else(|| format!("plan references package `{package}` absent from inventory"))?; + let mut libraries = package.cargo().targets().iter().filter_map(|target| { + let kinds = target.kinds(); + if kinds.len() != 1 { + return None; + } + if kinds.contains("lib") { + Some(PackageLibraryKind::Ordinary) + } else if kinds.contains("proc-macro") { + Some(PackageLibraryKind::ProcMacro) + } else { + None + } + }); + let Some(kind) = libraries.next() else { + return Err(format!("policy package `{}` has no library target", package.cargo().name())); + }; + if libraries.next().is_some() { + return Err(format!( + "policy package `{}` has more than one library target", + package.cargo().name() + )); + } + Ok(kind) +} + +fn native_build_strategy( + inputs: &CiInputs, + package: &str, + features: &FeatureSelection, +) -> Result { + let package_inventory = inputs + .repository() + .policy_packages() + .get(package) + .ok_or_else(|| format!("plan references package `{package}` absent from inventory"))?; + let library_kind = package_library_kind(inputs, package)?; + let has_enabled_integration_test = package_inventory.cargo().targets().iter().any(|target| { + target.kinds().len() == 1 + && target.kinds().contains("test") + && target.is_tested() + && feature_selection_enables_target(package_inventory, features, target) + }); + Ok(native_build_strategy_for(library_kind, has_enabled_integration_test)) +} + +fn native_build_strategy_for( + library_kind: PackageLibraryKind, + has_enabled_integration_test: bool, +) -> NativeBuildStrategy { + match (library_kind, has_enabled_integration_test) { + // Cargo 1.56 does not emit a proc macro's dev artifact from its test + // build. This remains separate even when it has integration tests. + (PackageLibraryKind::ProcMacro, _) | (PackageLibraryKind::Ordinary, false) => { + NativeBuildStrategy::SeparateBuild + } + (PackageLibraryKind::Ordinary, true) => NativeBuildStrategy::TestCoversBuild, + } +} + +fn feature_selection_enables_target( + package: &crate::inventory::PackageInventory, + features: &FeatureSelection, + target: &crate::inventory::CargoTarget, +) -> bool { + // Keep this exhaustive translation coordinated with + // `inventory::profile_enables_target`. Inventory evaluates the policy's + // semantic profile before planning; execution evaluates the resulting + // `FeatureSelection` so its build strategy cannot drift from the exact + // Cargo arguments it will run. The strategy truth-table test below makes + // the no-enabled-integration fallback independently load-bearing. + match features { + FeatureSelection::Default => { + target.required_features().is_subset(package.default_features()) + } + FeatureSelection::NoDefault => target.required_features().is_empty(), + FeatureSelection::StableAggregate { .. } => { + target.required_features().is_subset(package.stable_features()) + } + FeatureSelection::All => target + .required_features() + .iter() + .all(|feature| package.cargo().features().contains_key(feature)), + } +} + #[derive(Clone, Debug)] struct SemverCellSemantics { package: String, @@ -1109,6 +1503,19 @@ struct DerivedExecution { commands: CommandBehaviors, } +/// One corrective native build selected for an event class. +/// +/// This record deliberately includes both logical identity and the complete +/// command. The current-state audit derives its expected records directly +/// from checked plans and Cargo inventory, independently of +/// `build_operations`. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct RequiredNativeBuild { + class: EventClass, + logical: LogicalSpec, + command: CommandSpec, +} + trait ModelMutation { fn mutate_docs_rs_rustdoc_args(&mut self, _arguments: &mut Vec) {} fn mutate_build_cell(&mut self, _class: EventClass, _cell: &mut BuildCellSemantics) {} @@ -1139,20 +1546,36 @@ fn derive_execution( let full = plan_for_class(inputs, EventClass::Full)?; let mut logical = BTreeMap::::new(); let mut commands = CommandBehaviors::new(); + let mut required_native_builds = BTreeSet::new(); for (class, plan) in [(EventClass::Reduced, &reduced), (EventClass::Full, &full)] { for cell in plan.builds() { - let mut cell = BuildCellSemantics::from_plan(cell, inputs.policy())?; + let mut cell = BuildCellSemantics::from_plan(cell, inputs)?; mutation.mutate_build_cell(class, &mut cell); for mut operation in build_operations(&docs_rs_rustdoc_args, &cell)? { mutation.mutate_operation(class, &mut operation); validate_operation(&operation)?; + validate_exact_native_operation(&operation, &cell)?; if !operation.applicable { continue; } - record_logical(&mut logical, class, &operation.logical)?; - if class == EventClass::Full { - collect_golden(&mut commands, operation)?; + match operation.kind.evidence() { + ExecutionEvidence::FrozenLegacy => { + record_logical(&mut logical, class, &operation.logical)?; + if class == EventClass::Full { + collect_golden(&mut commands, operation)?; + } + } + ExecutionEvidence::CurrentRequiredNativeBuild => { + let record = RequiredNativeBuild { + class, + logical: operation.logical, + command: operation.command, + }; + if !required_native_builds.insert(record.clone()) { + return Err(format!("required native build is duplicated: {record:?}")); + } + } } } } @@ -1194,6 +1617,9 @@ fn derive_execution( } } + let expected_required_builds = expected_required_native_builds(inputs, &reduced, &full)?; + compare_required_native_builds(&expected_required_builds, &required_native_builds)?; + let mut standalone = BTreeSet::new(); for spec in LEGACY_STANDALONE_SPECS { let obligation = standalone_obligation(spec)?; @@ -1227,6 +1653,105 @@ fn plan_for_class(inputs: &CiInputs, class: EventClass) -> Result Plan::create(inputs, event.as_str()).map_err(|error| error.to_string()) } +/// Derives the corrective build set without consulting `build_operations`. +/// +/// The legacy TSVs describe an immutable source commit at which native +/// production builds were absent. This separate current-state assertion joins +/// the checked plan to Cargo's audited target kind and enabled integration +/// targets, then constructs the exact command required whenever tests cannot +/// prove the production artifact. A future package, toolchain, profile, or +/// native target therefore enters the set naturally. +fn expected_required_native_builds( + inputs: &CiInputs, + reduced: &Plan, + full: &Plan, +) -> Result, String> { + let mut expected = BTreeSet::new(); + for (class, plan) in [(EventClass::Reduced, reduced), (EventClass::Full, full)] { + for cell in plan.builds() { + if cell.target().mode() != ExecutionMode::Native + || native_build_strategy(inputs, cell.package().id(), cell.features().selection())? + != NativeBuildStrategy::SeparateBuild + { + continue; + } + + let package = cell.package().id().to_owned(); + let toolchain = cell.toolchain().id().to_owned(); + let feature_profile = cell.features().profile().to_owned(); + let target = cell.target().triple().to_owned(); + let mut argv = vec![ + CARGO_WRAPPER.to_owned(), + format!("+{toolchain}"), + "build".to_owned(), + "--package".to_owned(), + package.clone(), + "--target".to_owned(), + target.clone(), + ]; + argv.extend(cell.features().selection().cargo_args()); + argv.push("--verbose".to_owned()); + + let pinned_nightly = is_pinned_nightly(inputs.policy(), &toolchain)?; + let mut environment = BTreeMap::from([ + ("RUSTDOCFLAGS".to_owned(), BASE_RUSTDOCFLAGS.to_owned()), + ("RUSTFLAGS".to_owned(), BASE_RUSTFLAGS.to_owned()), + ]); + if pinned_nightly { + environment.insert( + "RUSTFLAGS".to_owned(), + format!("{BASE_RUSTFLAGS} {NIGHTLY_RUSTFLAGS}"), + ); + environment.insert("MIRIFLAGS".to_owned(), format!(" {NIGHTLY_MIRIFLAGS}")); + } + + let record = RequiredNativeBuild { + class, + logical: LogicalSpec { + key: LogicalKey { + kind: "cargo-build".to_owned(), + package: Some(package), + toolchain: Some(toolchain), + feature_profile: Some(feature_profile), + target: Some(target), + miri_model: None, + }, + condition: ObligationCondition::Always, + job: BUILD_JOB.to_owned(), + step: "Build native target".to_owned(), + }, + command: CommandSpec { + job: BUILD_JOB.to_owned(), + step: "Build native target".to_owned(), + working_directory: WorkingDirectory::Relative( + MATRIX_WORKING_DIRECTORY.to_owned(), + ), + environment, + payload: CommandPayload::Argv { argv, dynamic_value: None }, + }, + }; + if !expected.insert(record.clone()) { + return Err(format!( + "checked plans select a duplicate required native build: {record:?}" + )); + } + } + } + Ok(expected) +} + +fn compare_required_native_builds( + expected: &BTreeSet, + actual: &BTreeSet, +) -> Result<(), String> { + if expected == actual { + return Ok(()); + } + let missing = expected.difference(actual).collect::>(); + let extra = actual.difference(expected).collect::>(); + Err(format!("required native build audit differs: missing {missing:?}; extra {extra:?}")) +} + fn record_logical( logical: &mut BTreeMap, class: EventClass, @@ -1288,10 +1813,152 @@ fn validate_operation(operation: &MatrixOperation) -> Result<(), String> { operation.kind, operation.command.step, operation.logical.step )); } + if operation.kind == MatrixOperationKind::CargoTest { + validate_native_test_consolidation(&operation.command)?; + } operation.command.validate(operation.kind)?; + if let Some(expected) = operation.kind.cargo_subcommand() { + let argv = match &operation.command.payload { + CommandPayload::Argv { argv, .. } | CommandPayload::ArgvTemplate { argv, .. } => argv, + CommandPayload::ActionInputs { .. } => { + return Err(format!( + "typed operation {:?} must invoke Cargo subcommand `{expected}` through argv", + operation.kind + )); + } + }; + if argv.get(2).map(String::as_str) != Some(expected) { + return Err(format!( + "typed operation {:?} must invoke Cargo subcommand `{expected}`; found {:?}", + operation.kind, + argv.get(2) + )); + } + } + Ok(()) +} + +/// Checks the complete generated form of consolidated native operations. +/// +/// Logical parity and representative command goldens cannot protect a +/// non-golden cell from a conditional command change. Reconstructing the +/// expected operation from the checked cell catches added test filters such +/// as `--no-run`, changed feature arguments, or a different package/target. +/// The current-state required-build set below additionally constructs its +/// expected commands without calling `cargo_operation`. +fn validate_exact_native_operation( + operation: &MatrixOperation, + cell: &BuildCellSemantics, +) -> Result<(), String> { + let expected = match operation.kind { + MatrixOperationKind::CargoTest if operation.golden.is_none() => Some(cargo_operation( + MatrixOperationKind::CargoTest, + cell, + "test", + &[], + &[], + Some(cell.target.as_str()), + native_default_golden(cell), + )), + MatrixOperationKind::CargoBuildNative => Some(cargo_operation( + MatrixOperationKind::CargoBuildNative, + cell, + "build", + &[], + &[], + Some(cell.target.as_str()), + None, + )), + _ => None, + }; + if expected.as_ref().is_some_and(|expected| expected != operation) { + return Err(format!( + "typed {:?} operation differs from the exact checked native cell", + operation.kind + )); + } + Ok(()) +} + +/// Checks the modeled half of native build/test consolidation. +/// +/// [`preflight_ci_cargo_environment`] rejects equivalent inputs inherited from +/// the runner before a selected cell is planned. The modeled environment is +/// applied later, when [`run_process`] constructs its [`ProcessInvocation`], +/// so it needs an independent check here. Keeping the argv check beside it +/// also makes the consolidation fail closed for non-golden cells: frozen +/// command evidence covers representative rows, but it is not the owner of +/// this semantic invariant. +fn validate_native_test_consolidation(command: &CommandSpec) -> Result<(), String> { + let profile_environment = command + .environment + .keys() + // The same model executes on Unix and Windows. Reject every ASCII-case + // spelling so a harmless Unix near-miss cannot become an override on + // a case-insensitive Windows runner. + .filter(|name| { + name.eq_ignore_ascii_case(CARGO_DEV_PANIC_ENVIRONMENT) + || name.get(..CARGO_TEST_PROFILE_ENV_PREFIX.len()).is_some_and(|candidate| { + candidate.eq_ignore_ascii_case(CARGO_TEST_PROFILE_ENV_PREFIX) + }) + }) + .cloned() + .collect::>(); + if !profile_environment.is_empty() { + return Err(format!( + "native Cargo test must not set test-profile or dev-panic environment override(s): {}", + profile_environment.join(", ") + )); + } + + let CommandPayload::Argv { argv, .. } = &command.payload else { + return Err("native Cargo test must use one concrete argv payload".to_owned()); + }; + if let Some(argument) = argv + .iter() + // Arguments after Cargo's separator belong to the test binary and + // cannot select Cargo configuration or a compilation profile. + .take_while(|argument| argument.as_str() != "--") + .find(|argument| cargo_argument_changes_profile(argument)) + { + return Err(format!( + "native Cargo test must not select a profile or command-line configuration; found {argument:?}" + )); + } Ok(()) } +fn cargo_argument_changes_profile(argument: &str) -> bool { + matches!(argument, "--release" | "--profile" | "--config") + || argument.starts_with("--profile=") + || argument.starts_with("--config=") + || cargo_short_flags_select_release(argument) +} + +/// Returns whether a Cargo short-option cluster contains the release flag. +/// +/// Cargo accepts boolean flags in either order, so both `-rv` and `-vr` +/// select the release profile. Once a value-taking flag appears, however, the +/// remainder belongs to that flag: the `r` in `-pcrate` is package-name data, +/// not another option. The typed generator currently uses long spellings for +/// these options, but recognizing their compact Cargo forms keeps this guard +/// semantically accurate when it diagnoses a future modeled change. +fn cargo_short_flags_select_release(argument: &str) -> bool { + let Some(cluster) = argument.strip_prefix('-').filter(|cluster| !cluster.starts_with('-')) + else { + return false; + }; + for flag in cluster.chars() { + if flag == 'r' { + return true; + } + if matches!(flag, 'p' | 'j' | 'F' | 'Z' | 'C') { + return false; + } + } + false +} + /// Adapts a validated live operation to the source labels in frozen evidence. /// /// The baseline source commit ran semver as a step of `build_test`, whereas @@ -1363,15 +2030,46 @@ fn build_operations( ) -> Result, String> { let mut operations = Vec::new(); match cell.mode { - ExecutionMode::Native => operations.push(cargo_operation( - MatrixOperationKind::CargoTest, - cell, - "test", - &[], - &[], - Some(cell.target.as_str()), - native_default_golden(cell), - )), + ExecutionMode::Native => { + // An ordinary library with an enabled integration target is + // compiled as that target's normal dependency, so one Cargo test + // pass covers both its production artifact and its tests. The + // checked root manifest, repository Cargo configuration, and + // runner preflight keep the test profile unmodified and the dev + // panic strategy at unwind. + // + // Cargo 1.56 does not produce a proc macro's dev-profile artifact + // this way. In addition, zerocopy-derive's tests enable a `syn` + // feature which its production dependency does not. Retain a + // separate build for every proc-macro package, deriving that + // choice from audited Cargo target metadata rather than a package + // name. An ordinary package/profile with no enabled integration + // target also retains its build; unit tests alone do not produce + // the normal library artifact. `expected_required_native_builds` + // independently checks the exact selected set because these + // corrective builds postdate the immutable legacy evidence. + if cell.native_build_strategy == NativeBuildStrategy::SeparateBuild { + let build = cargo_operation( + MatrixOperationKind::CargoBuildNative, + cell, + "build", + &[], + &[], + Some(cell.target.as_str()), + None, + ); + operations.push(build); + } + operations.push(cargo_operation( + MatrixOperationKind::CargoTest, + cell, + "test", + &[], + &[], + Some(cell.target.as_str()), + native_default_golden(cell), + )); + } ExecutionMode::Cross => { operations.push(cargo_operation( MatrixOperationKind::CargoCheckTests, @@ -2321,25 +3019,27 @@ fn collect_difference( #[cfg(test)] mod tests { use std::{ - collections::{BTreeMap, VecDeque}, - ffi::OsStr, + collections::{BTreeMap, BTreeSet, VecDeque}, + ffi::{OsStr, OsString}, io, num::NonZeroUsize, path::{Path, PathBuf}, + process::Command as StdCommand, sync::OnceLock, }; use super::{ - audit_execution, checked_miri_thread_count, compare_execution, derive_execution, - execute_build_cell_with, execute_miri_cell_with, miri_thread_count, - miri_wrapper_invocation, operation_for_frozen_legacy_evidence, parse_nproc_thread_count, - semver_operation, substitute_dynamic, system_command_for_platform, unique_match, - BuildCellSelector, BuildCellSemantics, CapturedProcessOutcome, CellExecutionError, - CommandSpec, EventClass, ExecutionHost, ExecutionMode, FeatureSelection, HostPlatform, - MatrixOperation, MatrixOperationKind, MiriCellSelector, ModelMutation, ProcessInvocation, - ProcessOutcome, SemverCellSemantics, WorkingDirectory, AARCH64_TARGET, BUILD_JOB, - CARGO_WRAPPER, EXECUTION_CONTEXT_ENV, MIRI_JOB, MIRI_REPOSITORY_ROOT_CONTEXT, - MIRI_THREAD_PLACEHOLDER, NPROC_STEP, SEMVER_JOB, WINDOWS_CARGO_WRAPPER, + audit_execution, cargo_configuration_candidates, checked_miri_thread_count, + compare_execution, derive_execution, execute_build_cell_with, execute_miri_cell_with, + miri_thread_count, miri_wrapper_invocation, operation_for_frozen_legacy_evidence, + parse_nproc_thread_count, preflight_ci_cargo_environment, semver_operation, + substitute_dynamic, system_command_for_platform, unique_match, BuildCellSelector, + BuildCellSemantics, CapturedProcessOutcome, CellExecutionError, CommandSpec, EventClass, + ExecutionHost, ExecutionMode, FeatureSelection, HostPlatform, MatrixOperation, + MatrixOperationKind, MiriCellSelector, ModelMutation, ProcessInvocation, ProcessOutcome, + SemverCellSemantics, WorkingDirectory, AARCH64_TARGET, BUILD_JOB, CARGO_WRAPPER, + EXECUTION_CONTEXT_ENV, MIRI_JOB, MIRI_REPOSITORY_ROOT_CONTEXT, MIRI_THREAD_PLACEHOLDER, + NPROC_STEP, SEMVER_JOB, WINDOWS_CARGO_WRAPPER, }; use crate::{ baseline::{CommandPayload, JsonValue}, @@ -2368,6 +3068,8 @@ mod tests { #[derive(Debug, Default)] struct FakeExecutionHost { platform: Option, + environment: BTreeMap, + path_entries: BTreeMap>, parallelism: Option>, invocations: Vec, outcomes: VecDeque, @@ -2384,6 +3086,12 @@ mod tests { fn platform(&self) -> HostPlatform { self.platform.unwrap_or(HostPlatform::Linux) } + fn environment_variables(&self) -> Vec<(OsString, OsString)> { + self.environment.iter().map(|(name, value)| (name.clone(), value.clone())).collect() + } + fn path_entry_exists(&self, path: &Path) -> io::Result { + self.path_entries.get(path).copied().unwrap_or(Ok(false)).map_err(io::Error::from) + } fn available_parallelism(&self) -> io::Result { self.parallelism .unwrap_or_else(|| NonZeroUsize::new(4).ok_or(io::ErrorKind::Other)) @@ -2437,23 +3145,371 @@ mod tests { MiriCellSelector::new(event, "zerocopy", "nightly", "default", target, model) } + fn ci_environment(home_name: &str, home_value: &str) -> BTreeMap { + BTreeMap::from([ + (OsString::from("CI"), OsString::from("true")), + (OsString::from(home_name), OsString::from(home_value)), + ]) + } + + fn fake_repository_root() -> PathBuf { + PathBuf::from("/checkout/repository") + } + + fn fake_preflight(host: &FakeExecutionHost) -> Result<(), CellExecutionError> { + preflight_ci_cargo_environment(host, &fake_repository_root()) + } + #[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(), - }, + fn ci_preflight_rejects_both_global_config_names_and_home_locations() { + for (home_name, home_value, cargo_home) in [ + ("CARGO_HOME", "/runner/cargo", PathBuf::from("/runner/cargo")), + ("HOME", "/runner/home", PathBuf::from("/runner/home/.cargo")), + ] { + for name in ["config", "config.toml"] { + let path = cargo_home.join(name); + let mut host = FakeExecutionHost { + environment: ci_environment(home_name, home_value), + ..Default::default() + }; + host.path_entries.insert(path.clone(), Ok(true)); + + let error = fake_preflight(&host).unwrap_err(); + assert!( + matches!(error, CellExecutionError::RunnerCargoConfiguration { path: ref actual } if actual == &path), + "{home_name}/{name}: {error}" + ); + } + } + } + + #[test] + fn ci_preflight_rejects_configs_in_every_ancestor_above_the_repository() { + for path in [ + PathBuf::from("/checkout/.cargo/config"), + PathBuf::from("/checkout/.cargo/config.toml"), + PathBuf::from("/.cargo/config"), + PathBuf::from("/.cargo/config.toml"), + ] { + let mut host = FakeExecutionHost { + environment: ci_environment("CARGO_HOME", "/runner/cargo"), + ..Default::default() + }; + // `true` represents any directory entry. SystemExecutionHost uses + // symlink_metadata, so regular files, directories, and broken + // links all reach this same rejection. + host.path_entries.insert(path.clone(), Ok(true)); + + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::RunnerCargoConfiguration { path: actual }) + if actual == path + )); + } + } + + #[test] + fn ci_preflight_leaves_repository_owned_config_paths_to_inventory() { + let mut host = FakeExecutionHost { + environment: ci_environment("CARGO_HOME", "/runner/cargo"), + ..Default::default() + }; + for path in [ + "/checkout/repository/.cargo/config", + "/checkout/repository/.cargo/config.toml", + "/checkout/repository/zerocopy/.cargo/config", + "/checkout/repository/zerocopy/.cargo/config.toml", + ] { + host.path_entries.insert(PathBuf::from(path), Ok(true)); + } + + // Inventory accepts only the reviewed zerocopy config and rejects the + // other in-repository entries. The process boundary must not inspect + // or misclassify any of them as runner-owned input. + fake_preflight(&host).unwrap(); + } + + #[test] + fn cargo_config_candidates_preserve_precedence_and_remove_duplicates() { + let paths = cargo_configuration_candidates( + &fake_repository_root(), + // This Cargo home is the nearest ancestor's `.cargo` directory. + // Cargo reaches it twice conceptually but the preflight should + // inspect each physical spelling once. + Path::new("/checkout/.cargo"), + ); + assert_eq!( + paths, + [ + "/checkout/.cargo/config", + "/checkout/.cargo/config.toml", + "/.cargo/config", + "/.cargo/config.toml", + ] + .into_iter() + .map(PathBuf::from) + .collect::>() + ); + + let mut host = FakeExecutionHost { + environment: ci_environment("CARGO_HOME", "/runner/cargo"), + ..Default::default() + }; + for path in [ + "/checkout/.cargo/config.toml", + "/checkout/.cargo/config", + "/.cargo/config", + "/runner/cargo/config", + ] { + host.path_entries.insert(PathBuf::from(path), Ok(true)); + } + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::RunnerCargoConfiguration { path }) + if path == Path::new("/checkout/.cargo/config") + )); + } + + #[test] + fn ci_preflight_reports_uninspectable_ancestor_configuration() { + let path = PathBuf::from("/checkout/.cargo/config.toml"); + let mut host = FakeExecutionHost { + environment: ci_environment("CARGO_HOME", "/runner/cargo"), + ..Default::default() + }; + host.path_entries.insert(path.clone(), Err(io::ErrorKind::PermissionDenied)); + + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::InspectRunnerCargoConfiguration { path: actual, .. }) + if actual == path + )); + } + + #[test] + fn windows_uses_case_insensitive_userprofile_fallback_and_inspects_it() { + let cargo_home = PathBuf::from("C:\\Users\\runner").join(".cargo"); + let path = cargo_home.join("config.toml"); + let mut host = FakeExecutionHost { + platform: Some(HostPlatform::Windows), + environment: BTreeMap::from([ + (OsString::from("github_actions"), OsString::from("true")), + (OsString::from("userprofile"), OsString::from("C:\\Users\\runner")), + ]), + ..Default::default() + }; + host.path_entries.insert(path.clone(), Ok(true)); + + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::RunnerCargoConfiguration { path: actual }) + if actual == path + )); + + let missing = FakeExecutionHost { + platform: Some(HostPlatform::Windows), + environment: BTreeMap::from([( + OsString::from("GITHUB_ACTIONS"), + OsString::from("true"), + )]), + ..Default::default() + }; + assert!(matches!( + fake_preflight(&missing), + Err(CellExecutionError::CargoHomeUnavailable { fallback: "USERPROFILE" }) + )); + } + + #[test] + fn windows_cargo_home_precedes_userprofile() { + let fallback = PathBuf::from("C:\\Users\\runner").join(".cargo/config"); + let mut host = FakeExecutionHost { + platform: Some(HostPlatform::Windows), + environment: BTreeMap::from([ + (OsString::from("CI"), OsString::from("true")), + (OsString::from("cargo_home"), OsString::from("C:\\cargo")), + (OsString::from("userprofile"), OsString::from("C:\\Users\\runner")), + ]), + ..Default::default() + }; + host.path_entries.insert(fallback, Ok(true)); + + fake_preflight(&host).unwrap(); + } + + #[test] + fn ci_preflight_uses_cargo_home_before_the_home_fallback() { + let mut host = FakeExecutionHost { + environment: BTreeMap::from([ + (OsString::from("CI"), OsString::from("true")), + (OsString::from("CARGO_HOME"), OsString::from("/runner/cargo")), + (OsString::from("HOME"), OsString::from("/runner/home")), + ]), + ..Default::default() + }; + // Cargo ignores HOME/.cargo when CARGO_HOME is present. The preflight + // must inspect the same location rather than rejecting an irrelevant + // personal file. + host.path_entries.insert(PathBuf::from("/runner/home/.cargo/config.toml"), Ok(true)); + + fake_preflight(&host).unwrap(); + } + + #[test] + fn ci_preflight_fails_closed_when_cargo_home_cannot_be_resolved() { + let host = FakeExecutionHost { + // Presence, not a convenient marker value, identifies CI. An + // empty value must not silently turn the strict boundary off. + environment: BTreeMap::from([(OsString::from("CI"), OsString::new())]), + ..Default::default() + }; + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::CargoHomeUnavailable { fallback: "HOME" }) + )); + + for (home_name, home_value) in [("HOME", ""), ("CARGO_HOME", "relative/cargo")] { + let mut environment = ci_environment(home_name, home_value); + // A present but invalid CARGO_HOME takes precedence over a valid + // HOME, matching Cargo instead of silently selecting a fallback. + if home_name == "CARGO_HOME" { + environment.insert(OsString::from("HOME"), OsString::from("/runner/home")); + } + let host = FakeExecutionHost { environment, ..Default::default() }; + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::InvalidCargoHome { variable, .. }) if variable == home_name + )); + } + } + + #[test] + fn ci_preflight_rejects_every_test_profile_environment_form() { + for name in [ + "CARGO_PROFILE_TEST_OPT_LEVEL", + "CARGO_PROFILE_TEST_BUILD_OVERRIDE_OPT_LEVEL", + "CARGO_PROFILE_TEST_PACKAGE_ZEROCOPY_OPT_LEVEL", + "CARGO_PROFILE_TEST_", + ] { + let mut environment = ci_environment("CARGO_HOME", "/runner/cargo"); + // Even an empty value remains an attempted Cargo override; Cargo, + // not the executor, would otherwise decide how to interpret it. + environment.insert(OsString::from(name), OsString::new()); + let host = FakeExecutionHost { environment, ..Default::default() }; + let error = fake_preflight(&host).unwrap_err(); + assert!( + matches!(error, CellExecutionError::AmbientCargoTestProfile { ref variables } if variables == name), + "{name}: {error}" + ); + } + + let host = FakeExecutionHost { + platform: Some(HostPlatform::Windows), + environment: BTreeMap::from([ + (OsString::from("github_actions"), OsString::from("true")), + (OsString::from("cargo_home"), OsString::from("C:\\runner\\cargo")), + (OsString::from("cargo_profile_test_opt_level"), OsString::from("3")), + ]), + ..Default::default() + }; + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::AmbientCargoTestProfile { .. }) + )); + + let mut environment = ci_environment("CARGO_HOME", "/runner/cargo"); + environment.insert(OsString::from("CARGO_PROFILE_TESTS_OPT_LEVEL"), OsString::from("3")); + let host = FakeExecutionHost { environment, ..Default::default() }; + fake_preflight(&host).unwrap(); + } + + #[test] + fn ci_preflight_rejects_only_the_dev_panic_environment_override() { + let name = "CARGO_PROFILE_DEV_PANIC"; + let mut environment = ci_environment("CARGO_HOME", "/runner/cargo"); + // Presence is sufficient. Parsing Cargo's ambient value here would + // create a second profile implementation at the process boundary. + environment.insert(OsString::from(name), OsString::new()); + let host = FakeExecutionHost { environment, ..Default::default() }; + let error = fake_preflight(&host).unwrap_err(); + assert!( + matches!(error, CellExecutionError::AmbientCargoDevPanic { ref variables } if variables == name), + "{error}" + ); + + let host = FakeExecutionHost { + platform: Some(HostPlatform::Windows), + environment: BTreeMap::from([ + (OsString::from("github_actions"), OsString::from("true")), + (OsString::from("cargo_home"), OsString::from("C:\\runner\\cargo")), + (OsString::from("cargo_profile_dev_panic"), OsString::from("abort")), + ]), + ..Default::default() + }; + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::AmbientCargoDevPanic { .. }) + )); + + for near_miss in ["CARGO_PROFILE_DEV_OPT_LEVEL", "CARGO_PROFILE_DEV_PANICS"] { + let mut environment = ci_environment("CARGO_HOME", "/runner/cargo"); + environment.insert(OsString::from(near_miss), OsString::from("3")); + let host = FakeExecutionHost { environment, ..Default::default() }; + fake_preflight(&host).unwrap(); + } + } + + #[test] + fn non_ci_execution_preserves_personal_cargo_behavior() { + let path = PathBuf::from("/developer/cargo/config.toml"); + let mut host = FakeExecutionHost { + environment: BTreeMap::from([ + (OsString::from("CARGO_HOME"), OsString::from("/developer/cargo")), + (OsString::from("CARGO_PROFILE_TEST_OPT_LEVEL"), OsString::from("3")), + (OsString::from("CARGO_PROFILE_DEV_PANIC"), OsString::from("abort")), + ]), + ..Default::default() + }; + host.path_entries.insert(path, Ok(true)); + + // The personal path and override are both allowed, and no HOME is + // needed, because the strict boundary is intentionally CI-only. + fake_preflight(&host).unwrap(); + } + + #[test] + fn ci_preflight_reports_uninspectable_global_configuration() { + let path = PathBuf::from("/runner/cargo/config"); + let mut host = FakeExecutionHost { + environment: ci_environment("CARGO_HOME", "/runner/cargo"), + ..Default::default() + }; + host.path_entries.insert(path.clone(), Err(io::ErrorKind::PermissionDenied)); + + assert!(matches!( + fake_preflight(&host), + Err(CellExecutionError::InspectRunnerCargoConfiguration { path: actual, .. }) + if actual == path + )); + } + + #[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(), @@ -2568,6 +3624,42 @@ mod tests { ); } + #[test] + fn native_proc_macro_execution_builds_before_running_tests() { + let root = test_root(); + let mut host = FakeExecutionHost::default(); + let selector = BuildCellSelector::new( + "pull_request", + "zerocopy-derive", + "stable", + "default", + "x86_64-unknown-linux-gnu", + ); + let report = execute_build_cell_with(inputs(), &selector, &mut host).unwrap(); + + assert_eq!( + report.executed_steps, + ["Build native target", "Test native target", "Cargo doc"] + ); + assert_eq!(host.invocations.len(), 3); + for (invocation, subcommand) in host.invocations[..2].iter().zip(["build", "test"]) { + assert_eq!( + invocation.argv, + [ + "./cargo.sh", + "+stable", + subcommand, + "--package", + "zerocopy-derive", + "--target", + "x86_64-unknown-linux-gnu", + "--verbose", + ] + ); + assert_eq!(invocation.working_directory, root.join("zerocopy")); + } + } + #[test] fn windows_translates_only_the_repository_cargo_wrapper() { let working_directory = test_root().join("zerocopy"); @@ -2958,10 +4050,320 @@ mod tests { } #[test] - fn typed_behavior_matches_all_frozen_legacy_evidence_exactly() { + fn typed_behavior_matches_all_legacy_and_current_evidence() { audit_execution(inputs()).unwrap(); } + #[test] + fn current_audit_derives_every_required_native_build_from_inventory() { + assert_eq!( + super::package_library_kind(inputs(), "zerocopy").unwrap(), + super::PackageLibraryKind::Ordinary + ); + assert_eq!( + super::package_library_kind(inputs(), "zerocopy-derive").unwrap(), + super::PackageLibraryKind::ProcMacro + ); + assert_eq!( + super::native_build_strategy_for(super::PackageLibraryKind::Ordinary, true), + super::NativeBuildStrategy::TestCoversBuild + ); + for (library_kind, has_enabled_integration_test) in [ + (super::PackageLibraryKind::Ordinary, false), + (super::PackageLibraryKind::ProcMacro, false), + (super::PackageLibraryKind::ProcMacro, true), + ] { + assert_eq!( + super::native_build_strategy_for(library_kind, has_enabled_integration_test), + super::NativeBuildStrategy::SeparateBuild + ); + } + + let reduced = super::plan_for_class(inputs(), EventClass::Reduced).unwrap(); + let full = super::plan_for_class(inputs(), EventClass::Full).unwrap(); + let builds = super::expected_required_native_builds(inputs(), &reduced, &full).unwrap(); + assert_eq!(builds.iter().filter(|build| build.class == EventClass::Reduced).count(), 6); + assert_eq!(builds.iter().filter(|build| build.class == EventClass::Full).count(), 6); + } + + fn parse_cargo_tree_features( + stdout: &str, + context: &str, + ) -> Result>, String> { + let mut packages = BTreeMap::>::new(); + for line in stdout.lines() { + // Feature-edge labels are not package nodes and do not receive + // the requested format. Package rows use the literal tab below; + // Cargo may suffix a repeated row with its ordinary `(*)` marker. + let Some((package, features)) = line.split_once('\t') else { + continue; + }; + let features = features.strip_suffix(" (*)").unwrap_or(features); + let features = features + .split(',') + .filter(|feature| !feature.is_empty()) + .map(str::to_owned) + .collect::>(); + if let Some(previous) = packages.get(package) { + if previous != &features { + return Err(format!( + "cargo tree reported ambiguous feature contexts for {package} in {context}: {previous:?} and {features:?}" + )); + } + } else { + packages.insert(package.to_owned(), features); + } + } + if packages.is_empty() { + return Err(format!("cargo tree returned no package rows for {context}")); + } + Ok(packages) + } + + fn cargo_tree_features( + package: &str, + target: &str, + features: &FeatureSelection, + edges: &str, + ) -> BTreeMap> { + let output = StdCommand::new(env!("CARGO")) + .current_dir(test_root().join("zerocopy")) + // CI requests colored Cargo diagnostics globally. Exercise that + // environment locally too, while the command-line override below + // keeps this machine-readable stdout free of terminal escapes. + // If the override is removed, the repeated-node `(*)` marker is + // colored and these smoke tests fail instead of silently parsing + // the escape bytes as part of a feature name. + .env("CARGO_TERM_COLOR", "always") + .args([ + "tree", + "--color", + "never", + "--locked", + "--offline", + "--manifest-path", + "Cargo.toml", + "--package", + package, + "--target", + target, + "--edges", + edges, + "--prefix", + "none", + "--format", + "{p}\t{f}", + ]) + .args(features.cargo_args()) + .output() + .unwrap_or_else(|error| panic!("failed to start cargo tree for {package}: {error}")); + assert!( + output.status.success(), + "cargo tree for {package}/{target}/{features:?}/{edges} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("cargo tree output must be UTF-8"); + parse_cargo_tree_features(&stdout, &format!("{package}/{target}/{features:?}/{edges}")) + .unwrap_or_else(|error| panic!("{error}")) + } + + #[test] + fn cargo_tree_feature_parser_rejects_ambiguous_compile_contexts() { + let error = parse_cargo_tree_features( + "dependency v1.0.0\tdefault\ndependency v1.0.0\tdefault,test-only\n", + "fixture", + ) + .unwrap_err(); + assert!(error.contains("ambiguous feature contexts")); + } + + #[test] + fn cargo_tree_smoke_test_detects_the_known_derive_feature_delta() { + // This negative control makes the smoke test fail if its two Cargo + // views accidentally become equivalent. The explicit proc-macro build + // remains the actual coverage for this known production/test delta. + let production = cargo_tree_features( + "zerocopy-derive", + "x86_64-unknown-linux-gnu", + &FeatureSelection::Default, + "normal,build", + ); + let tests = cargo_tree_features( + "zerocopy-derive", + "x86_64-unknown-linux-gnu", + &FeatureSelection::Default, + "all", + ); + let (syn, production_features) = production + .iter() + .find(|(package, _)| package.starts_with("syn v")) + .expect("the derive production graph must contain syn"); + let test_features = + tests.get(syn).expect("the derive test graph must contain the same syn"); + assert!(!production_features.contains("visit")); + assert!(test_features.contains("visit")); + } + + #[test] + fn consolidated_library_tests_preserve_production_dependency_features() { + // This is a deliberately basic smoke test for the one assumption + // Cargo metadata cannot express directly. Cargo documents these tree + // edge selections as approximations rather than exact build plans, so + // do not treat this as a compilation proof. For every full-event + // consolidated profile/target, compare Cargo tree's production and + // test views. Extra test-only packages are harmless; a shared package + // gaining features is not. Ambiguous per-context rows fail closed + // rather than being merged. The check is a unit test so it runs once + // in `check_tools` instead of repeating on every matrix runner. Keep + // that ownership coordinated with `ci/check_tools.sh`. + let plan = Plan::create(inputs(), "push").unwrap(); + let cases = plan + .builds() + .iter() + .filter(|cell| { + cell.target().mode() == ExecutionMode::Native + && super::native_build_strategy( + inputs(), + cell.package().id(), + cell.features().selection(), + ) + .unwrap() + == super::NativeBuildStrategy::TestCoversBuild + }) + .map(|cell| { + ( + cell.package().id().to_owned(), + cell.features().profile().to_owned(), + cell.features().selection().clone(), + cell.target().triple().to_owned(), + ) + }) + .collect::>(); + assert!(!cases.is_empty(), "the live full plan must contain an ordinary native library"); + + for (package, profile, features, target) in cases { + let production = cargo_tree_features(&package, &target, &features, "normal,build"); + let tests = cargo_tree_features(&package, &target, &features, "all"); + for (dependency, production_features) in production { + let test_features = tests.get(&dependency).unwrap_or_else(|| { + panic!( + "test graph for {package}/{profile}/{target} omitted production dependency {dependency}" + ) + }); + assert_eq!( + test_features, &production_features, + "test graph for {package}/{profile}/{target} changes production dependency features for {dependency}; retain a separate native build or extend the typed consolidation proof" + ); + } + } + } + + struct MissingRequiredNativeBuild(bool); + + impl ModelMutation for MissingRequiredNativeBuild { + fn mutate_build_cell(&mut self, class: EventClass, cell: &mut BuildCellSemantics) { + if !self.0 + && class == EventClass::Reduced + && cell.package == "zerocopy-derive" + && cell.mode == ExecutionMode::Native + { + cell.native_build_strategy = super::NativeBuildStrategy::TestCoversBuild; + self.0 = true; + } + } + } + + #[test] + fn current_audit_rejects_a_missing_required_native_build() { + let mut mutation = MissingRequiredNativeBuild(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate one selected proc-macro cell"); + assert!(diagnostic.contains("required native build audit differs")); + assert!(diagnostic.contains("missing")); + } + + struct ExtraRequiredNativeBuild(bool); + + impl ModelMutation for ExtraRequiredNativeBuild { + fn mutate_build_cell(&mut self, class: EventClass, cell: &mut BuildCellSemantics) { + if !self.0 + && class == EventClass::Full + && cell.package == "zerocopy" + && cell.toolchain == "stable" + && cell.feature_profile == "default" + && cell.mode == ExecutionMode::Native + { + cell.native_build_strategy = super::NativeBuildStrategy::SeparateBuild; + self.0 = true; + } + } + } + + #[test] + fn current_audit_rejects_an_extra_native_library_build() { + let mut mutation = ExtraRequiredNativeBuild(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate one selected library cell"); + assert!(diagnostic.contains("required native build audit differs")); + assert!(diagnostic.contains("extra")); + } + + #[derive(Clone, Copy, Debug)] + enum RequiredNativeBuildRecordMutation { + Target, + Features, + Environment, + } + + struct MutatedRequiredNativeBuild { + mutation: RequiredNativeBuildRecordMutation, + mutated: bool, + } + + impl ModelMutation for MutatedRequiredNativeBuild { + fn mutate_build_cell(&mut self, class: EventClass, cell: &mut BuildCellSemantics) { + if self.mutated + || class != EventClass::Reduced + || cell.package != "zerocopy-derive" + || cell.toolchain != "msrv" + || cell.target != "i686-unknown-linux-gnu" + || cell.native_build_strategy != super::NativeBuildStrategy::SeparateBuild + { + return; + } + match self.mutation { + RequiredNativeBuildRecordMutation::Target => { + cell.target = "aarch64-unknown-linux-gnu".to_owned(); + } + RequiredNativeBuildRecordMutation::Features => { + cell.feature_profile = "mutated".to_owned(); + cell.features = FeatureSelection::NoDefault; + } + RequiredNativeBuildRecordMutation::Environment => { + cell.pinned_nightly = true; + } + } + self.mutated = true; + } + } + + #[test] + fn current_audit_exactly_compares_each_required_native_build_record() { + for mutation in [ + RequiredNativeBuildRecordMutation::Target, + RequiredNativeBuildRecordMutation::Features, + RequiredNativeBuildRecordMutation::Environment, + ] { + let mut mutation = MutatedRequiredNativeBuild { mutation, mutated: false }; + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.mutated, "the test must mutate one required build record"); + assert!(diagnostic.contains("required native build audit differs"), "{diagnostic}"); + assert!(diagnostic.contains("missing"), "{diagnostic}"); + assert!(diagnostic.contains("extra"), "{diagnostic}"); + } + } + #[test] fn semver_normalizes_only_its_two_deliberate_post_baseline_changes() { let plan = Plan::create(inputs(), "push").unwrap(); @@ -3071,6 +4473,172 @@ mod tests { assert!(!diagnostic.contains('\n')); } + struct NativeTestProfileEnvironmentMutation(bool); + + impl ModelMutation for NativeTestProfileEnvironmentMutation { + fn mutate_operation(&mut self, class: EventClass, operation: &mut MatrixOperation) { + if self.0 || !is_non_golden_native_test(class, operation) { + return; + } + operation + .command + .environment + .insert("CARGO_PROFILE_TEST_OPT_LEVEL".to_owned(), "3".to_owned()); + self.0 = true; + } + } + + #[test] + fn native_test_rejects_a_modeled_test_profile_environment_override() { + let mut mutation = NativeTestProfileEnvironmentMutation(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate its intended non-golden cell"); + assert!( + diagnostic.contains("must not set test-profile or dev-panic environment override"), + "{diagnostic}" + ); + assert!(diagnostic.contains("CARGO_PROFILE_TEST_OPT_LEVEL")); + } + + struct NativeDevPanicEnvironmentMutation(bool); + + impl ModelMutation for NativeDevPanicEnvironmentMutation { + fn mutate_operation(&mut self, class: EventClass, operation: &mut MatrixOperation) { + if self.0 || !is_non_golden_native_test(class, operation) { + return; + } + operation + .command + .environment + .insert("cargo_profile_dev_panic".to_owned(), "abort".to_owned()); + self.0 = true; + } + } + + #[test] + fn native_test_rejects_a_modeled_dev_panic_environment_override() { + let mut mutation = NativeDevPanicEnvironmentMutation(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate its intended non-golden cell"); + assert!( + diagnostic.contains("must not set test-profile or dev-panic environment override"), + "{diagnostic}" + ); + assert!(diagnostic.contains("cargo_profile_dev_panic")); + } + + struct NativeTestCargoArgumentMutation { + argument: &'static str, + mutated: bool, + } + + impl ModelMutation for NativeTestCargoArgumentMutation { + fn mutate_operation(&mut self, class: EventClass, operation: &mut MatrixOperation) { + if self.mutated || !is_non_golden_native_test(class, operation) { + return; + } + let CommandPayload::Argv { argv, .. } = &mut operation.command.payload else { + panic!("selected native Cargo test must use an argv payload"); + }; + argv.push(self.argument.to_owned()); + self.mutated = true; + } + } + + #[test] + fn native_test_rejects_every_modeled_profile_and_config_selector() { + for argument in [ + "--release", + "-r", + "-rv", + "-vr", + "--profile", + "--profile=release", + "--config", + "--config=profile.test.opt-level=3", + "--config=profile.dev.panic='abort'", + ] { + let mut mutation = NativeTestCargoArgumentMutation { argument, mutated: false }; + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.mutated, "the test must mutate its intended non-golden cell"); + assert!( + diagnostic.contains("must not select a profile or command-line configuration"), + "{argument}: {diagnostic}" + ); + assert!(diagnostic.contains(argument), "{argument}: {diagnostic}"); + } + } + + #[test] + fn native_test_rejects_a_filter_which_would_only_compile_tests() { + let mut mutation = NativeTestCargoArgumentMutation { argument: "--no-run", mutated: false }; + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.mutated, "the test must mutate its intended non-golden cell"); + assert!(diagnostic.contains("differs from the exact checked native cell")); + } + + struct NativeTestSubcommandMutation(bool); + + impl ModelMutation for NativeTestSubcommandMutation { + fn mutate_operation(&mut self, class: EventClass, operation: &mut MatrixOperation) { + if self.0 || !is_non_golden_native_test(class, operation) { + return; + } + let CommandPayload::Argv { argv, .. } = &mut operation.command.payload else { + panic!("selected native Cargo test must use an argv payload"); + }; + argv[2] = "build".to_owned(); + self.0 = true; + } + } + + #[test] + fn native_test_kind_requires_the_test_subcommand() { + let mut mutation = NativeTestSubcommandMutation(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate its intended non-golden cell"); + assert!(diagnostic.contains("must invoke Cargo subcommand `test`")); + } + + struct NativeProcMacroBuildSubcommandMutation(bool); + + impl ModelMutation for NativeProcMacroBuildSubcommandMutation { + fn mutate_operation(&mut self, class: EventClass, operation: &mut MatrixOperation) { + if self.0 + || class != EventClass::Full + || operation.kind != MatrixOperationKind::CargoBuildNative + { + return; + } + let CommandPayload::Argv { argv, .. } = &mut operation.command.payload else { + panic!("native proc-macro build must use an argv payload"); + }; + argv[2] = "test".to_owned(); + self.0 = true; + } + } + + #[test] + fn native_proc_macro_build_kind_requires_the_build_subcommand() { + let mut mutation = NativeProcMacroBuildSubcommandMutation(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate one native proc-macro build"); + assert!(diagnostic.contains("must invoke Cargo subcommand `build`")); + } + + #[test] + fn release_short_flag_detection_does_not_search_option_values() { + for argument in ["-pcrate", "-Fderive", "-j4", "-Zunstable-options", "-Ccheckout"] { + assert!( + !super::cargo_argument_changes_profile(argument), + "{argument} contains value data, not a release flag" + ); + } + for argument in ["-r", "-rv", "-vr", "-vqr"] { + assert!(super::cargo_argument_changes_profile(argument), "{argument}"); + } + } + struct NonGoldenCommandJobMutation(bool); impl ModelMutation for NonGoldenCommandJobMutation { diff --git a/tools/zc/src/inventory.rs b/tools/zc/src/inventory.rs index 1f9e51b3ca..69d7941694 100644 --- a/tools/zc/src/inventory.rs +++ b/tools/zc/src/inventory.rs @@ -1253,8 +1253,11 @@ fn validate_cargo_source_configuration( // spelling in every repository-owned ancestor so a future root-level or // intermediate configuration cannot add unmodeled sources, environment, // build flags, aliases, or target-directory behavior before metadata. - // Host configuration above `repository_root` is outside this repository - // audit and is constrained separately at the process boundary. + // Host configuration strictly above `repository_root` is outside this + // repository audit. `execution::preflight_ci_cargo_environment` walks + // those remaining ancestors and Cargo home immediately before a selected + // CI cell can run. Keep that split exact so neither side skips the root or + // rejects the reviewed configuration below it. for ancestor in cargo_directory.ancestors().skip(1) { if !ancestor.starts_with(repository_root) { break; @@ -4230,9 +4233,10 @@ mod tests { validate_package_source_symlink_containment, validate_policy_targets, validate_rust_target_support, validate_toolchain_lockfile_compatibility, validate_toolchain_package_floor, validate_workspace_package_classification_from_manifests, - workspace_dependency_closure, CargoLockfileVersion, CargoPackage, CargoTarget, - CollectError, CompilerFloorSource, Dependency, ErrorSink, PackageCompilerFloor, - ResolvedPackage, RustEdition, RustTargetSupport, RustTargetSupportEntry, + workspace_dependency_closure, CargoConfiguration, CargoLockfileVersion, CargoPackage, + CargoTarget, CollectError, CompilerFloorSource, Dependency, ErrorSink, + PackageCompilerFloor, ResolvedPackage, RustEdition, RustTargetSupport, + RustTargetSupportEntry, }; use crate::{ metadata::ToolchainMetadata, @@ -4293,6 +4297,45 @@ mod tests { assert_eq!(closure, expected.iter().map(|name| (*name).to_owned()).collect()); } + #[test] + fn checked_cargo_configuration_rejects_every_dev_and_test_profile_spelling() { + let reviewed = "[env]\n__ZEROCOPY_LOCAL_DEV = \"1\"\n\n\ + [source.crates-io]\nreplace-with = \"vendored\"\n\n\ + [source.vendored]\ndirectory = \"vendor\"\n"; + + // `metadata::ToolchainMetadata` performs the parallel typed check for + // the workspace manifest. This strict configuration schema covers the + // repository-owned Cargo input which is merged with that manifest; + // `execution::preflight_ci_cargo_environment` covers global + // configuration on the runner. Keep all three checks coordinated with + // native `cargo test` consolidation in `execution::build_operations`. + for declaration in [ + "[profile.test]\nopt-level = 1\n", + "[profile.\"test\"]\nopt-level = 1\n", + "[\"profile\".test]\nopt-level = 1\n", + "profile.test.opt-level = 1\n", + "profile = { test = { opt-level = 1 } }\n", + "[profile.test.package.zerocopy]\nopt-level = 1\n", + "[profile.test.build-override]\nopt-level = 1\n", + "[profile.dev]\nopt-level = 1\n", + "[profile.\"dev\"]\nopt-level = 1\n", + "[\"profile\".dev]\nopt-level = 1\n", + "profile.dev.opt-level = 1\n", + "profile = { dev = { opt-level = 1 } }\n", + "[profile.dev.package.zerocopy]\nopt-level = 1\n", + "[profile.dev.build-override]\nopt-level = 1\n", + ] { + let source = format!("{declaration}\n{reviewed}"); + let error = toml::from_str::(&source) + .err() + .expect("Cargo profiles must be outside the reviewed configuration schema"); + assert!( + error.to_string().contains("unknown field `profile`"), + "declaration {declaration:?} produced {error}" + ); + } + } + #[test] fn dep_edge_does_not_enable_an_explicit_same_name_feature() { let mut package = feature_package(&["dep:shared"]); diff --git a/tools/zc/src/metadata.rs b/tools/zc/src/metadata.rs index 00504411e3..d7ee2ae644 100644 --- a/tools/zc/src/metadata.rs +++ b/tools/zc/src/metadata.rs @@ -65,6 +65,31 @@ impl ToolchainMetadata { let manifest: Manifest = toml::from_str(source) .map_err(|source| ReadMetadataError::Parse { path: path.to_path_buf(), source })?; + // `execution::build_operations` deliberately uses one native + // `cargo test` invocation in place of separate dev-profile build and + // test passes for ordinary libraries. A selected test profile can + // change that build, while Cargo always forces tests and their + // dependencies to unwind even if the dev profile requests abort. + // Reject both differences before inventory or execution can rely on + // consolidation. Other dev settings remain supported: a consolidated + // ordinary library's integration tests compile its normal artifact + // with those settings, while the executor retains an explicit build + // for proc macros and ordinary selections without an enabled + // integration target. The executor separately rejects corresponding + // runner-owned inputs in CI; keep both halves coordinated. + if manifest.profile.contains_key("test") { + return Err(ReadMetadataError::TestProfile { path: path.to_path_buf() }); + } + if manifest + .profile + .get("dev") + .and_then(toml::Value::as_table) + .and_then(|dev| dev.get("panic")) + .is_some_and(|panic| panic.as_str() != Some("unwind")) + { + return Err(ReadMetadataError::DevPanicProfile { path: path.to_path_buf() }); + } + Ok(Self { rust_version: manifest.package.rust_version, pinned_stable: manifest.package.metadata.ci.pinned_stable, @@ -105,12 +130,35 @@ pub enum ReadMetadataError { #[source] source: toml::de::Error, }, + /// The workspace root declared a test profile which breaks CI's + /// build/test consolidation assumption. + #[error( + "Cargo test profile in `{path}` is unsupported: consolidated ordinary-library CI requires Cargo's built-in test profile" + )] + TestProfile { + /// The manifest which declared the profile. + path: PathBuf, + }, + /// The workspace root selected a dev panic strategy which Cargo does not + /// preserve while compiling tests and their dependencies. + #[error( + "Cargo dev panic profile in `{path}` is unsupported: consolidated CI requires the unwind panic strategy" + )] + DevPanicProfile { + /// The manifest which declared the profile. + path: PathBuf, + }, } #[derive(Deserialize)] struct Manifest { workspace: Option, package: Package, + // Cargo accepts table headers, dotted keys, quoted keys, and inline tables + // for the same logical profile map. Deserialize the map instead of + // searching source text so all equivalent spellings reach one check. + #[serde(default)] + profile: BTreeMap, } #[derive(Deserialize)] @@ -184,7 +232,11 @@ mod tests { assert_eq!(error_path, path); assert!(source.to_string().contains("pinned-nightly")); } - ReadMetadataError::Read { .. } => panic!("expected a parse error"), + ReadMetadataError::Read { .. } + | ReadMetadataError::TestProfile { .. } + | ReadMetadataError::DevPanicProfile { .. } => { + panic!("expected a parse error") + } } assert!(error.to_string().contains(&path.display().to_string())); } @@ -197,8 +249,74 @@ mod tests { match &error { ReadMetadataError::Read { path: error_path, .. } => assert_eq!(error_path, &path), - ReadMetadataError::Parse { .. } => panic!("expected a read error"), + ReadMetadataError::Parse { .. } + | ReadMetadataError::TestProfile { .. } + | ReadMetadataError::DevPanicProfile { .. } => { + panic!("expected a read error") + } } assert!(error.to_string().contains(&path.display().to_string())); } + + #[test] + fn rejects_every_test_profile_toml_spelling() { + let path = Path::new("some-worktree/zerocopy/Cargo.toml"); + let base = include_str!("../testdata/toolchains.toml"); + + for declaration in [ + "[profile.test]\nopt-level = 1\n", + "[profile.\"test\"]\nopt-level = 1\n", + "[\"profile\".test]\nopt-level = 1\n", + "profile.test.opt-level = 1\n", + "profile = { test = { opt-level = 1 } }\n", + "[profile.test.package.zerocopy]\nopt-level = 1\n", + "[profile.test.build-override]\nopt-level = 1\n", + ] { + let source = format!("{declaration}\n{base}"); + let error = ToolchainMetadata::parse(path, &source).unwrap_err(); + assert!( + matches!(error, ReadMetadataError::TestProfile { path: ref error_path } if error_path == path), + "declaration {declaration:?} produced {error}" + ); + } + + // Profiles which cannot be selected by the consolidated native + // command do not affect its dev/test equivalence. + let source = format!("[profile.release]\nopt-level = 1\n\n{base}"); + ToolchainMetadata::parse(path, &source).unwrap(); + } + + #[test] + fn rejects_every_non_unwind_dev_panic_toml_spelling() { + let path = Path::new("some-worktree/zerocopy/Cargo.toml"); + let base = include_str!("../testdata/toolchains.toml"); + + for declaration in [ + "[profile.dev]\npanic = \"abort\"\n", + "[profile.\"dev\"]\npanic = \"abort\"\n", + "[\"profile\".dev]\npanic = \"abort\"\n", + "profile.dev.panic = \"abort\"\n", + "profile = { dev = { panic = \"abort\" } }\n", + ] { + let source = format!("{declaration}\n{base}"); + let error = ToolchainMetadata::parse(path, &source).unwrap_err(); + assert!( + matches!(error, ReadMetadataError::DevPanicProfile { path: ref error_path } if error_path == path), + "declaration {declaration:?} produced {error}" + ); + } + + // Cargo preserves ordinary dev settings for a library artifact built + // by integration tests, and proc-macro cells retain an explicit build. + // An explicit unwind strategy also matches Cargo's forced test value. + for declaration in [ + "[profile.dev]\nopt-level = 1\n", + "[profile.dev]\npanic = \"unwind\"\n", + "[profile.dev.package.zerocopy]\nopt-level = 1\n", + "[profile.dev.build-override]\nopt-level = 1\n", + ] { + let source = format!("{declaration}\n{base}"); + ToolchainMetadata::parse(path, &source).unwrap(); + } + } } diff --git a/zerocopy/cargo.sh b/zerocopy/cargo.sh index 8ffe0d4f1d..5d721b2f91 100755 --- a/zerocopy/cargo.sh +++ b/zerocopy/cargo.sh @@ -28,4 +28,8 @@ env -u RUSTFLAGS -u CARGO_TARGET_DIR -u RUSTUP_TOOLCHAIN \ # the repository root, so both platform wrappers must invoke cargo-zerocopy # from the Zerocopy crate directory. cd "$ZEROCOPY_DIR" +# Typed CI runs `execution::preflight_ci_cargo_environment` before entering +# this wrapper. Keep this final handoff transparent to its argv and inherited +# environment; `configure_delegated_cargo_command` owns and tests the remaining +# Rust-to-Cargo handoff used by native build/test consolidation. exec "$REPO_DIR/tools/target/debug/cargo-zerocopy" "$@" diff --git a/zerocopy/win-cargo.bat b/zerocopy/win-cargo.bat index fa1ff914ec..a8347e8913 100644 --- a/zerocopy/win-cargo.bat +++ b/zerocopy/win-cargo.bat @@ -124,6 +124,10 @@ @rem as the repository root, so both wrappers must run from this directory. @pushd "%~dp0" @if errorlevel 1 exit /b 1 +@rem Typed CI runs execution::preflight_ci_cargo_environment before entering +@rem this wrapper. Keep this final handoff transparent to argv and the restored +@rem environment; configure_delegated_cargo_command owns and tests the remaining +@rem Rust-to-Cargo handoff used by native build/test consolidation. @..\tools\target\debug\cargo-zerocopy %* @set "CARGO_ZEROCOPY_STATUS=%ERRORLEVEL%" @popd diff --git a/zerocopy/zerocopy-derive/Cargo.toml b/zerocopy/zerocopy-derive/Cargo.toml index 24a28a905a..b08bba0b2f 100644 --- a/zerocopy/zerocopy-derive/Cargo.toml +++ b/zerocopy/zerocopy-derive/Cargo.toml @@ -44,6 +44,10 @@ dissimilar = "1.0.9" prettyplease = "0.2.17" rustversion = "1.0" static_assertions = "1.1" +# CI deliberately checks the production graph separately from the test graph: +# this dev-only feature is unified with the production `syn/full` feature by +# `cargo test`. Keep this coordinated with the proc-macro build rule and the +# production-vs-test `cargo tree` reminder in `tools/zc/src/execution.rs`. syn = { version = "2.0.46", features = ["visit"] } testutil = { path = "../testutil" } # We import as `zerocopy-renamed` so that we can refer to the crate as