From dde27f126eb1f36ea23dba73d5cc4740678aafe3 Mon Sep 17 00:00:00 2001 From: joshlf's Agent Date: Tue, 25 Aug 2026 17:09:52 +0000 Subject: [PATCH] [ci] Define a typed CI coverage policy Describe ordinary build, Miri, and semver coverage in a versioned TOML file which the repository-owned zc tool parses into typed data. Keep workflow permissions, secrets, runners, action references, and publication in the hand-written workflows so generated data cannot grant privileges. Reject unknown fields, unsafe paths, duplicate or dangling identifiers, overlapping work, incomplete target coverage, stale waivers, and matrix limits above GitHub's bounds. Require semver checks to use the semantic stable-aggregate profile and the pinned-stable toolchain source; policy names may change without weakening either constraint. Report independent semantic errors together and preserve deterministic ordering throughout the validated model. Preflight selected Cartesian products with saturating arithmetic before allocating their cells. Count the complete required Miri universe without materializing it. Validated selected cells are a distinct subset of that universe, so equal cardinality proves complete coverage. A malformed policy with a large missing product receives one bounded summary instead of one allocation and diagnostic per missing cell. Bound target and target-set declarations, aggregate resolved memberships, and identifier bytes before cloning target sets. If a plan is too large to materialize, continue scalar, reference, unused-set, and Miri-eligibility validation while skipping only pairwise checks which need those cells. An invalid empty target set also skips cell-dependent package/profile products after its own diagnostic, so multiplying by zero cannot bypass the allocation and diagnostic bound. This keeps derived work within hard limits without discarding independent errors. This commit only establishes and tests the policy boundary. Existing workflows remain authoritative until later commits add inventory, planning, baseline comparison, and shadow-mode integration. Tests: CARGO_NET_OFFLINE=true ./ci/check_tools.sh Tests: ./ci/check_fmt.sh gherrit-pr-id: Gqofuqcjxqt64bw7di2snqyunualoi3ht --- ci/zc.toml | 410 +++ tools/zc/src/lib.rs | 1 + tools/zc/src/policy.rs | 2909 +++++++++++++++++ .../testdata/policy-invalid-multiple.stderr | 34 + .../zc/testdata/policy-invalid-multiple.toml | 91 + tools/zc/testdata/policy-unknown-key.toml | 11 + 6 files changed, 3456 insertions(+) create mode 100644 ci/zc.toml create mode 100644 tools/zc/src/policy.rs create mode 100644 tools/zc/testdata/policy-invalid-multiple.stderr create mode 100644 tools/zc/testdata/policy-invalid-multiple.toml create mode 100644 tools/zc/testdata/policy-unknown-key.toml diff --git a/ci/zc.toml b/ci/zc.toml new file mode 100644 index 0000000000..4e96e746b2 --- /dev/null +++ b/ci/zc.toml @@ -0,0 +1,410 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# This file describes ordinary, unprivileged CI coverage. It deliberately does +# not configure workflow permissions, secrets, runners, action references, or +# publication. Keep those security-sensitive choices visible in the small, +# hand-written GitHub Actions workflows. A generated plan may select work, but +# it must never grant privileges. +# +# `tools/zc/src/policy.rs` parses this file strictly. Keep field names and +# selection spellings coordinated with that module. After this schema has +# consumers, an incompatible change must increment `schema_version` and teach +# the parser how to reject or migrate the old form deliberately. +schema_version = 1 + +[events] +# These names must remain coordinated with `.github/workflows/ci.yml`. The +# planner rejects an event absent from both lists, instead of assuming every +# event other than `pull_request` deserves full or privileged behavior. +reduced = ["pull_request"] +full = ["merge_group", "push", "workflow_dispatch"] + +[features] +# This is the one feature list which remains authoritative in +# `zerocopy/Cargo.toml`. Repository inventory follows its Cargo feature edges +# to find every stable feature, then derives the nightly-only complement from +# the complete Cargo feature graph. Do not duplicate either set here. +stable_feature_root = "__internal_use_only_features_that_work_on_stable" + +[[feature_profiles]] +id = "default" +selection = "default" + +[[feature_profiles]] +id = "stable" +selection = "stable-aggregate" + +[[feature_profiles]] +id = "all" +selection = "all" + +# The manifests are repository-relative so inventory can compare these entries +# with Cargo metadata from any worktree. A package lists only profiles which +# have package-local meaning; `zerocopy-derive` has no Zerocopy feature set. +[[packages]] +id = "zerocopy" +manifest = "zerocopy/Cargo.toml" +profiles = ["default", "stable", "all"] + +[[packages]] +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. +# `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 +# of coverage. +[[targets]] +id = "i686-unknown-linux-gnu" +mode = "native" +pr_eligible = true +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "x86_64-unknown-linux-gnu" +mode = "native" +pr_eligible = true +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "arm-unknown-linux-gnueabi" +mode = "cross" +pr_eligible = false +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "aarch64-unknown-linux-gnu" +mode = "cross" +pr_eligible = false +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "powerpc-unknown-linux-gnu" +mode = "cross" +pr_eligible = false +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "powerpc64-unknown-linux-gnu" +mode = "cross" +pr_eligible = false +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "riscv64gc-unknown-linux-gnu" +mode = "cross" +pr_eligible = false +miri_eligible = false +semver_eligible = true + +[[targets]] +id = "s390x-unknown-linux-gnu" +mode = "cross" +pr_eligible = false +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "x86_64-pc-windows-msvc" +mode = "cross" +pr_eligible = true +miri_eligible = true +semver_eligible = true + +[[targets]] +id = "thumbv6m-none-eabi" +mode = "thumb" +pr_eligible = false +miri_eligible = false +semver_eligible = false + +[[targets]] +id = "wasm32-unknown-unknown" +mode = "cross" +pr_eligible = false +miri_eligible = false +semver_eligible = true + +# A target-set expression starts with all targets, all Miri-eligible targets, +# or no targets (`explicit`), then applies its required `include` and `exclude` +# lists. The Rust validator rejects redundant or ineffective adjustments. This +# keeps deliberate exceptions visible while ensuring that a newly declared +# target flows into broad sets automatically. +[[target_sets]] +id = "all" +selection = "all" +include = [] +exclude = [] + +[[target_sets]] +id = "without-thumb" +selection = "all" +include = [] +exclude = ["thumbv6m-none-eabi"] + +[[target_sets]] +id = "without-wasm" +selection = "all" +include = [] +exclude = ["wasm32-unknown-unknown"] + +[[target_sets]] +id = "without-thumb-or-wasm" +selection = "all" +include = [] +exclude = ["thumbv6m-none-eabi", "wasm32-unknown-unknown"] + +[[target_sets]] +id = "x86-linux" +selection = "explicit" +include = ["i686-unknown-linux-gnu", "x86_64-unknown-linux-gnu"] +exclude = [] + +[[target_sets]] +id = "aarch64" +selection = "explicit" +include = ["aarch64-unknown-linux-gnu"] +exclude = [] + +[[target_sets]] +id = "miri-supported" +selection = "miri-eligible" +include = [] +exclude = [] + +[[target_sets]] +id = "semver" +selection = "all" +include = [] +exclude = ["thumbv6m-none-eabi", "wasm32-unknown-unknown"] + +# `source` coordinates each descriptor with `zerocopy/Cargo.toml`. Standard +# versions come from `rust-version` or `[package.metadata.ci]`; every `build-rs` +# ID must exactly equal one key under `[package.metadata.build-rs]`. Inventory +# validation owns that comparison so version numbers never drift into this file. +[[toolchains]] +id = "msrv" +source = "manifest-rust-version" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "all" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "without-thumb" + +[[toolchains.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default"] +target_set = "all" + +[[toolchains]] +id = "stable" +source = "pinned-stable" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "without-wasm" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "without-thumb-or-wasm" + +[[toolchains.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default"] +target_set = "without-wasm" + +[[toolchains]] +id = "nightly" +source = "pinned-nightly" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "all" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable", "all"] +target_set = "without-thumb" + +[[toolchains.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default"] +target_set = "all" + +[[toolchains]] +id = "no-zerocopy-simd-x86-avx12-1-89-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "all" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "without-thumb" + +[[toolchains]] +id = "no-zerocopy-core-error-1-81-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable"] +target_set = "x86-linux" + +[[toolchains]] +id = "no-zerocopy-diagnostic-on-unimplemented-1-78-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable"] +target_set = "x86-linux" + +[[toolchains]] +id = "no-zerocopy-generic-bounds-in-const-fn-1-61-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable"] +target_set = "x86-linux" + +[[toolchains]] +id = "no-zerocopy-target-has-atomics-1-60-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "all" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "without-thumb" + +[[toolchains]] +id = "no-zerocopy-aarch64-simd-1-59-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable"] +target_set = "aarch64" + +[[toolchains]] +id = "no-zerocopy-aarch64-simd-be-1-87-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable"] +target_set = "aarch64" + +[[toolchains]] +id = "no-zerocopy-panic-in-const-and-vec-try-reserve-1-57-0" +source = "build-rs" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "all" + +[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "without-thumb" + +# Every declared model runs automatically. These are per-model additions to +# the global `ZC_NIGHTLY_MIRIFLAGS` in `.github/workflows/ci.yml`; keep that +# workflow setting and the executor's flag composition coordinated with this +# list. The empty list for stacked borrows is explicit because behavior-bearing +# lists never come from parser defaults. +[[miri_models]] +id = "stacked" +flags = [] + +[[miri_models]] +id = "tree" +flags = ["-Zmiri-tree-borrows"] + +[miri] +toolchain = "nightly" +event_category = "full" + +[[miri.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable", "all"] +target_set = "miri-supported" + +[[miri.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default"] +target_set = "miri-supported" + +[semver] +# Every target marked `semver_eligible` must be selected by this set or have +# exactly one waiver below. Thumb is structurally inapplicable; wasm remains a +# visible desired target whose current tool limitation requires a waiver. +package = "zerocopy" +toolchain = "stable" +profile = "stable" +target_set = "semver" + +# wasm remains a visible desired target even though it cannot yet run. Without +# this waiver, removing wasm from the selected set could look like success. +[[semver.waivers]] +target = "wasm32-unknown-unknown" +issue = "#1565" +reason = "cargo-semver-checks does not yet support this target" + +[baselines] +# These files are an independent observation of the workflow on main, not +# generated truth from this policy. Later shadow planning must match them before +# Actions consumes a plan. Update them only for an intentional coverage change. +manifest = "ci/baselines/manifest.tsv" +build_reduced = "ci/baselines/build-pr.tsv" +build_full = "ci/baselines/build-full.tsv" +miri_reduced = "ci/baselines/miri-pr.tsv" +miri_full = "ci/baselines/miri-full.tsv" +logical_obligations = "ci/baselines/logical-obligations.tsv" +standalone_obligations = "ci/baselines/standalone-obligations.tsv" +command_goldens = "ci/baselines/command-goldens.tsv" + +[limits] +# GitHub expands at most 256 jobs in one matrix. The planner enforces that bound +# after splitting logical work into shards; it must not reject a larger logical +# plan merely because one unsharded matrix could not hold it. `max_plan_cells` +# independently bounds expansion before sharding. Job outputs have a one- +# megabyte limit estimated as UTF-16; 900,000 leaves room for accounting +# differences. Rust validation also caps all three configured limits. +max_matrix_cells = 256 +max_plan_cells = 4096 +max_job_output_utf16_bytes = 900000 diff --git a/tools/zc/src/lib.rs b/tools/zc/src/lib.rs index 75727c0ade..fe02e42880 100644 --- a/tools/zc/src/lib.rs +++ b/tools/zc/src/lib.rs @@ -9,3 +9,4 @@ //! Shared, typed access to repository configuration and CI behavior. pub mod metadata; +pub mod policy; diff --git a/tools/zc/src/policy.rs b/tools/zc/src/policy.rs new file mode 100644 index 0000000000..2894fe0b67 --- /dev/null +++ b/tools/zc/src/policy.rs @@ -0,0 +1,2909 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! The strictly validated, repository-owned CI policy. +//! +//! This module describes ordinary, unprivileged build and test coverage. It +//! intentionally does not describe workflow permissions, secrets, runners, +//! action references, or publication. Those security-sensitive choices remain +//! in the small, hand-written GitHub Actions workflows and must be reviewed as +//! YAML. A future planner may use this policy to choose work, but it must not +//! use policy data to grant privileges. +//! +//! Cargo metadata remains authoritative for package features and Rust version +//! numbers. The policy names the stable aggregate feature and says which Cargo +//! metadata field supplies each toolchain. Repository inventory validation is +//! responsible for checking those cross-file contracts before planning work. + +use std::{ + borrow::Borrow, + collections::{BTreeMap, BTreeSet}, + error::Error, + fmt, fs, io, + path::{Component, Path, PathBuf}, +}; + +use serde::Deserialize; +use thiserror::Error; + +/// The only policy schema understood by this version of `zc`. +pub const POLICY_SCHEMA_VERSION: u32 = 1; + +/// GitHub Actions expands at most 256 jobs from one matrix. +pub const GITHUB_MAX_MATRIX_CELLS: u64 = 256; + +/// A hard bound on logical work expanded before it is split into matrices. +/// +/// This permits up to 256 maximum-sized shards while preventing an accidental +/// Cartesian-product explosion from consuming unbounded memory in the planner. +pub const MAX_PLAN_CELLS: u64 = GITHUB_MAX_MATRIX_CELLS * GITHUB_MAX_MATRIX_CELLS; + +/// A hard bound on targets declared in one policy. +/// +/// Target IDs are copied into resolved target sets. Bounding the declarations +/// as well as the copies prevents malformed policy from turning a derived data +/// structure into unbounded memory use. +const MAX_TARGET_DECLARATIONS: usize = MAX_PLAN_CELLS as usize; + +/// A hard bound on target sets declared in one policy. +const MAX_TARGET_SET_DECLARATIONS: usize = MAX_PLAN_CELLS as usize; + +/// A hard bound on target IDs copied across all resolved target sets. +/// +/// This is checked from the declarative expressions before resolving any set. +/// In particular, many `selection = "all"` sets cannot repeatedly clone every +/// target unless their aggregate upper bound fits the planner's existing hard +/// expansion limit. +const MAX_TARGET_SET_MEMBERSHIPS: u64 = MAX_PLAN_CELLS; + +/// Maximum bytes in one stable policy identifier. +/// +/// Target-set resolution clones identifiers. The aggregate membership bound +/// limits the number of clones, while this limit bounds the bytes in each one. +const MAX_ID_BYTES: usize = 256; + +/// A conservative interpretation of GitHub's one-megabyte job-output limit. +/// +/// GitHub estimates output size using UTF-16. Keeping the configured ceiling at +/// or below this decimal megabyte leaves the planner responsible for any +/// additional safety margin. +pub const GITHUB_MAX_JOB_OUTPUT_UTF16_BYTES: u64 = 1_000_000; + +/// A validated CI policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Policy { + /// The parsed schema version. + schema_version: u32, + /// GitHub event names grouped by the amount of work they receive. + events: Events, + /// Cargo feature selection behavior. + features: Features, + /// Cargo packages, keyed by stable policy identifier. + packages: BTreeMap, + /// Compilation targets, keyed by target triple. + targets: BTreeMap, + /// Named, resolved target selections. + target_sets: BTreeMap>, + /// Rust toolchains and the exact work assigned to each one. + toolchains: BTreeMap, + /// Miri borrow models, keyed by stable policy identifier. + miri_models: BTreeMap, + /// The Miri matrix policy. + miri: Miri, + /// The semver-check matrix policy. + semver: Semver, + /// Independent files which freeze behavior of the old workflow. + baselines: Baselines, + /// Safety limits applied before data reaches GitHub Actions. + limits: Limits, +} + +impl Policy { + /// Reads and validates policy at `path`. + pub fn read(path: impl AsRef) -> Result { + let path = path.as_ref(); + let source = fs::read_to_string(path) + .map_err(|source| ReadPolicyError::Read { path: path.to_path_buf(), source })?; + Self::parse(&source) + .map_err(|source| ReadPolicyError::Policy { path: path.to_path_buf(), source }) + } + + /// Parses and validates policy source. + pub fn parse(source: &str) -> Result { + let raw: RawPolicy = toml::from_str(source).map_err(PolicyError::Toml)?; + raw.validate().map_err(PolicyError::Invalid) + } + + /// Returns the policy schema version. + pub fn schema_version(&self) -> u32 { + self.schema_version + } + + /// Returns the event classification policy. + pub fn events(&self) -> &Events { + &self.events + } + + /// Returns the feature-selection policy. + pub fn features(&self) -> &Features { + &self.features + } + + /// Returns packages keyed by stable policy identifier. + pub fn packages(&self) -> &BTreeMap { + &self.packages + } + + /// Returns compilation targets keyed by target triple. + pub fn targets(&self) -> &BTreeMap { + &self.targets + } + + /// Returns resolved target sets keyed by stable policy identifier. + pub fn target_sets(&self) -> &BTreeMap> { + &self.target_sets + } + + /// Returns toolchains keyed by stable policy identifier. + pub fn toolchains(&self) -> &BTreeMap { + &self.toolchains + } + + /// Returns Miri models keyed by stable policy identifier. + pub fn miri_models(&self) -> &BTreeMap { + &self.miri_models + } + + /// Returns the Miri coverage policy. + pub fn miri(&self) -> &Miri { + &self.miri + } + + /// Returns the semver-check coverage policy. + pub fn semver(&self) -> &Semver { + &self.semver + } + + /// Returns paths to independently captured legacy behavior. + pub fn baselines(&self) -> &Baselines { + &self.baselines + } + + /// Returns planning safety limits. + pub fn limits(&self) -> &Limits { + &self.limits + } +} + +/// An error reading a policy file. +#[derive(Debug, Error)] +pub enum ReadPolicyError { + /// The file could not be read. + #[error("failed to read CI policy `{path}`: {source}")] + Read { + /// The path passed to [`Policy::read`]. + path: PathBuf, + /// The underlying file-system error. + #[source] + source: io::Error, + }, + /// The file did not contain a valid policy. + #[error("failed to load CI policy `{path}`: {source}")] + Policy { + /// The path passed to [`Policy::read`]. + path: PathBuf, + /// The parse or validation error. + #[source] + source: PolicyError, + }, +} + +/// An error parsing or semantically validating policy source. +#[derive(Debug, Error)] +pub enum PolicyError { + /// TOML syntax, types, or table keys were invalid. + #[error("failed to parse policy TOML: {0}")] + Toml(#[source] toml::de::Error), + /// The TOML was well-typed but violated one or more policy invariants. + #[error("{0}")] + Invalid(ValidationErrors), +} + +/// All semantic errors found in one validation pass. +#[derive(Debug)] +pub struct ValidationErrors(Vec); + +impl ValidationErrors { + /// Returns the individual errors in deterministic reporting order. + pub fn errors(&self) -> &[ValidationError] { + &self.0 + } +} + +impl fmt::Display for ValidationErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "CI policy has {} validation error(s):", self.0.len())?; + for error in &self.0 { + writeln!(f, "- {}: {}", error.location, error.message)?; + } + Ok(()) + } +} + +impl Error for ValidationErrors {} + +/// One actionable policy validation error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidationError { + location: String, + message: String, +} + +impl ValidationError { + /// The dotted field or indexed selection which caused the error. + pub fn location(&self) -> &str { + &self.location + } + + /// A plain-language description of how to repair the error. + pub fn message(&self) -> &str { + &self.message + } +} + +/// A stable identifier used by references in the policy. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Id(String); + +impl Id { + /// Returns the identifier as policy text. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Borrow for Id { + fn borrow(&self) -> &str { + self.as_str() + } +} + +/// A normalized path relative to the repository root. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct RepoPath(PathBuf); + +impl RepoPath { + /// Returns the path without joining it to a checkout. + pub fn as_path(&self) -> &Path { + &self.0 + } +} + +/// Event categories understood by the planner. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Events { + /// Events which run the latency-optimized selection. + reduced: BTreeSet, + /// Events which run all required work. + full: BTreeSet, +} + +impl Events { + /// Returns events which run the latency-optimized selection. + pub fn reduced(&self) -> &BTreeSet { + &self.reduced + } + + /// Returns events which run all required work. + pub fn full(&self) -> &BTreeSet { + &self.full + } + + /// Classifies an exact GitHub event name. + /// + /// `None` is intentional: callers must reject unknown events instead of + /// treating every non-pull-request event as full coverage. + pub fn category(&self, event_name: &str) -> Option { + if self.reduced.iter().any(|event| event.as_str() == event_name) { + Some(EventCategory::Reduced) + } else if self.full.iter().any(|event| event.as_str() == event_name) { + Some(EventCategory::Full) + } else { + None + } + } +} + +/// Cargo feature behavior and its named profiles. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Features { + /// The Cargo feature which reaches every stable feature. + /// + /// Inventory code derives the nightly-only complement from Cargo's feature + /// graph. Do not add a second, hand-maintained nightly-feature list here. + stable_feature_root: Id, + /// Semantic feature profiles keyed by stable policy identifier. + profiles: BTreeMap, +} + +impl Features { + /// Returns the Cargo feature which reaches every stable feature. + pub fn stable_feature_root(&self) -> &Id { + &self.stable_feature_root + } + + /// Returns semantic feature profiles keyed by stable identifier. + pub fn profiles(&self) -> &BTreeMap { + &self.profiles + } +} + +/// One named Cargo feature selection. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum FeatureProfile { + /// Use Cargo's default feature selection. + Default, + /// Pass `--no-default-features` without selecting a feature. + /// + /// The current manifest has no `default` feature, so current policy does + /// not need this profile. The schema supports it so inventory validation + /// can require it if a future manifest adds a Cargo `default` feature. + NoDefault, + /// Select only the stable aggregate feature without default features. + StableAggregate, + /// Pass `--all-features`. + All, +} + +/// A Cargo package covered by the ordinary build and Miri matrices. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Package { + /// The package manifest relative to the repository root. + manifest: RepoPath, + /// Feature profiles which are meaningful for this package. + profiles: BTreeSet, +} + +impl Package { + /// Returns the package manifest relative to the repository root. + pub fn manifest(&self) -> &RepoPath { + &self.manifest + } + + /// Returns feature profiles which are meaningful for this package. + pub fn profiles(&self) -> &BTreeSet { + &self.profiles + } +} + +/// How the executor exercises a compilation target. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetMode { + /// Build and execute tests on the runner. + Native, + /// Compile test code and build library code without executing tests. + Cross, + /// Check only library code because test dependencies cannot be compiled. + /// + /// This name records the current thumb-specific workflow contract. If a + /// second target needs the same treatment, changing this schema to a + /// behavior-based name should be a deliberate, reviewed migration. + Thumb, +} + +/// One Rust compilation target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Target { + /// How ordinary Cargo work runs for this target. + mode: TargetMode, + /// Whether reduced pull-request CI includes this target. + pr_eligible: bool, + /// Whether the current Miri setup supports this target. + miri_eligible: bool, + /// Whether semver coverage must select or explicitly waive this target. + semver_eligible: bool, +} + +impl Target { + /// Returns how ordinary Cargo work runs for this target. + pub fn mode(&self) -> TargetMode { + self.mode + } + + /// Returns whether reduced pull-request CI includes this target. + pub fn pr_eligible(&self) -> bool { + self.pr_eligible + } + + /// Returns whether the current Miri setup supports this target. + pub fn miri_eligible(&self) -> bool { + self.miri_eligible + } + + /// Returns whether semver must select or explicitly waive this target. + pub fn semver_eligible(&self) -> bool { + self.semver_eligible + } +} + +/// The Cargo metadata field which supplies a toolchain's exact version. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ToolchainSource { + /// `package.rust-version` in `zerocopy/Cargo.toml`. + ManifestRustVersion, + /// `package.metadata.ci.pinned-stable` in `zerocopy/Cargo.toml`. + PinnedStable, + /// `package.metadata.ci.pinned-nightly` in `zerocopy/Cargo.toml`. + PinnedNightly, + /// An entry in `package.metadata.build-rs`. + /// + /// For this source, the toolchain identifier is also the metadata key. + BuildRs, +} + +/// Exact package, feature-profile, and target selections. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Scope { + /// Selected package identifiers. + packages: BTreeSet, + /// Selected feature-profile identifiers. + profiles: BTreeSet, + /// The named target set to expand. + target_set: Id, +} + +impl Scope { + /// Returns selected package identifiers. + pub fn packages(&self) -> &BTreeSet { + &self.packages + } + + /// Returns selected feature-profile identifiers. + pub fn profiles(&self) -> &BTreeSet { + &self.profiles + } + + /// Returns the named target set to expand. + pub fn target_set(&self) -> &Id { + &self.target_set + } +} + +/// One toolchain and all ordinary build-matrix work assigned to it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Toolchain { + /// Where repository inventory finds the exact version. + source: ToolchainSource, + /// Non-overlapping scopes run on this toolchain. + scopes: Vec, +} + +impl Toolchain { + /// Returns where repository inventory finds the exact version. + pub fn source(&self) -> ToolchainSource { + self.source + } + + /// Returns non-overlapping scopes run on this toolchain. + pub fn scopes(&self) -> &[Scope] { + &self.scopes + } +} + +/// One Miri borrow model. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MiriModel { + /// Additional `MIRIFLAGS` arguments for the model, in argument order. + flags: Vec, +} + +impl MiriModel { + /// Returns additional `MIRIFLAGS` arguments in argument order. + pub fn flags(&self) -> &[String] { + &self.flags + } +} + +/// The Miri matrix configuration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Miri { + /// The toolchain used by Miri. + toolchain: Id, + /// The event category on which Miri runs. + event_category: EventCategory, + /// Non-overlapping package, profile, and target selections. + scopes: Vec, +} + +impl Miri { + /// Returns the toolchain used by Miri. + pub fn toolchain(&self) -> &Id { + &self.toolchain + } + + /// Returns the event category on which Miri runs. + pub fn event_category(&self) -> EventCategory { + self.event_category + } + + /// Returns non-overlapping package, profile, and target selections. + pub fn scopes(&self) -> &[Scope] { + &self.scopes + } +} + +/// Which event category receives a kind of work. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventCategory { + /// Only latency-optimized events. + Reduced, + /// Full-coverage events. + Full, +} + +/// Semver checking and its explicit coverage waivers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Semver { + /// The Cargo package to check. + package: Id, + /// The toolchain used by cargo-semver-checks. + toolchain: Id, + /// The feature profile used by cargo-semver-checks. + profile: Id, + /// The exact selected target set. + target_set: Id, + /// Targets deliberately omitted from semver checking. + waivers: BTreeMap, +} + +impl Semver { + /// Returns the Cargo package to check. + pub fn package(&self) -> &Id { + &self.package + } + + /// Returns the toolchain used by cargo-semver-checks. + pub fn toolchain(&self) -> &Id { + &self.toolchain + } + + /// Returns the feature profile used by cargo-semver-checks. + pub fn profile(&self) -> &Id { + &self.profile + } + + /// Returns the exact selected target set. + pub fn target_set(&self) -> &Id { + &self.target_set + } + + /// Returns targets deliberately omitted from semver checking. + pub fn waivers(&self) -> &BTreeMap { + &self.waivers + } +} + +/// A deliberate target-specific exception to semver coverage. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SemverWaiver { + /// The tracking issue, written as `#` followed by decimal digits. + issue: String, + /// Why the target cannot currently be checked. + reason: String, +} + +impl SemverWaiver { + /// Returns the tracking issue. + pub fn issue(&self) -> &str { + &self.issue + } + + /// Returns why the target cannot currently be checked. + pub fn reason(&self) -> &str { + &self.reason + } +} + +/// Paths to independently captured legacy behavior. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Baselines { + /// Source hashes and independently checked counts. + manifest: RepoPath, + /// Reduced ordinary build-matrix cells. + build_reduced: RepoPath, + /// Full ordinary build-matrix cells. + build_full: RepoPath, + /// Reduced Miri cells. + miri_reduced: RepoPath, + /// Full Miri cells. + miri_full: RepoPath, + /// Normalized commands implied by matrix cells. + logical_obligations: RepoPath, + /// Operations outside the two Cargo matrices. + standalone_obligations: RepoPath, + /// Exact argument vectors for behavior-sensitive commands. + command_goldens: RepoPath, +} + +impl Baselines { + /// Returns the source-hash and count manifest. + pub fn manifest(&self) -> &RepoPath { + &self.manifest + } + + /// Returns the reduced ordinary build-cell baseline. + pub fn build_reduced(&self) -> &RepoPath { + &self.build_reduced + } + + /// Returns the full ordinary build-cell baseline. + pub fn build_full(&self) -> &RepoPath { + &self.build_full + } + + /// Returns the reduced Miri-cell baseline. + pub fn miri_reduced(&self) -> &RepoPath { + &self.miri_reduced + } + + /// Returns the full Miri-cell baseline. + pub fn miri_full(&self) -> &RepoPath { + &self.miri_full + } + + /// Returns the normalized logical-obligation baseline. + pub fn logical_obligations(&self) -> &RepoPath { + &self.logical_obligations + } + + /// Returns the standalone-obligation baseline. + pub fn standalone_obligations(&self) -> &RepoPath { + &self.standalone_obligations + } + + /// Returns exact argument-vector goldens. + pub fn command_goldens(&self) -> &RepoPath { + &self.command_goldens + } +} + +/// Limits which keep generated Actions data within documented safe bounds. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Limits { + /// Maximum cells allowed in one generated matrix. + max_matrix_cells: u64, + /// Maximum logical cells expanded before matrix sharding. + max_plan_cells: u64, + /// Maximum UTF-16 byte estimate allowed in one job's outputs. + max_job_output_utf16_bytes: u64, +} + +impl Limits { + /// Returns the maximum cells allowed in one generated matrix. + pub fn max_matrix_cells(&self) -> u64 { + self.max_matrix_cells + } + + /// Returns the maximum logical cells expanded before matrix sharding. + pub fn max_plan_cells(&self) -> u64 { + self.max_plan_cells + } + + /// Returns the maximum UTF-16 byte estimate allowed in job outputs. + pub fn max_job_output_utf16_bytes(&self) -> u64 { + self.max_job_output_utf16_bytes + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPolicy { + schema_version: u32, + events: RawEvents, + features: RawFeatures, + feature_profiles: Vec, + packages: Vec, + targets: Vec, + target_sets: Vec, + toolchains: Vec, + miri_models: Vec, + miri: RawMiri, + semver: RawSemver, + baselines: RawBaselines, + limits: RawLimits, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEvents { + reduced: Vec, + full: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawFeatures { + stable_feature_root: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawFeatureProfile { + id: String, + selection: RawFeatureSelection, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum RawFeatureSelection { + Default, + NoDefault, + StableAggregate, + All, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPackage { + id: String, + manifest: String, + profiles: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTarget { + id: String, + mode: RawTargetMode, + pr_eligible: bool, + miri_eligible: bool, + semver_eligible: bool, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum RawTargetMode { + Native, + Cross, + Thumb, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTargetSet { + id: String, + selection: RawTargetSetSelection, + include: Vec, + exclude: Vec, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum RawTargetSetSelection { + All, + MiriEligible, + Explicit, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawToolchain { + id: String, + source: RawToolchainSource, + scopes: Vec, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum RawToolchainSource { + ManifestRustVersion, + PinnedStable, + PinnedNightly, + BuildRs, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawScope { + packages: Vec, + profiles: Vec, + target_set: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawMiriModel { + id: String, + flags: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawMiri { + toolchain: String, + event_category: RawEventCategory, + scopes: Vec, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum RawEventCategory { + Reduced, + Full, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSemver { + package: String, + toolchain: String, + profile: String, + target_set: String, + waivers: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSemverWaiver { + target: String, + issue: String, + reason: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawBaselines { + manifest: String, + build_reduced: String, + build_full: String, + miri_reduced: String, + miri_full: String, + logical_obligations: String, + standalone_obligations: String, + command_goldens: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawLimits { + max_matrix_cells: u64, + max_plan_cells: u64, + max_job_output_utf16_bytes: u64, +} + +impl RawPolicy { + fn validate(self) -> Result { + let mut validator = Validator::default(); + + if self.schema_version != POLICY_SCHEMA_VERSION { + validator.error( + "schema_version", + format!( + "unsupported schema version {}; this zc supports exactly {}", + self.schema_version, POLICY_SCHEMA_VERSION + ), + ); + } + + let events = Events { + reduced: validator.id_set("events.reduced", &self.events.reduced), + full: validator.id_set("events.full", &self.events.full), + }; + for event in events.reduced.intersection(&events.full) { + validator.error( + "events", + format!( + "event `{event}` appears in both reduced and full; every event must select exactly one category" + ), + ); + } + + let stable_feature_root = + validator.id("features.stable_feature_root", &self.features.stable_feature_root); + let profiles = validator.feature_profiles(self.feature_profiles); + let packages = validator.packages(self.packages); + let targets = validator.targets(self.targets); + let target_sets = validator.target_sets(self.target_sets, &targets); + let toolchains = validator.toolchains(self.toolchains); + let miri_models = validator.miri_models(self.miri_models); + let miri = validator.miri(self.miri); + let semver = validator.semver(self.semver); + let baselines = validator.baselines(self.baselines); + let limits = Limits { + max_matrix_cells: self.limits.max_matrix_cells, + max_plan_cells: self.limits.max_plan_cells, + max_job_output_utf16_bytes: self.limits.max_job_output_utf16_bytes, + }; + + validator.validate_limits(&limits); + validator.validate_references( + &profiles, + &packages, + &targets, + &target_sets, + &toolchains, + &miri_models, + &miri, + &semver, + &limits, + ); + + if !validator.errors.is_empty() { + return Err(ValidationErrors(validator.errors)); + } + + Ok(Policy { + schema_version: self.schema_version, + events, + features: Features { + stable_feature_root: stable_feature_root + .expect("a missing feature root must produce a validation error"), + profiles, + }, + packages, + targets, + target_sets, + toolchains, + miri_models, + miri, + semver, + baselines: baselines.expect("an invalid path must produce a validation error"), + limits, + }) + } +} + +#[derive(Default)] +struct Validator { + errors: Vec, +} + +impl Validator { + fn error(&mut self, location: impl Into, message: impl Into) { + self.errors.push(ValidationError { + location: escape_control_characters(location.into()), + message: escape_control_characters(message.into()), + }); + } + + fn id(&mut self, location: &str, value: &str) -> Option { + if value.len() > MAX_ID_BYTES { + self.error( + location, + format!( + "identifier is {} bytes, above the hard safety bound of {MAX_ID_BYTES}", + value.len() + ), + ); + return None; + } + let valid = !value.is_empty() + && value.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'0'..=b'9' | b'_' => true, + b'-' | b'.' => index != 0, + _ => false, + }) + && !value.ends_with('-') + && !value.ends_with('.') + && value != "." + && value != ".."; + if !valid { + self.error( + location, + format!( + "`{value}` is not a stable identifier; use lowercase ASCII letters, digits, `_`, `-`, or `.`, and do not start or end with `-` or `.`" + ), + ); + return None; + } + Some(Id(value.to_owned())) + } + + fn id_set(&mut self, location: &str, values: &[String]) -> BTreeSet { + self.id_set_inner(location, values, false) + } + + fn id_set_allow_empty(&mut self, location: &str, values: &[String]) -> BTreeSet { + self.id_set_inner(location, values, true) + } + + fn id_set_inner( + &mut self, + location: &str, + values: &[String], + allow_empty: bool, + ) -> BTreeSet { + if values.is_empty() && !allow_empty { + self.error(location, "selection cannot be empty"); + } + + let mut ids = BTreeSet::new(); + for (index, value) in values.iter().enumerate() { + if let Some(id) = self.id(&format!("{location}[{index}]"), value) { + if !ids.insert(id.clone()) { + self.error( + format!("{location}[{index}]"), + format!("duplicate identifier `{id}` in the same selection"), + ); + } + } + } + ids + } + + fn repo_path(&mut self, location: &str, value: &str) -> Option { + let path = Path::new(value); + let has_empty_component = value.split('/').any(str::is_empty); + let has_dot_component = value.split('/').any(|component| matches!(component, "." | "..")); + let has_windows_separator = value.contains('\\'); + let has_windows_prefix = value.contains(':'); + let has_control_character = value.chars().any(char::is_control); + let has_unsafe_component = + path.components().any(|component| !matches!(component, Component::Normal(_))); + if value.is_empty() + || has_empty_component + || has_dot_component + || has_windows_separator + || has_windows_prefix + || has_control_character + || has_unsafe_component + { + self.error( + location, + format!( + "{value:?} is not a safe repository-relative path; use non-empty `/`-separated components without `.`, `..`, a root, or a platform prefix" + ), + ); + return None; + } + Some(RepoPath(path.to_path_buf())) + } + + fn feature_profiles( + &mut self, + raw_profiles: Vec, + ) -> BTreeMap { + if raw_profiles.is_empty() { + self.error("feature_profiles", "category cannot be empty"); + } + + let mut profiles = BTreeMap::new(); + let mut selections = BTreeMap::new(); + for (index, raw) in raw_profiles.into_iter().enumerate() { + let location = format!("feature_profiles[{index}]"); + let Some(id) = self.id(&format!("{location}.id"), &raw.id) else { + continue; + }; + let selection = match raw.selection { + RawFeatureSelection::Default => FeatureProfile::Default, + RawFeatureSelection::NoDefault => FeatureProfile::NoDefault, + RawFeatureSelection::StableAggregate => FeatureProfile::StableAggregate, + RawFeatureSelection::All => FeatureProfile::All, + }; + if profiles.contains_key(&id) { + self.error(format!("{location}.id"), format!("duplicate profile ID `{id}`")); + continue; + } + if let Some(previous) = selections.insert(selection, id.clone()) { + self.error( + format!("{location}.selection"), + format!( + "selection duplicates profile `{previous}`; equivalent profiles would silently repeat work" + ), + ); + } + profiles.insert(id, selection); + } + profiles + } + + fn packages(&mut self, raw_packages: Vec) -> BTreeMap { + if raw_packages.is_empty() { + self.error("packages", "category cannot be empty"); + } + + let mut packages = BTreeMap::new(); + let mut manifests = BTreeMap::new(); + for (index, raw) in raw_packages.into_iter().enumerate() { + let location = format!("packages[{index}]"); + let id = self.id(&format!("{location}.id"), &raw.id); + let manifest = self.repo_path(&format!("{location}.manifest"), &raw.manifest); + let profiles = self.id_set(&format!("{location}.profiles"), &raw.profiles); + let (Some(id), Some(manifest)) = (id, manifest) else { + continue; + }; + if packages.contains_key(&id) { + self.error(format!("{location}.id"), format!("duplicate package ID `{id}`")); + continue; + } + if let Some(previous) = manifests.insert(manifest.clone(), id.clone()) { + self.error( + format!("{location}.manifest"), + format!("manifest is already assigned to package `{previous}`"), + ); + } + packages.insert(id, Package { manifest, profiles }); + } + packages + } + + fn targets(&mut self, raw_targets: Vec) -> BTreeMap { + if raw_targets.is_empty() { + self.error("targets", "category cannot be empty"); + } + + let declaration_count = raw_targets.len(); + if declaration_count > MAX_TARGET_DECLARATIONS { + self.error( + "targets", + format!( + "category has {declaration_count} declarations, above the hard safety bound of {MAX_TARGET_DECLARATIONS}" + ), + ); + } + + let mut targets = BTreeMap::new(); + // The policy is already invalid above the bound. Do not construct an + // equally unbounded intermediate map merely to collect more errors. + for (index, raw) in raw_targets.into_iter().take(MAX_TARGET_DECLARATIONS).enumerate() { + let location = format!("targets[{index}]"); + let Some(id) = self.id(&format!("{location}.id"), &raw.id) else { + continue; + }; + if targets.contains_key(&id) { + self.error(format!("{location}.id"), format!("duplicate target ID `{id}`")); + continue; + } + let mode = match raw.mode { + RawTargetMode::Native => TargetMode::Native, + RawTargetMode::Cross => TargetMode::Cross, + RawTargetMode::Thumb => TargetMode::Thumb, + }; + targets.insert( + id, + Target { + mode, + pr_eligible: raw.pr_eligible, + miri_eligible: raw.miri_eligible, + semver_eligible: raw.semver_eligible, + }, + ); + } + + if !targets.values().any(|target| target.pr_eligible) { + self.error("targets", "at least one target must be eligible for pull-request CI"); + } + if !targets.values().any(|target| target.miri_eligible) { + self.error("targets", "at least one target must be eligible for Miri"); + } + if !targets.values().any(|target| target.semver_eligible) { + self.error("targets", "at least one target must be eligible for semver checking"); + } + targets + } + + fn target_sets( + &mut self, + raw_sets: Vec, + targets: &BTreeMap, + ) -> BTreeMap> { + if raw_sets.is_empty() { + self.error("target_sets", "category cannot be empty"); + } + + let declaration_count = raw_sets.len(); + if declaration_count > MAX_TARGET_SET_DECLARATIONS { + self.error( + "target_sets", + format!( + "category has {declaration_count} declarations, above the hard safety bound of {MAX_TARGET_SET_DECLARATIONS}" + ), + ); + return BTreeMap::new(); + } + + // Compute a conservative upper bound before cloning target IDs. An + // include can add at most one membership for each declaration, while + // excludes never grow a set. Redundant or unknown includes may make + // this larger than the eventual set, which is safe: a policy near this + // hard bound should use explicit smaller sets rather than depend on an + // enormous intermediate expansion which is later subtracted away. + let all_target_count = u64::try_from(targets.len()).unwrap_or(u64::MAX); + let miri_target_count = + u64::try_from(targets.values().filter(|target| target.miri_eligible).count()) + .unwrap_or(u64::MAX); + let aggregate_membership_bound = raw_sets.iter().fold(0u64, |count, raw| { + let base = match raw.selection { + RawTargetSetSelection::All => all_target_count, + RawTargetSetSelection::MiriEligible => miri_target_count, + RawTargetSetSelection::Explicit => 0, + }; + count.saturating_add( + base.saturating_add(u64::try_from(raw.include.len()).unwrap_or(u64::MAX)), + ) + }); + if aggregate_membership_bound > MAX_TARGET_SET_MEMBERSHIPS { + self.error( + "target_sets", + format!( + "target-set expressions can expand to {aggregate_membership_bound} aggregate memberships, above the hard safety bound of {MAX_TARGET_SET_MEMBERSHIPS}" + ), + ); + return BTreeMap::new(); + } + + let mut sets = BTreeMap::new(); + for (index, raw) in raw_sets.into_iter().enumerate() { + let location = format!("target_sets[{index}]"); + let id = self.id(&format!("{location}.id"), &raw.id); + let include = self.id_set_allow_empty(&format!("{location}.include"), &raw.include); + let exclude = self.id_set_allow_empty(&format!("{location}.exclude"), &raw.exclude); + let mut members = match raw.selection { + RawTargetSetSelection::All => targets.keys().cloned().collect(), + RawTargetSetSelection::MiriEligible => targets + .iter() + .filter(|(_, target)| target.miri_eligible) + .map(|(id, _)| id.clone()) + .collect(), + RawTargetSetSelection::Explicit => BTreeSet::new(), + }; + + for target in &include { + if !targets.contains_key(target) { + self.error(format!("{location}.include"), format!("unknown target `{target}`")); + } else if !members.insert(target.clone()) { + self.error( + format!("{location}.include"), + format!( + "target `{target}` is already selected by the base expression; remove the redundant include" + ), + ); + } + } + for target in &exclude { + if include.contains(target) { + self.error( + &location, + format!("target `{target}` cannot appear in both include and exclude"), + ); + continue; + } + if !targets.contains_key(target) { + self.error(format!("{location}.exclude"), format!("unknown target `{target}`")); + } else if !members.remove(target) { + self.error( + format!("{location}.exclude"), + format!( + "target `{target}` is not selected by the base expression; remove the ineffective exclude" + ), + ); + } + } + if members.is_empty() { + self.error(&location, "target-set expression cannot select no targets"); + } + let Some(id) = id else { + continue; + }; + if sets.contains_key(&id) { + self.error(format!("{location}.id"), format!("duplicate target-set ID `{id}`")); + continue; + } + sets.insert(id, members); + } + sets + } + + fn scope(&mut self, location: &str, raw: RawScope) -> Option { + let packages = self.id_set(&format!("{location}.packages"), &raw.packages); + let profiles = self.id_set(&format!("{location}.profiles"), &raw.profiles); + let target_set = self.id(&format!("{location}.target_set"), &raw.target_set); + target_set.map(|target_set| Scope { packages, profiles, target_set }) + } + + fn scopes(&mut self, location: &str, raw_scopes: Vec) -> Vec { + if raw_scopes.is_empty() { + self.error(location, "selection cannot be empty"); + } + raw_scopes + .into_iter() + .enumerate() + .filter_map(|(index, raw)| self.scope(&format!("{location}[{index}]"), raw)) + .collect() + } + + fn toolchains(&mut self, raw_toolchains: Vec) -> BTreeMap { + if raw_toolchains.is_empty() { + self.error("toolchains", "category cannot be empty"); + } + + let mut toolchains = BTreeMap::new(); + let mut standard_sources = BTreeMap::new(); + for (index, raw) in raw_toolchains.into_iter().enumerate() { + let location = format!("toolchains[{index}]"); + let id = self.id(&format!("{location}.id"), &raw.id); + let source = match raw.source { + RawToolchainSource::ManifestRustVersion => ToolchainSource::ManifestRustVersion, + RawToolchainSource::PinnedStable => ToolchainSource::PinnedStable, + RawToolchainSource::PinnedNightly => ToolchainSource::PinnedNightly, + RawToolchainSource::BuildRs => ToolchainSource::BuildRs, + }; + let scopes = self.scopes(&format!("{location}.scopes"), raw.scopes); + let Some(id) = id else { + continue; + }; + if toolchains.contains_key(&id) { + self.error(format!("{location}.id"), format!("duplicate toolchain ID `{id}`")); + continue; + } + if source != ToolchainSource::BuildRs { + if let Some(previous) = standard_sources.insert(source, id.clone()) { + self.error( + format!("{location}.source"), + format!("source is already used by standard toolchain `{previous}`"), + ); + } + } + toolchains.insert(id, Toolchain { source, scopes }); + } + toolchains + } + + fn miri_models(&mut self, raw_models: Vec) -> BTreeMap { + if raw_models.is_empty() { + self.error("miri_models", "category cannot be empty"); + } + + let mut models = BTreeMap::new(); + let mut flag_sets = BTreeMap::new(); + for (index, raw) in raw_models.into_iter().enumerate() { + let location = format!("miri_models[{index}]"); + let Some(id) = self.id(&format!("{location}.id"), &raw.id) else { + continue; + }; + for (flag_index, flag) in raw.flags.iter().enumerate() { + if flag.is_empty() + || !flag.starts_with('-') + || flag + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + self.error( + format!("{location}.flags[{flag_index}]"), + "each Miri flag must be one non-empty argument beginning with `-` and containing no whitespace or control characters", + ); + } + } + if models.contains_key(&id) { + self.error(format!("{location}.id"), format!("duplicate Miri-model ID `{id}`")); + continue; + } + if let Some(previous) = flag_sets.insert(raw.flags.clone(), id.clone()) { + self.error( + format!("{location}.flags"), + format!("flags duplicate Miri model `{previous}` and would repeat work"), + ); + } + models.insert(id, MiriModel { flags: raw.flags }); + } + models + } + + fn miri(&mut self, raw: RawMiri) -> Miri { + let toolchain = self + .id("miri.toolchain", &raw.toolchain) + .unwrap_or_else(|| Id("invalid-miri-toolchain".to_owned())); + let event_category = match raw.event_category { + RawEventCategory::Reduced => EventCategory::Reduced, + RawEventCategory::Full => EventCategory::Full, + }; + let scopes = self.scopes("miri.scopes", raw.scopes); + Miri { toolchain, event_category, scopes } + } + + fn semver(&mut self, raw: RawSemver) -> Semver { + let package = self + .id("semver.package", &raw.package) + .unwrap_or_else(|| Id("invalid-semver-package".to_owned())); + let toolchain = self + .id("semver.toolchain", &raw.toolchain) + .unwrap_or_else(|| Id("invalid-semver-toolchain".to_owned())); + let profile = self + .id("semver.profile", &raw.profile) + .unwrap_or_else(|| Id("invalid-semver-profile".to_owned())); + let target_set = self + .id("semver.target_set", &raw.target_set) + .unwrap_or_else(|| Id("invalid-semver-target-set".to_owned())); + let mut waivers = BTreeMap::new(); + for (index, raw) in raw.waivers.into_iter().enumerate() { + let location = format!("semver.waivers[{index}]"); + let Some(target) = self.id(&format!("{location}.target"), &raw.target) else { + continue; + }; + if !is_issue_reference(&raw.issue) { + self.error( + format!("{location}.issue"), + format!("`{}` is not an issue reference such as `#1565`", raw.issue), + ); + } + if raw.reason.trim().is_empty() { + self.error(format!("{location}.reason"), "waiver reason cannot be empty"); + } + if waivers.contains_key(&target) { + self.error( + format!("{location}.target"), + format!("duplicate semver waiver for target `{target}`"), + ); + continue; + } + waivers.insert(target, SemverWaiver { issue: raw.issue, reason: raw.reason }); + } + Semver { package, toolchain, profile, target_set, waivers } + } + + fn baselines(&mut self, raw: RawBaselines) -> Option { + let manifest = self.repo_path("baselines.manifest", &raw.manifest); + let build_reduced = self.repo_path("baselines.build_reduced", &raw.build_reduced); + let build_full = self.repo_path("baselines.build_full", &raw.build_full); + let miri_reduced = self.repo_path("baselines.miri_reduced", &raw.miri_reduced); + let miri_full = self.repo_path("baselines.miri_full", &raw.miri_full); + let logical_obligations = + self.repo_path("baselines.logical_obligations", &raw.logical_obligations); + let standalone_obligations = + self.repo_path("baselines.standalone_obligations", &raw.standalone_obligations); + let command_goldens = self.repo_path("baselines.command_goldens", &raw.command_goldens); + + let paths = [ + manifest.as_ref(), + build_reduced.as_ref(), + build_full.as_ref(), + miri_reduced.as_ref(), + miri_full.as_ref(), + logical_obligations.as_ref(), + standalone_obligations.as_ref(), + command_goldens.as_ref(), + ]; + let mut distinct = BTreeSet::new(); + for path in paths.into_iter().flatten() { + if !distinct.insert(path.clone()) { + self.error( + "baselines", + format!( + "baseline path `{}` is assigned more than once", + path.as_path().display() + ), + ); + } + } + + Some(Baselines { + manifest: manifest?, + build_reduced: build_reduced?, + build_full: build_full?, + miri_reduced: miri_reduced?, + miri_full: miri_full?, + logical_obligations: logical_obligations?, + standalone_obligations: standalone_obligations?, + command_goldens: command_goldens?, + }) + } + + fn validate_limits(&mut self, limits: &Limits) { + if !(1..=GITHUB_MAX_MATRIX_CELLS).contains(&limits.max_matrix_cells) { + self.error( + "limits.max_matrix_cells", + format!("must be between 1 and GitHub's hard limit of {GITHUB_MAX_MATRIX_CELLS}"), + ); + } + if !(1..=MAX_PLAN_CELLS).contains(&limits.max_plan_cells) { + self.error( + "limits.max_plan_cells", + format!("must be between 1 and the hard safety bound of {MAX_PLAN_CELLS}"), + ); + } + if !(1..=GITHUB_MAX_JOB_OUTPUT_UTF16_BYTES).contains(&limits.max_job_output_utf16_bytes) { + self.error( + "limits.max_job_output_utf16_bytes", + format!( + "must be between 1 and the safe GitHub bound of {GITHUB_MAX_JOB_OUTPUT_UTF16_BYTES}" + ), + ); + } + } + + #[allow(clippy::too_many_arguments)] + fn validate_references( + &mut self, + profiles: &BTreeMap, + packages: &BTreeMap, + targets: &BTreeMap, + target_sets: &BTreeMap>, + toolchains: &BTreeMap, + miri_models: &BTreeMap, + miri: &Miri, + semver: &Semver, + limits: &Limits, + ) { + let mut package_profiles = BTreeSet::new(); + for (package_id, package) in packages { + for profile in &package.profiles { + if !profiles.contains_key(profile) { + self.error( + format!("packages.{package_id}.profiles"), + format!("unknown feature profile `{profile}`"), + ); + } + package_profiles.insert(profile.clone()); + } + } + for profile in profiles.keys() { + if !package_profiles.contains(profile) { + self.error( + format!("feature_profiles.{profile}"), + "feature profile is not valid for any package", + ); + } + } + + let mut targets_in_sets = BTreeSet::new(); + for (set_id, members) in target_sets { + for target in members { + if targets.contains_key(target) { + targets_in_sets.insert(target.clone()); + } else { + self.error( + format!("target_sets.{set_id}.members"), + format!("unknown target `{target}`"), + ); + } + } + } + for target in targets.keys() { + if !targets_in_sets.contains(target) { + self.error(format!("targets.{target}"), "target is not selected by any target set"); + } + } + + for source in [ + ToolchainSource::ManifestRustVersion, + ToolchainSource::PinnedStable, + ToolchainSource::PinnedNightly, + ] { + if !toolchains.values().any(|toolchain| toolchain.source == source) { + self.error( + "toolchains", + format!("no standard toolchain uses source `{}`", source.description()), + ); + } + } + + // Count every Cartesian product before constructing its cells. For a + // valid policy these counts are exact: later validation rejects + // dangling references, invalid package/profile pairs, and overlapping + // scopes. An invalid policy can only make these counts conservative, + // which is safe because that policy will be rejected regardless. + // Cache each target-set cardinality so that many scopes which name the + // same set cannot turn the preflight itself into repeated expansion. + let full_target_counts = target_sets + .iter() + .map(|(id, members)| (id.clone(), u64::try_from(members.len()).unwrap_or(u64::MAX))) + .collect::>(); + let reduced_target_counts = target_sets + .iter() + .map(|(id, members)| { + let count = members + .iter() + .filter(|target| { + targets.get(*target).is_some_and(|target| target.pr_eligible()) + }) + .count(); + (id.clone(), u64::try_from(count).unwrap_or(u64::MAX)) + }) + .collect::>(); + let full_build_cell_count = toolchains.values().fold(0u64, |count, toolchain| { + count.saturating_add(Self::scope_cell_count(&toolchain.scopes, &full_target_counts)) + }); + let reduced_build_cell_count = toolchains.values().fold(0u64, |count, toolchain| { + count.saturating_add(Self::scope_cell_count(&toolchain.scopes, &reduced_target_counts)) + }); + let miri_logical_cell_count = Self::scope_cell_count(&miri.scopes, &full_target_counts); + let miri_matrix_cell_count = miri_logical_cell_count + .saturating_mul(u64::try_from(miri_models.len()).unwrap_or(u64::MAX)); + // The coverage proof below compares the selected Miri cells with the + // complete required universe. Count that universe without constructing + // it: malformed policy may make the missing Cartesian product much + // larger than the selected scopes which `max_plan_cells` bounds. + let required_miri_cell_count = miri_coverage_cell_count( + packages.values().map(|package| package.profiles.len()), + targets.values().filter(|target| target.miri_eligible).count(), + ); + let (reduced_miri_cell_count, full_miri_cell_count) = match miri.event_category { + EventCategory::Reduced => (miri_matrix_cell_count, 0), + EventCategory::Full => (0, miri_matrix_cell_count), + }; + let reduced_event_cell_count = + reduced_build_cell_count.saturating_add(reduced_miri_cell_count); + let full_event_cell_count = full_build_cell_count.saturating_add(full_miri_cell_count); + self.check_plan_size("reduced-event", reduced_event_cell_count, limits); + self.check_plan_size("full-event", full_event_cell_count, limits); + + // `max_plan_cells` is not trusted until this validator succeeds. Cap + // allocation at the hard limit even when the configured value is + // invalidly large. Bound the two raw expansions independently too: an + // invalid empty Miri-model list would otherwise multiply a large Miri + // scope by zero and hide the allocation from the event counts. Only + // cell materialization and audits which require those cells stop at + // this boundary; reference, semver, and unused-set validation below + // must still aggregate their independent diagnostics. + let materialization_limit = limits.max_plan_cells.min(MAX_PLAN_CELLS); + let materialize_cells = !(full_build_cell_count > materialization_limit + || miri_logical_cell_count > materialization_limit + || reduced_event_cell_count > materialization_limit + || full_event_cell_count > materialization_limit); + + let mut used_target_sets = BTreeSet::new(); + let mut build_cells = BuildCellIndex::default(); + for (toolchain_id, toolchain) in toolchains { + for scope in &toolchain.scopes { + used_target_sets.insert(scope.target_set.clone()); + } + let cells = self.validate_and_expand_scopes( + &format!("toolchains.{toolchain_id}.scopes"), + &toolchain.scopes, + profiles, + packages, + targets, + target_sets, + false, + materialize_cells, + ); + for (package, profile, target) in cells { + build_cells.insert(toolchain_id, package, profile, target); + } + } + + if materialize_cells { + for (package_id, package) in packages { + for profile in &package.profiles { + if build_cells.package_profiles.contains(&(package_id.clone(), profile.clone())) + { + continue; + } + self.error( + format!("packages.{package_id}.profiles"), + format!( + "profile `{profile}` is never selected by an ordinary toolchain scope" + ), + ); + } + } + for target in targets.keys() { + if build_cells.targets.contains(target) { + continue; + } + self.error( + format!("targets.{target}"), + "target is never selected by an ordinary toolchain scope", + ); + } + } + if let Some(toolchain) = toolchains.get(&miri.toolchain) { + if toolchain.source != ToolchainSource::PinnedNightly { + self.error( + "miri.toolchain", + format!( + "toolchain `{}` does not use the pinned-nightly Cargo metadata source", + miri.toolchain + ), + ); + } + } else { + self.error("miri.toolchain", format!("unknown toolchain `{}`", miri.toolchain)); + } + for scope in &miri.scopes { + used_target_sets.insert(scope.target_set.clone()); + } + let miri_cells = self.validate_and_expand_scopes( + "miri.scopes", + &miri.scopes, + profiles, + packages, + targets, + target_sets, + true, + materialize_cells, + ); + // `validate_and_expand_scopes` admits only existing packages, profiles + // declared by those packages, and existing Miri-eligible targets. Its + // BTreeSet also makes the selected cells distinct. The selected set is + // therefore a subset of the required Cartesian universe, so equal + // cardinality is equivalent to complete coverage. Do not restore a + // traversal of the missing universe here: malformed policy can make it + // arbitrarily larger than the selected scopes and produce one + // allocation per cell. + if materialize_cells { + let selected_miri_cell_count = u64::try_from(miri_cells.len()).unwrap_or(u64::MAX); + if selected_miri_cell_count != required_miri_cell_count { + self.error( + "miri.scopes", + format!( + "Miri scopes select {selected_miri_cell_count} of {required_miri_cell_count} required Miri-eligible package/profile/target cells" + ), + ); + } + } + + used_target_sets.insert(semver.target_set.clone()); + self.validate_semver( + semver, + profiles, + packages, + targets, + target_sets, + toolchains, + materialize_cells.then_some(&build_cells), + ); + + for target_set in target_sets.keys() { + if !used_target_sets.contains(target_set) { + self.error(format!("target_sets.{target_set}"), "target set is never referenced"); + } + } + } + + fn scope_cell_count(scopes: &[Scope], target_counts: &BTreeMap) -> u64 { + scopes.iter().fold(0, |count, scope| { + let target_count = target_counts.get(&scope.target_set).copied().unwrap_or(0); + count.saturating_add(cartesian_cell_count( + scope.packages.len(), + scope.profiles.len(), + target_count, + )) + }) + } + + #[allow(clippy::too_many_arguments)] + fn validate_and_expand_scopes( + &mut self, + location: &str, + scopes: &[Scope], + profiles: &BTreeMap, + packages: &BTreeMap, + targets: &BTreeMap, + target_sets: &BTreeMap>, + require_miri_eligible: bool, + materialize_cells: bool, + ) -> BTreeSet<(Id, Id, Id)> { + let mut cells = BTreeMap::new(); + let mut checked_miri_target_sets = BTreeSet::new(); + for (scope_index, scope) in scopes.iter().enumerate() { + let scope_location = format!("{location}[{scope_index}]"); + for package in &scope.packages { + if !packages.contains_key(package) { + self.error( + format!("{scope_location}.packages"), + format!("unknown package `{package}`"), + ); + } + } + for profile in &scope.profiles { + if !profiles.contains_key(profile) { + self.error( + format!("{scope_location}.profiles"), + format!("unknown feature profile `{profile}`"), + ); + } + } + let Some(scope_targets) = target_sets.get(&scope.target_set) else { + self.error( + format!("{scope_location}.target_set"), + format!("unknown target set `{}`", scope.target_set), + ); + continue; + }; + + // Target-set validation has already diagnosed an empty resolved + // set. It represents no cells, so neither eligibility nor + // package/profile compatibility has anything further to check. + // This early exit is also a resource bound: an invalid empty set + // contributes zero to the preflight Cartesian count, and without + // this guard a linearly sized package/profile declaration could + // still create a quadratic number of compatibility diagnostics. + // Continue with later scopes so their independent errors remain + // aggregated. + if scope_targets.is_empty() { + continue; + } + + // Target eligibility is a reference property, not a Cartesian-cell + // property. Continue checking it when an oversized plan prevents + // materialization. Check each distinct set only once, so many + // scopes which reuse it cannot recreate the oversized scope/target + // product which the preflight rejected. + if !materialize_cells + && require_miri_eligible + && checked_miri_target_sets.insert(scope.target_set.clone()) + { + for target in scope_targets { + if targets.get(target).is_some_and(|target| !target.miri_eligible) { + self.error( + format!("{scope_location}.target_set"), + format!("target `{target}` is not marked Miri-eligible"), + ); + } + } + } + + // Package/profile compatibility is itself one axis of the + // Cartesian product. Unknown package and profile identifiers were + // checked above; defer pairwise compatibility and overlap checks + // together when the plan is too large to materialize safely. + if !materialize_cells { + continue; + } + + for package in &scope.packages { + let Some(package_policy) = packages.get(package) else { + continue; + }; + for profile in &scope.profiles { + if !package_policy.profiles.contains(profile) { + self.error( + format!("{scope_location}.profiles"), + format!("profile `{profile}` is not valid for package `{package}`"), + ); + continue; + } + for target in scope_targets { + let Some(target_policy) = targets.get(target) else { + continue; + }; + if require_miri_eligible && !target_policy.miri_eligible { + self.error( + format!("{scope_location}.target_set"), + format!("target `{target}` is not marked Miri-eligible"), + ); + continue; + } + let cell = (package.clone(), profile.clone(), target.clone()); + if let Some(previous_scope) = cells.insert(cell.clone(), scope_index) { + self.error( + &scope_location, + format!( + "cell `{package}/{profile}/{target}` overlaps scope {previous_scope} and would run twice" + ), + ); + } + } + } + } + } + cells.into_keys().collect() + } + + fn check_plan_size(&mut self, name: &str, count: u64, limits: &Limits) { + if count > limits.max_plan_cells { + self.error( + "limits.max_plan_cells", + format!( + "{name} plan expands to {count} cells before sharding, above the configured limit of {}", + limits.max_plan_cells + ), + ); + } + } + + #[allow(clippy::too_many_arguments)] + fn validate_semver( + &mut self, + semver: &Semver, + profiles: &BTreeMap, + packages: &BTreeMap, + targets: &BTreeMap, + target_sets: &BTreeMap>, + toolchains: &BTreeMap, + build_cells: Option<&BuildCellIndex>, + ) { + let package = packages.get(&semver.package); + if package.is_none() { + self.error("semver.package", format!("unknown package `{}`", semver.package)); + } + match profiles.get(&semver.profile) { + None => self.error( + "semver.profile", + format!("unknown feature profile `{}`", semver.profile), + ), + Some(FeatureProfile::StableAggregate) => {} + Some(_) => self.error( + "semver.profile", + format!( + "profile `{}` must select the stable aggregate because nightly-only API has no compatibility guarantee", + semver.profile + ), + ), + } + if let Some(package) = package { + if !package.profiles.contains(&semver.profile) { + self.error( + "semver.profile", + format!( + "profile `{}` is not valid for package `{}`", + semver.profile, semver.package + ), + ); + } + } + let toolchain = toolchains.get(&semver.toolchain); + match toolchain { + None => { + self.error("semver.toolchain", format!("unknown toolchain `{}`", semver.toolchain)) + } + Some(toolchain) if toolchain.source != ToolchainSource::PinnedStable => self.error( + "semver.toolchain", + format!( + "toolchain `{}` does not use the pinned-stable Cargo metadata source", + semver.toolchain + ), + ), + Some(_) => {} + } + let selected_targets = target_sets.get(&semver.target_set); + if selected_targets.is_none() { + self.error("semver.target_set", format!("unknown target set `{}`", semver.target_set)); + } + + // Reuse is a package/profile/target-cell property. Keep validating the + // scalar references and the target eligibility/waiver partition below + // when an oversized plan prevents cell-dependent audits. + if let Some(build_cells) = build_cells { + if let (Some(_), Some(selected_targets)) = (toolchain, selected_targets) { + for target in selected_targets { + if !build_cells.selects( + &semver.toolchain, + &semver.package, + &semver.profile, + target, + ) { + self.error( + "semver.target_set", + format!( + "toolchain `{}` does not build `{}/{}/{target}`, so semver cannot reuse that matrix cell", + semver.toolchain, semver.package, semver.profile + ), + ); + } + } + } + } + + for target in semver.waivers.keys() { + if !targets.contains_key(target) { + self.error("semver.waivers", format!("unknown target `{target}`")); + } + } + + if let Some(selected_targets) = selected_targets { + for (target_id, target) in targets { + let selected = selected_targets.contains(target_id); + let waived = semver.waivers.contains_key(target_id); + if target.semver_eligible { + match (selected, waived) { + (false, false) => self.error( + "semver.target_set", + format!( + "semver-eligible target `{target_id}` is neither selected nor waived" + ), + ), + (true, true) => self.error( + "semver.waivers", + format!("target `{target_id}` is both selected and waived"), + ), + (true, false) | (false, true) => {} + } + } else { + if selected { + self.error( + "semver.target_set", + format!( + "target `{target_id}` is selected but is not marked semver-eligible" + ), + ); + } + if waived { + self.error( + "semver.waivers", + format!( + "target `{target_id}` is waived but is not marked semver-eligible" + ), + ); + } + } + } + } + } +} + +/// Audit-only indexes over the exact materialized ordinary-build cells. +/// +/// Policy permits enough cells and declarations that scanning every cell once +/// for each package/profile or target can exceed billions of comparisons. +/// Build these three query shapes while cells are expanded instead. Semver +/// reuse deliberately consults the same validated cells rather than separately +/// interpreting raw scopes and target sets a second time. +#[derive(Default)] +struct BuildCellIndex { + targets_by_selection: BTreeMap<(Id, Id, Id), BTreeSet>, + package_profiles: BTreeSet<(Id, Id)>, + targets: BTreeSet, +} + +impl BuildCellIndex { + fn insert(&mut self, toolchain: &Id, package: Id, profile: Id, target: Id) { + self.package_profiles.insert((package.clone(), profile.clone())); + self.targets.insert(target.clone()); + self.targets_by_selection + .entry((toolchain.clone(), package, profile)) + .or_default() + .insert(target); + } + + fn selects(&self, toolchain: &Id, package: &Id, profile: &Id, target: &Id) -> bool { + self.targets_by_selection + .get(&(toolchain.clone(), package.clone(), profile.clone())) + .is_some_and(|targets| targets.contains(target)) + } +} + +fn cartesian_cell_count(packages: usize, profiles: usize, targets: u64) -> u64 { + u64::try_from(packages) + .unwrap_or(u64::MAX) + .saturating_mul(u64::try_from(profiles).unwrap_or(u64::MAX)) + .saturating_mul(targets) +} + +fn miri_coverage_cell_count( + package_profile_counts: impl IntoIterator, + miri_eligible_targets: usize, +) -> u64 { + package_profile_counts + .into_iter() + .fold(0u64, |count, profiles| { + count.saturating_add(u64::try_from(profiles).unwrap_or(u64::MAX)) + }) + .saturating_mul(u64::try_from(miri_eligible_targets).unwrap_or(u64::MAX)) +} + +fn escape_control_characters(value: String) -> String { + let mut escaped = String::new(); + for character in value.chars() { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + +impl ToolchainSource { + fn description(self) -> &'static str { + match self { + Self::ManifestRustVersion => "manifest-rust-version", + Self::PinnedStable => "pinned-stable", + Self::PinnedNightly => "pinned-nightly", + Self::BuildRs => "build-rs", + } + } +} + +fn is_issue_reference(issue: &str) -> bool { + issue.strip_prefix('#').is_some_and(|number| { + !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeSet, path::PathBuf}; + + use super::{ + miri_coverage_cell_count, BuildCellIndex, EventCategory, FeatureProfile, Policy, + PolicyError, ReadPolicyError, TargetMode, ToolchainSource, ValidationErrors, MAX_ID_BYTES, + MAX_TARGET_SET_MEMBERSHIPS, + }; + + const REPOSITORY_POLICY: &str = include_str!("../../../ci/zc.toml"); + const BUILD_REDUCED_BASELINE: &str = include_str!("../../../ci/baselines/build-pr.tsv"); + const BUILD_FULL_BASELINE: &str = include_str!("../../../ci/baselines/build-full.tsv"); + const MIRI_REDUCED_BASELINE: &str = include_str!("../../../ci/baselines/miri-pr.tsv"); + const MIRI_FULL_BASELINE: &str = include_str!("../../../ci/baselines/miri-full.tsv"); + const UNKNOWN_KEY: &str = include_str!("../testdata/policy-unknown-key.toml"); + const INVALID_MULTIPLE: &str = include_str!("../testdata/policy-invalid-multiple.toml"); + const INVALID_MULTIPLE_DIAGNOSTICS: &str = + include_str!("../testdata/policy-invalid-multiple.stderr"); + + fn id(value: &str) -> super::Id { + super::Id(value.to_owned()) + } + + fn mutate(source: &str, needle: &str, replacement: &str) -> String { + assert!(source.contains(needle), "mutation needle not found: {needle}"); + source.replacen(needle, replacement, 1) + } + + fn validation_errors(source: &str) -> ValidationErrors { + match Policy::parse(source).unwrap_err() { + PolicyError::Invalid(errors) => errors, + PolicyError::Toml(error) => panic!("expected semantic errors, got TOML error: {error}"), + } + } + + fn invalid(source: &str) -> String { + validation_errors(source).to_string() + } + + fn baseline_rows(source: &str, header: &str) -> BTreeSet { + source + .lines() + .filter(|line| !line.is_empty() && !line.starts_with('#') && *line != header) + .map(str::to_owned) + .collect() + } + + fn assert_invalid_contains(source: &str, expected: &[&str]) { + let rendered = invalid(source); + for expected in expected { + assert!( + rendered.contains(expected), + "expected `{expected}` in validation errors:\n{rendered}" + ); + } + } + + #[test] + fn build_cell_index_separates_global_coverage_from_exact_selection() { + let mut cells = BuildCellIndex::default(); + cells.insert(&id("stable"), id("zerocopy"), id("stable"), id("x86-linux")); + cells.insert(&id("msrv"), id("zerocopy"), id("default"), id("arm-linux")); + + assert!(cells.package_profiles.contains(&(id("zerocopy"), id("stable")))); + assert!(cells.targets.contains(&id("arm-linux"))); + assert!(cells.selects(&id("stable"), &id("zerocopy"), &id("stable"), &id("x86-linux"),)); + // Global target coverage by another toolchain must not make this exact + // semver-reuse query pass. + assert!(!cells.selects(&id("stable"), &id("zerocopy"), &id("stable"), &id("arm-linux"),)); + assert!(!cells.selects(&id("msrv"), &id("zerocopy"), &id("stable"), &id("x86-linux"),)); + } + + #[test] + fn semver_reuse_checks_the_materialized_build_selection() { + let narrowed = mutate( + REPOSITORY_POLICY, + "packages = [\"zerocopy\"]\nprofiles = [\"stable\"]\ntarget_set = \"without-thumb-or-wasm\"", + "packages = [\"zerocopy\"]\nprofiles = [\"stable\"]\ntarget_set = \"x86-linux\"", + ); + let errors = validation_errors(&narrowed); + let reuse_errors = errors + .errors() + .iter() + .filter(|error| error.message().contains("semver cannot reuse that matrix cell")) + .collect::>(); + assert_eq!(reuse_errors.len(), 7, "{errors}"); + let rendered = errors.to_string(); + for target in [ + "arm-unknown-linux-gnueabi", + "aarch64-unknown-linux-gnu", + "powerpc-unknown-linux-gnu", + "powerpc64-unknown-linux-gnu", + "riscv64gc-unknown-linux-gnu", + "s390x-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ] { + assert!(rendered.contains(target), "missing reuse error for {target}:\n{rendered}"); + } + for target in ["i686-unknown-linux-gnu", "x86_64-unknown-linux-gnu"] { + assert!( + !reuse_errors.iter().any(|error| error.message().contains(target)), + "selected target {target} was rejected:\n{rendered}" + ); + } + } + + #[test] + fn repository_policy_models_the_frozen_matrix() { + let policy = Policy::parse(REPOSITORY_POLICY).unwrap(); + + assert_eq!(policy.schema_version(), 1); + assert_eq!(policy.packages().len(), 2); + assert_eq!(policy.targets().len(), 11); + assert_eq!(policy.toolchains().len(), 11); + assert_eq!(policy.miri_models().len(), 2); + assert_eq!(policy.features().profiles().len(), 3); + assert_eq!( + policy.features().stable_feature_root().as_str(), + "__internal_use_only_features_that_work_on_stable" + ); + assert_eq!( + policy.features().profiles().get(&id("default")), + Some(&FeatureProfile::Default) + ); + assert_eq!( + policy.features().profiles().get(&id("stable")), + Some(&FeatureProfile::StableAggregate) + ); + assert_eq!(policy.features().profiles().get(&id("all")), Some(&FeatureProfile::All)); + + let thumb = policy.targets().get(&id("thumbv6m-none-eabi")).unwrap(); + assert_eq!(thumb.mode(), TargetMode::Thumb); + assert!(!thumb.pr_eligible()); + assert!(!thumb.miri_eligible()); + assert!(!thumb.semver_eligible()); + assert_eq!( + policy.toolchains().get(&id("nightly")).unwrap().source(), + ToolchainSource::PinnedNightly + ); + + let mut build_cells = BTreeSet::new(); + for (toolchain_id, toolchain) in policy.toolchains() { + for scope in toolchain.scopes() { + let targets = &policy.target_sets()[scope.target_set()]; + for package in scope.packages() { + for profile in scope.profiles() { + for target in targets { + build_cells + .insert(format!("{package}\t{toolchain_id}\t{profile}\t{target}")); + } + } + } + } + } + assert_eq!(build_cells.len(), 182); + assert_eq!( + build_cells, + baseline_rows(BUILD_FULL_BASELINE, "crate\ttoolchain\tfeature_profile\ttarget") + ); + let reduced_build_cells = build_cells + .iter() + .filter(|cell| { + let target = cell.rsplit('\t').next().unwrap(); + policy.targets()[target].pr_eligible() + }) + .cloned() + .collect::>(); + assert_eq!(reduced_build_cells.len(), 60); + assert_eq!( + reduced_build_cells, + baseline_rows(BUILD_REDUCED_BASELINE, "crate\ttoolchain\tfeature_profile\ttarget") + ); + + let mut miri_cells = BTreeSet::new(); + for scope in policy.miri().scopes() { + let targets = &policy.target_sets()[scope.target_set()]; + for package in scope.packages() { + for profile in scope.profiles() { + for target in targets { + for (model_id, model) in policy.miri_models() { + let flags = if model.flags().is_empty() { + "".to_owned() + } else { + model.flags().join(" ") + }; + miri_cells.insert(format!( + "{package}\t{}\t{profile}\t{target}\t{model_id}\t{flags}", + policy.miri().toolchain() + )); + } + } + } + } + } + assert_eq!(policy.miri().event_category(), EventCategory::Full); + assert_eq!(miri_cells.len(), 64); + assert_eq!( + miri_cells, + baseline_rows( + MIRI_FULL_BASELINE, + "crate\ttoolchain\tfeature_profile\ttarget\tmiri_model\tmiri_model_flags" + ) + ); + assert_eq!( + baseline_rows( + MIRI_REDUCED_BASELINE, + "crate\ttoolchain\tfeature_profile\ttarget\tmiri_model\tmiri_model_flags" + ), + BTreeSet::new() + ); + + assert_eq!(policy.target_sets()[policy.semver().target_set()].len(), 9); + let wasm_waiver = policy.semver().waivers().get(&id("wasm32-unknown-unknown")).unwrap(); + assert_eq!(wasm_waiver.issue(), "#1565"); + } + + #[test] + fn event_lookup_fails_closed() { + let policy = Policy::parse(REPOSITORY_POLICY).unwrap(); + + assert_eq!(policy.events().category("pull_request"), Some(EventCategory::Reduced)); + assert_eq!(policy.events().category("merge_group"), Some(EventCategory::Full)); + assert_eq!(policy.events().category("unrecognized"), None); + } + + #[test] + fn rejects_unknown_keys_in_every_table_shape() { + assert!(matches!(Policy::parse(UNKNOWN_KEY), Err(PolicyError::Toml(_)))); + + let mutations = [ + ( + "events", + "reduced = [\"pull_request\"]", + "reduced = [\"pull_request\"]\nunexpected = true", + ), + ( + "features", + "stable_feature_root = \"__internal_use_only_features_that_work_on_stable\"", + "stable_feature_root = \"__internal_use_only_features_that_work_on_stable\"\nunexpected = true", + ), + ( + "feature profile", + "selection = \"default\"", + "selection = \"default\"\nunexpected = true", + ), + ( + "package", + "manifest = \"zerocopy/Cargo.toml\"", + "manifest = \"zerocopy/Cargo.toml\"\nunexpected = true", + ), + ( + "target", + "id = \"i686-unknown-linux-gnu\"\nmode = \"native\"", + "id = \"i686-unknown-linux-gnu\"\nmode = \"native\"\nunexpected = true", + ), + ( + "target set", + "id = \"all\"\nselection = \"all\"", + "id = \"all\"\nunexpected = true\nselection = \"all\"", + ), + ( + "toolchain", + "source = \"manifest-rust-version\"", + "source = \"manifest-rust-version\"\nunexpected = true", + ), + ( + "toolchain scope", + "packages = [\"zerocopy\"]\nprofiles = [\"default\"]\ntarget_set = \"all\"", + "packages = [\"zerocopy\"]\nprofiles = [\"default\"]\ntarget_set = \"all\"\nunexpected = true", + ), + ( + "Miri model", + "id = \"stacked\"\nflags = []", + "id = \"stacked\"\nflags = []\nunexpected = true", + ), + ( + "Miri", + "[miri]\ntoolchain = \"nightly\"\nevent_category = \"full\"", + "[miri]\ntoolchain = \"nightly\"\nevent_category = \"full\"\nunexpected = true", + ), + ( + "Miri scope", + "profiles = [\"default\", \"stable\", \"all\"]\ntarget_set = \"miri-supported\"", + "profiles = [\"default\", \"stable\", \"all\"]\ntarget_set = \"miri-supported\"\nunexpected = true", + ), + ( + "semver", + "profile = \"stable\"\ntarget_set = \"semver\"", + "profile = \"stable\"\ntarget_set = \"semver\"\nunexpected = true", + ), + ( + "semver waiver", + "issue = \"#1565\"\nreason =", + "issue = \"#1565\"\nunexpected = true\nreason =", + ), + ( + "baselines", + "manifest = \"ci/baselines/manifest.tsv\"", + "manifest = \"ci/baselines/manifest.tsv\"\nunexpected = true", + ), + ( + "limits", + "max_matrix_cells = 256", + "max_matrix_cells = 256\nunexpected = true", + ), + ]; + + for (name, needle, replacement) in mutations { + let source = mutate(REPOSITORY_POLICY, needle, replacement); + let error = Policy::parse(&source).unwrap_err(); + assert!( + matches!(error, PolicyError::Toml(_)), + "unknown key in {name} was not rejected during parsing: {error}" + ); + assert!(error.to_string().contains("unknown field")); + } + } + + #[test] + fn reports_independent_semantic_errors_in_a_stable_order() { + assert_eq!(invalid(INVALID_MULTIPLE), INVALID_MULTIPLE_DIAGNOSTICS); + } + + #[test] + fn rejects_unsupported_schema_versions() { + let source = mutate(REPOSITORY_POLICY, "schema_version = 1", "schema_version = 2"); + assert_invalid_contains(&source, &["unsupported schema version 2"]); + } + + #[test] + fn rejects_unstable_identifiers_and_unsafe_paths() { + for invalid_id in ["Uppercase", "has/slash", "has space", ".leading"] { + let source = mutate( + REPOSITORY_POLICY, + "reduced = [\"pull_request\"]", + &format!("reduced = [\"{invalid_id}\"]"), + ); + assert_invalid_contains(&source, &["is not a stable identifier"]); + } + + let escaped = mutate( + REPOSITORY_POLICY, + "reduced = [\"pull_request\"]", + "reduced = [\"pull_\\u001Brequest\"]", + ); + assert!(!Policy::parse(&escaped).unwrap_err().to_string().contains('\u{001b}')); + + let oversized_id = "a".repeat(MAX_ID_BYTES + 1); + let source = mutate( + REPOSITORY_POLICY, + "reduced = [\"pull_request\"]", + &format!("reduced = [\"{oversized_id}\"]"), + ); + let expected = format!("identifier is {} bytes", MAX_ID_BYTES + 1); + assert_invalid_contains(&source, &[&expected]); + + for unsafe_path in [ + "/absolute.toml", + "../outside.toml", + "./manifest.toml", + "dir/./manifest.toml", + "dir/.", + "dir//manifest.toml", + "dir\\manifest.toml", + "C:/manifest.toml", + ] { + let source = mutate( + REPOSITORY_POLICY, + "manifest = \"zerocopy/Cargo.toml\"", + &format!("manifest = '{unsafe_path}'"), + ); + assert_invalid_contains(&source, &["is not a safe repository-relative path"]); + } + } + + #[test] + fn rejects_duplicates_empty_selections_and_dangling_references() { + let duplicate = mutate( + REPOSITORY_POLICY, + "id = \"stable\"\nselection = \"stable-aggregate\"", + "id = \"default\"\nselection = \"stable-aggregate\"", + ); + assert_invalid_contains(&duplicate, &["duplicate profile ID `default`"]); + + let empty = mutate( + REPOSITORY_POLICY, + "profiles = [\"default\", \"stable\", \"all\"]", + "profiles = []", + ); + assert_invalid_contains(&empty, &["selection cannot be empty"]); + + let dangling = mutate( + REPOSITORY_POLICY, + "manifest = \"zerocopy/zerocopy-derive/Cargo.toml\"\nprofiles = [\"default\"]", + "manifest = \"zerocopy/zerocopy-derive/Cargo.toml\"\nprofiles = [\"missing\"]", + ); + assert_invalid_contains(&dangling, &["unknown feature profile `missing`"]); + } + + #[test] + fn rejects_overlapping_events_and_scope_products() { + let events = mutate( + REPOSITORY_POLICY, + "full = [\"merge_group\", \"push\", \"workflow_dispatch\"]", + "full = [\"pull_request\", \"merge_group\", \"push\"]", + ); + assert_invalid_contains(&events, &["appears in both reduced and full"]); + + let scopes = mutate( + REPOSITORY_POLICY, + "profiles = [\"stable\", \"all\"]\ntarget_set = \"without-thumb\"", + "profiles = [\"default\"]\ntarget_set = \"all\"", + ); + assert_invalid_contains(&scopes, &["overlaps scope 0 and would run twice"]); + } + + #[test] + fn rejects_miri_and_semver_coverage_drift() { + let unsupported_miri_target = mutate( + REPOSITORY_POLICY, + "profiles = [\"default\", \"stable\", \"all\"]\ntarget_set = \"miri-supported\"", + "profiles = [\"default\", \"stable\", \"all\"]\ntarget_set = \"semver\"", + ); + assert_invalid_contains( + &unsupported_miri_target, + &["target `riscv64gc-unknown-linux-gnu` is not marked Miri-eligible"], + ); + + let missing_miri_profile = mutate( + REPOSITORY_POLICY, + "profiles = [\"default\", \"stable\", \"all\"]\ntarget_set = \"miri-supported\"", + "profiles = [\"default\", \"stable\"]\ntarget_set = \"miri-supported\"", + ); + assert_invalid_contains( + &missing_miri_profile, + &["Miri scopes select 24 of 32 required Miri-eligible package/profile/target cells"], + ); + + let wrong_semver_profile = mutate( + REPOSITORY_POLICY, + "toolchain = \"stable\"\nprofile = \"stable\"\ntarget_set = \"semver\"", + "toolchain = \"stable\"\nprofile = \"default\"\ntarget_set = \"semver\"", + ); + assert_invalid_contains( + &wrong_semver_profile, + &["must select the stable aggregate because nightly-only API has no compatibility guarantee"], + ); + + let wrong_semver_toolchain = mutate( + REPOSITORY_POLICY, + "toolchain = \"stable\"\nprofile = \"stable\"\ntarget_set = \"semver\"", + "toolchain = \"nightly\"\nprofile = \"stable\"\ntarget_set = \"semver\"", + ); + assert_invalid_contains( + &wrong_semver_toolchain, + &["does not use the pinned-stable Cargo metadata source"], + ); + + let selected_and_waived = mutate( + REPOSITORY_POLICY, + "target = \"wasm32-unknown-unknown\"\nissue = \"#1565\"", + "target = \"x86_64-unknown-linux-gnu\"\nissue = \"#1565\"", + ); + assert_invalid_contains(&selected_and_waived, &["is both selected and waived"]); + + let omitted = mutate( + REPOSITORY_POLICY, + "id = \"semver\"\nselection = \"all\"\ninclude = []\nexclude = [\"thumbv6m-none-eabi\", \"wasm32-unknown-unknown\"]", + "id = \"semver\"\nselection = \"all\"\ninclude = []\nexclude = [\"s390x-unknown-linux-gnu\", \"thumbv6m-none-eabi\", \"wasm32-unknown-unknown\"]", + ); + assert_invalid_contains(&omitted, &["is neither selected nor waived"]); + + let inapplicable = mutate( + REPOSITORY_POLICY, + "id = \"semver\"\nselection = \"all\"\ninclude = []\nexclude = [\"thumbv6m-none-eabi\", \"wasm32-unknown-unknown\"]", + "id = \"semver\"\nselection = \"all\"\ninclude = []\nexclude = [\"wasm32-unknown-unknown\"]", + ); + assert_invalid_contains(&inapplicable, &["is not marked semver-eligible"]); + } + + #[test] + fn target_set_expressions_reject_redundant_or_ineffective_changes() { + let redundant = mutate( + REPOSITORY_POLICY, + "id = \"all\"\nselection = \"all\"\ninclude = []", + "id = \"all\"\nselection = \"all\"\ninclude = [\"x86_64-unknown-linux-gnu\"]", + ); + assert_invalid_contains(&redundant, &["remove the redundant include"]); + + let ineffective = mutate( + REPOSITORY_POLICY, + "id = \"aarch64\"\nselection = \"explicit\"\ninclude = [\"aarch64-unknown-linux-gnu\"]\nexclude = []", + "id = \"aarch64\"\nselection = \"explicit\"\ninclude = [\"aarch64-unknown-linux-gnu\"]\nexclude = [\"x86_64-unknown-linux-gnu\"]", + ); + assert_invalid_contains(&ineffective, &["remove the ineffective exclude"]); + + let overlap = mutate( + REPOSITORY_POLICY, + "id = \"x86-linux\"\nselection = \"explicit\"\ninclude = [\"i686-unknown-linux-gnu\", \"x86_64-unknown-linux-gnu\"]\nexclude = []", + "id = \"x86-linux\"\nselection = \"explicit\"\ninclude = [\"i686-unknown-linux-gnu\", \"x86_64-unknown-linux-gnu\"]\nexclude = [\"i686-unknown-linux-gnu\"]", + ); + assert_invalid_contains(&overlap, &["cannot appear in both include and exclude"]); + } + + #[test] + fn rejects_platform_limits_and_expansions_above_policy_limits() { + for (needle, replacement, expected) in [ + ("max_matrix_cells = 256", "max_matrix_cells = 0", "between 1"), + ("max_matrix_cells = 256", "max_matrix_cells = 257", "hard limit of 256"), + ("max_plan_cells = 4096", "max_plan_cells = 0", "between 1"), + ("max_plan_cells = 4096", "max_plan_cells = 65537", "hard safety bound of 65536"), + ( + "max_job_output_utf16_bytes = 900000", + "max_job_output_utf16_bytes = 1000001", + "safe GitHub bound of 1000000", + ), + ] { + let source = mutate(REPOSITORY_POLICY, needle, replacement); + assert_invalid_contains(&source, &[expected]); + } + + let matrix_limit = + mutate(REPOSITORY_POLICY, "max_matrix_cells = 256", "max_matrix_cells = 181"); + assert_eq!(Policy::parse(&matrix_limit).unwrap().limits().max_matrix_cells(), 181); + + let plan_limit = mutate(REPOSITORY_POLICY, "max_plan_cells = 4096", "max_plan_cells = 245"); + assert_invalid_contains(&plan_limit, &["full-event plan expands to 246 cells"]); + } + + #[test] + fn oversized_plan_continues_independent_validation_without_materializing_cells() { + const FIRST_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default"] +target_set = "all""#; + const UNKNOWN_PACKAGE_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["missing"] +profiles = ["default"] +target_set = "all""#; + const UNUSED_TARGET_SET: &str = r#"[[target_sets]] +id = "unused" +selection = "explicit" +include = ["i686-unknown-linux-gnu"] +exclude = [] +"#; + const STABLE_PROFILE_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "without-thumb-or-wasm""#; + const NARROW_STABLE_PROFILE_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["stable"] +target_set = "x86-linux""#; + + // The duplicate valid scope would produce overlap diagnostics if the + // validator materialized cells after rejecting the plan size. The + // unknown scope, semver profile, and unused set are independent of + // those cells and must still be reported together. + let source = mutate( + REPOSITORY_POLICY, + FIRST_SCOPE, + &format!("{FIRST_SCOPE}\n\n{FIRST_SCOPE}\n\n{UNKNOWN_PACKAGE_SCOPE}"), + ); + let source = mutate(&source, "max_plan_cells = 4096", "max_plan_cells = 254"); + let source = mutate(&source, STABLE_PROFILE_SCOPE, NARROW_STABLE_PROFILE_SCOPE); + let source = mutate(&source, "profile = \"stable\"", "profile = \"default\""); + let source = mutate(&source, "target_set = \"miri-supported\"", "target_set = \"all\""); + let source = mutate(&source, "target_set = \"miri-supported\"", "target_set = \"all\""); + let source = + mutate(&source, "[[target_sets]]", &format!("{UNUSED_TARGET_SET}\n[[target_sets]]")); + let errors = validation_errors(&source); + let rendered = errors.to_string(); + + for expected in [ + "full-event plan expands to", + "unknown package `missing`", + "must select the stable aggregate", + "target_sets.unused: target set is never referenced", + ] { + assert!( + rendered.contains(expected), + "expected `{expected}` in validation errors:\n{rendered}" + ); + } + assert!( + !rendered.contains("overlaps scope"), + "cell-dependent overlap audit ran for an oversized plan:\n{rendered}" + ); + assert!( + !rendered.contains("cannot reuse that matrix cell"), + "cell-dependent semver reuse audit ran for an oversized plan:\n{rendered}" + ); + let miri_eligibility_errors = errors + .errors() + .iter() + .filter(|error| error.message().contains("is not marked Miri-eligible")) + .collect::>(); + assert_eq!( + miri_eligibility_errors.len(), + 3, + "a reused Miri target set should be checked only once: {rendered}" + ); + assert!( + miri_eligibility_errors + .iter() + .all(|error| error.location() == "miri.scopes[0].target_set"), + "Miri eligibility diagnostics should use the first referencing scope: {rendered}" + ); + } + + #[test] + fn empty_target_sets_do_not_bypass_the_cartesian_resource_bound() { + const AARCH64_SET: &str = r#"[[target_sets]] +id = "aarch64" +selection = "explicit" +include = ["aarch64-unknown-linux-gnu"] +exclude = []"#; + const EMPTY_AARCH64_SET: &str = r#"[[target_sets]] +id = "aarch64" +selection = "explicit" +include = [] +exclude = []"#; + const EMPTY_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy"] +profiles = ["default", "stable"] +target_set = "aarch64""#; + const EMPTY_INVALID_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default", "stable"] +target_set = "aarch64""#; + const NONEMPTY_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default"] +target_set = "without-wasm""#; + const NONEMPTY_INVALID_SCOPE: &str = r#"[[toolchains.scopes]] +packages = ["zerocopy-derive"] +profiles = ["default", "stable"] +target_set = "without-wasm""#; + + let source = mutate(REPOSITORY_POLICY, AARCH64_SET, EMPTY_AARCH64_SET); + let source = mutate(&source, EMPTY_SCOPE, EMPTY_INVALID_SCOPE); + let source = mutate(&source, NONEMPTY_SCOPE, NONEMPTY_INVALID_SCOPE); + let errors = validation_errors(&source); + let compatibility_errors = errors + .errors() + .iter() + .filter(|error| error.message().contains("is not valid for package")) + .collect::>(); + + assert!( + errors.to_string().contains("target-set expression cannot select no targets"), + "{errors}" + ); + assert_eq!( + compatibility_errors.len(), + 1, + "the empty scope must not produce pairwise diagnostics, while an independent nonempty scope must: {errors}" + ); + assert_eq!(compatibility_errors[0].location(), "toolchains.stable.scopes[2].profiles"); + } + + #[test] + fn bounds_aggregate_target_set_expansion_before_resolution() { + let target_count = + u64::try_from(Policy::parse(REPOSITORY_POLICY).unwrap().targets().len()).unwrap(); + let extra_set_count = MAX_TARGET_SET_MEMBERSHIPS / target_count + 1; + let extra_sets = (0..extra_set_count) + .map(|index| { + format!( + "[[target_sets]]\nid = \"overflow-{index}\"\nselection = \"all\"\ninclude = []\nexclude = []\n\n" + ) + }) + .collect::(); + let source = + mutate(REPOSITORY_POLICY, "[[target_sets]]", &format!("{extra_sets}[[target_sets]]")); + let source = mutate(&source, "profile = \"stable\"", "profile = \"default\""); + let rendered = invalid(&source); + + assert!( + rendered.contains("target-set expressions can expand to") + && rendered.contains(&format!( + "above the hard safety bound of {MAX_TARGET_SET_MEMBERSHIPS}" + )), + "aggregate target-set bound was not reported:\n{rendered}" + ); + assert!( + rendered.contains("must select the stable aggregate"), + "independent validation stopped at the target-set bound:\n{rendered}" + ); + } + + #[test] + fn cartesian_cell_counts_saturate_instead_of_wrapping() { + assert_eq!(super::cartesian_cell_count(2, 3, 5), 30); + assert_eq!(super::cartesian_cell_count(usize::MAX, usize::MAX, 2), u64::MAX); + assert_eq!(miri_coverage_cell_count([3, 1], 8), 32); + assert_eq!(miri_coverage_cell_count([usize::MAX, usize::MAX], usize::MAX), u64::MAX); + } + + #[test] + fn miri_coverage_audit_summarizes_an_unselected_cartesian_product() { + let extra_packages = (0..29) + .map(|index| { + format!( + "[[packages]]\nid = \"extra-{index:02}\"\nmanifest = \"extra/{index:02}/Cargo.toml\"\nprofiles = [\"default\"]\n\n" + ) + }) + .collect::(); + let source = + mutate(REPOSITORY_POLICY, "[[targets]]", &format!("{extra_packages}[[targets]]")); + let source = mutate(&source, "max_plan_cells = 4096", "max_plan_cells = 256"); + let errors = validation_errors(&source); + + assert!( + errors.errors().iter().all(|error| error.location() != "limits.max_plan_cells"), + "selected plans should remain within the configured limit: {errors}" + ); + let miri_errors = errors + .errors() + .iter() + .filter(|error| error.location() == "miri.scopes") + .collect::>(); + assert_eq!(miri_errors.len(), 1, "unexpected Miri diagnostics: {errors}"); + assert_eq!( + miri_errors[0].message(), + "Miri scopes select 32 of 264 required Miri-eligible package/profile/target cells" + ); + } + + #[test] + fn rejects_control_characters_in_miri_flags() { + for flag in [r"-Zfoo\u0000bar", r"-Zfoo\u001Bbar"] { + let source = mutate( + REPOSITORY_POLICY, + r#"flags = ["-Zmiri-tree-borrows"]"#, + &format!(r#"flags = ["{flag}"]"#), + ); + assert_invalid_contains( + &source, + &["each Miri flag must be one non-empty argument beginning with `-`"], + ); + } + } + + #[test] + fn behavior_significant_lists_are_required() { + let flags = mutate(REPOSITORY_POLICY, "flags = []\n", ""); + let flags_error = Policy::parse(&flags).unwrap_err(); + assert!(matches!(flags_error, PolicyError::Toml(_))); + assert!(flags_error.to_string().contains("missing field `flags`")); + + for (needle, replacement) in [ + ("id = \"all\"\nselection = \"all\"\ninclude = []", "id = \"all\"\ninclude = []"), + ( + "id = \"all\"\nselection = \"all\"\ninclude = []", + "id = \"all\"\nselection = \"all\"", + ), + ( + "id = \"all\"\nselection = \"all\"\ninclude = []\nexclude = []", + "id = \"all\"\nselection = \"all\"\ninclude = []", + ), + ] { + let target_set = mutate(REPOSITORY_POLICY, needle, replacement); + let error = Policy::parse(&target_set).unwrap_err(); + assert!(matches!(error, PolicyError::Toml(_))); + assert!(error.to_string().contains("missing field")); + } + + let waiver_block = r##" +[[semver.waivers]] +target = "wasm32-unknown-unknown" +issue = "#1565" +reason = "cargo-semver-checks does not yet support this target" +"##; + let waivers = mutate(REPOSITORY_POLICY, waiver_block, "\n"); + let waivers_error = Policy::parse(&waivers).unwrap_err(); + assert!(matches!(waivers_error, PolicyError::Toml(_))); + assert!(waivers_error.to_string().contains("missing field `waivers`")); + } + + #[test] + fn read_errors_retain_the_requested_path() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata/policy-intentionally-does-not-exist.toml"); + let error = Policy::read(&path).unwrap_err(); + + match &error { + ReadPolicyError::Read { path: error_path, .. } => assert_eq!(error_path, &path), + ReadPolicyError::Policy { .. } => panic!("expected a read error"), + } + assert!(error.to_string().contains(&path.display().to_string())); + } +} diff --git a/tools/zc/testdata/policy-invalid-multiple.stderr b/tools/zc/testdata/policy-invalid-multiple.stderr new file mode 100644 index 0000000000..bfa3296358 --- /dev/null +++ b/tools/zc/testdata/policy-invalid-multiple.stderr @@ -0,0 +1,34 @@ +CI policy has 33 validation error(s): +- schema_version: unsupported schema version 99; this zc supports exactly 1 +- events: event `pull_request` appears in both reduced and full; every event must select exactly one category +- features.stable_feature_root: `Bad root` is not a stable identifier; use lowercase ASCII letters, digits, `_`, `-`, or `.`, and do not start or end with `-` or `.` +- feature_profiles[1].id: duplicate profile ID `same` +- packages[0].id: `bad package` is not a stable identifier; use lowercase ASCII letters, digits, `_`, `-`, or `.`, and do not start or end with `-` or `.` +- packages[0].manifest: "../outside.toml" is not a safe repository-relative path; use non-empty `/`-separated components without `.`, `..`, a root, or a platform prefix +- packages[0].profiles: selection cannot be empty +- targets[1].id: duplicate target ID `target` +- targets: at least one target must be eligible for pull-request CI +- targets: at least one target must be eligible for Miri +- targets: at least one target must be eligible for semver checking +- target_sets[0]: target-set expression cannot select no targets +- toolchains[0].scopes: selection cannot be empty +- miri_models[0].flags[0]: each Miri flag must be one non-empty argument beginning with `-` and containing no whitespace or control characters +- miri.scopes: selection cannot be empty +- semver.waivers[0].issue: `not-an-issue` is not an issue reference such as `#1565` +- semver.waivers[0].reason: waiver reason cannot be empty +- baselines.manifest: "../manifest.tsv" is not a safe repository-relative path; use non-empty `/`-separated components without `.`, `..`, a root, or a platform prefix +- limits.max_matrix_cells: must be between 1 and GitHub's hard limit of 256 +- limits.max_plan_cells: must be between 1 and the hard safety bound of 65536 +- limits.max_job_output_utf16_bytes: must be between 1 and the safe GitHub bound of 1000000 +- feature_profiles.same: feature profile is not valid for any package +- targets.target: target is not selected by any target set +- toolchains: no standard toolchain uses source `manifest-rust-version` +- toolchains: no standard toolchain uses source `pinned-stable` +- targets.target: target is never selected by an ordinary toolchain scope +- miri.toolchain: unknown toolchain `missing` +- semver.package: unknown package `missing` +- semver.profile: unknown feature profile `missing` +- semver.toolchain: unknown toolchain `missing` +- semver.target_set: unknown target set `missing` +- semver.waivers: unknown target `missing` +- target_sets.empty: target set is never referenced diff --git a/tools/zc/testdata/policy-invalid-multiple.toml b/tools/zc/testdata/policy-invalid-multiple.toml new file mode 100644 index 0000000000..f2bdfa81be --- /dev/null +++ b/tools/zc/testdata/policy-invalid-multiple.toml @@ -0,0 +1,91 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# Keep independent semantic mistakes in this fixture. One validation pass must +# report them together so a policy author can repair more than one at a time. +schema_version = 99 + +[events] +reduced = ["pull_request"] +full = ["pull_request"] + +[features] +stable_feature_root = "Bad root" + +[[feature_profiles]] +id = "same" +selection = "default" + +[[feature_profiles]] +id = "same" +selection = "all" + +[[packages]] +id = "bad package" +manifest = "../outside.toml" +profiles = [] + +[[targets]] +id = "target" +mode = "native" +pr_eligible = false +miri_eligible = false +semver_eligible = false + +[[targets]] +id = "target" +mode = "cross" +pr_eligible = false +miri_eligible = false +semver_eligible = false + +[[target_sets]] +id = "empty" +selection = "explicit" +include = [] +exclude = [] + +[[toolchains]] +id = "nightly" +source = "pinned-nightly" +scopes = [] + +[[miri_models]] +id = "stacked" +flags = [""] + +[miri] +toolchain = "missing" +event_category = "full" +scopes = [] + +[semver] +package = "missing" +toolchain = "missing" +profile = "missing" +target_set = "missing" + +[[semver.waivers]] +target = "missing" +issue = "not-an-issue" +reason = "" + +[baselines] +manifest = "../manifest.tsv" +build_reduced = "ci/baselines/build-pr.tsv" +build_full = "ci/baselines/build-full.tsv" +miri_reduced = "ci/baselines/miri-pr.tsv" +miri_full = "ci/baselines/miri-full.tsv" +logical_obligations = "ci/baselines/logical-obligations.tsv" +standalone_obligations = "ci/baselines/standalone-obligations.tsv" +command_goldens = "ci/baselines/command-goldens.tsv" + +[limits] +max_matrix_cells = 257 +max_plan_cells = 65537 +max_job_output_utf16_bytes = 1000001 diff --git a/tools/zc/testdata/policy-unknown-key.toml b/tools/zc/testdata/policy-unknown-key.toml new file mode 100644 index 0000000000..f133512c31 --- /dev/null +++ b/tools/zc/testdata/policy-unknown-key.toml @@ -0,0 +1,11 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# This fixture proves that top-level typos fail before semantic validation. +schema_version = 1 +unexpected = true