diff --git a/.gitattributes b/.gitattributes index 4021947934..7edea75741 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,14 @@ -# Shell entry points must remain LF so they can run before Rust tooling exists. -# The shared bootstrap parser still accepts well-formed CRLF in the two TOML -# files needed by an existing Windows worktree. Keep these rules coordinated -# with `tools/toolchain.sh` and `ci/check_tools.sh`. +# The typed CI tools audit the data formats below at source level. The shared +# repository-text boundary normalizes CRLF left in an existing Windows +# worktree. Shell entry points must themselves remain LF so they can run before +# Rust is built; `tools/toolchain.sh` then handles the two TOML values needed +# during bootstrap. Pure parsers and bare carriage returns remain strict. Keep +# this list coordinated with `tools/zc/src/ci.rs`, `repository_text.rs`, the +# bootstrap parser, and their tests so Git's index remains canonical and new +# checkouts receive LF. *.sh text eol=lf githooks/pre-push text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.tsv text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4335e457c6..011442293b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Every job and action inherits this mapping. The exact keys and values are a +# source-level contract with `planned_adapter/planner.rs`; adding even a +# seemingly unrelated variable can change Cargo, rustup, the compiler, or a +# third-party action. Coordinate every change with that complete typed audit. env: CARGO_TERM_COLOR: always # These are bounded, client-native retries for recognized transient download @@ -59,8 +63,10 @@ jobs: # jobs consume only the checked selectors from each matrix cell and pass them # back to `cargo-zerocopy`, which reconstructs and executes the complete # command without interpreting matrix data as shell text. Keep the output - # names coordinated with `tools/zc/src/github.rs`, the two `fromJSON` - # expressions below, and the Miri eligibility consumers below. + # names coordinated with `tools/zc/src/workflow_protocol.rs`, the two + # `fromJSON` expressions below, and the Miri eligibility consumers below. + # The producer fields are checked by the planned-job workflow audit in + # `tools/zc/src/planned_adapter/planner.rs`. plan_ci: name: Plan ordinary CI work runs-on: ubuntu-latest @@ -70,7 +76,7 @@ jobs: build_matrix: ${{ steps.plan.outputs.build_matrix }} miri_matrix: ${{ steps.plan.outputs.miri_matrix }} # This gate is derived from the projected Miri matrix, not independently - # from the event name. Keep it coordinated with `tools/zc/src/github.rs`, + # from the event name. Keep it coordinated with `workflow_protocol.rs`, # the Miri job condition, and the required-check aggregation. miri_enabled: ${{ steps.plan.outputs.miri_enabled }} env: @@ -88,6 +94,7 @@ jobs: # The absolute custom shell removes Bash startup-control variables and # enables privileged mode so inherited startup files, shell options, # and exported functions cannot turn this step into a successful no-op. + # The planner audit checks this exact shell template. shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} working-directory: zerocopy env: @@ -95,7 +102,8 @@ jobs: # The runner applies GITHUB_PATH after merging step env, so assign the # fixed path directly to the child command. Do not rely on cargo.sh's # /usr/bin/env bash shebang. Privileged mode also prevents an imported - # function from replacing a builtin when this child Bash starts. + # function from replacing a builtin when this child Bash starts. The + # planner audit checks the run block line-for-line. run: | set -euo pipefail PATH=/home/runner/.cargo/bin:/usr/local/bin:/usr/bin:/bin \ @@ -142,7 +150,7 @@ jobs: name: Build & Test (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.target }}) steps: - - &matrix_checkout + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # `Prepare cargo-semver-checks` reads the pull request head commit by @@ -156,7 +164,7 @@ jobs: # artifact name. The ID identifies one immutable artifact from this exact # run, so a renamed, stale, or Buildx-generated artifact cannot be selected # accidentally. Checkout must remain first because this is a local action. - - &download_ci_image + - name: Download prebuilt Docker image uses: ./.github/actions/download-artifact-with-retry with: @@ -169,7 +177,7 @@ jobs: # producer and every consumer intentionally use the same ubuntu-latest # runner architecture. If CI gains another architecture, build and select a # distinct artifact ID for it rather than silently sharing this archive. - - &load_ci_image + - name: Load prebuilt Docker image shell: bash env: @@ -187,7 +195,7 @@ jobs: # that the archive matches this runner's architecture. docker run --rm "$IMAGE_NAME" true - - &create_docker_shell + - name: Create Docker Shell Wrapper shell: bash run: | @@ -383,9 +391,50 @@ jobs: name: Miri (${{ matrix.crate }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }}) steps: - - *matrix_checkout - - *download_ci_image - - *load_ci_image + - + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # `Prepare cargo-semver-checks` reads the pull request head commit by + # its explicit SHA. Keep this depth large enough to include that parent + # of GitHub's synthetic pull request merge commit. A missing object + # makes `git log` fail rather than checking a different message. + fetch-depth: 2 + persist-credentials: false + + # The producer exposes the ID assigned by upload-artifact, rather than an + # artifact name. The ID identifies one immutable artifact from this exact + # run, so a renamed, stale, or Buildx-generated artifact cannot be selected + # accidentally. Checkout must remain first because this is a local action. + - + name: Download prebuilt Docker image + uses: ./.github/actions/download-artifact-with-retry + with: + artifact-id: ${{ needs.build_docker_env.outputs.image_artifact_id }} + path: ${{ runner.temp }} + expected-file: ${{ env.ZC_CI_IMAGE_ARCHIVE }} + + # This step runs before /tmp/docker-shell.sh exists, so its explicit Bash + # shell is load-bearing. The Docker exporter is single-platform; the + # producer and every consumer intentionally use the same ubuntu-latest + # runner architecture. If CI gains another architecture, build and select a + # distinct artifact ID for it rather than silently sharing this archive. + - + name: Load prebuilt Docker image + shell: bash + env: + IMAGE_ARCHIVE: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }} + IMAGE_NAME: ${{ env.ZC_CI_IMAGE }} + run: | + set -euo pipefail + # Keep the large archive only as long as `docker load` needs it. The + # trap also reclaims it if loading or validation fails, avoiding archive + # plus expanded-image disk pressure for later diagnostic steps. + trap 'rm -f -- "$IMAGE_ARCHIVE"' EXIT + docker load --input "$IMAGE_ARCHIVE" + docker image inspect "$IMAGE_NAME" >/dev/null + # `inspect` proves the tag exists; running a trivial command also proves + # that the archive matches this runner's architecture. + docker run --rm "$IMAGE_NAME" true # As with ordinary cells, the workflow passes only selectors. Model flags, # feature arguments, the Cargo configuration transaction, and target diff --git a/tools/Cargo.lock b/tools/Cargo.lock index 41ae72c476..bcacb778d7 100644 --- a/tools/Cargo.lock +++ b/tools/Cargo.lock @@ -868,6 +868,7 @@ name = "zc" version = "0.0.0" dependencies = [ "cargo_metadata 0.23.1", + "libyaml-rs", "same-file", "serde", "serde_json", diff --git a/tools/Cargo.toml b/tools/Cargo.toml index ae08afacba..927557e768 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -22,6 +22,7 @@ license = "BSD-2-Clause OR Apache-2.0 OR MIT" publish = false [workspace.dependencies] +libyaml-rs = "0.3.0" regex = "1" serde = { version = "1", features = ["derive"] } thiserror = "2" diff --git a/tools/generate-readme/Cargo.toml b/tools/generate-readme/Cargo.toml index 1c83e4b527..626f66dadb 100644 --- a/tools/generate-readme/Cargo.toml +++ b/tools/generate-readme/Cargo.toml @@ -1,17 +1,17 @@ -# Copyright 2024 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. - -[package] -edition.workspace = true -name = "generate-readme" -version.workspace = true -license.workspace = true -publish.workspace = true - -[dependencies] -regex.workspace = true +# Copyright 2024 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. + +[package] +edition.workspace = true +name = "generate-readme" +version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +regex.workspace = true diff --git a/tools/zc/Cargo.toml b/tools/zc/Cargo.toml index 46509f7930..94fbdd47d7 100644 --- a/tools/zc/Cargo.toml +++ b/tools/zc/Cargo.toml @@ -15,6 +15,7 @@ publish.workspace = true [dependencies] cargo_metadata = "0.23.1" +libyaml-rs.workspace = true same-file = "1.0.6" serde.workspace = true serde_json = "1" diff --git a/tools/zc/src/baseline.rs b/tools/zc/src/baseline.rs index dcf1ee6bf9..60db61f867 100644 --- a/tools/zc/src/baseline.rs +++ b/tools/zc/src/baseline.rs @@ -25,13 +25,16 @@ use std::{ collections::{BTreeMap, BTreeSet}, error::Error, fmt, - fs::{self, File}, - io::{self, Read}, + fs::File, + io, path::{Component, Path, PathBuf}, str::FromStr, }; -use crate::identifier::{self, IdentifierError, MAX_ID_BYTES}; +use crate::{ + identifier::{self, IdentifierError, MAX_ID_BYTES}, + repository_text, +}; const MANIFEST_HEADER: &str = "key\tvalue"; const BUILD_HEADER: &str = "crate\ttoolchain\tfeature_profile\ttarget"; const MIRI_HEADER: &str = "crate\ttoolchain\tfeature_profile\ttarget\tmiri_model\tmiri_model_flags"; @@ -1122,7 +1125,7 @@ impl Error for BaselineError { } fn read_source(path: &Path) -> Result { - fs::read_to_string(path).map_err(|source| BaselineError { + repository_text::read(path).map_err(|source| BaselineError { path: path.to_path_buf(), line: None, message: format!("failed to read file: {source}"), @@ -1131,15 +1134,12 @@ fn read_source(path: &Path) -> Result { } fn read_open_source(path: &Path, file: &File) -> Result { - let mut source = String::new(); - let mut reader = file; - reader.read_to_string(&mut source).map_err(|source| BaselineError { + repository_text::read_open(file).map_err(|source| BaselineError { path: path.to_path_buf(), line: None, message: format!("failed to read file: {source}"), source: Some(source), - })?; - Ok(source) + }) } struct BaselineSources<'a> { @@ -2419,6 +2419,37 @@ mod tests { .contains("comments must not contain control characters")); } + #[test] + fn retained_baseline_handles_normalize_only_well_formed_crlf() { + use std::{ + fs, process, + sync::atomic::{AtomicU64, Ordering}, + }; + + static NEXT_FILE: AtomicU64 = AtomicU64::new(0); + let unique = NEXT_FILE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir() + .join(format!("zerocopy-open-baseline-text-test-{}-{unique}.tsv", process::id())); + + // `CiInputs::load` deliberately retains already-open baseline handles + // so identity checks and parsing inspect the same file. Prove that + // this identity-preserving path has exactly the repository-text + // boundary promised for path-based reads: complete CRLF records are + // normalized, while an isolated carriage return remains invalid. + fs::write(&path, "header\r\nrow\r\n").unwrap(); + let file = File::open(&path).unwrap(); + assert_eq!(read_open_source(Path::new("baseline.tsv"), &file).unwrap(), "header\nrow\n"); + drop(file); + + fs::write(&path, "header\rrow\n").unwrap(); + let file = File::open(&path).unwrap(); + let error = read_open_source(Path::new("baseline.tsv"), &file).unwrap_err(); + assert!(error.to_string().contains("bare carriage return"), "{error}"); + drop(file); + + fs::remove_file(path).unwrap(); + } + #[test] fn baseline_identifiers_enforce_the_shared_byte_bound() { let maximum = "a".repeat(MAX_ID_BYTES); diff --git a/tools/zc/src/ci.rs b/tools/zc/src/ci.rs index 0a305ddf17..d535a4d443 100644 --- a/tools/zc/src/ci.rs +++ b/tools/zc/src/ci.rs @@ -11,8 +11,9 @@ //! Loading CI inputs is intentionally all-or-nothing. A caller cannot obtain a //! [`CiInputs`] until the policy is valid, its references agree with live Cargo //! metadata and repository files, every workflow job has an exact reviewed -//! role, every independently recorded legacy baseline parses canonically, and -//! the typed execution model exactly reproduces that legacy evidence. Planners +//! role, the handwritten plan publisher exactly exposes typed outputs, the +//! independently recorded legacy baseline parses canonically, and the typed +//! execution model exactly reproduces that legacy evidence. Planners //! therefore consume checked data rather than remembering which validation //! passes must precede which lookups. @@ -28,12 +29,14 @@ use crate::{ baseline::{BaselineError, LegacyBaselineFiles, LegacyBaselinePaths, LegacyBaselines}, execution::{audit_execution, ExecutionAuditError}, inventory::{AuditError, RepositoryInventory}, + planned_adapter::{audit_planned_adapter, PlannedAdapterAuditError}, policy::{Baselines, Policy, ReadPolicyError}, repository_file::{self, OpenRepositoryFileError, OpenedRepositoryFile}, workflow::{ audit_workflows, ReviewedWorkflowJobs, WorkflowAuditError, WorkflowRegistryError, WORKFLOW_REGISTRY_PATH, }, + workflow_protocol::WORKFLOW_PATH, }; /// The repository-relative location of the typed CI policy. @@ -89,9 +92,17 @@ impl CiInputs { .map_err(|error| { LoadCiError::Workflow(Box::new(WorkflowAuditError::Registry(error))) })?; - let (workflow_jobs, _workflow_sources) = + let (workflow_jobs, workflow_sources) = audit_workflows(&repository_root, reviewed_workflow_jobs) .map_err(|error| LoadCiError::Workflow(Box::new(error)))?; + // Job-ID inventory cannot prove that the producer publishes the exact + // typed outputs through a real command. Audit that small planned-job + // workflow bridge using the exact bytes retained by the inventory + // pass, rather than reopening a possibly replaced path. + let workflow_source = workflow_sources.source(WORKFLOW_PATH).ok_or_else(|| { + LoadCiError::RequiredWorkflowMissing { path: WORKFLOW_PATH.to_owned() } + })?; + audit_planned_adapter(workflow_source).map_err(LoadCiError::PlannedAdapter)?; let repository = RepositoryInventory::audit(&repository_root, &policy) .map_err(LoadCiError::Inventory)?; let baseline_files = OpenLegacyBaselineFiles::open(&repository_root, policy.baselines())?; @@ -334,6 +345,12 @@ pub enum LoadCiError { /// Workflow files or their reviewed role assignments were invalid. #[error(transparent)] Workflow(Box), + /// A behavioral audit expected a workflow absent from the checked tree. + #[error("required CI workflow `{path}` was not discovered")] + RequiredWorkflowMissing { path: String }, + /// The planned-job workflow bridge did not publish typed outputs exactly. + #[error(transparent)] + PlannedAdapter(PlannedAdapterAuditError), /// The frozen legacy evidence was unreadable or noncanonical. #[error(transparent)] Baseline(BaselineError), @@ -344,7 +361,7 @@ pub enum LoadCiError { #[cfg(test)] mod tests { - use std::{fs, path::Path}; + use std::{fs, path::Path, process::Command}; use super::{ open_repository_file, reject_duplicate_baseline_inputs, CiInputs, LoadCiError, @@ -394,6 +411,56 @@ mod tests { assert_eq!(inputs.repository().policy_packages().len(), 2); } + #[test] + fn repository_attributes_keep_ci_inputs_and_bootstrap_scripts_lf() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + // These paths represent every semantic extension consumed by CiInputs + // plus the shell code which must run before the Rust reader exists. The + // hypothetical YAML path keeps the currently unused `*.yaml` rule + // checked as well. Keep the set coordinated with `.gitattributes` when + // a new source-level input or bootstrap format is introduced. + let paths = [ + "tools/toolchain.sh", + "githooks/pre-push", + ".github/workflows/ci.yml", + "ci/future-input.yaml", + "ci/zc.toml", + "ci/workflow-jobs.tsv", + "ci/baselines/command-goldens.tsv", + "zerocopy/Cargo.toml", + ]; + let output = Command::new("git") + .current_dir(&root) + .args(["check-attr", "eol", "--"]) + .args(paths) + .output() + .unwrap(); + assert!(output.status.success(), "git check-attr failed: {output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let expected = paths.map(|path| format!("{path}: eol: lf")); + assert_eq!(stdout.lines().collect::>(), expected); + + // Assigning `eol=lf` does not retroactively rewrite blobs already in + // Git's index, and changing the attribute does not rewrite an existing + // Windows worktree. `repository_text` handles the latter by + // normalizing well-formed CRLF at the read boundary. Check the index + // separately so adding or broadening a rule cannot leave a fresh + // checkout dirty. A `w/crlf` worktree report is intentionally allowed. + let output = + Command::new("git").current_dir(&root).args(["ls-files", "--eol"]).output().unwrap(); + assert!(output.status.success(), "git ls-files --eol failed: {output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let governed = + stdout.lines().filter(|line| line.contains("attr/text eol=lf")).collect::>(); + assert!(!governed.is_empty(), "no tracked paths are governed by eol=lf"); + for line in governed { + assert!( + line.starts_with("i/lf"), + "tracked path governed by eol=lf is not normalized: {line}" + ); + } + } + #[cfg(unix)] #[test] fn rejects_an_input_symlink_which_escapes_the_repository() { diff --git a/tools/zc/src/cli.rs b/tools/zc/src/cli.rs index bca4d67228..4a17a7939d 100644 --- a/tools/zc/src/cli.rs +++ b/tools/zc/src/cli.rs @@ -39,6 +39,11 @@ use crate::{ BuildPlanCell, ExecutionMode, FeatureSelection, MiriPlanCell, Plan, PlanError, PlanExplanation, }, + workflow_protocol::{ + CELL_FEATURE_PROFILE_OPTION, CELL_MIRI_MODEL_OPTION, CELL_PACKAGE_OPTION, + CELL_TARGET_OPTION, CELL_TOOLCHAIN_OPTION, CI_EVENT_OPTION, EXECUTE_BUILD_CELL_COMMAND, + EXECUTE_MIRI_CELL_COMMAND, GITHUB_OUTPUT_OPTION, GITHUB_PLAN_COMMAND, PLAN_ARTIFACT_OPTION, + }, }; /// Runs one local `cargo-zerocopy ci` command. @@ -114,9 +119,9 @@ impl Command { Ok(Self::Explain { event }) } } - "github-plan" => parse_github_plan(args), - "execute-build-cell" => parse_execution_cell(args, false), - "execute-miri-cell" => parse_execution_cell(args, true), + GITHUB_PLAN_COMMAND => parse_github_plan(args), + EXECUTE_BUILD_CELL_COMMAND => parse_execution_cell(args, false), + EXECUTE_MIRI_CELL_COMMAND => parse_execution_cell(args, true), _ => Err(CliError::UnknownCommand { command }), } } @@ -126,7 +131,7 @@ fn parse_execution_cell( args: impl IntoIterator, miri: bool, ) -> Result { - let command = if miri { "execute-miri-cell" } else { "execute-build-cell" }; + let command = if miri { EXECUTE_MIRI_CELL_COMMAND } else { EXECUTE_BUILD_CELL_COMMAND }; let mut args = args.into_iter(); let mut event = None; let mut package = None; @@ -140,12 +145,12 @@ fn parse_execution_cell( .split_once('=') .map_or((argument.as_str(), None), |(name, value)| (name, Some(value))); let destination = match name { - "--event" => &mut event, - "--package" => &mut package, - "--toolchain" => &mut toolchain, - "--feature-profile" => &mut feature_profile, - "--target" => &mut target, - "--miri-model" if miri => &mut miri_model, + CI_EVENT_OPTION => &mut event, + CELL_PACKAGE_OPTION => &mut package, + CELL_TOOLCHAIN_OPTION => &mut toolchain, + CELL_FEATURE_PROFILE_OPTION => &mut feature_profile, + CELL_TARGET_OPTION => &mut target, + CELL_MIRI_MODEL_OPTION if miri => &mut miri_model, _ => { return Err(CliError::UnknownArgument { command: command.to_owned(), argument }); } @@ -182,11 +187,11 @@ fn parse_execution_cell( *destination = Some(value); } - let event = required_option(command, "--event", event)?; - let package = required_option(command, "--package", package)?; - let toolchain = required_option(command, "--toolchain", toolchain)?; - let feature_profile = required_option(command, "--feature-profile", feature_profile)?; - let target = required_option(command, "--target", target)?; + let event = required_option(command, CI_EVENT_OPTION, event)?; + let package = required_option(command, CELL_PACKAGE_OPTION, package)?; + let toolchain = required_option(command, CELL_TOOLCHAIN_OPTION, toolchain)?; + let feature_profile = required_option(command, CELL_FEATURE_PROFILE_OPTION, feature_profile)?; + let target = required_option(command, CELL_TARGET_OPTION, target)?; if miri { Ok(Command::ExecuteMiriCell { selector: MiriCellSelector::new( @@ -195,7 +200,7 @@ fn parse_execution_cell( toolchain, feature_profile, target, - required_option(command, "--miri-model", miri_model)?, + required_option(command, CELL_MIRI_MODEL_OPTION, miri_model)?, ), }) } else { @@ -206,7 +211,7 @@ fn parse_execution_cell( } fn parse_github_plan(args: impl IntoIterator) -> Result { - let command = "github-plan"; + let command = GITHUB_PLAN_COMMAND; let mut args = args.into_iter(); let mut event = None; let mut github_output = None; @@ -217,9 +222,9 @@ fn parse_github_plan(args: impl IntoIterator) -> Result, 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. + +//! Fail-closed audits of the handwritten bridge from typed plans to Actions. +//! +//! The general workflow inventory in [`crate::workflow`] proves that every job +//! has a reviewed role, but it intentionally does not inspect job behavior. +//! The plan producer is a smaller handwritten boundary: it must publish the +//! planner's exact outputs through one unconditional singleton job and one +//! bounded step. A missing output, changed producer, no-op interpreter, or +//! stale CLI command could otherwise silently reduce coverage while the Rust +//! plan and job-ID inventory remained valid. +//! +//! This module is deliberately not a YAML or GitHub Actions interpreter. It +//! recognizes the canonical source forms which carry the planned-job workflow +//! bridge and rejects ambiguous or extended forms. `action-validator` +//! continues to own the complete workflow schema. + +use std::{collections::BTreeSet, error::Error, fmt}; + +use thiserror::Error; + +mod planner; +mod source; +mod yaml_source; + +/// Audits the checked workflow's typed plan publication bridge. +/// +/// `workflow` must be the exact source retained by the earlier workflow +/// inventory pass. Taking source rather than a path prevents this behavioral +/// audit from reopening a replacement file after its job IDs were approved. +pub(crate) fn audit_planned_adapter(workflow: &str) -> Result<(), PlannedAdapterAuditError> { + audit_source(workflow)?; + Ok(()) +} + +fn audit_source(workflow: &str) -> Result<(), PlannedAdapterViolations> { + let mut errors = ViolationSink::default(); + let Some(lines) = source::canonical_workflow_lines(workflow, &mut errors) else { + return Err(errors.finish()); + }; + planner::audit(&lines, &mut errors); + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.finish()) + } +} + +/// A failure reading or validating the planned-job workflow bridge. +#[derive(Debug, Error)] +pub enum PlannedAdapterAuditError { + /// The handwritten bridge no longer publishes the typed plan exactly. + #[error(transparent)] + Invalid(#[from] PlannedAdapterViolations), +} + +/// Deterministically ordered planned-job workflow audit violations. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlannedAdapterViolations(Vec); + +impl PlannedAdapterViolations { + /// Returns all violations in location and message order. + pub fn violations(&self) -> &[PlannedAdapterViolation] { + &self.0 + } +} + +impl fmt::Display for PlannedAdapterViolations { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(formatter, "planned-job workflow audit has {} violation(s):", self.0.len())?; + for violation in &self.0 { + writeln!(formatter, "- {}: {}", violation.location, violation.message)?; + } + Ok(()) + } +} + +impl Error for PlannedAdapterViolations {} + +/// One actionable mismatch in the planned-job workflow bridge. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct PlannedAdapterViolation { + location: String, + message: String, +} + +impl PlannedAdapterViolation { + /// Returns the workflow field which must be repaired. + pub fn location(&self) -> &str { + &self.location + } + + /// Returns a plain-language repair diagnostic. + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Default)] +struct ViolationSink(BTreeSet); + +impl ViolationSink { + fn push(&mut self, location: impl Into, message: impl Into) { + self.0.insert(PlannedAdapterViolation { + location: source::escape_control_characters(&location.into()), + message: source::escape_control_characters(&message.into()), + }); + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn finish(self) -> PlannedAdapterViolations { + PlannedAdapterViolations(self.0.into_iter().collect()) + } +} diff --git a/tools/zc/src/planned_adapter/planner.rs b/tools/zc/src/planned_adapter/planner.rs new file mode 100644 index 0000000000..70392b593e --- /dev/null +++ b/tools/zc/src/planned_adapter/planner.rs @@ -0,0 +1,1137 @@ +// 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. + +//! Exact publication audit for the typed CI plan producer. + +use std::collections::BTreeMap; + +use super::{ + source::{ + audit_exact_job_fields, audit_exact_mapping, audit_exact_scalar_field, + audit_read_permissions, audit_singleton_job_contract, audit_step, + audit_unique_run_mentions, audited_steps_block, compare_map, exact_step_lines, find_job, + indentation, job_field_location, job_fields, nested_mapping, step_field_location, + unique_field, Field, MappingExpectation, RunForm, StepExpectation, + }, + ViolationSink, +}; +use crate::workflow_protocol::{ + BUILD_MATRIX_OUTPUT, CI_EVENT_OPTION, GITHUB_OUTPUT_OPTION, GITHUB_PLAN_COMMAND, + MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT, PLANNER_PATH, PLAN_ARTIFACT_OPTION, PLAN_JOB, + PLAN_STEP_ID, PLAN_STEP_NAME, REPOSITORY_WORKING_DIRECTORY, TRUSTED_SHELL, WORKFLOW_PATH, +}; + +const PLAN_JOB_FIELDS: &[&str] = &["name", "runs-on", "permissions", "outputs", "env", "steps"]; +const PLAN_DISPLAY_NAME: &str = "Plan ordinary CI work"; +const PLAN_ARTIFACT_ENVIRONMENT: &str = "CI_PLAN_ARTIFACT"; +const PLAN_ARTIFACT_NAME: &str = "ci-plan.json"; +// GitHub merges the workflow-level environment into every job. Require one +// canonical spelling and position for every top-level declaration so a second +// YAML spelling such as `'env':` cannot override the mapping which the narrower +// environment audit below sees. This also makes a new root-level `defaults` or +// merge key fail review instead of silently changing command behavior. +// +// Keep this list synchronized with the indentation-zero declarations in +// `.github/workflows/ci.yml`. This list deliberately fixes only the root +// grammar and order; the complete `env` contents are audited below. Other +// nested root mappings retain their existing workflow checks. +const WORKFLOW_ROOT_DECLARATIONS: &[&str] = + &["name: Build & Tests", "'on':", "permissions:", "concurrency:", "env:", "jobs:"]; +// These three steps are a source-level contract with the Rust planner and the +// local upload action. Keep this list synchronized with `.github/workflows/ci.yml`: +// changing the artifact name, checkout credential behavior, or step order here +// without changing that workflow would make the audit describe a different +// bridge than the one GitHub executes. +const CHECKOUT_STEP: &[&str] = &[ + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", + " with:", + " persist-credentials: false", +]; +const UPLOAD_STEP: &[&str] = &[ + " - name: Upload detailed plan for review", + " uses: ./.github/actions/upload-file-artifact", + " with:", + " name: ${{ env.CI_PLAN_ARTIFACT }}", + " path: ${{ runner.temp }}/${{ env.CI_PLAN_ARTIFACT }}", + " retention-days: 14", +]; + +pub(super) fn audit(lines: &[&str], errors: &mut ViolationSink) { + audit_workflow_environment(lines, errors); + if let Some(job) = find_job(lines, PLAN_JOB, errors) { + let fields = job_fields(lines, job.clone(), PLAN_JOB, errors); + audit_exact_job_fields(&fields, PLAN_JOB, PLAN_JOB_FIELDS, errors); + audit_exact_scalar_field(&fields, PLAN_JOB, "name", PLAN_DISPLAY_NAME, errors); + audit_read_permissions(lines, job.end, &fields, PLAN_JOB, errors); + let environment = + BTreeMap::from([(PLAN_ARTIFACT_ENVIRONMENT.to_owned(), PLAN_ARTIFACT_NAME.to_owned())]); + audit_exact_mapping( + lines, + job.end, + &fields, + MappingExpectation { job: PLAN_JOB, field: "env", values: &environment }, + errors, + ); + audit_outputs(lines, job.end, &fields, errors); + audit_job(lines, job, &fields, errors); + } + audit_unique_run_mentions(lines, PLAN_STEP_NAME, GITHUB_PLAN_COMMAND, errors); +} + +/// Audits every value inherited from the workflow by the planner and actions. +/// +/// Step- and job-level audits already reject unmodeled environment entries in +/// the jobs they own. The workflow mapping is the remaining merge layer. Check +/// the complete map rather than a list of dangerous variable names: Cargo, +/// rustup, compilers, shells, and third-party actions can all acquire new +/// behavior-bearing variables over time. +fn audit_workflow_environment(lines: &[&str], errors: &mut ViolationSink) { + // Do not try to recognize all equivalent YAML key spellings. Instead, + // require the workflow's small root grammar exactly. This rejects duplicate + // keys even when a YAML parser would normalize a quoted or tagged key to + // `env`, and it keeps future root-level defaults inside this review + // boundary. + let root_declarations = lines + .iter() + .filter(|line| { + indentation(line) == 0 && !line.trim().is_empty() && !line.trim_start().starts_with('#') + }) + .copied() + .collect::>(); + if root_declarations != WORKFLOW_ROOT_DECLARATIONS { + errors.push( + WORKFLOW_PATH, + format!( + "top-level declarations must be exactly {WORKFLOW_ROOT_DECLARATIONS:?} in order, found {root_declarations:?}" + ), + ); + } + + // The root check above proves that this is the only workflow-level `env` + // declaration. Use the same exact nested-mapping audit as job and step + // environments for its complete contents. + let mut fields = Vec::new(); + for (line_number, line) in lines.iter().enumerate() { + if indentation(line) != 0 || line.trim_start().starts_with('#') { + continue; + } + let Some((key, remainder)) = line.split_once(':') else { + continue; + }; + if key != "env" { + continue; + } + if line.trim_end() != *line { + errors.push( + format!(".github/workflows/ci.yml:{}", line_number + 1), + "the workflow environment declaration must not trail spaces", + ); + } + let value = if remainder.is_empty() { + "" + } else { + remainder.strip_prefix(' ').unwrap_or(remainder) + }; + fields.push(Field { line: line_number, indent: 0, key, value }); + } + let expected = BTreeMap::from([ + ("CARGO_NET_RETRY".to_owned(), "\"10\"".to_owned()), + ("CARGO_TERM_COLOR".to_owned(), "always".to_owned()), + ("CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN".to_owned(), "1".to_owned()), + ("RUSTDOCFLAGS".to_owned(), "-Dwarnings --cfg=zerocopy_unstable_ptr".to_owned()), + ("RUSTFLAGS".to_owned(), "-Dwarnings".to_owned()), + ("RUSTUP_MAX_RETRIES".to_owned(), "\"10\"".to_owned()), + ("ZC_CI_IMAGE".to_owned(), "zerocopy-ci:local".to_owned()), + ("ZC_CI_IMAGE_ARCHIVE".to_owned(), "zerocopy-ci.tar".to_owned()), + ( + "ZC_NIGHTLY_MIRIFLAGS".to_owned(), + "\"-Zmiri-strict-provenance -Zmiri-backtrace=full\"".to_owned(), + ), + ("ZC_NIGHTLY_RUSTFLAGS".to_owned(), "-Zrandomize-layout".to_owned()), + ]); + audit_exact_mapping( + lines, + lines.len(), + &fields, + MappingExpectation { job: "workflow", field: "env", values: &expected }, + errors, + ); +} + +fn audit_outputs( + lines: &[&str], + job_end: usize, + fields: &[super::source::Field<'_>], + errors: &mut ViolationSink, +) { + let Some(outputs) = unique_field(fields, "outputs", PLAN_JOB, errors) else { + return; + }; + if !outputs.value.is_empty() { + errors.push( + job_field_location(PLAN_JOB, "outputs"), + "outputs must use the canonical nested mapping form", + ); + return; + } + + let actual = nested_mapping(lines, outputs, job_end, PLAN_JOB, errors); + let expected = [BUILD_MATRIX_OUTPUT, MIRI_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT] + .into_iter() + .map(|output| (output.to_owned(), plan_output_expression(output))) + .collect::>(); + compare_map(job_field_location(PLAN_JOB, "outputs"), &expected, &actual, errors); +} + +fn audit_job( + lines: &[&str], + job: std::ops::Range, + fields: &[super::source::Field<'_>], + errors: &mut ViolationSink, +) { + if fields.iter().any(|field| field.key == "if") { + errors.push( + job_field_location(PLAN_JOB, "if"), + "the checked planner must run on every workflow event", + ); + } + audit_singleton_job_contract(fields, PLAN_JOB, errors); + + let scalar_fields = BTreeMap::from([ + ("id".to_owned(), PLAN_STEP_ID.to_owned()), + ("shell".to_owned(), TRUSTED_SHELL.to_owned()), + ("working-directory".to_owned(), REPOSITORY_WORKING_DIRECTORY.to_owned()), + ]); + let environment = + BTreeMap::from([("EVENT_NAME".to_owned(), "${{ github.event_name }}".to_owned())]); + let run = planner_run(); + if let Some(steps) = audited_steps_block(fields, job, PLAN_JOB, errors) { + let actual_steps = exact_step_lines(lines, &steps); + if actual_steps.len() != 3 { + errors.push( + job_field_location(PLAN_JOB, "steps"), + format!( + "plan_ci.steps must contain exactly three steps, found {}", + actual_steps.len() + ), + ); + } else { + // The first and last steps are intentionally checked as complete + // source snippets. This makes additions, reordering, action-pin + // changes, and artifact-path drift fail closed at the handwritten + // workflow/Rust protocol boundary. + for (actual, expected, label) in [ + (&actual_steps[0], CHECKOUT_STEP, "checkout"), + (&actual_steps[2], UPLOAD_STEP, "upload"), + ] { + if actual != expected { + errors.push( + job_field_location(PLAN_JOB, "steps"), + format!("{label} step must match the exact canonical contract"), + ); + } + } + } + audit_step( + lines, + &steps, + StepExpectation { + job: PLAN_JOB, + name: PLAN_STEP_NAME, + root_fields: &["id", "shell", "working-directory", "env", "run"], + scalar_fields: &scalar_fields, + environment: &environment, + run: &run, + run_form: RunForm::Block, + }, + errors, + ); + + let canonical_id = format!(" id: {PLAN_STEP_ID}"); + let id_count = lines + .iter() + .enumerate() + .filter(|(index, line)| steps.range.contains(index) && **line == canonical_id) + .count(); + if id_count != 1 { + errors.push( + step_field_location(PLAN_STEP_NAME, "id"), + format!( + "expected exactly one canonical `{PLAN_STEP_ID}` step ID, found {id_count}" + ), + ); + } + } +} + +fn planner_run() -> Vec { + vec![ + "set -euo pipefail".to_owned(), + format!("PATH={PLANNER_PATH} \\"), + format!("/bin/bash --noprofile --norc -p ./cargo.sh ci {GITHUB_PLAN_COMMAND} \\"), + format!(" {CI_EVENT_OPTION} \"$EVENT_NAME\" \\"), + format!(" {GITHUB_OUTPUT_OPTION} \"$GITHUB_OUTPUT\" \\"), + format!(" {PLAN_ARTIFACT_OPTION} \"$RUNNER_TEMP/$CI_PLAN_ARTIFACT\""), + ] +} + +fn plan_output_expression(output: &str) -> String { + format!("${{{{ steps.{PLAN_STEP_ID}.outputs.{output} }}}}") +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::super::{audit_planned_adapter, audit_source, PlannedAdapterViolations}; + use crate::workflow_protocol::{ + GITHUB_PLAN_COMMAND, PLAN_JOB, PLAN_STEP_NAME, TRUSTED_SHELL, WORKFLOW_PATH, + }; + + const CANONICAL_SOURCE: &str = r#"name: Build & Tests +'on': + pull_request: +permissions: + contents: read +concurrency: + group: test +env: + CARGO_TERM_COLOR: always + CARGO_NET_RETRY: "10" + RUSTUP_MAX_RETRIES: "10" + ZC_CI_IMAGE: zerocopy-ci:local + ZC_CI_IMAGE_ARCHIVE: zerocopy-ci.tar + RUSTFLAGS: -Dwarnings + RUSTDOCFLAGS: -Dwarnings --cfg=zerocopy_unstable_ptr + ZC_NIGHTLY_RUSTFLAGS: -Zrandomize-layout + ZC_NIGHTLY_MIRIFLAGS: "-Zmiri-strict-provenance -Zmiri-backtrace=full" + CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN: 1 +jobs: + plan_ci: + name: Plan ordinary CI work + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + build_matrix: ${{ steps.plan.outputs.build_matrix }} + miri_matrix: ${{ steps.plan.outputs.miri_matrix }} + miri_enabled: ${{ steps.plan.outputs.miri_enabled }} + env: + CI_PLAN_ARTIFACT: ci-plan.json + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate inputs and project the plan + id: plan + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + working-directory: zerocopy + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + PATH=/home/runner/.cargo/bin:/usr/local/bin:/usr/bin:/bin \ + /bin/bash --noprofile --norc -p ./cargo.sh ci github-plan \ + --event "$EVENT_NAME" \ + --github-output "$GITHUB_OUTPUT" \ + --artifact "$RUNNER_TEMP/$CI_PLAN_ARTIFACT" + + - name: Upload detailed plan for review + uses: ./.github/actions/upload-file-artifact + with: + name: ${{ env.CI_PLAN_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.CI_PLAN_ARTIFACT }} + retention-days: 14 + next_job: + runs-on: ubuntu-latest +"#; + + fn rejected(label: &str, source: &str, expected: &str) { + let error = match audit_source(source) { + Ok(()) => panic!("{label}: mutation was accepted"), + Err(error) => error, + }; + assert!(error.to_string().contains(expected), "{label}: {error}"); + } + + fn replace_once(source: &str, from: &str, to: &str) -> String { + assert_eq!(source.matches(from).count(), 1, "fixture occurrence for {from:?}"); + source.replacen(from, to, 1) + } + + fn reorder_plan_steps(source: &str, order: [usize; 3]) -> String { + let starts = [ + source.find(" - uses: actions/checkout@").unwrap(), + source.find(" - name: Validate inputs and project the plan").unwrap(), + source.find(" - name: Upload detailed plan for review").unwrap(), + ]; + let end = source.find(" next_job:").unwrap(); + let mut chunks = Vec::new(); + for start in &starts { + let chunk_end = + starts.iter().copied().filter(|candidate| *candidate > *start).min().unwrap_or(end); + chunks.push(&source[*start..chunk_end]); + } + format!( + "{}{}{}{}{}", + &source[..starts[0]], + chunks[order[0]], + chunks[order[1]], + chunks[order[2]], + &source[end..] + ) + } + + #[test] + fn accepts_the_literal_fixture_and_live_workflow() { + audit_source(CANONICAL_SOURCE).unwrap(); + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap(); + let workflow = crate::repository_text::read(&root.join(WORKFLOW_PATH)).unwrap(); + audit_planned_adapter(&workflow).unwrap(); + } + + #[test] + fn outputs_are_exact_and_come_from_the_bounded_producer() { + let cases = [ + ( + "wrong build producer", + replace_once( + CANONICAL_SOURCE, + "steps.plan.outputs.build_matrix", + "steps.other.outputs.build_matrix", + ), + "plan_ci.outputs.build_matrix", + ), + ( + "missing Miri output", + replace_once( + CANONICAL_SOURCE, + " miri_matrix: ${{ steps.plan.outputs.miri_matrix }}\n", + "", + ), + "plan_ci.outputs.miri_matrix", + ), + ( + "extra output", + replace_once( + CANONICAL_SOURCE, + " miri_enabled: ${{ steps.plan.outputs.miri_enabled }}\n", + " miri_enabled: ${{ steps.plan.outputs.miri_enabled }}\n surprise: ${{ steps.plan.outputs.surprise }}\n", + ), + "plan_ci.outputs.surprise", + ), + ( + "scalar outputs", + replace_once(CANONICAL_SOURCE, " outputs:\n", " outputs: disabled\n"), + "canonical nested mapping", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn producer_is_an_unconditional_host_singleton_with_one_steps_mapping() { + let header = " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n"; + let cases = [ + ( + "changed runner", + replace_once( + CANONICAL_SOURCE, + header, + " plan_ci:\n name: Plan ordinary CI work\n runs-on: self-hosted\n", + ), + ".runs-on", + ), + ( + "job container", + replace_once( + CANONICAL_SOURCE, + header, + " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n container: ignored.invalid/noop\n", + ), + ".container", + ), + ( + "strategy", + replace_once( + CANONICAL_SOURCE, + header, + " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n strategy:\n matrix:\n include: []\n", + ), + ".strategy", + ), + ( + "job condition", + replace_once( + CANONICAL_SOURCE, + header, + " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n if: github.ref == 'refs/heads/main'\n", + ), + ".if", + ), + ( + "continue on error", + replace_once( + CANONICAL_SOURCE, + header, + " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n continue-on-error: true\n", + ), + ".continue-on-error", + ), + ( + "duplicate steps mapping", + replace_once( + CANONICAL_SOURCE, + " steps:\n", + " steps: []\n steps:\n", + ), + ".steps", + ), + ( + "scalar steps mapping", + replace_once(CANONICAL_SOURCE, " steps:\n", " steps: []\n"), + "nested sequence", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn producer_top_level_fields_permissions_and_environment_are_exact() { + let header = " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n"; + let additions = [ + ("needs", " needs: build_docker_env\n"), + ("concurrency", " concurrency: one-at-a-time\n"), + ("environment", " environment: protected\n"), + ("services", " services: {}\n"), + ("timeout-minutes", " timeout-minutes: 1\n"), + ("defaults", " defaults: {}\n"), + ("uses", " uses: example.invalid/owner/workflow@main\n"), + ("with", " with: {}\n"), + ("secrets", " secrets: inherit\n"), + ]; + for (field, addition) in additions { + let source = replace_once(CANONICAL_SOURCE, header, &format!("{header}{addition}")); + rejected(field, &source, &format!("plan_ci.{field}")); + } + + let cases = [ + ( + "missing name", + replace_once(CANONICAL_SOURCE, " name: Plan ordinary CI work\n", ""), + "plan_ci.name", + ), + ( + "changed name", + replace_once( + CANONICAL_SOURCE, + "name: Plan ordinary CI work", + "name: Maybe plan ordinary CI work", + ), + "plan_ci.name", + ), + ( + "write permissions", + replace_once(CANONICAL_SOURCE, " contents: read", " contents: write"), + "plan_ci.permissions.contents", + ), + ( + "extra permission", + replace_once( + CANONICAL_SOURCE, + " contents: read\n", + " contents: read\n id-token: write\n", + ), + "plan_ci.permissions.id-token", + ), + ( + "scalar permissions", + replace_once(CANONICAL_SOURCE, " permissions:\n", " permissions: read-all\n"), + "canonical nested mapping", + ), + ( + "artifact name", + replace_once(CANONICAL_SOURCE, "CI_PLAN_ARTIFACT: ci-plan.json", "CI_PLAN_ARTIFACT: other.json"), + "plan_ci.env.CI_PLAN_ARTIFACT", + ), + ( + "extra environment", + replace_once( + CANONICAL_SOURCE, + " CI_PLAN_ARTIFACT: ci-plan.json\n", + " CI_PLAN_ARTIFACT: ci-plan.json\n SURPRISE: value\n", + ), + "plan_ci.env.SURPRISE", + ), + ( + "duplicate environment field", + replace_once( + CANONICAL_SOURCE, + " env:\n CI_PLAN_ARTIFACT: ci-plan.json\n", + " env:\n CI_PLAN_ARTIFACT: ci-plan.json\n env:\n CI_PLAN_ARTIFACT: ci-plan.json\n", + ), + "plan_ci.env", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn workflow_environment_is_a_complete_inherited_contract() { + let cases = [ + ( + "added behavior variable", + replace_once( + CANONICAL_SOURCE, + " CARGO_TERM_COLOR: always\n", + " CARGO_TERM_COLOR: always\n LD_PRELOAD: /tmp/interceptor.so\n", + ), + "workflow.env.LD_PRELOAD", + ), + ( + "changed behavior variable", + replace_once( + CANONICAL_SOURCE, + " RUSTFLAGS: -Dwarnings\n", + " RUSTFLAGS: -Awarnings\n", + ), + "workflow.env.RUSTFLAGS", + ), + ( + "removed behavior variable", + replace_once( + CANONICAL_SOURCE, + " RUSTDOCFLAGS: -Dwarnings --cfg=zerocopy_unstable_ptr\n", + "", + ), + "workflow.env.RUSTDOCFLAGS", + ), + ( + "duplicate workflow environment", + replace_once( + CANONICAL_SOURCE, + "jobs:\n", + "env:\n RUSTC_WRAPPER: /tmp/wrapper\njobs:\n", + ), + "workflow.env", + ), + ( + "quoted duplicate workflow environment", + replace_once( + CANONICAL_SOURCE, + "jobs:\n", + "'env':\n RUSTC_WRAPPER: /tmp/wrapper\njobs:\n", + ), + "top-level declarations", + ), + ( + "root-level defaults", + replace_once( + CANONICAL_SOURCE, + "jobs:\n", + "defaults:\n run:\n shell: bash\njobs:\n", + ), + "top-level declarations", + ), + ( + "scalar workflow environment", + replace_once( + CANONICAL_SOURCE, + "env:\n CARGO_TERM_COLOR", + "env: inherited\n CARGO_TERM_COLOR", + ), + "canonical nested mapping", + ), + ( + "scalar continuation", + replace_once( + CANONICAL_SOURCE, + "env:\n CARGO_TERM_COLOR", + "env: inherited\n RUSTC_WRAPPER: /tmp/wrapper\n CARGO_TERM_COLOR", + ), + "canonical nested mapping", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn producer_step_fields_environment_and_run_lines_are_exact() { + let cases = [ + ( + "name", + replace_once(CANONICAL_SOURCE, PLAN_STEP_NAME, "Maybe project the plan"), + "canonical step declaration", + ), + ( + "ID", + replace_once(CANONICAL_SOURCE, " id: plan\n", " id: other\n"), + ".fields.id", + ), + ( + "duplicate ID", + replace_once( + CANONICAL_SOURCE, + " id: plan\n", + " id: plan\n id: plan\n", + ), + "exactly one canonical `plan` step ID", + ), + ( + "working directory", + replace_once( + CANONICAL_SOURCE, + " working-directory: zerocopy\n", + " working-directory: .\n", + ), + ".fields.working-directory", + ), + ( + "event source", + replace_once( + CANONICAL_SOURCE, + "EVENT_NAME: ${{ github.event_name }}", + "EVENT_NAME: ${{ github.ref }}", + ), + ".env.EVENT_NAME", + ), + ( + "conditional step", + replace_once( + CANONICAL_SOURCE, + " id: plan\n", + " id: plan\n if: success()\n", + ), + ".shape", + ), + ( + "weakened strict mode", + replace_once( + CANONICAL_SOURCE, + " set -euo pipefail\n", + " set -eo pipefail\n", + ), + ".run", + ), + ( + "renamed command", + replace_once(CANONICAL_SOURCE, "ci github-plan", "ci wrong-command"), + ".run", + ), + ( + "unquoted event", + replace_once(CANONICAL_SOURCE, "\"$EVENT_NAME\"", "$EVENT_NAME"), + ".run", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn trailing_block_scalar_comments_remain_audited_shell_content() { + let before_upload = "\n\n - name: Upload detailed plan for review"; + for (label, insertion) in [ + ( + "expression in shell comment", + "\n # ${{ github.event.pull_request.body }}\n\n\n - name: Upload detailed plan for review", + ), + ( + "deeper expression in shell comment", + "\n #${{ github.event.pull_request.body }}\n\n - name: Upload detailed plan for review", + ), + ] { + let source = replace_once(CANONICAL_SOURCE, before_upload, insertion); + rejected(label, &source, ".run"); + } + + // A full-line comment below the scalar's content indentation ends the + // scalar and remains free workflow documentation rather than shell + // input. Preserve that distinction while retaining trailing comments. + let source = replace_once( + CANONICAL_SOURCE, + before_upload, + "\n # This comment is outside the run scalar.\n\n - name: Upload detailed plan for review", + ); + audit_source(&source).unwrap(); + } + + #[test] + fn producer_rejects_startup_controls_ambient_path_and_shebang_dispatch() { + let shell = format!(" shell: {TRUSTED_SHELL}\n"); + let mut cases = vec![ + ("bare Bash", replace_once(CANONICAL_SOURCE, &shell, " shell: bash\n")), + ( + "unprivileged shell", + replace_once(CANONICAL_SOURCE, " -p -euo pipefail", " -euo pipefail"), + ), + ( + "ambient PATH", + replace_once( + CANONICAL_SOURCE, + "PATH=/home/runner/.cargo/bin:/usr/local/bin:/usr/bin:/bin", + "PATH=\"$PATH\"", + ), + ), + ( + "cargo shebang", + replace_once( + CANONICAL_SOURCE, + "/bin/bash --noprofile --norc -p ./cargo.sh", + "./cargo.sh", + ), + ), + ]; + for variable in ["BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS"] { + cases.push((variable, replace_once(CANONICAL_SOURCE, &format!("-u {variable} "), ""))); + } + for (label, source) in cases { + rejected( + label, + &source, + if label == "ambient PATH" || label == "cargo shebang" { + ".run" + } else { + ".fields.shell" + }, + ); + } + } + + #[test] + fn command_is_globally_unique_but_step_names_are_scoped_to_steps() { + for (label, run) in [ + ("canonical run key", format!("run: ./cargo.sh ci {GITHUB_PLAN_COMMAND}")), + ("spaced run key", format!("run : ./cargo.sh ci {GITHUB_PLAN_COMMAND}")), + ("quoted run key", format!("\"run\": ./cargo.sh ci {GITHUB_PLAN_COMMAND}")), + ("trailing whitespace", format!("run: ./cargo.sh ci {GITHUB_PLAN_COMMAND} ")), + ] { + let duplicate_command = replace_once( + CANONICAL_SOURCE, + " next_job:\n", + &format!(" next_job:\n steps:\n - {run}\n"), + ); + rejected(label, &duplicate_command, "command mention"); + } + + let duplicate_name = replace_once( + CANONICAL_SOURCE, + " next_job:\n", + &format!( + " next_job:\n steps:\n - name: {PLAN_STEP_NAME}\n run: echo unrelated\n" + ), + ); + audit_source(&duplicate_name).unwrap(); + + let comment = format!("# ./cargo.sh ci {GITHUB_PLAN_COMMAND}\n{CANONICAL_SOURCE}"); + audit_source(&comment).unwrap(); + } + + #[test] + fn line_endings_and_diagnostics_fail_closed() { + rejected("CRLF", &CANONICAL_SOURCE.replace('\n', "\r\n"), "only YAML line break"); + + for separator in ["\r", "\u{85}", "\u{2028}", "\u{2029}"] { + let marker = " - name: Validate inputs and project the plan\n"; + let source = replace_once( + CANONICAL_SOURCE, + marker, + &format!(" # ignored{separator} - run: echo replace-planner\n{marker}"), + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected("YAML-only line break", &source, "only YAML line break"); + } + + let source = CANONICAL_SOURCE.replace(PLAN_JOB, "plan_ci\u{7}"); + let error: PlannedAdapterViolations = audit_source(&source).unwrap_err(); + assert!(!error.to_string().contains('\u{7}')); + } + + #[test] + fn noncanonical_job_indentation_cannot_hide_a_scalar_decoy() { + // YAML sees one three-space job mapping. Its `name` is a multiline + // single-quoted scalar containing a complete, canonical-looking copy + // of the job. The old source scanner skipped the real three-space + // fields and audited that four-space scalar content instead. + let source = + replace_once(CANONICAL_SOURCE, " plan_ci:\n", " plan_ci:\n name: 'decoy\n"); + let source = replace_once( + &source, + " next_job:\n", + " # '\n runs-on: ubuntu-latest\n steps:\n - run: echo replace-planner\n next_job:\n", + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected( + "three-space job with scalar decoy", + &source, + "root `jobs` mapping permits significant lines only", + ); + } + + #[test] + fn job_declaration_cannot_be_borrowed_from_a_root_scalar() { + let job_start = CANONICAL_SOURCE.find(" plan_ci:\n").unwrap(); + let job_end = CANONICAL_SOURCE.find(" next_job:\n").unwrap(); + let decoy = &CANONICAL_SOURCE[job_start..job_end]; + + // The root concurrency value is a valid block scalar with an explicit + // one-space indentation increment. YAML therefore treats the complete + // canonical-looking job and its command as inert scalar text. The real + // job keeps the same parsed key through a quoted spelling but runs a + // different command. A global source search used to audit the decoy. + let source = replace_once(CANONICAL_SOURCE, " plan_ci:\n", " 'plan_ci':\n"); + let source = replace_once(&source, GITHUB_PLAN_COMMAND, "attacker-command"); + let source = replace_once( + &source, + "concurrency:\n group: test\n", + &format!("concurrency:\n group: |1\n{decoy} sentinel\n"), + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected( + "job decoy in root block scalar", + &source, + "direct jobs must use canonical unquoted", + ); + } + + #[test] + fn workflow_root_cannot_be_borrowed_from_flow_scalars() { + let on = CANONICAL_SOURCE.find("'on':\n").unwrap(); + let permissions = CANONICAL_SOURCE.find("permissions:\n").unwrap(); + let name_decoy = &CANONICAL_SOURCE[..on]; + let on_decoy = &CANONICAL_SOURCE[on..permissions]; + let remainder_decoy = &CANONICAL_SOURCE[permissions..]; + assert!(!remainder_decoy.contains('\'')); + + // This is one valid flow mapping. Its multiline scalar values contain + // all of the exact column-zero root declarations and the complete + // canonical planner job. The real jobs mapping is the final flow-map + // entry and runs an unrelated command. Column zero alone therefore + // cannot authenticate scanner-visible source; the first significant + // line must establish the root block mapping before indentation is + // meaningful. + let source = format!( + " {{ name: 'holder\n{name_decoy} end-name',\n run-name: \"holder\n{on_decoy} end-on\",\n concurrency: {{ group: 'holder\n{remainder_decoy} end-rest' }},\n 'on': {{ pull_request: null }},\n permissions: {{ contents: read }},\n env: {{}},\n jobs: {{ 'plan_ci': {{ runs-on: ubuntu-latest, steps: [{{ run: attacker-command }}] }}, next_job: {{ runs-on: ubuntu-latest }} }}\n }}\n" + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected("workflow decoy in root flow scalars", &source, "first significant line"); + } + + #[test] + fn job_declaration_cannot_be_borrowed_from_a_later_flow_scalar() { + let job_start = CANONICAL_SOURCE.find(" plan_ci:\n").unwrap(); + let job_end = CANONICAL_SOURCE.find(" next_job:\n").unwrap(); + let decoy = &CANONICAL_SOURCE[job_start..job_end]; + + // The real jobs use valid one-space indentation. The following job's + // multiline name scalar then contains a complete two-space planner + // decoy. Requiring every direct job declaration through EOF prevents + // the scanner from treating the scalar copy as the real job or from + // ignoring an alternate root mapping after the inspected `jobs:`. + let source = replace_once(CANONICAL_SOURCE, " plan_ci:\n", " 'plan_ci':\n"); + let source = replace_once(&source, GITHUB_PLAN_COMMAND, "attacker-command"); + let source = replace_once( + &source, + " next_job:\n runs-on: ubuntu-latest\n", + &format!( + " next_job: {{ name: 'holder\n{decoy} sentinel', runs-on: ubuntu-latest }}\n" + ), + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected( + "job decoy in later flow scalar", + &source, + "direct jobs must use canonical unquoted", + ); + } + + #[test] + fn job_fields_cannot_be_borrowed_from_a_multiline_scalar() { + let job_marker = " plan_ci:\n"; + let job_start = CANONICAL_SOURCE.find(job_marker).unwrap() + job_marker.len(); + let job_end = CANONICAL_SOURCE.find(" next_job:\n").unwrap(); + let body = &CANONICAL_SOURCE[job_start..job_end]; + + let canonical_order = ["name", "runs-on", "permissions", "outputs", "env", "steps"]; + let starts = canonical_order.map(|key| body.find(&format!(" {key}:")).unwrap()); + let field = |key: &str| { + let position = canonical_order.iter().position(|candidate| *candidate == key).unwrap(); + let start = starts[position]; + let end = starts.get(position + 1).copied().unwrap_or(body.len()); + &body[start..end] + }; + + // The real job uses valid five-space fields. Its first field is a + // multiline scalar containing all canonical four-space fields and + // steps in an order that fooled each old bounded scanner. The actual + // five-space `steps` field then runs an unrelated command. The parser + // precondition rejects the scalar before indentation is trusted. + let decoy = ["steps", "name", "runs-on", "permissions", "outputs", "env"] + .into_iter() + .map(field) + .collect::(); + let source = format!( + "{} name: 'holder\n{decoy} - inert-boundary\n end'\n runs-on: ubuntu-latest\n steps:\n - run: attacker-command\n{}", + &CANONICAL_SOURCE[..job_start], + &CANONICAL_SOURCE[job_end..], + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected( + "job-field decoy in multiline scalar", + &source, + "scalars must stay on one source line", + ); + } + + #[test] + fn job_fields_cannot_be_borrowed_from_an_explicit_block_scalar() { + let job_marker = " plan_ci:\n"; + let job_start = CANONICAL_SOURCE.find(job_marker).unwrap() + job_marker.len(); + let job_end = CANONICAL_SOURCE.find(" next_job:\n").unwrap(); + let decoy = &CANONICAL_SOURCE[job_start..job_end]; + let source = format!( + "{} name: |1\n{decoy} runs-on: ubuntu-latest\n steps:\n - run: attacker-command\n{}", + &CANONICAL_SOURCE[..job_start], + &CANONICAL_SOURCE[job_end..], + ); + let _: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + rejected( + "job-field decoy in explicit block scalar", + &source, + "root `jobs` mapping permits significant lines only", + ); + } + + #[test] + fn planner_job_cannot_be_replayed_through_an_alias() { + // The anchor property is valid at five spaces and does not alter the + // canonical four-space fields below it. Replacing another job with an + // alias then makes Actions execute the complete planner mapping twice, + // even though the checked command still appears only once in source. + // The parser-backed source precondition rejects this valid-YAML replay + // even though the scanner-visible checked command appears only once. + let source = + replace_once(CANONICAL_SOURCE, " plan_ci:\n", " plan_ci:\n &replay_planner\n"); + let source = replace_once( + &source, + " next_job:\n runs-on: ubuntu-latest\n", + " next_job:\n *replay_planner\n", + ); + let parsed: yaml_serde::Value = yaml_serde::from_str(&source).unwrap(); + assert_eq!(parsed["jobs"]["plan_ci"], parsed["jobs"]["next_job"]); + rejected("planner replay through an alias", &source, "anchors"); + } + + #[test] + fn scalar_fields_reject_indented_continuations_but_mappings_remain_valid() { + let source = replace_once( + CANONICAL_SOURCE, + " name: Plan ordinary CI work\n runs-on: ubuntu-latest\n", + " name: Plan ordinary CI work\n runs-on: ubuntu-latest\n accidentally-nested\n", + ); + rejected("scalar continuation", &source, "indented scalar continuation"); + audit_source(CANONICAL_SOURCE).unwrap(); + } + + #[test] + fn plan_steps_are_an_exact_three_step_contract() { + let bare_item_before_checkout = replace_once( + CANONICAL_SOURCE, + " steps:\n - uses: actions/checkout@", + " steps:\n -\n name: Unexpected hidden step\n run: echo unexpected\n - uses: actions/checkout@", + ); + rejected("bare item before checkout", &bare_item_before_checkout, "exactly three steps"); + + // The field-by-field planner audit stops at the bare item's + // indentation. The exact sequence audit must still see the item, + // rather than silently assigning it to the preceding planner block. + let bare_item_after_planner = replace_once( + CANONICAL_SOURCE, + " --artifact \"$RUNNER_TEMP/$CI_PLAN_ARTIFACT\"\n\n - name: Upload detailed plan for review", + " --artifact \"$RUNNER_TEMP/$CI_PLAN_ARTIFACT\"\n\n -\n name: Unexpected hidden step\n run: echo unexpected\n\n - name: Upload detailed plan for review", + ); + rejected("bare item after planner", &bare_item_after_planner, "exactly three steps"); + + let extra = replace_once( + CANONICAL_SOURCE, + " - name: Validate inputs and project the plan\n", + " - name: Unexpected extra step\n run: echo unexpected\n\n - name: Validate inputs and project the plan\n", + ); + rejected("extra planner step", &extra, "exactly three steps"); + + let inserted_run = replace_once( + CANONICAL_SOURCE, + " - name: Validate inputs and project the plan\n", + " - run: echo unexpected\n\n - name: Validate inputs and project the plan\n", + ); + rejected("inserted run step", &inserted_run, "exactly three steps"); + + let inserted_uses = replace_once( + CANONICAL_SOURCE, + " - name: Validate inputs and project the plan\n", + " - uses: ./unexpected-action\n\n - name: Validate inputs and project the plan\n", + ); + rejected("inserted uses step", &inserted_uses, "exactly three steps"); + + let reordered = reorder_plan_steps(CANONICAL_SOURCE, [1, 2, 0]); + rejected("reordered planner steps", &reordered, "checkout step"); + + let changed_checkout = replace_once( + CANONICAL_SOURCE, + " persist-credentials: false", + " persist-credentials: true", + ); + rejected("checkout credentials", &changed_checkout, "checkout step"); + + let changed_checkout_pin = replace_once( + CANONICAL_SOURCE, + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/checkout@0000000000000000000000000000000000000000", + ); + rejected("checkout action pin", &changed_checkout_pin, "checkout step"); + + let changed_upload_action = replace_once( + CANONICAL_SOURCE, + "./.github/actions/upload-file-artifact", + "./.github/actions/other-upload-action", + ); + rejected("upload action", &changed_upload_action, "upload step"); + + let changed_artifact_name = replace_once( + CANONICAL_SOURCE, + " name: ${{ env.CI_PLAN_ARTIFACT }}", + " name: unexpected-artifact", + ); + rejected("artifact name", &changed_artifact_name, "upload step"); + + let changed_artifact_path = replace_once( + CANONICAL_SOURCE, + "path: ${{ runner.temp }}/${{ env.CI_PLAN_ARTIFACT }}", + "path: unexpected-path", + ); + rejected("artifact path", &changed_artifact_path, "upload step"); + + let changed_upload_retention = replace_once( + CANONICAL_SOURCE, + " retention-days: 14", + " retention-days: 7", + ); + rejected("upload retention", &changed_upload_retention, "upload step"); + } +} diff --git a/tools/zc/src/planned_adapter/source.rs b/tools/zc/src/planned_adapter/source.rs new file mode 100644 index 0000000000..a4f709a498 --- /dev/null +++ b/tools/zc/src/planned_adapter/source.rs @@ -0,0 +1,921 @@ +// 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. + +//! Private canonical-source scanner used by the focused bridge audits. + +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, +}; + +use super::{yaml_source, ViolationSink}; +use crate::workflow_protocol::{HOST_RUNNER, WORKFLOW_PATH}; + +#[derive(Clone)] +pub(super) struct StepsBlock { + pub range: Range, + marker_indent: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum RunForm { + Block, + Inline, +} + +pub(super) struct StepExpectation<'a> { + pub job: &'a str, + pub name: &'a str, + pub root_fields: &'a [&'a str], + pub scalar_fields: &'a BTreeMap, + pub environment: &'a BTreeMap, + pub run: &'a [String], + pub run_form: RunForm, +} + +pub(super) struct MappingExpectation<'a> { + pub job: &'a str, + pub field: &'a str, + pub values: &'a BTreeMap, +} + +#[derive(Clone, Copy)] +pub(super) struct Field<'a> { + pub line: usize, + pub indent: usize, + pub key: &'a str, + pub value: &'a str, +} + +/// Splits workflow source only after proving that Rust and YAML see the same +/// line boundaries. +/// +/// The bridge scanners deliberately accept one canonical source spelling. +/// YAML additionally treats carriage return, next-line, line-separator, and +/// paragraph-separator characters as line breaks, while [`str::lines`] does +/// not split all of them. If one were allowed through, executable YAML could +/// follow a scanner-visible comment on what Rust considers the same line. +/// Repository reads normalize well-formed CRLF before this boundary, so every +/// remaining non-LF YAML line break is rejected rather than normalized here. +pub(super) fn canonical_workflow_lines<'a>( + source: &'a str, + errors: &mut ViolationSink, +) -> Option> { + if source + .chars() + .any(|character| matches!(character, '\r' | '\u{85}' | '\u{2028}' | '\u{2029}')) + { + errors.push(WORKFLOW_PATH, "workflow must use LF as its only YAML line break"); + return None; + } + if let Err(violation) = yaml_source::require_line_local_flow_nodes(source) { + let location = + violation.line.map(line_location).unwrap_or_else(|| WORKFLOW_PATH.to_owned()); + errors.push(location, violation.message); + // Continue only to aggregate useful source-level diagnostics. The + // recorded violation is never removed, so downstream scanners cannot + // turn this source into an accepted workflow even though their + // indentation assumptions are no longer authoritative. + } + let lines = source.lines().collect::>(); + + // The source audits below deliberately recognize a small block-style YAML + // grammar instead of implementing YAML. Before indentation can establish + // structure, prove that no earlier token could have opened a multiline + // quoted or flow scalar. YAML permits scalar content to deindent all the + // way to column zero, so merely finding `jobs:` at column zero is not a + // sufficient boundary: it could be inert text inside such a scalar. + // + // The complete workflow starts with its exact `name` entry. Focused unit + // fixtures start directly with `jobs:`. Blank lines and source comments + // cannot open YAML structure and may precede either anchor. Once one of + // these exact first entries establishes a root block mapping at column + // zero, `planner::WORKFLOW_ROOT_DECLARATIONS` proves the complete root + // grammar for production, and `canonical_jobs_block` below proves the + // complete tail grammar shared with focused fixtures. Keep all three + // checks coordinated if either accepted source form changes. + let anchor = lines + .iter() + .copied() + .find(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#')); + if !matches!(anchor, Some("name: Build & Tests" | "jobs:")) { + errors.push( + WORKFLOW_PATH, + "the first significant line must establish the canonical root block mapping with `name: Build & Tests` or `jobs:`", + ); + return None; + } + + Some(lines) +} + +pub(super) fn find_job( + lines: &[&str], + job: &str, + errors: &mut ViolationSink, +) -> Option> { + let jobs = canonical_jobs_block(lines, errors)?; + let marker = format!(" {job}:"); + let starts = lines + .iter() + .enumerate() + .take(jobs.end) + .skip(jobs.start) + .filter_map(|(index, line)| (*line == marker).then_some(index)) + .collect::>(); + if starts.len() != 1 { + errors.push( + job_location(job), + format!( + "expected exactly one canonical job declaration inside the root `jobs` mapping, found {}", + starts.len() + ), + ); + return None; + } + let start = starts[0]; + let end = lines + .iter() + .enumerate() + .take(jobs.end) + .skip(start + 1) + .find_map(|(index, line)| { + (!line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) == 2) + .then_some(index) + }) + .unwrap_or(jobs.end); + Some(start..end) +} + +fn canonical_jobs_block(lines: &[&str], errors: &mut ViolationSink) -> Option> { + // A canonical-looking job declaration elsewhere in the document is not a + // job. In particular, a scalar in another root field may contain an exact + // textual copy of the reviewed job. `canonical_workflow_lines` first + // proves that the document began as a root block mapping; only after that + // precondition can this exact indentation-zero `jobs:` line establish the + // real root boundary without reproducing YAML's scalar grammar. + let jobs_starts = lines + .iter() + .enumerate() + .filter_map(|(index, line)| (*line == "jobs:").then_some(index)) + .collect::>(); + if jobs_starts.len() != 1 { + errors.push( + WORKFLOW_PATH, + format!( + "expected exactly one canonical root `jobs:` declaration, found {}", + jobs_starts.len() + ), + ); + return None; + } + let jobs_start = jobs_starts[0]; + // `jobs` is the final root entry. Scanning through EOF is intentional: a + // later alternate spelling such as `'jobs':` must be rejected rather than + // ending this range and overriding the mapping which the focused audit + // inspected. In the full workflow, this invariant is also coordinated + // with `planner::WORKFLOW_ROOT_DECLARATIONS`, where `jobs:` is last. + let jobs_end = lines.len(); + let jobs = jobs_start + 1..jobs_end; + + // Binding only to the root range is not enough. YAML permits a direct job + // at one or three spaces, and a multiline quoted scalar in such a job can + // contain a two-space canonical-looking decoy. Fix the complete direct + // child grammar: every job header has exactly two spaces, an unquoted safe + // identifier, an empty value, and no trailing text. All nested job content + // begins at four spaces. This makes any two-space marker found below a real + // YAML job key rather than scalar content. + let mut valid = true; + let mut saw_job = false; + for (index, line) in lines.iter().enumerate().take(jobs.end).skip(jobs.start) { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + let indent = indentation(line); + if indent == 2 { + let canonical = line.trim_end() == *line + && parse_mapping(&line[2..]) + .is_some_and(|(job, value)| is_job_id(job) && value.is_empty()); + if canonical { + saw_job = true; + } else { + valid = false; + errors.push( + line_location(index + 1), + "direct jobs must use canonical unquoted ` job_id:` declarations", + ); + } + } else if indent < 4 { + valid = false; + errors.push( + line_location(index + 1), + "the root `jobs` mapping permits significant lines only at canonical two-space job headers or inside their four-space content", + ); + } else if !saw_job { + valid = false; + errors.push( + line_location(index + 1), + "nested job content must follow a canonical two-space job declaration", + ); + } + } + + valid.then_some(jobs) +} + +pub(super) fn job_fields<'a>( + lines: &'a [&'a str], + job: Range, + job_name: &str, + errors: &mut ViolationSink, +) -> Vec> { + job_fields_at_indent(lines, job, job_name, 4, errors) +} + +pub(super) fn job_fields_at_indent<'a>( + lines: &'a [&'a str], + job: Range, + job_name: &str, + field_indent: usize, + errors: &mut ViolationSink, +) -> Vec> { + let mut fields: Vec> = Vec::new(); + for (index, line) in lines.iter().enumerate().take(job.end).skip(job.start + 1) { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + let indent = indentation(line); + // A direct child mapping may choose any indentation greater than its + // parent in YAML. This scanner intentionally recognizes only the + // reviewed four-space spelling. A shallower valid mapping could make + // canonical-looking lines into multiline scalar content, causing the + // scanner to audit a decoy while Actions executes the shallower job. + // Deeper lines remain valid children of canonical mapping and sequence + // fields and are checked by their dedicated audits below. + if indent < field_indent { + errors.push( + line_location(index + 1), + format!( + "job `{job_name}` contains significant content shallower than its canonical {field_indent}-space field indentation" + ), + ); + continue; + } + // A scalar field cannot acquire an indented continuation without + // changing its meaning. We must reject that ambiguity, while still + // allowing children of mapping-valued fields such as `permissions:`. + // The latter have an empty value and are therefore deliberately left + // alone for the dedicated nested-mapping audit below. + if indent > field_indent + && fields.last().map(|field| !field.value.is_empty()).unwrap_or(false) + { + errors.push( + line_location(index + 1), + format!( + "field `{}` cannot have an indented scalar continuation", + fields.last().unwrap().key + ), + ); + continue; + } + if indent != field_indent || line[field_indent..].starts_with("- ") { + continue; + } + if line.trim_end() != *line { + errors.push(line_location(index + 1), "semantic bridge lines must not trail spaces"); + continue; + } + let Some((key, value)) = parse_mapping(&line[field_indent..]) else { + errors.push( + line_location(index + 1), + format!("job `{job_name}` fields must use canonical unquoted `key: value` form"), + ); + continue; + }; + fields.push(Field { line: index, indent: field_indent, key, value }); + } + fields +} + +pub(super) fn unique_field<'a>( + fields: &'a [Field<'a>], + key: &str, + job: &str, + errors: &mut ViolationSink, +) -> Option<&'a Field<'a>> { + let found = fields.iter().filter(|field| field.key == key).collect::>(); + match found.as_slice() { + [field] => Some(field), + [] => { + errors.push(job_field_location(job, key), "required field is absent"); + None + } + _ => { + errors.push( + job_field_location(job, key), + format!("field appears {} times; expected exactly once", found.len()), + ); + None + } + } +} + +pub(super) fn audit_exact_job_fields( + fields: &[Field<'_>], + job: &str, + expected: &[&str], + errors: &mut ViolationSink, +) { + let expected = expected.iter().copied().collect::>(); + let mut counts = BTreeMap::new(); + for field in fields { + *counts.entry(field.key).or_insert(0usize) += 1; + } + + for field in &expected { + match counts.get(field) { + None => errors.push(job_field_location(job, field), "required field is absent"), + Some(1) => {} + Some(count) => errors.push( + job_field_location(job, field), + format!("field appears {count} times; expected exactly once"), + ), + } + } + for field in counts.keys() { + if !expected.contains(field) { + errors.push( + job_field_location(job, field), + "field is not part of the exact planned-job workflow bridge", + ); + } + } +} + +pub(super) fn audit_exact_scalar_field( + fields: &[Field<'_>], + job: &str, + field: &str, + expected: &str, + errors: &mut ViolationSink, +) { + let Some(actual) = unique_field(fields, field, job, errors) else { + return; + }; + if actual.value != expected { + errors.push( + job_field_location(job, field), + format!("expected `{expected}`, found `{}`", escape_control_characters(actual.value)), + ); + } +} + +pub(super) fn audit_exact_mapping( + lines: &[&str], + block_end: usize, + fields: &[Field<'_>], + expected: MappingExpectation<'_>, + errors: &mut ViolationSink, +) { + let Some(parent) = unique_field(fields, expected.field, expected.job, errors) else { + return; + }; + if !parent.value.is_empty() { + errors.push( + job_field_location(expected.job, expected.field), + format!("{} must use the canonical nested mapping form", expected.field), + ); + return; + } + let actual = nested_mapping(lines, parent, block_end, expected.job, errors); + compare_map(job_field_location(expected.job, expected.field), expected.values, &actual, errors); +} + +pub(super) fn audit_read_permissions( + lines: &[&str], + block_end: usize, + fields: &[Field<'_>], + job: &str, + errors: &mut ViolationSink, +) { + let expected = BTreeMap::from([("contents".to_owned(), "read".to_owned())]); + audit_exact_mapping( + lines, + block_end, + fields, + MappingExpectation { job, field: "permissions", values: &expected }, + errors, + ); +} + +pub(super) fn nested_mapping( + lines: &[&str], + parent: &Field<'_>, + block_end: usize, + job: &str, + errors: &mut ViolationSink, +) -> BTreeMap { + let end = nested_block_end(lines, parent, block_end); + let child_indent = parent.indent + 2; + let mut mapping = BTreeMap::new(); + for (index, line) in lines.iter().enumerate().take(end).skip(parent.line + 1) { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if indentation(line) != child_indent || line.trim_end() != *line { + errors.push( + line_location(index + 1), + format!("`{job}.{}` must contain only canonical scalar entries", parent.key), + ); + continue; + } + let Some((key, value)) = parse_mapping(&line[child_indent..]) else { + errors.push( + line_location(index + 1), + format!("`{job}.{}` entries must use canonical `key: value` form", parent.key), + ); + continue; + }; + if value.is_empty() { + errors.push( + line_location(index + 1), + format!("`{job}.{}.{key}` must have one scalar value", parent.key), + ); + } else if mapping.insert(key.to_owned(), value.to_owned()).is_some() { + errors.push( + line_location(index + 1), + format!("`{job}.{}` repeats key `{key}`", parent.key), + ); + } + } + mapping +} + +pub(super) fn audited_steps_block( + fields: &[Field<'_>], + job: Range, + job_name: &str, + errors: &mut ViolationSink, +) -> Option { + let steps = unique_field(fields, "steps", job_name, errors)?; + if !steps.value.is_empty() { + errors.push( + job_field_location(job_name, "steps"), + "steps must use the canonical nested sequence form", + ); + return None; + } + let end = fields + .iter() + .filter_map(|field| (field.line > steps.line).then_some(field.line)) + .min() + .unwrap_or(job.end); + Some(StepsBlock { range: steps.line + 1..end, marker_indent: steps.indent + 2 }) +} + +/// Returns the significant source lines for each top-level item in `steps`. +/// +/// This is intentionally source-oriented rather than a general YAML parser: +/// the planner workflow is a reviewed bridge whose exact checkout, planner, +/// and artifact-upload steps must remain coordinated with the Rust protocol. +/// Full-line YAML comments remain free, but a comment indented beneath +/// `run: |` is shell-script content. Preserve the latter because Actions +/// expands `${{ ... }}` before invoking the shell, even on a line Bash will +/// otherwise treat as a comment. +pub(super) fn exact_step_lines<'a>(lines: &'a [&'a str], steps: &StepsBlock) -> Vec> { + let starts = lines + .iter() + .enumerate() + .filter_map(|(index, line)| { + // Treat every significant line at item indentation as a boundary, + // including syntax other than the canonical `- ...` form. YAML + // permits a bare `-` followed by an indented mapping; filtering + // only for `- ` would make that entire step disappear before the + // callers compare exact counts and markers. Unfamiliar syntax is + // therefore returned as its own block and rejected explicitly. + (steps.range.contains(&index) + && !line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) == steps.marker_indent) + .then_some(index) + }) + .collect::>(); + starts + .iter() + .enumerate() + .map(|(position, start)| { + let end = starts.get(position + 1).copied().unwrap_or(steps.range.end); + let mut exact = Vec::new(); + let mut block_scalar_indent = None; + for line in &lines[*start..end] { + // Empty scalar lines cannot contain Actions expressions. Keep + // ignoring them here; field-by-field run audits preserve them + // where shell continuation semantics matter. + if line.trim().is_empty() { + continue; + } + let indent = indentation(line); + if block_scalar_indent.is_some_and(|parent| indent > parent) { + exact.push(*line); + continue; + } + block_scalar_indent = None; + if line.trim_start().starts_with('#') { + continue; + } + exact.push(*line); + if &line[indent..] == "run: |" { + block_scalar_indent = Some(indent); + } + } + exact + }) + .collect() +} + +pub(super) fn audit_step( + lines: &[&str], + steps: &StepsBlock, + expected: StepExpectation<'_>, + errors: &mut ViolationSink, +) { + let marker = format!("{}- name: {}", " ".repeat(steps.marker_indent), expected.name); + let markers = lines + .iter() + .enumerate() + .filter_map(|(index, line)| { + (steps.range.contains(&index) && *line == marker).then_some(index) + }) + .collect::>(); + if markers.len() != 1 { + errors.push( + step_location(expected.name), + format!( + "expected exactly one canonical step declaration inside `{}.steps`, found {}", + expected.job, + markers.len() + ), + ); + return; + } + let start = markers[0]; + + let root_indent = steps.marker_indent + 2; + let nested_indent = root_indent + 2; + let end = step_end(lines, start + 1, steps.range.end, steps.marker_indent); + let mut section = StepSection::Root; + let mut root_fields = Vec::new(); + let mut scalar_fields = BTreeMap::new(); + let mut environment = BTreeMap::new(); + let mut run = Vec::new(); + let mut run_form = None; + + for (index, line) in lines.iter().enumerate().take(end).skip(start + 1) { + let line_number = index + 1; + if line.trim().is_empty() { + if section == StepSection::Run { + run.push(String::new()); + } + continue; + } + if line.trim_start().starts_with('#') { + if section == StepSection::Run { + run.push(line.get(nested_indent..).unwrap_or(line).to_owned()); + } + continue; + } + if line.trim_end() != *line { + errors.push(line_location(line_number), "semantic bridge lines must not trail spaces"); + continue; + } + + match indentation(line) { + indent if indent == root_indent => { + let Some((key, value)) = parse_mapping(&line[root_indent..]) else { + errors.push( + line_location(line_number), + "step fields must use canonical unquoted `key: value` form", + ); + section = StepSection::Root; + continue; + }; + root_fields.push(key.to_owned()); + match (key, value) { + ("env", "") => section = StepSection::Environment, + ("run", "|") => { + run_form = Some(RunForm::Block); + section = StepSection::Run; + } + ("run", value) if !value.is_empty() => { + run_form = Some(RunForm::Inline); + run.push(value.to_owned()); + section = StepSection::Root; + } + (_, "") => { + errors.push( + line_location(line_number), + format!("step scalar field `{key}` must not be empty"), + ); + section = StepSection::Root; + } + _ => { + if scalar_fields.insert(key.to_owned(), value.to_owned()).is_some() { + errors.push( + line_location(line_number), + format!("step repeats scalar field `{key}`"), + ); + } + section = StepSection::Root; + } + } + } + indent if indent == nested_indent && section == StepSection::Environment => { + let Some((key, value)) = parse_mapping(&line[nested_indent..]) else { + errors.push( + line_location(line_number), + "step environment must use canonical `key: value` form", + ); + continue; + }; + if value.is_empty() { + errors.push(line_location(line_number), "step environment value is empty"); + } else if environment.insert(key.to_owned(), value.to_owned()).is_some() { + errors.push( + line_location(line_number), + format!("step environment repeats `{key}`"), + ); + } + } + indent if indent >= nested_indent && section == StepSection::Run => { + run.push(line[nested_indent..].to_owned()); + } + _ => errors.push( + line_location(line_number), + format!( + "unsupported audited-step indentation in `{}`", + escape_control_characters(line) + ), + ), + } + } + + if root_fields != expected.root_fields { + errors.push( + step_field_location(expected.name, "shape"), + format!( + "root fields must appear exactly as {:?}, found {root_fields:?}", + expected.root_fields + ), + ); + } + compare_map( + step_field_location(expected.name, "fields"), + expected.scalar_fields, + &scalar_fields, + errors, + ); + compare_map( + step_field_location(expected.name, "env"), + expected.environment, + &environment, + errors, + ); + if run_form != Some(expected.run_form) { + errors.push( + step_field_location(expected.name, "run"), + format!( + "run field must use canonical {:?} form, found {run_form:?}", + expected.run_form + ), + ); + } + if run != expected.run { + errors.push( + step_field_location(expected.name, "run"), + format!( + "run block must match line-for-line: expected {:?}, found {run:?}", + expected.run + ), + ); + } +} + +pub(super) fn audit_unique_run_mentions( + lines: &[&str], + step: &str, + command: &str, + errors: &mut ViolationSink, +) { + let command_mentions = run_mentions(lines, command); + if command_mentions != 1 { + errors.push( + step_field_location(step, "run"), + format!( + "expected exactly one `{command}` command mention on non-comment workflow lines, found {command_mentions}" + ), + ); + } +} + +pub(super) fn audit_singleton_job_contract( + fields: &[Field<'_>], + job: &str, + errors: &mut ViolationSink, +) { + if let Some(runner) = unique_field(fields, "runs-on", job, errors) { + if runner.value != HOST_RUNNER { + errors.push( + job_field_location(job, "runs-on"), + format!( + "expected `{HOST_RUNNER}` for audited absolute host paths, found `{}`", + escape_control_characters(runner.value) + ), + ); + } + } + if fields.iter().any(|field| field.key == "container") { + errors.push( + job_field_location(job, "container"), + "audited absolute host paths must not resolve inside a job container", + ); + } + if fields.iter().any(|field| field.key == "continue-on-error") { + errors.push( + job_field_location(job, "continue-on-error"), + "audited jobs must not turn failures into successful conclusions", + ); + } + if fields.iter().any(|field| field.key == "strategy") { + errors.push( + job_field_location(job, "strategy"), + "audited singleton jobs must run exactly once, without a strategy", + ); + } +} + +pub(super) fn compare_map( + location: String, + expected: &BTreeMap, + actual: &BTreeMap, + errors: &mut ViolationSink, +) { + for (key, value) in expected { + match actual.get(key) { + Some(actual) if actual == value => {} + Some(actual) => errors + .push(format!("{location}.{key}"), format!("expected `{value}`, found `{actual}`")), + None => errors.push(format!("{location}.{key}"), "required field is absent"), + } + } + for key in actual.keys() { + if !expected.contains_key(key) { + errors.push( + format!("{location}.{key}"), + "field is not part of the planned-job workflow bridge", + ); + } + } +} + +pub(super) fn job_field_location(job: &str, field: &str) -> String { + format!("{WORKFLOW_PATH}:{job}.{field}") +} + +pub(super) fn step_field_location(step: &str, field: &str) -> String { + format!("{WORKFLOW_PATH}:{step}.{field}") +} + +pub(super) fn escape_control_characters(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + +fn run_mentions(lines: &[&str], command: &str) -> usize { + lines + .iter() + .filter(|line| !line.trim_start().starts_with('#')) + .map(|line| token_mentions(line, command)) + .sum() +} + +fn nested_block_end(lines: &[&str], parent: &Field<'_>, block_end: usize) -> usize { + lines + .iter() + .enumerate() + .take(block_end) + .skip(parent.line + 1) + .find_map(|(index, line)| { + (!line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) <= parent.indent) + .then_some(index) + }) + .unwrap_or(block_end) +} + +fn token_mentions(text: &str, token: &str) -> usize { + text.match_indices(token) + .filter(|(start, _)| { + let end = start + token.len(); + let before = text[..*start].bytes().next_back(); + let after = text[end..].bytes().next(); + !before.is_some_and(is_token_byte) && !after.is_some_and(is_token_byte) + }) + .count() +} + +fn is_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') +} + +fn parse_mapping(declaration: &str) -> Option<(&str, &str)> { + let (key, remainder) = declaration.split_once(':')?; + if key.is_empty() + || !key.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return None; + } + if remainder.is_empty() { + Some((key, "")) + } else { + remainder.strip_prefix(' ').map(|value| (key, value)) + } +} + +fn is_job_id(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn step_end(lines: &[&str], start: usize, block_end: usize, marker_indent: usize) -> usize { + // A canonical block scalar's content begins four spaces beneath the step + // marker: two for the `run` field and two more for its content. A + // hash-prefixed line at or below that indentation is scalar data, not a + // YAML comment. In particular, GitHub expands `${{ ... }}` before the + // scalar becomes a shell script, so discarding such a line here could hide + // executable text from the exact run comparison below. Comments above the + // content indentation remain free workflow documentation. + let block_scalar_content_indent = marker_indent + 4; + let mut end = lines + .iter() + .enumerate() + .take(block_end) + .skip(start) + .find_map(|(index, line)| { + (!line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) <= marker_indent) + .then_some(index) + }) + .unwrap_or(block_end); + while end > start { + let line = lines[end - 1]; + if line.trim().is_empty() + || (line.trim_start().starts_with('#') + && indentation(line) < block_scalar_content_indent) + { + end -= 1; + } else { + break; + } + } + end +} + +pub(super) fn indentation(line: &str) -> usize { + line.bytes().take_while(|byte| *byte == b' ').count() +} + +fn line_location(line: usize) -> String { + format!("{WORKFLOW_PATH}:{line}") +} + +fn job_location(job: &str) -> String { + format!("{WORKFLOW_PATH}:{job}") +} + +fn step_location(step: &str) -> String { + format!("{WORKFLOW_PATH}:{step}") +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum StepSection { + Root, + Environment, + Run, +} diff --git a/tools/zc/src/planned_adapter/yaml_source.rs b/tools/zc/src/planned_adapter/yaml_source.rs new file mode 100644 index 0000000000..0d54203f64 --- /dev/null +++ b/tools/zc/src/planned_adapter/yaml_source.rs @@ -0,0 +1,353 @@ +// 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. + +//! Parser-backed preconditions for the canonical workflow source scanner. + +use std::{marker::PhantomData, mem::MaybeUninit}; + +use libyaml_rs::{ + yaml_event_delete, yaml_event_t, yaml_parser_delete, yaml_parser_initialize, yaml_parser_parse, + yaml_parser_set_encoding, yaml_parser_set_input_string, yaml_parser_t, YAML_ALIAS_EVENT, + YAML_BLOCK_MAPPING_STYLE, YAML_BLOCK_SEQUENCE_STYLE, YAML_DOCUMENT_END_EVENT, + YAML_DOCUMENT_START_EVENT, YAML_DOUBLE_QUOTED_SCALAR_STYLE, YAML_FLOW_MAPPING_STYLE, + YAML_FLOW_SEQUENCE_STYLE, YAML_FOLDED_SCALAR_STYLE, YAML_LITERAL_SCALAR_STYLE, + YAML_MAPPING_END_EVENT, YAML_MAPPING_START_EVENT, YAML_PLAIN_SCALAR_STYLE, YAML_SCALAR_EVENT, + YAML_SEQUENCE_END_EVENT, YAML_SEQUENCE_START_EVENT, YAML_SINGLE_QUOTED_SCALAR_STYLE, + YAML_STREAM_END_EVENT, YAML_STREAM_START_EVENT, YAML_UTF8_ENCODING, +}; + +#[derive(Debug)] +pub(super) struct Violation { + pub line: Option, + pub message: &'static str, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum CollectionKind { + Mapping, + Sequence, +} + +struct OpenCollection { + kind: CollectionKind, + flow: bool, + line: usize, +} + +/// Rejects YAML constructs which can make source indentation misleading. +/// +/// The bridge audit intentionally scans a small, canonical block-style source +/// grammar. YAML normally permits quoted and plain scalars, flow mappings, and +/// flow sequences to continue across line boundaries while ignoring block +/// indentation. Without this precondition, text inside one of those nodes can +/// look exactly like an audited job, field, or step even though Actions sees +/// only inert scalar text or a child of an unrelated flow collection. +/// +/// Literal and folded block scalars remain multiline because their content is +/// bounded by indentation. Block mappings and sequences likewise retain +/// YAML's indentation structure. Flow nodes remain available in their ordinary +/// single-line form, such as a canonical `needs: [first, second]` value. +pub(super) fn require_line_local_flow_nodes(source: &str) -> Result<(), Violation> { + let Some(mut parser) = EventParser::new(source.as_bytes()) else { + return Err(Violation { + line: None, + message: "could not initialize the YAML parser for the canonical source audit", + }); + }; + let mut collections = Vec::new(); + let mut documents = 0; + + loop { + let mut event = MaybeUninit::::uninit(); + // SAFETY: `EventParser` owns a successfully initialized parser whose + // input bytes remain borrowed for its complete lifetime. On success, + // libyaml fully initializes `event`; each such event is deleted once + // after the fields needed below have been copied. On failure, no event + // fields are read or deleted, and the parser's `Drop` still releases + // all parser-owned allocations. + let parsed = unsafe { yaml_parser_parse(parser.raw_mut(), event.as_mut_ptr()) }; + if parsed.fail { + return Err(Violation { + line: parser.problem_line(), + message: "workflow must be valid YAML before its canonical source is audited", + }); + } + // SAFETY: the successful parse above initialized the complete event. + let mut event = unsafe { event.assume_init() }; + let event_type = event.type_; + let start_line = mark_line(event.start_mark.line); + let end_line = mark_line(event.end_mark.line); + + let violation = match event_type { + YAML_DOCUMENT_START_EVENT => { + documents += 1; + None + } + YAML_SCALAR_EVENT => { + // SAFETY: libyaml's event tag proves that the scalar union arm + // is active until `yaml_event_delete` below. + let scalar = unsafe { event.data.scalar }; + if let Some(violation) = + node_property_violation(scalar.anchor, scalar.tag, start_line) + { + Some(violation) + } else if start_line == end_line { + None + } else { + match scalar.style { + YAML_LITERAL_SCALAR_STYLE | YAML_FOLDED_SCALAR_STYLE => None, + YAML_PLAIN_SCALAR_STYLE + | YAML_SINGLE_QUOTED_SCALAR_STYLE + | YAML_DOUBLE_QUOTED_SCALAR_STYLE => Some(Violation { + line: Some(start_line), + message: "plain and quoted YAML scalars must stay on one source line so canonical indentation cannot be borrowed from scalar text", + }), + _ => Some(Violation { + line: Some(start_line), + message: "the canonical source audit does not recognize this multiline YAML scalar style", + }), + } + } + } + YAML_MAPPING_START_EVENT => { + // SAFETY: this event tag activates the mapping-start arm. + let mapping = unsafe { event.data.mapping_start }; + if let Some(violation) = + node_property_violation(mapping.anchor, mapping.tag, start_line) + { + Some(violation) + } else { + match mapping.style { + YAML_BLOCK_MAPPING_STYLE | YAML_FLOW_MAPPING_STYLE => { + collections.push(OpenCollection { + kind: CollectionKind::Mapping, + flow: mapping.style == YAML_FLOW_MAPPING_STYLE, + line: start_line, + }); + None + } + _ => Some(Violation { + line: Some(start_line), + message: "the canonical source audit does not recognize this YAML mapping style", + }), + } + } + } + YAML_SEQUENCE_START_EVENT => { + // SAFETY: this event tag activates the sequence-start arm. + let sequence = unsafe { event.data.sequence_start }; + if let Some(violation) = + node_property_violation(sequence.anchor, sequence.tag, start_line) + { + Some(violation) + } else { + match sequence.style { + YAML_BLOCK_SEQUENCE_STYLE | YAML_FLOW_SEQUENCE_STYLE => { + collections.push(OpenCollection { + kind: CollectionKind::Sequence, + flow: sequence.style == YAML_FLOW_SEQUENCE_STYLE, + line: start_line, + }); + None + } + _ => Some(Violation { + line: Some(start_line), + message: "the canonical source audit does not recognize this YAML sequence style", + }), + } + } + } + YAML_MAPPING_END_EVENT => close_collection( + &mut collections, + CollectionKind::Mapping, + start_line, + "flow mappings must stay on one source line so canonical indentation remains structural", + ), + YAML_SEQUENCE_END_EVENT => close_collection( + &mut collections, + CollectionKind::Sequence, + start_line, + "flow sequences must stay on one source line so canonical indentation remains structural", + ), + YAML_ALIAS_EVENT => { + Some(Violation { + line: Some(start_line), + message: "YAML aliases are not supported by the canonical workflow source", + }) + } + YAML_STREAM_START_EVENT | YAML_STREAM_END_EVENT | YAML_DOCUMENT_END_EVENT => None, + _ => Some(Violation { + line: Some(start_line), + message: "the canonical source audit does not recognize this YAML parser event", + }), + }; + + // SAFETY: this event was initialized successfully and has not been + // deleted or moved into libyaml. No union field is read afterward. + unsafe { yaml_event_delete(&raw mut event) }; + if let Some(violation) = violation { + return Err(violation); + } + if event_type == YAML_STREAM_END_EVENT { + if !collections.is_empty() { + return Err(Violation { + line: None, + message: "YAML parser returned unclosed collection boundaries", + }); + } + return (documents == 1).then_some(()).ok_or(Violation { + line: None, + message: "the canonical workflow source must contain exactly one YAML document", + }); + } + } +} + +fn node_property_violation(anchor: *const u8, tag: *const u8, line: usize) -> Option { + if !tag.is_null() { + return Some(Violation { + line: Some(line), + message: "explicit YAML tags are not supported by the canonical workflow source", + }); + } + (!anchor.is_null()).then_some(Violation { + line: Some(line), + message: "YAML anchors are not supported by the canonical workflow source", + }) +} + +fn close_collection( + collections: &mut Vec, + expected: CollectionKind, + end_line: usize, + message: &'static str, +) -> Option { + let Some(open) = collections.pop() else { + return Some(Violation { + line: Some(end_line), + message: "YAML parser returned an unmatched collection boundary", + }); + }; + if open.kind != expected { + return Some(Violation { + line: Some(end_line), + message: "YAML parser returned mismatched collection boundaries", + }); + } + (open.flow && open.line != end_line).then_some(Violation { line: Some(open.line), message }) +} + +fn mark_line(line: u64) -> usize { + usize::try_from(line).unwrap_or(usize::MAX - 1).saturating_add(1) +} + +/// A pinned-by-allocation libyaml parser borrowing `source`. +/// +/// `yaml_parser_set_input_string` stores both the source pointer and a pointer +/// back to the parser. Keeping the parser in one `Box` allocation ensures that +/// moving this Rust wrapper never invalidates libyaml's self-reference. +struct EventParser<'source> { + raw: Box, + _source: PhantomData<&'source [u8]>, +} + +impl<'source> EventParser<'source> { + fn new(source: &'source [u8]) -> Option { + let mut raw = Box::::new_uninit(); + // SAFETY: `raw` points to storage of the exact required type. A + // successful call initializes it completely. `Box::assume_init` + // preserves the allocation address before libyaml records that address + // in `yaml_parser_set_input_string`. + if unsafe { yaml_parser_initialize(raw.as_mut_ptr()) }.fail { + return None; + } + // SAFETY: initialization succeeded immediately above. + let mut raw = unsafe { raw.assume_init() }; + // SAFETY: the parser is initialized, its heap address is now stable, + // and the lifetime marker prevents this wrapper from outliving source. + unsafe { + yaml_parser_set_encoding(&raw mut *raw, YAML_UTF8_ENCODING); + yaml_parser_set_input_string(&raw mut *raw, source.as_ptr(), source.len() as u64); + } + Some(Self { raw, _source: PhantomData }) + } + + fn raw_mut(&mut self) -> *mut yaml_parser_t { + &raw mut *self.raw + } + + fn problem_line(&self) -> Option { + Some(mark_line(self.raw.problem_mark.line)) + } +} + +impl Drop for EventParser<'_> { + fn drop(&mut self) { + // SAFETY: construction returned `Some` only after successful + // initialization, and this is the parser's single deletion point. + unsafe { yaml_parser_delete(&raw mut *self.raw) } + } +} + +#[cfg(test)] +mod tests { + use super::require_line_local_flow_nodes; + + const LIVE_WORKFLOW: &str = include_str!("../../../../.github/workflows/ci.yml"); + + fn rejected(source: &str, expected: &str) { + let error = require_line_local_flow_nodes(source).unwrap_err(); + assert!(error.message.contains(expected), "unexpected violation: {}", error.message); + } + + #[test] + fn live_workflow_uses_only_line_local_flow_nodes() { + require_line_local_flow_nodes(LIVE_WORKFLOW).unwrap(); + } + + #[test] + fn multiline_flow_scalars_are_rejected_but_block_scalars_remain_supported() { + for source in [ + "value: first\n second\n", + "value: 'first\n second'\n", + "value: \"first\n second\"\n", + ] { + rejected(source, "scalars must stay on one source line"); + } + + require_line_local_flow_nodes("value: |\n first\n second\n").unwrap(); + require_line_local_flow_nodes("value: >\n first\n second\n").unwrap(); + } + + #[test] + fn multiline_flow_collections_are_rejected_without_scalar_decoys() { + rejected("value: {first: one,\n second: two}\n", "flow mappings must stay"); + rejected("value: [one,\n two]\n", "flow sequences must stay"); + rejected("value: [{first: one,\n second: two}]\n", "flow mappings must stay"); + + require_line_local_flow_nodes("value: {first: one, second: two}\n").unwrap(); + require_line_local_flow_nodes("value: [one, two]\n").unwrap(); + } + + #[test] + fn exactly_one_yaml_document_is_required() { + rejected("first: document\n---\nsecond: document\n", "exactly one YAML document"); + } + + #[test] + fn anchors_and_aliases_are_rejected() { + rejected("value: &replay_planner text\n", "YAML anchors are not supported"); + rejected("first: value\nsecond: *replay_planner\n", "YAML aliases are not supported"); + } + + #[test] + fn explicit_tags_are_rejected() { + rejected("value: !!str text\n", "explicit YAML tags"); + rejected("value: !!map {first: one}\n", "explicit YAML tags"); + } +} diff --git a/tools/zc/src/repository_file.rs b/tools/zc/src/repository_file.rs index c026dff9ab..7a9f52bfc2 100644 --- a/tools/zc/src/repository_file.rs +++ b/tools/zc/src/repository_file.rs @@ -16,13 +16,15 @@ use std::{ fs::File, - io::{self, Read}, + io, path::{Path, PathBuf}, }; use same_file::Handle; use thiserror::Error; +use crate::repository_text; + /// One repository input whose path, identity, and bytes share an open handle. #[derive(Debug)] pub(crate) struct OpenedRepositoryFile { @@ -52,10 +54,7 @@ impl OpenedRepositoryFile { /// Reads text from the retained file rather than reopening its path. pub(crate) fn read_to_string(&self) -> io::Result { - let mut file = &self.file; - let mut source = String::new(); - file.read_to_string(&mut source)?; - Ok(source) + repository_text::read_open(&self.file) } /// Returns the opened file's permissions for a replacement file. diff --git a/tools/zc/src/repository_text.rs b/tools/zc/src/repository_text.rs new file mode 100644 index 0000000000..4900431c77 --- /dev/null +++ b/tools/zc/src/repository_text.rs @@ -0,0 +1,116 @@ +// 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. + +//! Text-file boundary for canonical repository inputs. +//! +//! `.gitattributes` records LF in Git's index and asks new checkouts to write +//! LF, but adding that attribute does not rewrite a file which is already in a +//! Windows worktree. Normalize only well-formed CRLF while reading the +//! checkout. The source-level parsers still receive and require one canonical +//! LF spelling, and a bare carriage return remains an error rather than being +//! silently reinterpreted. + +use std::{ + fs::{self, File}, + io::{self, Read}, + path::Path, +}; + +/// Reads one repository-owned text file into its canonical LF representation. +pub(crate) fn read(path: &Path) -> io::Result { + normalize(fs::read_to_string(path)?) +} + +/// Reads canonical repository text from an already-open file. +/// +/// Callers which also rely on file identity use this entry point so the bytes +/// and identity come from the same operating-system handle rather than two +/// path lookups with a rename window between them. +pub(crate) fn read_open(file: &File) -> io::Result { + let mut reader = file; + let mut source = String::new(); + reader.read_to_string(&mut source)?; + normalize(source) +} + +fn normalize(source: String) -> io::Result { + if !source.contains('\r') { + return Ok(source); + } + + let mut normalized = String::with_capacity(source.len()); + let mut characters = source.chars(); + while let Some(character) = characters.next() { + if character != '\r' { + normalized.push(character); + continue; + } + if characters.next() != Some('\n') { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "repository text contains a bare carriage return", + )); + } + normalized.push('\n'); + } + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, process, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::{normalize, read, read_open}; + + #[test] + fn preserves_lf_and_normalizes_only_well_formed_crlf() { + assert_eq!(normalize("one\ntwo\n".to_owned()).unwrap(), "one\ntwo\n"); + assert_eq!(normalize("one\r\ntwo\r\n".to_owned()).unwrap(), "one\ntwo\n"); + + for source in ["one\rtwo\n", "one\r\r\ntwo\n", "one\n\r"] { + let error = normalize(source.to_owned()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("bare carriage return")); + } + } + + #[test] + fn repository_read_applies_normalization_at_the_file_boundary() { + static NEXT_FILE: AtomicU64 = AtomicU64::new(0); + let unique = NEXT_FILE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir() + .join(format!("zerocopy-repository-text-test-{}-{unique}.txt", process::id())); + + fs::write(&path, "one\r\ntwo\r\n").unwrap(); + assert_eq!(read(&path).unwrap(), "one\ntwo\n"); + + fs::write(&path, "one\rtwo\n").unwrap(); + let error = read(&path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + + fs::remove_file(path).unwrap(); + } + + #[test] + fn open_file_read_applies_the_same_normalization() { + static NEXT_FILE: AtomicU64 = AtomicU64::new(0); + let unique = NEXT_FILE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir() + .join(format!("zerocopy-open-text-test-{}-{unique}.txt", process::id())); + fs::write(&path, "one\r\ntwo\r\n").unwrap(); + + let file = fs::File::open(&path).unwrap(); + assert_eq!(read_open(&file).unwrap(), "one\ntwo\n"); + drop(file); + + fs::remove_file(path).unwrap(); + } +} diff --git a/tools/zc/src/workflow.rs b/tools/zc/src/workflow.rs index e4b2dbe903..806cced92e 100644 --- a/tools/zc/src/workflow.rs +++ b/tools/zc/src/workflow.rs @@ -129,7 +129,6 @@ pub(crate) struct WorkflowSources { impl WorkflowSources { /// Returns the already-read source for one GitHub-visible workflow path. - #[cfg(test)] pub(crate) fn source(&self, path: &str) -> Option<&str> { self.sources .iter() @@ -1319,6 +1318,20 @@ mod tests { fs::remove_dir_all(repository).unwrap(); } + #[test] + fn discovered_sources_use_canonical_line_endings() { + let repository = temporary_directory("canonical-source"); + let workflows = repository.join(".github/workflows"); + fs::create_dir_all(&workflows).unwrap(); + let canonical = "name: CI\non:\n push:\njobs:\n original:\n runs-on: ubuntu-latest\n"; + fs::write(workflows.join("ci.yml"), canonical.replace('\n', "\r\n")).unwrap(); + + let discovered = read_workflow_sources(&repository).unwrap(); + assert_eq!(discovered.source(".github/workflows/ci.yml"), Some(canonical)); + + fs::remove_dir_all(repository).unwrap(); + } + #[cfg(unix)] #[test] fn workflow_directory_redirects_are_rejected_inside_and_outside_the_repository() { diff --git a/tools/zc/src/workflow_protocol.rs b/tools/zc/src/workflow_protocol.rs new file mode 100644 index 0000000000..794a4b51fd --- /dev/null +++ b/tools/zc/src/workflow_protocol.rs @@ -0,0 +1,42 @@ +// 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. + +//! Private spellings shared by the typed CI CLI, projection, and workflow audit. +//! +//! This leaf module contains no parsing or policy. Keeping the handwritten +//! workflow protocol here makes a producer, consumer, or CLI rename one atomic +//! Rust change while the planned-job workflow audit checks the literal YAML. + +pub(crate) const WORKFLOW_PATH: &str = ".github/workflows/ci.yml"; + +pub(crate) const PLAN_JOB: &str = "plan_ci"; + +pub(crate) const PLAN_STEP_NAME: &str = "Validate inputs and project the plan"; +pub(crate) const PLAN_STEP_ID: &str = "plan"; + +pub(crate) const GITHUB_PLAN_COMMAND: &str = "github-plan"; +pub(crate) const EXECUTE_BUILD_CELL_COMMAND: &str = "execute-build-cell"; +pub(crate) const EXECUTE_MIRI_CELL_COMMAND: &str = "execute-miri-cell"; + +pub(crate) const CI_EVENT_OPTION: &str = "--event"; +pub(crate) const GITHUB_OUTPUT_OPTION: &str = "--github-output"; +pub(crate) const PLAN_ARTIFACT_OPTION: &str = "--artifact"; +pub(crate) const CELL_PACKAGE_OPTION: &str = "--package"; +pub(crate) const CELL_TOOLCHAIN_OPTION: &str = "--toolchain"; +pub(crate) const CELL_FEATURE_PROFILE_OPTION: &str = "--feature-profile"; +pub(crate) const CELL_TARGET_OPTION: &str = "--target"; +pub(crate) const CELL_MIRI_MODEL_OPTION: &str = "--miri-model"; + +pub(crate) const BUILD_MATRIX_OUTPUT: &str = "build_matrix"; +pub(crate) const MIRI_MATRIX_OUTPUT: &str = "miri_matrix"; +pub(crate) const MIRI_ENABLED_OUTPUT: &str = "miri_enabled"; + +pub(crate) const HOST_RUNNER: &str = "ubuntu-latest"; +pub(crate) const REPOSITORY_WORKING_DIRECTORY: &str = "zerocopy"; +pub(crate) const TRUSTED_SHELL: &str = "/usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0}"; +pub(crate) const PLANNER_PATH: &str = "/home/runner/.cargo/bin:/usr/local/bin:/usr/bin:/bin";