From 0adf9856a0bd269c890e841a70544c56dc0eac54 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Tue, 25 Aug 2026 00:20:43 +0000 Subject: [PATCH] [ci] Read frozen CI baselines strictly Add typed, data-only readers for the eight files which record legacy CI behavior. Require exact headers, field counts, row order, identifiers, paths, counts, hashes, command forms, action references, and canonical JSON. Reject invalid UTF-8, control characters, duplicate rows, and manifest counts which disagree with the parsed data. Keep parsed fields private and expose validated constructors so future plans cannot bypass the same invariants when comparing exact sets. Reparse public string-bearing enum values and compare the resulting canonical variant, preventing `WorkingDirectory::Relative(".")` from entering a record as an alternate spelling of the repository root. Report missing and extra work separately and in deterministic order. This reader deliberately does not parse workflow YAML or regenerate the baseline. The checked-in files remain independent review evidence from the old workflow rather than an expectation produced by the new planner. Tests: standalone warning-free compilation; baseline mutation tests. *Authored by an agent, posting via joshlf's account* gherrit-pr-id: Gt4itljw3xh6tci2mjqj4z245i6pwle2i --- tools/zc/src/baseline.rs | 2755 ++++++++++++++++++++++++++++++++++++ tools/zc/src/identifier.rs | 53 + tools/zc/src/lib.rs | 2 + tools/zc/src/policy.rs | 57 +- 4 files changed, 2832 insertions(+), 35 deletions(-) create mode 100644 tools/zc/src/baseline.rs create mode 100644 tools/zc/src/identifier.rs diff --git a/tools/zc/src/baseline.rs b/tools/zc/src/baseline.rs new file mode 100644 index 0000000000..ae65f7d287 --- /dev/null +++ b/tools/zc/src/baseline.rs @@ -0,0 +1,2755 @@ +// 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. + +//! Strict, data-only access to the frozen legacy CI baseline. +//! +//! The files in `ci/baselines` are independent review evidence. They were +//! derived from the old workflow at a pinned commit; this module deliberately +//! does not parse that workflow or regenerate its truth. A future planner can +//! instead construct the same public row types and compare exact sets. This is +//! stronger than comparing counts: one omitted obligation and one unrelated +//! addition cannot cancel each other out. +//! +//! This reader is intentionally stricter than a general TSV reader. The +//! baseline is repository-owned canonical data, so accepting alternate headers, +//! row orders, numeric spellings, JSON spellings, paths, or identifiers would +//! create needless ways for two reviews to describe the same fact. Strictness +//! also makes accidental hand edits fail near their source. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + fmt, fs, io, + path::{Component, Path, PathBuf}, + str::FromStr, +}; + +use crate::identifier::{self, IdentifierError, MAX_ID_BYTES}; +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"; +const LOGICAL_HEADER: &str = "kind\tcrate\ttoolchain\tfeature_profile\ttarget\tmiri_model\tpr_occurrences\tfull_occurrences\tcondition\tsources"; +const STANDALONE_HEADER: &str = "obligation\tevents\tjob\tstep\tworking_directory\tform\tpayload"; +const COMMAND_GOLDEN_HEADER: &str = + "golden\tjob\tstep\tworking_directory\tenvironment_json\tform\tpayload\tdynamic_value\tnote"; + +/// Paths to all files which together form one legacy baseline. +/// +/// Callers should obtain these paths from the validated CI policy and join +/// them to a pinned repository root. The reader takes explicit paths rather +/// than assuming filenames so that the policy remains the authority for where +/// reviewed evidence lives. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyBaselinePaths { + pub manifest: PathBuf, + pub build_reduced: PathBuf, + pub build_full: PathBuf, + pub miri_reduced: PathBuf, + pub miri_full: PathBuf, + pub logical_obligations: PathBuf, + pub standalone_obligations: PathBuf, + pub command_goldens: PathBuf, +} + +/// One lowercase, stable identifier used by the old workflow matrices. +/// +/// This deliberately matches the identifier grammar used by the typed policy. +/// Keeping path separators, whitespace, shell punctuation, and control bytes +/// out of identifiers makes them safe to carry through JSON and GitHub matrix +/// fields without adding a second interpretation layer. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct BaselineId(String); + +impl BaselineId { + /// Returns the identifier exactly as recorded in the baseline. + pub fn as_str(&self) -> &str { + &self.0 + } + + fn parse(value: &str) -> Result { + match identifier::validate(value) { + Ok(()) => Ok(Self(value.to_owned())), + Err(IdentifierError::TooLong { bytes }) => Err(format!( + "must be at most {MAX_ID_BYTES} bytes; found {bytes} bytes" + )), + Err(IdentifierError::InvalidSyntax) => Err( + "must be a lowercase stable identifier using only ASCII letters, digits, `_`, `-`, or `.`" + .into(), + ), + } + } +} + +impl fmt::Display for BaselineId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for BaselineId { + type Err = String; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +/// A canonical working directory relative to the repository root. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum WorkingDirectory { + /// The repository root, spelled exactly `.` in the legacy files. + RepositoryRoot, + /// A nonempty, normalized repository-relative path. + Relative(String), +} + +impl WorkingDirectory { + /// Parses the baseline spelling of a working directory. + pub fn parse(value: &str) -> Result { + if value == "." { + return Ok(Self::RepositoryRoot); + } + validate_repo_relative_path(value)?; + Ok(Self::Relative(value.to_owned())) + } + + /// Returns the canonical baseline spelling. + pub fn as_str(&self) -> &str { + match self { + Self::RepositoryRoot => ".", + Self::Relative(path) => path, + } + } + + /// Rechecks both the spelling and its canonical enum representation. + /// + /// The variants are public because planned records expose this type, so a + /// caller can construct `Relative(".")` without going through `parse`. + /// Comparing the reparsed value, rather than merely accepting its string, + /// prevents that alternate representation from entering a typed baseline. + fn validate(&self) -> Result<(), String> { + let canonical = Self::parse(self.as_str())?; + if canonical == *self { + Ok(()) + } else { + Err(format!( + "working directory `{}` must use its canonical enum representation", + self.as_str() + )) + } + } +} + +/// One ordinary build matrix cell. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct BuildCell { + package: BaselineId, + toolchain: BaselineId, + feature_profile: BaselineId, + target: BaselineId, +} + +impl BuildCell { + /// Constructs a cell from already validated identifiers. + pub fn new( + package: BaselineId, + toolchain: BaselineId, + feature_profile: BaselineId, + target: BaselineId, + ) -> Self { + Self { package, toolchain, feature_profile, target } + } + + pub fn package(&self) -> &BaselineId { + &self.package + } + + pub fn toolchain(&self) -> &BaselineId { + &self.toolchain + } + + pub fn feature_profile(&self) -> &BaselineId { + &self.feature_profile + } + + pub fn target(&self) -> &BaselineId { + &self.target + } +} + +/// One Miri matrix cell. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct MiriCell { + package: BaselineId, + toolchain: BaselineId, + feature_profile: BaselineId, + target: BaselineId, + model: BaselineId, + /// Exact model-specific flags. `` in the TSV becomes an empty vector. + model_flags: Vec, +} + +impl MiriCell { + /// Constructs a Miri cell, rejecting flags which cannot have canonical TSV + /// or command-line representations. + pub fn new( + package: BaselineId, + toolchain: BaselineId, + feature_profile: BaselineId, + target: BaselineId, + model: BaselineId, + model_flags: Vec, + ) -> Result { + validate_model_flags(&model_flags)?; + Ok(Self { package, toolchain, feature_profile, target, model, model_flags }) + } + + pub fn package(&self) -> &BaselineId { + &self.package + } + + pub fn toolchain(&self) -> &BaselineId { + &self.toolchain + } + + pub fn feature_profile(&self) -> &BaselineId { + &self.feature_profile + } + + pub fn target(&self) -> &BaselineId { + &self.target + } + + pub fn model(&self) -> &BaselineId { + &self.model + } + + pub fn model_flags(&self) -> &[String] { + &self.model_flags + } +} + +/// A condition attached to one normalized logical obligation. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ObligationCondition { + /// The operation runs without an additional workflow condition. + Always, + /// The operation runs only after all required dependencies succeed. + Success, + /// The operation runs when GitHub reports workflow cancellation. + Cancelled, + /// The operation is omitted only by the documented local escape hatch. + UnlessSkipCargoSemverChecks, +} + +/// One physical workflow location which performs a logical obligation. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ObligationSource { + job: BaselineId, + step: String, +} + +impl ObligationSource { + pub fn new(job: BaselineId, step: impl Into) -> Result { + let step = step.into(); + validate_text(&step)?; + Ok(Self { job, step }) + } + + pub fn job(&self) -> &BaselineId { + &self.job + } + + pub fn step(&self) -> &str { + &self.step + } +} + +/// One normalized unit of legacy CI work. +/// +/// Optional matrix dimensions are empty in the TSV and become `None`. This +/// avoids giving the empty string a second meaning in future plan comparison. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct LogicalObligation { + kind: BaselineId, + package: Option, + toolchain: Option, + feature_profile: Option, + target: Option, + miri_model: Option, + reduced_occurrences: u32, + full_occurrences: u32, + condition: ObligationCondition, + sources: Vec, +} + +impl LogicalObligation { + #[allow(clippy::too_many_arguments)] + pub fn new( + kind: BaselineId, + package: Option, + toolchain: Option, + feature_profile: Option, + target: Option, + miri_model: Option, + reduced_occurrences: u32, + full_occurrences: u32, + condition: ObligationCondition, + sources: Vec, + ) -> Result { + if reduced_occurrences == 0 && full_occurrences == 0 { + return Err("must occur in at least one event category".into()); + } + if sources.is_empty() { + return Err("must have at least one physical source".into()); + } + if sources.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err("sources must be unique and strictly sorted".into()); + } + Ok(Self { + kind, + package, + toolchain, + feature_profile, + target, + miri_model, + reduced_occurrences, + full_occurrences, + condition, + sources, + }) + } + + pub fn kind(&self) -> &BaselineId { + &self.kind + } + + pub fn package(&self) -> Option<&BaselineId> { + self.package.as_ref() + } + + pub fn toolchain(&self) -> Option<&BaselineId> { + self.toolchain.as_ref() + } + + pub fn feature_profile(&self) -> Option<&BaselineId> { + self.feature_profile.as_ref() + } + + pub fn target(&self) -> Option<&BaselineId> { + self.target.as_ref() + } + + pub fn miri_model(&self) -> Option<&BaselineId> { + self.miri_model.as_ref() + } + + pub fn reduced_occurrences(&self) -> u32 { + self.reduced_occurrences + } + + pub fn full_occurrences(&self) -> u32 { + self.full_occurrences + } + + pub fn condition(&self) -> &ObligationCondition { + &self.condition + } + + pub fn sources(&self) -> &[ObligationSource] { + &self.sources + } + + /// Returns the fields which identify this logical unit of work. + pub fn key(&self) -> LogicalObligationKey { + LogicalObligationKey { + kind: self.kind.clone(), + package: self.package.clone(), + toolchain: self.toolchain.clone(), + feature_profile: self.feature_profile.clone(), + target: self.target.clone(), + miri_model: self.miri_model.clone(), + } + } +} + +/// The fields which uniquely identify one logical CI obligation. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct LogicalObligationKey { + kind: BaselineId, + package: Option, + toolchain: Option, + feature_profile: Option, + target: Option, + miri_model: Option, +} + +/// Logical obligations which are known to have unique semantic keys. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LogicalObligations(BTreeMap); + +impl LogicalObligations { + /// Creates an empty validated collection. + pub fn new() -> Self { + Self(BTreeMap::new()) + } + + /// Inserts an obligation unless its semantic key is already present. + pub fn insert( + &mut self, + obligation: LogicalObligation, + ) -> Result<(), DuplicateLogicalObligation> { + let key = obligation.key(); + if self.0.contains_key(&key) { + return Err(DuplicateLogicalObligation { key: Box::new(key) }); + } + self.0.insert(key, obligation); + Ok(()) + } + + /// Validates and collects logical obligations without overwriting a key. + pub fn try_from_iter( + obligations: impl IntoIterator, + ) -> Result { + let mut collected = Self::new(); + for obligation in obligations { + collected.insert(obligation)?; + } + Ok(collected) + } + + /// Returns the number of unique obligations. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether no obligations are present. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Iterates over obligations in semantic-key order. + pub fn values(&self) -> impl Iterator { + self.0.values() + } + + fn value_set(&self) -> BTreeSet { + self.0.values().cloned().collect() + } +} + +/// Two planned logical obligations used the same semantic key. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DuplicateLogicalObligation { + key: Box, +} + +impl DuplicateLogicalObligation { + /// Returns the duplicated key. + pub fn key(&self) -> &LogicalObligationKey { + &self.key + } +} + +impl fmt::Display for DuplicateLogicalObligation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "duplicate logical-obligation key: {:?}", self.key) + } +} + +impl Error for DuplicateLogicalObligation {} + +impl Default for LogicalObligations { + fn default() -> Self { + Self::new() + } +} + +/// Event coverage recorded for a standalone obligation. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum StandaloneEvents { + PullRequestAndFull, +} + +/// How a standalone legacy obligation is invoked. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum StandaloneInvocation { + Direct(Vec), + Action(String), + PrePushChild(Vec), + PrePushInternal(String), + ShellContract(String), +} + +/// One physical operation outside the ordinary build and Miri matrices. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct StandaloneObligation { + obligation: BaselineId, + events: StandaloneEvents, + job: BaselineId, + step: String, + working_directory: WorkingDirectory, + invocation: StandaloneInvocation, +} + +impl StandaloneObligation { + pub fn new( + obligation: BaselineId, + events: StandaloneEvents, + job: BaselineId, + step: impl Into, + working_directory: WorkingDirectory, + invocation: StandaloneInvocation, + ) -> Result { + let step = step.into(); + validate_text(&step)?; + // Reparse the public enum's string-bearing variants so constructing a + // planned row cannot bypass the same invariants as reading a fixture. + working_directory.validate()?; + validate_standalone_invocation(&invocation)?; + Ok(Self { obligation, events, job, step, working_directory, invocation }) + } + + pub fn obligation(&self) -> &BaselineId { + &self.obligation + } + + pub fn events(&self) -> &StandaloneEvents { + &self.events + } + + pub fn job(&self) -> &BaselineId { + &self.job + } + + pub fn step(&self) -> &str { + &self.step + } + + pub fn working_directory(&self) -> &WorkingDirectory { + &self.working_directory + } + + pub fn invocation(&self) -> &StandaloneInvocation { + &self.invocation + } +} + +/// A small, canonical JSON value used by action-input command goldens. +/// +/// The legacy payloads need only strings, arrays, and objects. Rejecting JSON +/// numbers, booleans, and null keeps this representation precise and prevents +/// a future file edit from gaining an unreviewed interpretation. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum JsonValue { + String(String), + Array(Vec), + Object(BTreeMap), +} + +/// The typed payload recorded by one representative command golden. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum CommandPayload { + Argv { argv: Vec, dynamic_value: Option }, + ArgvTemplate { argv: Vec, dynamic_value: String }, + ActionInputs { inputs: BTreeMap, dynamic_value: String }, +} + +/// The behavior-bearing part of one representative command or action. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct CommandBehavior { + name: BaselineId, + job: BaselineId, + step: String, + working_directory: WorkingDirectory, + environment: BTreeMap, + payload: CommandPayload, +} + +impl CommandBehavior { + pub fn new( + name: BaselineId, + job: BaselineId, + step: impl Into, + working_directory: WorkingDirectory, + environment: BTreeMap, + payload: CommandPayload, + ) -> Result { + let step = step.into(); + validate_text(&step)?; + working_directory.validate()?; + validate_environment(&environment)?; + validate_command_payload(&payload)?; + Ok(Self { name, job, step, working_directory, environment, payload }) + } + + pub fn name(&self) -> &BaselineId { + &self.name + } + + pub fn job(&self) -> &BaselineId { + &self.job + } + + pub fn step(&self) -> &str { + &self.step + } + + pub fn working_directory(&self) -> &WorkingDirectory { + &self.working_directory + } + + pub fn environment(&self) -> &BTreeMap { + &self.environment + } + + pub fn payload(&self) -> &CommandPayload { + &self.payload + } +} + +/// Planned command behaviors with one behavior per golden name. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandBehaviors(BTreeMap); + +impl CommandBehaviors { + /// Creates an empty validated collection. + pub fn new() -> Self { + Self(BTreeMap::new()) + } + + /// Inserts a behavior unless its golden name is already present. + pub fn insert(&mut self, behavior: CommandBehavior) -> Result<(), DuplicateCommandBehavior> { + let name = behavior.name.clone(); + if self.0.contains_key(&name) { + return Err(DuplicateCommandBehavior { name }); + } + self.0.insert(name, behavior); + Ok(()) + } + + /// Validates and collects behaviors without overwriting a golden name. + pub fn try_from_iter( + behaviors: impl IntoIterator, + ) -> Result { + let mut collected = Self::new(); + for behavior in behaviors { + collected.insert(behavior)?; + } + Ok(collected) + } + + /// Returns the number of named behaviors. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether no behaviors are present. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Iterates over behavior in golden-name order. + pub fn values(&self) -> impl Iterator { + self.0.values() + } + + fn value_set(&self) -> BTreeSet { + self.0.values().cloned().collect() + } +} + +impl Default for CommandBehaviors { + fn default() -> Self { + Self::new() + } +} + +/// Two planned command behaviors used the same golden name. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DuplicateCommandBehavior { + name: BaselineId, +} + +impl DuplicateCommandBehavior { + /// Returns the duplicated name. + pub fn name(&self) -> &BaselineId { + &self.name + } +} + +impl fmt::Display for DuplicateCommandBehavior { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "duplicate command-golden name `{}`", self.name) + } +} + +impl Error for DuplicateCommandBehavior {} + +/// One reviewed command behavior and the prose which explains its purpose. +/// +/// The note is intentionally not part of [`CommandBehavior`]. Editing review +/// prose must not look like an executable plan change, and a planner must not +/// reproduce prose merely to compare command behavior. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandGolden { + behavior: CommandBehavior, + note: String, +} + +impl CommandGolden { + /// Returns the behavior compared with a future plan. + pub fn behavior(&self) -> &CommandBehavior { + &self.behavior + } + + /// Returns the reviewer-facing explanation recorded with the behavior. + pub fn note(&self) -> &str { + &self.note + } +} + +/// Source identity and independently checked row counts for a legacy baseline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyManifest { + source_commit: String, + workflow_sha256: String, + pre_push_sha256: String, + cargo_toml_sha256: String, + build_reduced_cells: u64, + build_full_cells: u64, + miri_reduced_cells: u64, + miri_full_cells: u64, + standalone_occurrences: u64, + normalized_logical_obligations: u64, +} + +impl LegacyManifest { + pub fn source_commit(&self) -> &str { + &self.source_commit + } + + pub fn workflow_sha256(&self) -> &str { + &self.workflow_sha256 + } + + pub fn pre_push_sha256(&self) -> &str { + &self.pre_push_sha256 + } + + pub fn cargo_toml_sha256(&self) -> &str { + &self.cargo_toml_sha256 + } + + pub fn build_reduced_cells(&self) -> u64 { + self.build_reduced_cells + } + + pub fn build_full_cells(&self) -> u64 { + self.build_full_cells + } + + pub fn miri_reduced_cells(&self) -> u64 { + self.miri_reduced_cells + } + + pub fn miri_full_cells(&self) -> u64 { + self.miri_full_cells + } + + pub fn standalone_occurrences(&self) -> u64 { + self.standalone_occurrences + } + + pub fn normalized_logical_obligations(&self) -> u64 { + self.normalized_logical_obligations + } +} + +/// All independently frozen legacy CI evidence, parsed into deterministic sets. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyBaselines { + manifest: LegacyManifest, + build_reduced: BTreeSet, + build_full: BTreeSet, + miri_reduced: BTreeSet, + miri_full: BTreeSet, + logical_obligations: LogicalObligations, + standalone_obligations: BTreeSet, + command_goldens: BTreeMap, +} + +impl LegacyBaselines { + pub fn manifest(&self) -> &LegacyManifest { + &self.manifest + } + + pub fn build_reduced(&self) -> &BTreeSet { + &self.build_reduced + } + + pub fn build_full(&self) -> &BTreeSet { + &self.build_full + } + + pub fn miri_reduced(&self) -> &BTreeSet { + &self.miri_reduced + } + + pub fn miri_full(&self) -> &BTreeSet { + &self.miri_full + } + + pub fn logical_obligations(&self) -> &LogicalObligations { + &self.logical_obligations + } + + pub fn standalone_obligations(&self) -> &BTreeSet { + &self.standalone_obligations + } + + pub fn command_goldens(&self) -> &BTreeMap { + &self.command_goldens + } + + /// Reads, strictly parses, and cross-checks all files in one baseline. + pub fn read(paths: &LegacyBaselinePaths) -> Result { + let manifest = read_source(&paths.manifest)?; + let build_reduced = read_source(&paths.build_reduced)?; + let build_full = read_source(&paths.build_full)?; + let miri_reduced = read_source(&paths.miri_reduced)?; + let miri_full = read_source(&paths.miri_full)?; + let logical_obligations = read_source(&paths.logical_obligations)?; + let standalone_obligations = read_source(&paths.standalone_obligations)?; + let command_goldens = read_source(&paths.command_goldens)?; + Self::parse_sources( + paths, + BaselineSources { + manifest: &manifest, + build_reduced: &build_reduced, + build_full: &build_full, + miri_reduced: &miri_reduced, + miri_full: &miri_full, + logical_obligations: &logical_obligations, + standalone_obligations: &standalone_obligations, + command_goldens: &command_goldens, + }, + ) + } + + fn parse_sources( + paths: &LegacyBaselinePaths, + sources: BaselineSources<'_>, + ) -> Result { + let manifest = parse_manifest(&paths.manifest, sources.manifest)?; + let build_reduced = parse_build_cells(&paths.build_reduced, sources.build_reduced)?; + let build_full = parse_build_cells(&paths.build_full, sources.build_full)?; + let miri_reduced = parse_miri_cells(&paths.miri_reduced, sources.miri_reduced)?; + let miri_full = parse_miri_cells(&paths.miri_full, sources.miri_full)?; + let logical_obligations = + parse_logical_obligations(&paths.logical_obligations, sources.logical_obligations)?; + let standalone_obligations = parse_standalone_obligations( + &paths.standalone_obligations, + sources.standalone_obligations, + )?; + let command_goldens = + parse_command_goldens(&paths.command_goldens, sources.command_goldens)?; + + check_manifest_count( + &paths.manifest, + "build_pr_cells", + manifest.build_reduced_cells, + build_reduced.len(), + )?; + check_manifest_count( + &paths.manifest, + "build_full_cells", + manifest.build_full_cells, + build_full.len(), + )?; + check_manifest_count( + &paths.manifest, + "miri_pr_cells", + manifest.miri_reduced_cells, + miri_reduced.len(), + )?; + check_manifest_count( + &paths.manifest, + "miri_full_cells", + manifest.miri_full_cells, + miri_full.len(), + )?; + check_manifest_count( + &paths.manifest, + "standalone_occurrences", + manifest.standalone_occurrences, + standalone_obligations.len(), + )?; + check_manifest_count( + &paths.manifest, + "normalized_logical_obligations", + manifest.normalized_logical_obligations, + logical_obligations.len(), + )?; + + Ok(Self { + manifest, + build_reduced, + build_full, + miri_reduced, + miri_full, + logical_obligations, + standalone_obligations, + command_goldens, + }) + } + + /// Compares the reduced build plan with the exact frozen set. + pub fn compare_build_reduced( + &self, + planned: &BTreeSet, + ) -> Result<(), SetDifference> { + compare_exact(&self.build_reduced, planned) + } + + /// Compares the full build plan with the exact frozen set. + pub fn compare_build_full( + &self, + planned: &BTreeSet, + ) -> Result<(), SetDifference> { + compare_exact(&self.build_full, planned) + } + + /// Compares the reduced Miri plan with the exact frozen set. + pub fn compare_miri_reduced( + &self, + planned: &BTreeSet, + ) -> Result<(), SetDifference> { + compare_exact(&self.miri_reduced, planned) + } + + /// Compares the full Miri plan with the exact frozen set. + pub fn compare_miri_full( + &self, + planned: &BTreeSet, + ) -> Result<(), SetDifference> { + compare_exact(&self.miri_full, planned) + } + + /// Compares normalized logical work with the exact frozen set. + pub fn compare_logical_obligations( + &self, + planned: &LogicalObligations, + ) -> Result<(), SetDifference> { + compare_exact(&self.logical_obligations.value_set(), &planned.value_set()) + } + + /// Compares standalone work with the exact frozen set. + pub fn compare_standalone_obligations( + &self, + planned: &BTreeSet, + ) -> Result<(), SetDifference> { + compare_exact(&self.standalone_obligations, planned) + } + + /// Compares representative expanded commands with the exact frozen set. + pub fn compare_command_goldens( + &self, + planned: &CommandBehaviors, + ) -> Result<(), SetDifference> { + let baseline = + self.command_goldens.values().map(|golden| golden.behavior.clone()).collect(); + compare_exact(&baseline, &planned.value_set()) + } +} + +/// Exact missing and extra values found while comparing a plan to a baseline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SetDifference { + /// Baseline values which the proposed plan does not contain. + missing_from_plan: BTreeSet, + /// Proposed values which are absent from the reviewed baseline. + extra_in_plan: BTreeSet, +} + +impl SetDifference { + pub fn missing_from_plan(&self) -> &BTreeSet { + &self.missing_from_plan + } + + pub fn extra_in_plan(&self) -> &BTreeSet { + &self.extra_in_plan + } +} + +impl fmt::Display for SetDifference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + formatter, + "plan differs from the legacy baseline: {} missing, {} extra", + self.missing_from_plan.len(), + self.extra_in_plan.len() + )?; + for value in &self.missing_from_plan { + writeln!(formatter, "- missing: {value:?}")?; + } + for value in &self.extra_in_plan { + writeln!(formatter, "- extra: {value:?}")?; + } + Ok(()) + } +} + +impl Error for SetDifference {} + +/// Compares complete sets without allowing missing and extra rows to cancel. +pub fn compare_exact( + baseline: &BTreeSet, + planned: &BTreeSet, +) -> Result<(), SetDifference> { + let difference = SetDifference { + missing_from_plan: baseline.difference(planned).cloned().collect(), + extra_in_plan: planned.difference(baseline).cloned().collect(), + }; + if difference.missing_from_plan.is_empty() && difference.extra_in_plan.is_empty() { + Ok(()) + } else { + Err(difference) + } +} + +/// A read or strict-format error in one baseline file. +#[derive(Debug)] +pub struct BaselineError { + path: PathBuf, + line: Option, + message: String, + source: Option, +} + +impl BaselineError { + fn format(path: &Path, line: Option, message: impl Into) -> Self { + let mut escaped = String::new(); + for character in message.into().chars() { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + Self { path: path.to_path_buf(), line, message: escaped, source: None } + } + + /// Returns the file whose evidence could not be read or validated. + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns the one-based line number, when the error belongs to one line. + pub fn line(&self) -> Option { + self.line + } +} + +impl fmt::Display for BaselineError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "legacy baseline `{}`", self.path.display())?; + if let Some(line) = self.line { + write!(formatter, " line {line}")?; + } + write!(formatter, ": {}", self.message) + } +} + +impl Error for BaselineError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|source| source as &(dyn Error + 'static)) + } +} + +fn read_source(path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| BaselineError { + path: path.to_path_buf(), + line: None, + message: format!("failed to read file: {source}"), + source: Some(source), + }) +} + +struct BaselineSources<'a> { + manifest: &'a str, + build_reduced: &'a str, + build_full: &'a str, + miri_reduced: &'a str, + miri_full: &'a str, + logical_obligations: &'a str, + standalone_obligations: &'a str, + command_goldens: &'a str, +} + +struct DataRow<'a> { + line: usize, + fields: Vec<&'a str>, +} + +/// Parses the common canonical TSV envelope before schema-specific fields. +/// +/// Comments may document provenance before the header. Once data begins, every +/// physical line is significant: blank lines and comments are rejected rather +/// than silently disappearing from a reviewed fixture. +fn data_rows<'a>( + path: &Path, + source: &'a str, + header: &str, + field_count: usize, +) -> Result>, BaselineError> { + if source.contains('\r') { + return Err(BaselineError::format(path, None, "carriage returns are not permitted")); + } + if !source.ends_with('\n') { + return Err(BaselineError::format(path, None, "file must end with one newline")); + } + + let mut saw_header = false; + let mut previous_row: Option<&str> = None; + let mut rows = Vec::new(); + for (line_index, line) in source.split_terminator('\n').enumerate() { + let line_number = line_index + 1; + if !saw_header { + if line.starts_with('#') { + if line.chars().any(char::is_control) { + return Err(BaselineError::format( + path, + Some(line_number), + "comments must not contain control characters", + )); + } + continue; + } + if line.is_empty() { + return Err(BaselineError::format( + path, + Some(line_number), + "blank lines are not canonical", + )); + } + if line != header { + return Err(BaselineError::format( + path, + Some(line_number), + format!("expected exact header `{header}`, found `{line}`"), + )); + } + saw_header = true; + continue; + } + + if line.is_empty() { + return Err(BaselineError::format( + path, + Some(line_number), + "blank lines are not canonical", + )); + } + if line.starts_with('#') { + return Err(BaselineError::format( + path, + Some(line_number), + "comments are permitted only before the header", + )); + } + if let Some(previous) = previous_row { + if line == previous { + return Err(BaselineError::format( + path, + Some(line_number), + "duplicate row; data rows must be unique", + )); + } + if line < previous { + return Err(BaselineError::format( + path, + Some(line_number), + "row is out of order; data rows must be strictly bytewise sorted", + )); + } + } + previous_row = Some(line); + + let fields = line.split('\t').collect::>(); + if fields.len() != field_count { + return Err(BaselineError::format( + path, + Some(line_number), + format!("expected {field_count} tab-separated fields, found {}", fields.len()), + )); + } + rows.push(DataRow { line: line_number, fields }); + } + if !saw_header { + return Err(BaselineError::format(path, None, format!("missing exact header `{header}`"))); + } + Ok(rows) +} + +fn parse_manifest(path: &Path, source: &str) -> Result { + let rows = data_rows(path, source, MANIFEST_HEADER, 2)?; + let mut values = BTreeMap::new(); + for row in rows { + let key = row.fields[0]; + validate_manifest_key(key) + .map_err(|reason| field_error(path, row.line, "key", key, reason))?; + if values.insert(key, (row.line, row.fields[1])).is_some() { + // Raw duplicate rows were already rejected. Reaching this branch + // means one key has two conflicting values, which is equally + // ambiguous and must not be treated as last-one-wins data. + return Err(field_error(path, row.line, "key", key, "manifest keys must be unique")); + } + } + + const KEYS: &[&str] = &[ + "build_full_cells", + "build_pr_cells", + "cargo_toml_sha256", + "generic_manual_build_sets_equal", + "generic_manual_miri_sets_equal", + "miri_full_cells", + "miri_pr_cells", + "normalized_logical_obligations", + "pre_push_sha256", + "source_commit", + "standalone_occurrences", + "workflow_sha256", + ]; + for key in values.keys() { + if !KEYS.contains(key) { + return Err(BaselineError::format( + path, + Some(values[key].0), + format!("unknown manifest key `{key}`"), + )); + } + } + for key in KEYS { + if !values.contains_key(key) { + return Err(BaselineError::format(path, None, format!("missing manifest key `{key}`"))); + } + } + + let value = |key: &str| values[key].1; + for key in ["generic_manual_build_sets_equal", "generic_manual_miri_sets_equal"] { + if value(key) != "true" { + return Err(BaselineError::format( + path, + Some(values[key].0), + format!( + "`{key}` must be `true`; unequal independent derivations are not valid evidence" + ), + )); + } + } + + Ok(LegacyManifest { + source_commit: parse_lower_hex(value("source_commit"), 40).map_err(|reason| { + field_error(path, values["source_commit"].0, "value", value("source_commit"), reason) + })?, + workflow_sha256: parse_lower_hex(value("workflow_sha256"), 64).map_err(|reason| { + field_error( + path, + values["workflow_sha256"].0, + "value", + value("workflow_sha256"), + reason, + ) + })?, + pre_push_sha256: parse_lower_hex(value("pre_push_sha256"), 64).map_err(|reason| { + field_error( + path, + values["pre_push_sha256"].0, + "value", + value("pre_push_sha256"), + reason, + ) + })?, + cargo_toml_sha256: parse_lower_hex(value("cargo_toml_sha256"), 64).map_err(|reason| { + field_error( + path, + values["cargo_toml_sha256"].0, + "value", + value("cargo_toml_sha256"), + reason, + ) + })?, + build_reduced_cells: manifest_count(path, &values, "build_pr_cells")?, + build_full_cells: manifest_count(path, &values, "build_full_cells")?, + miri_reduced_cells: manifest_count(path, &values, "miri_pr_cells")?, + miri_full_cells: manifest_count(path, &values, "miri_full_cells")?, + standalone_occurrences: manifest_count(path, &values, "standalone_occurrences")?, + normalized_logical_obligations: manifest_count( + path, + &values, + "normalized_logical_obligations", + )?, + }) +} + +fn validate_manifest_key(value: &str) -> Result<(), String> { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + Ok(()) + } else { + Err("must use lowercase ASCII letters, digits, or `_`".into()) + } +} + +fn manifest_count( + path: &Path, + values: &BTreeMap<&str, (usize, &str)>, + key: &str, +) -> Result { + parse_u64(values[key].1) + .map_err(|reason| field_error(path, values[key].0, "value", values[key].1, reason)) +} + +fn parse_build_cells(path: &Path, source: &str) -> Result, BaselineError> { + let rows = data_rows(path, source, BUILD_HEADER, 4)?; + let mut cells = BTreeSet::new(); + for row in rows { + let cell = BuildCell { + package: parse_id_field(path, &row, 0, "crate")?, + toolchain: parse_id_field(path, &row, 1, "toolchain")?, + feature_profile: parse_id_field(path, &row, 2, "feature_profile")?, + target: parse_id_field(path, &row, 3, "target")?, + }; + insert_unique(path, row.line, &mut cells, cell)?; + } + Ok(cells) +} + +fn parse_miri_cells(path: &Path, source: &str) -> Result, BaselineError> { + let rows = data_rows(path, source, MIRI_HEADER, 6)?; + let mut cells = BTreeSet::new(); + for row in rows { + let cell = MiriCell { + package: parse_id_field(path, &row, 0, "crate")?, + toolchain: parse_id_field(path, &row, 1, "toolchain")?, + feature_profile: parse_id_field(path, &row, 2, "feature_profile")?, + target: parse_id_field(path, &row, 3, "target")?, + model: parse_id_field(path, &row, 4, "miri_model")?, + model_flags: parse_flag_list(row.fields[5]).map_err(|reason| { + field_error(path, row.line, "miri_model_flags", row.fields[5], reason) + })?, + }; + insert_unique(path, row.line, &mut cells, cell)?; + } + Ok(cells) +} + +fn parse_logical_obligations( + path: &Path, + source: &str, +) -> Result { + let rows = data_rows(path, source, LOGICAL_HEADER, 10)?; + let mut obligations = LogicalObligations::new(); + for row in rows { + let condition = match row.fields[8] { + "always" => ObligationCondition::Always, + "success()" => ObligationCondition::Success, + "cancelled()" => ObligationCondition::Cancelled, + "unless SKIP_CARGO_SEMVER_CHECKS=1" => ObligationCondition::UnlessSkipCargoSemverChecks, + value => { + return Err(field_error( + path, + row.line, + "condition", + value, + "is not a supported legacy condition", + )); + } + }; + let obligation = LogicalObligation { + kind: parse_id_field(path, &row, 0, "kind")?, + package: parse_optional_id_field(path, &row, 1, "crate")?, + toolchain: parse_optional_id_field(path, &row, 2, "toolchain")?, + feature_profile: parse_optional_id_field(path, &row, 3, "feature_profile")?, + target: parse_optional_id_field(path, &row, 4, "target")?, + miri_model: parse_optional_id_field(path, &row, 5, "miri_model")?, + reduced_occurrences: parse_u32(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "pr_occurrences", row.fields[6], reason) + })?, + full_occurrences: parse_u32(row.fields[7]).map_err(|reason| { + field_error(path, row.line, "full_occurrences", row.fields[7], reason) + })?, + condition, + sources: parse_obligation_sources(row.fields[9]) + .map_err(|reason| field_error(path, row.line, "sources", row.fields[9], reason))?, + }; + if obligation.reduced_occurrences == 0 && obligation.full_occurrences == 0 { + return Err(BaselineError::format( + path, + Some(row.line), + "logical obligation has zero occurrences in every event category", + )); + } + obligations + .insert(obligation) + .map_err(|error| BaselineError::format(path, Some(row.line), error.to_string()))?; + } + Ok(obligations) +} + +fn parse_standalone_obligations( + path: &Path, + source: &str, +) -> Result, BaselineError> { + let rows = data_rows(path, source, STANDALONE_HEADER, 7)?; + let mut obligations = BTreeSet::new(); + for row in rows { + let events = match row.fields[1] { + "pull_request,full" => StandaloneEvents::PullRequestAndFull, + value => { + return Err(field_error( + path, + row.line, + "events", + value, + "must use the canonical `pull_request,full` event set", + )); + } + }; + let invocation = match row.fields[5] { + "direct" => { + StandaloneInvocation::Direct(parse_argv(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?) + } + "action" => { + validate_action_reference(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + StandaloneInvocation::Action(row.fields[6].to_owned()) + } + "pre-push-child" => { + StandaloneInvocation::PrePushChild(parse_argv(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?) + } + "pre-push-internal" => { + validate_text(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + StandaloneInvocation::PrePushInternal(row.fields[6].to_owned()) + } + "shell-contract" => { + validate_text(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + StandaloneInvocation::ShellContract(row.fields[6].to_owned()) + } + value => { + return Err(field_error( + path, + row.line, + "form", + value, + "is not one of `direct`, `action`, `pre-push-child`, `pre-push-internal`, or `shell-contract`", + )); + } + }; + validate_text(row.fields[3]) + .map_err(|reason| field_error(path, row.line, "step", row.fields[3], reason))?; + let obligation = StandaloneObligation { + obligation: parse_id_field(path, &row, 0, "obligation")?, + events, + job: parse_id_field(path, &row, 2, "job")?, + step: row.fields[3].to_owned(), + working_directory: WorkingDirectory::parse(row.fields[4]).map_err(|reason| { + field_error(path, row.line, "working_directory", row.fields[4], reason) + })?, + invocation, + }; + insert_unique(path, row.line, &mut obligations, obligation)?; + } + Ok(obligations) +} + +fn parse_command_goldens( + path: &Path, + source: &str, +) -> Result, BaselineError> { + let rows = data_rows(path, source, COMMAND_GOLDEN_HEADER, 9)?; + let mut goldens = BTreeMap::new(); + for row in rows { + let environment = parse_environment(row.fields[4]).map_err(|reason| { + field_error(path, row.line, "environment_json", row.fields[4], reason) + })?; + let payload = match row.fields[5] { + "argv" => { + let dynamic_value = if row.fields[7].is_empty() { + None + } else { + validate_text(row.fields[7]).map_err(|reason| { + field_error(path, row.line, "dynamic_value", row.fields[7], reason) + })?; + Some(row.fields[7].to_owned()) + }; + CommandPayload::Argv { + argv: parse_argv(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?, + dynamic_value, + } + } + "argv-template" => { + validate_text(row.fields[7]).map_err(|reason| { + field_error(path, row.line, "dynamic_value", row.fields[7], reason) + })?; + let argv = parse_argv(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + let occurrences = argv.iter().filter(|argument| *argument == row.fields[7]).count(); + if occurrences != 1 { + return Err(BaselineError::format( + path, + Some(row.line), + format!( + "argv template must contain dynamic value `{}` exactly once, found {occurrences}", + row.fields[7] + ), + )); + } + CommandPayload::ArgvTemplate { argv, dynamic_value: row.fields[7].to_owned() } + } + "action-inputs" => { + validate_text(row.fields[7]).map_err(|reason| { + field_error(path, row.line, "dynamic_value", row.fields[7], reason) + })?; + let value = parse_canonical_json(row.fields[6]).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + let JsonValue::Object(inputs) = value else { + return Err(field_error( + path, + row.line, + "payload", + row.fields[6], + "action inputs must be a JSON object", + )); + }; + validate_json_text(&JsonValue::Object(inputs.clone())).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + validate_action_inputs(&inputs).map_err(|reason| { + field_error(path, row.line, "payload", row.fields[6], reason) + })?; + CommandPayload::ActionInputs { inputs, dynamic_value: row.fields[7].to_owned() } + } + value => { + return Err(field_error( + path, + row.line, + "form", + value, + "is not one of `argv`, `argv-template`, or `action-inputs`", + )); + } + }; + validate_text(row.fields[2]) + .map_err(|reason| field_error(path, row.line, "step", row.fields[2], reason))?; + validate_text(row.fields[8]) + .map_err(|reason| field_error(path, row.line, "note", row.fields[8], reason))?; + let name = parse_id_field(path, &row, 0, "golden")?; + let behavior = CommandBehavior { + name: name.clone(), + job: parse_id_field(path, &row, 1, "job")?, + step: row.fields[2].to_owned(), + working_directory: WorkingDirectory::parse(row.fields[3]).map_err(|reason| { + field_error(path, row.line, "working_directory", row.fields[3], reason) + })?, + environment, + payload, + }; + let golden = CommandGolden { behavior, note: row.fields[8].to_owned() }; + if goldens.insert(name.clone(), golden).is_some() { + return Err(BaselineError::format( + path, + Some(row.line), + format!("command-golden name `{name}` duplicates an earlier row"), + )); + } + } + Ok(goldens) +} + +fn parse_id_field( + path: &Path, + row: &DataRow<'_>, + index: usize, + field: &str, +) -> Result { + BaselineId::parse(row.fields[index]) + .map_err(|reason| field_error(path, row.line, field, row.fields[index], reason)) +} + +fn parse_optional_id_field( + path: &Path, + row: &DataRow<'_>, + index: usize, + field: &str, +) -> Result, BaselineError> { + if row.fields[index].is_empty() { + Ok(None) + } else { + parse_id_field(path, row, index, field).map(Some) + } +} + +fn field_error( + path: &Path, + line: usize, + field: &str, + value: &str, + reason: impl Into, +) -> BaselineError { + BaselineError::format( + path, + Some(line), + format!("invalid `{field}` value {value:?}: {}", reason.into()), + ) +} + +fn insert_unique( + path: &Path, + line: usize, + values: &mut BTreeSet, + value: T, +) -> Result<(), BaselineError> { + if values.insert(value) { + Ok(()) + } else { + Err(BaselineError::format(path, Some(line), "row duplicates an earlier typed value")) + } +} + +fn parse_u64(value: &str) -> Result { + if value.is_empty() || (value.len() > 1 && value.starts_with('0')) { + return Err("must be a canonical unsigned decimal integer".into()); + } + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("must be a canonical unsigned decimal integer".into()); + } + value.parse().map_err(|_| "is too large for an unsigned 64-bit integer".into()) +} + +fn parse_u32(value: &str) -> Result { + let value = parse_u64(value)?; + u32::try_from(value).map_err(|_| "is too large for an unsigned 32-bit integer".into()) +} + +fn parse_lower_hex(value: &str, length: usize) -> Result { + if value.len() == length + && value.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + Ok(value.to_owned()) + } else { + Err(format!("must contain exactly {length} lowercase hexadecimal digits")) + } +} + +fn validate_repo_relative_path(value: &str) -> Result<(), String> { + let path = Path::new(value); + let unsafe_component = + path.components().any(|component| !matches!(component, Component::Normal(_))); + let dot_component = value.split('/').any(|component| matches!(component, "." | "..")); + if value.is_empty() + || value.split('/').any(str::is_empty) + || dot_component + || value.contains('\\') + || value.contains(':') + || value.chars().any(char::is_control) + || unsafe_component + { + Err("must be a safe, normalized repository-relative path".into()) + } else { + Ok(()) + } +} + +fn validate_text(value: &str) -> Result<(), String> { + if value.is_empty() { + return Err("must not be empty".into()); + } + if value.trim() != value { + return Err("must not have leading or trailing whitespace".into()); + } + if value.chars().any(char::is_control) { + return Err("must not contain control characters".into()); + } + Ok(()) +} + +fn parse_flag_list(value: &str) -> Result, String> { + if value == "" { + return Ok(Vec::new()); + } + validate_text(value)?; + let flags = value.split(' ').map(str::to_owned).collect::>(); + validate_model_flags(&flags)?; + Ok(flags) +} + +fn validate_model_flags(flags: &[String]) -> Result<(), String> { + for flag in flags { + if flag.is_empty() + || !flag.starts_with('-') + || flag.chars().any(char::is_control) + || flag.chars().any(char::is_whitespace) + { + return Err( + "must be `` or a single-space-separated list of nonempty option flags".into(), + ); + } + } + Ok(()) +} + +fn validate_standalone_invocation(invocation: &StandaloneInvocation) -> Result<(), String> { + match invocation { + StandaloneInvocation::Direct(argv) | StandaloneInvocation::PrePushChild(argv) => { + validate_argv(argv) + } + StandaloneInvocation::Action(reference) => validate_action_reference(reference), + StandaloneInvocation::PrePushInternal(payload) + | StandaloneInvocation::ShellContract(payload) => validate_text(payload), + } +} + +fn parse_obligation_sources(value: &str) -> Result, String> { + validate_text(value)?; + let mut sources = Vec::new(); + for source in value.split(" | ") { + let Some((job, step)) = source.split_once('/') else { + return Err("each source must have canonical `job/step` form".into()); + }; + let job = + BaselineId::parse(job).map_err(|reason| format!("source job `{job}` {reason}"))?; + validate_text(step).map_err(|reason| format!("source step `{step}` {reason}"))?; + sources.push(ObligationSource { job, step: step.to_owned() }); + } + if sources.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err("sources must be unique and strictly sorted".into()); + } + Ok(sources) +} + +fn validate_action_reference(value: &str) -> Result<(), String> { + if let Some(path) = value.strip_prefix("./") { + validate_repo_relative_path(path)?; + return Ok(()); + } + let Some((repository, revision)) = value.split_once('@') else { + return Err( + "must be a local `./path` or an external `owner/repository@40-hex-revision`".into() + ); + }; + if repository.matches('/').count() != 1 + || repository.split('/').any(|component| { + component.is_empty() + || matches!(component, "." | "..") + || component.bytes().any(|byte| { + !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + }) + }) + { + return Err("external action repository must have canonical `owner/repository` form".into()); + } + parse_lower_hex(revision, 40).map(|_| ()) +} + +fn check_manifest_count( + path: &Path, + key: &str, + expected: u64, + actual: usize, +) -> Result<(), BaselineError> { + let actual = actual as u64; + if expected == actual { + Ok(()) + } else { + Err(BaselineError::format( + path, + None, + format!( + "manifest `{key}` records {expected}, but the parsed fixture contains {actual}" + ), + )) + } +} + +fn parse_environment(value: &str) -> Result, String> { + let parsed = parse_canonical_json(value)?; + let JsonValue::Object(values) = parsed else { + return Err("must be a JSON object".into()); + }; + let mut environment = BTreeMap::new(); + for (name, value) in values { + let JsonValue::String(value) = value else { + return Err(format!("environment variable `{name}` must have a string value")); + }; + environment.insert(name, value); + } + validate_environment(&environment)?; + Ok(environment) +} + +fn validate_environment(environment: &BTreeMap) -> Result<(), String> { + for (name, value) in environment { + validate_environment_name(name)?; + if value.chars().any(char::is_control) { + return Err(format!("environment variable `{name}` contains a control character")); + } + } + Ok(()) +} + +fn validate_environment_name(value: &str) -> Result<(), String> { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return Err("environment variable name must not be empty".into()); + }; + if !(first.is_ascii_uppercase() || first == b'_') + || !bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(format!("environment variable name `{value}` is not canonical ASCII")); + } + Ok(()) +} + +fn parse_argv(value: &str) -> Result, String> { + let parsed = parse_canonical_json(value)?; + let JsonValue::Array(values) = parsed else { + return Err("must be a JSON array".into()); + }; + let mut argv = Vec::new(); + for value in values { + let JsonValue::String(value) = value else { + return Err("argv elements must all be JSON strings".into()); + }; + if value.chars().any(char::is_control) { + return Err("argv strings must not contain control characters".into()); + } + argv.push(value); + } + validate_argv(&argv)?; + Ok(argv) +} + +fn validate_argv(argv: &[String]) -> Result<(), String> { + if argv.is_empty() { + return Err("argv must contain at least one argument".into()); + } + if argv.iter().any(|value| value.chars().any(char::is_control)) { + return Err("argv strings must not contain control characters".into()); + } + Ok(()) +} + +fn validate_command_payload(payload: &CommandPayload) -> Result<(), String> { + match payload { + CommandPayload::Argv { argv, dynamic_value } => { + validate_argv(argv)?; + if let Some(value) = dynamic_value { + validate_text(value)?; + } + } + CommandPayload::ArgvTemplate { argv, dynamic_value } => { + validate_argv(argv)?; + validate_text(dynamic_value)?; + let occurrences = argv.iter().filter(|argument| *argument == dynamic_value).count(); + if occurrences != 1 { + return Err(format!( + "argv template must contain dynamic value `{dynamic_value}` exactly once, found {occurrences}" + )); + } + } + CommandPayload::ActionInputs { inputs, dynamic_value } => { + validate_text(dynamic_value)?; + validate_action_inputs(inputs)?; + } + } + Ok(()) +} + +fn validate_action_inputs(inputs: &BTreeMap) -> Result<(), String> { + if inputs.keys().map(String::as_str).collect::>() != ["uses", "with"] { + return Err("action-input payload must have exactly sorted `uses` and `with` keys".into()); + } + let Some(JsonValue::String(uses)) = inputs.get("uses") else { + return Err("action-input `uses` must be a string".into()); + }; + validate_action_reference(uses)?; + let Some(JsonValue::Object(with)) = inputs.get("with") else { + return Err("action-input `with` must be an object".into()); + }; + if with.is_empty() { + return Err("action-input `with` must not be empty".into()); + } + for (name, value) in with { + BaselineId::parse(name).map_err(|reason| format!("action input name `{name}` {reason}"))?; + let JsonValue::String(value) = value else { + return Err(format!("action input `{name}` must have a string value")); + }; + if value.chars().any(char::is_control) { + return Err(format!("action input `{name}` contains a control character")); + } + } + Ok(()) +} + +fn validate_json_text(value: &JsonValue) -> Result<(), String> { + match value { + JsonValue::String(value) => { + if value.chars().any(char::is_control) { + Err("JSON strings must not contain control characters".into()) + } else { + Ok(()) + } + } + JsonValue::Array(values) => { + for value in values { + validate_json_text(value)?; + } + Ok(()) + } + JsonValue::Object(values) => { + for (key, value) in values { + if key.is_empty() || key.chars().any(char::is_control) { + return Err( + "JSON object keys must be nonempty and contain no control characters" + .into(), + ); + } + validate_json_text(value)?; + } + Ok(()) + } + } +} + +/// Parses the deliberately small JSON subset used by the baseline and then +/// requires byte-for-byte canonical serialization. This rejects insignificant +/// whitespace, alternate escapes, and unsorted object keys rather than allowing +/// multiple reviewed spellings for one command. +fn parse_canonical_json(source: &str) -> Result { + let mut parser = JsonParser { source, offset: 0 }; + let value = parser.parse_value()?; + if parser.offset != source.len() { + return Err(format!("unexpected JSON input at byte {}", parser.offset)); + } + let mut canonical = String::new(); + write_json(&value, &mut canonical); + if canonical != source { + return Err("JSON must use compact canonical spelling and sorted object keys".into()); + } + Ok(value) +} + +struct JsonParser<'a> { + source: &'a str, + offset: usize, +} + +impl JsonParser<'_> { + fn parse_value(&mut self) -> Result { + match self.peek_byte() { + Some(b'"') => self.parse_string().map(JsonValue::String), + Some(b'[') => self.parse_array(), + Some(b'{') => self.parse_object(), + Some(byte) => Err(format!( + "unsupported or malformed JSON value beginning with byte `{byte}` at byte {}", + self.offset + )), + None => Err("JSON value must not be empty".into()), + } + } + + fn parse_array(&mut self) -> Result { + self.expect_byte(b'[')?; + let mut values = Vec::new(); + if self.consume_byte(b']') { + return Ok(JsonValue::Array(values)); + } + loop { + values.push(self.parse_value()?); + if self.consume_byte(b']') { + break; + } + self.expect_byte(b',')?; + } + Ok(JsonValue::Array(values)) + } + + fn parse_object(&mut self) -> Result { + self.expect_byte(b'{')?; + let mut values = BTreeMap::new(); + if self.consume_byte(b'}') { + return Ok(JsonValue::Object(values)); + } + loop { + let key = self.parse_string()?; + self.expect_byte(b':')?; + let value = self.parse_value()?; + if values.insert(key.clone(), value).is_some() { + return Err(format!("duplicate JSON object key `{key}`")); + } + if self.consume_byte(b'}') { + break; + } + self.expect_byte(b',')?; + } + Ok(JsonValue::Object(values)) + } + + fn parse_string(&mut self) -> Result { + self.expect_byte(b'"')?; + let mut value = String::new(); + loop { + let Some(byte) = self.peek_byte() else { + return Err("unterminated JSON string".into()); + }; + match byte { + b'"' => { + self.offset += 1; + return Ok(value); + } + b'\\' => { + self.offset += 1; + let Some(escape) = self.peek_byte() else { + return Err("unterminated JSON escape".into()); + }; + self.offset += 1; + match escape { + b'"' => value.push('"'), + b'\\' => value.push('\\'), + b'/' => value.push('/'), + b'b' => value.push('\u{0008}'), + b'f' => value.push('\u{000c}'), + b'n' => value.push('\n'), + b'r' => value.push('\r'), + b't' => value.push('\t'), + b'u' => value.push(self.parse_unicode_escape()?), + _ => { + return Err(format!("invalid JSON escape at byte {}", self.offset - 1)); + } + } + } + 0x00..=0x1f => { + return Err(format!( + "unescaped control byte in JSON string at byte {}", + self.offset + )); + } + _ => { + let character = self.source[self.offset..] + .chars() + .next() + .expect("a nonempty UTF-8 suffix has one character"); + value.push(character); + self.offset += character.len_utf8(); + } + } + } + } + + fn parse_unicode_escape(&mut self) -> Result { + let first = self.parse_hex_quad()?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + self.expect_byte(b'\\')?; + self.expect_byte(b'u')?; + let second = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err("high UTF-16 surrogate is not followed by a low surrogate".into()); + } + 0x10000 + (((first - 0xd800) as u32) << 10) + (second - 0xdc00) as u32 + } else if (0xdc00..=0xdfff).contains(&first) { + return Err("unpaired low UTF-16 surrogate".into()); + } else { + first as u32 + }; + char::from_u32(scalar).ok_or_else(|| "JSON escape is not a Unicode scalar value".into()) + } + + fn parse_hex_quad(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let Some(byte) = self.peek_byte() else { + return Err("incomplete JSON Unicode escape".into()); + }; + let Some(digit) = (byte as char).to_digit(16) else { + return Err(format!("non-hexadecimal JSON Unicode escape byte at {}", self.offset)); + }; + self.offset += 1; + value = (value << 4) | digit as u16; + } + Ok(value) + } + + fn peek_byte(&self) -> Option { + self.source.as_bytes().get(self.offset).copied() + } + + fn consume_byte(&mut self, expected: u8) -> bool { + if self.peek_byte() == Some(expected) { + self.offset += 1; + true + } else { + false + } + } + + fn expect_byte(&mut self, expected: u8) -> Result<(), String> { + if self.consume_byte(expected) { + Ok(()) + } else { + Err(format!("expected JSON byte `{}` at byte {}", expected as char, self.offset)) + } + } +} + +fn write_json(value: &JsonValue, output: &mut String) { + match value { + JsonValue::String(value) => write_json_string(value, output), + JsonValue::Array(values) => { + output.push('['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + output.push(','); + } + write_json(value, output); + } + output.push(']'); + } + JsonValue::Object(values) => { + output.push('{'); + for (index, (key, value)) in values.iter().enumerate() { + if index != 0 { + output.push(','); + } + write_json_string(key, output); + output.push(':'); + write_json(value, output); + } + output.push('}'); + } + } +} + +fn write_json_string(value: &str, output: &mut String) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{0008}' => output.push_str("\\b"), + '\u{000c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character if character.is_control() => { + use fmt::Write as _; + write!(output, "\\u{:04x}", character as u32) + .expect("writing to a String cannot fail"); + } + character => output.push(character), + } + } + output.push('"'); +} + +#[cfg(test)] +mod tests { + use super::*; + + const MANIFEST: &str = include_str!("../../../ci/baselines/manifest.tsv"); + const BUILD_REDUCED: &str = include_str!("../../../ci/baselines/build-pr.tsv"); + const BUILD_FULL: &str = include_str!("../../../ci/baselines/build-full.tsv"); + const MIRI_REDUCED: &str = include_str!("../../../ci/baselines/miri-pr.tsv"); + const MIRI_FULL: &str = include_str!("../../../ci/baselines/miri-full.tsv"); + const LOGICAL: &str = include_str!("../../../ci/baselines/logical-obligations.tsv"); + const STANDALONE: &str = include_str!("../../../ci/baselines/standalone-obligations.tsv"); + const COMMAND_GOLDENS: &str = include_str!("../../../ci/baselines/command-goldens.tsv"); + + fn fixture_paths() -> LegacyBaselinePaths { + LegacyBaselinePaths { + manifest: "manifest.tsv".into(), + build_reduced: "build-pr.tsv".into(), + build_full: "build-full.tsv".into(), + miri_reduced: "miri-pr.tsv".into(), + miri_full: "miri-full.tsv".into(), + logical_obligations: "logical-obligations.tsv".into(), + standalone_obligations: "standalone-obligations.tsv".into(), + command_goldens: "command-goldens.tsv".into(), + } + } + + fn parse_sources_with<'a>( + paths: &LegacyBaselinePaths, + build_reduced: &'a str, + manifest: &'a str, + ) -> Result { + LegacyBaselines::parse_sources( + paths, + BaselineSources { + manifest, + build_reduced, + build_full: BUILD_FULL, + miri_reduced: MIRI_REDUCED, + miri_full: MIRI_FULL, + logical_obligations: LOGICAL, + standalone_obligations: STANDALONE, + command_goldens: COMMAND_GOLDENS, + }, + ) + } + + fn current_baseline() -> LegacyBaselines { + parse_sources_with(&fixture_paths(), BUILD_REDUCED, MANIFEST).unwrap() + } + + fn replace_once(source: &str, old: &str, new: &str) -> String { + assert!(source.contains(old), "mutation anchor must occur: {old:?}"); + source.replacen(old, new, 1) + } + + fn swap_first_two_data_rows(source: &str, header: &str) -> String { + let mut lines = source.lines().collect::>(); + let header = lines.iter().position(|line| *line == header).unwrap(); + lines.swap(header + 1, header + 2); + lines.join("\n") + "\n" + } + + #[test] + fn parses_every_current_fixture_exactly() { + let baseline = current_baseline(); + + assert_eq!(baseline.manifest.source_commit, "286dd29655e7ae2f5603a8af89c270cc3bd52f2e"); + assert_eq!(baseline.build_reduced.len(), 60); + assert_eq!(baseline.build_full.len(), 182); + assert!(baseline.miri_reduced.is_empty()); + assert_eq!(baseline.miri_full.len(), 64); + assert_eq!(baseline.logical_obligations.len(), 475); + assert_eq!(baseline.standalone_obligations.len(), 31); + assert_eq!(baseline.command_goldens.len(), 13); + + let stacked = + baseline.miri_full.iter().find(|cell| cell.model.as_str() == "stacked").unwrap(); + assert!(stacked.model_flags.is_empty()); + let tree = baseline.miri_full.iter().find(|cell| cell.model.as_str() == "tree").unwrap(); + assert_eq!(tree.model_flags, ["-Zmiri-tree-borrows"]); + + // Re-comparing the parsed sets also exercises the no-difference path + // through every public comparator. + baseline.compare_build_reduced(&baseline.build_reduced).unwrap(); + baseline.compare_build_full(&baseline.build_full).unwrap(); + baseline.compare_miri_reduced(&baseline.miri_reduced).unwrap(); + baseline.compare_miri_full(&baseline.miri_full).unwrap(); + baseline.compare_logical_obligations(&baseline.logical_obligations).unwrap(); + baseline.compare_standalone_obligations(&baseline.standalone_obligations).unwrap(); + let behaviors = CommandBehaviors::try_from_iter( + baseline.command_goldens.values().map(|golden| golden.behavior.clone()), + ) + .unwrap(); + baseline.compare_command_goldens(&behaviors).unwrap(); + } + + #[test] + fn exact_comparison_reports_missing_and_extra_rows_separately() { + let baseline = current_baseline(); + let mut planned = baseline.build_reduced.clone(); + let missing = planned.iter().next().unwrap().clone(); + assert!(planned.remove(&missing)); + let extra = BuildCell { + package: "future-package".parse().unwrap(), + toolchain: "stable".parse().unwrap(), + feature_profile: "default".parse().unwrap(), + target: "x86_64-unknown-linux-gnu".parse().unwrap(), + }; + assert!(planned.insert(extra.clone())); + + let difference = baseline.compare_build_reduced(&planned).unwrap_err(); + assert_eq!(difference.missing_from_plan(), &BTreeSet::from([missing])); + assert_eq!(difference.extra_in_plan(), &BTreeSet::from([extra])); + let diagnostic = difference.to_string(); + assert!(diagnostic.contains("1 missing, 1 extra")); + assert!(diagnostic.contains("- missing: BuildCell")); + assert!(diagnostic.contains("- extra: BuildCell")); + } + + #[test] + fn missing_and_extra_fixture_rows_fail_manifest_cross_checks() { + let paths = fixture_paths(); + let first = "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu\n"; + let missing = replace_once(BUILD_REDUCED, first, ""); + let error = parse_sources_with(&paths, &missing, MANIFEST).unwrap_err(); + assert!(error.to_string().contains("records 60")); + assert!(error.to_string().contains("contains 59")); + + // `qemu` sorts between the existing `i686` and `x86_64` target rows, + // so this mutation reaches the count cross-check rather than failing + // earlier for an unrelated ordering problem. + let extra_row = concat!( + "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu\n", + "zerocopy\tmsrv\tdefault\tqemu-unknown-linux-gnu\n" + ); + let extra = replace_once(BUILD_REDUCED, first, extra_row); + let error = parse_sources_with(&paths, &extra, MANIFEST).unwrap_err(); + assert!(error.to_string().contains("records 60")); + assert!(error.to_string().contains("contains 61")); + } + + #[test] + fn rejects_reordered_and_duplicate_rows() { + let path = Path::new("build-pr.tsv"); + let reordered = swap_first_two_data_rows(BUILD_REDUCED, BUILD_HEADER); + let error = parse_build_cells(path, &reordered).unwrap_err(); + assert!(error.to_string().contains("strictly bytewise sorted")); + + let first = "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu\n"; + let duplicate = replace_once(BUILD_REDUCED, first, &format!("{first}{first}")); + let error = parse_build_cells(path, &duplicate).unwrap_err(); + assert!(error.to_string().contains("duplicate row")); + } + + #[test] + fn rejects_wrong_headers_field_counts_and_carriage_returns() { + let path = Path::new("build-pr.tsv"); + let wrong_header = replace_once(BUILD_REDUCED, BUILD_HEADER, "crate\ttoolchain\ttarget"); + assert!(parse_build_cells(path, &wrong_header) + .unwrap_err() + .to_string() + .contains("expected exact header")); + + let row = "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu"; + let too_many = replace_once(BUILD_REDUCED, row, &format!("{row}\textra")); + assert!(parse_build_cells(path, &too_many) + .unwrap_err() + .to_string() + .contains("expected 4 tab-separated fields")); + + let carriage_return = BUILD_REDUCED.replacen('\n', "\r\n", 1); + assert!(parse_build_cells(path, &carriage_return) + .unwrap_err() + .to_string() + .contains("carriage returns")); + + let comment_control = + replace_once(BUILD_REDUCED, "# Copyright 2026", "# Copyright\u{0001} 2026"); + assert!(parse_build_cells(path, &comment_control) + .unwrap_err() + .to_string() + .contains("comments must not contain control characters")); + } + + #[test] + fn baseline_identifiers_enforce_the_shared_byte_bound() { + let maximum = "a".repeat(MAX_ID_BYTES); + assert_eq!(BaselineId::parse(&maximum).unwrap().as_str(), maximum); + + let oversized = "a".repeat(MAX_ID_BYTES + 1); + let error = BaselineId::parse(&oversized).unwrap_err(); + assert!(error.contains(&format!("at most {MAX_ID_BYTES} bytes")), "{error}"); + assert!(error.contains(&format!("found {} bytes", MAX_ID_BYTES + 1)), "{error}"); + + let build = replace_once( + BUILD_REDUCED, + "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu", + &format!("{oversized}\tmsrv\tdefault\ti686-unknown-linux-gnu"), + ); + let error = parse_build_cells(Path::new("build-pr.tsv"), &build).unwrap_err().to_string(); + assert!(error.contains(&format!("at most {MAX_ID_BYTES} bytes")), "{error}"); + } + + #[test] + fn rejects_unsafe_identifiers_paths_and_action_references() { + let build = replace_once( + BUILD_REDUCED, + "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu", + "../zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu", + ); + assert!(parse_build_cells(Path::new("build-pr.tsv"), &build) + .unwrap_err() + .to_string() + .contains("stable identifier")); + + let escaped = replace_once( + BUILD_REDUCED, + "zerocopy\tmsrv\tdefault\ti686-unknown-linux-gnu", + "zero\u{001b}copy\tmsrv\tdefault\ti686-unknown-linux-gnu", + ); + let diagnostic = + parse_build_cells(Path::new("build-pr.tsv"), &escaped).unwrap_err().to_string(); + assert!(!diagnostic.contains('\u{001b}')); + + let standalone = + replace_once(STANDALONE, "\tzerocopy\tdirect\t", "\t../zerocopy\tdirect\t"); + assert!(parse_standalone_obligations(Path::new("standalone.tsv"), &standalone) + .unwrap_err() + .to_string() + .contains("safe, normalized repository-relative path")); + + for path in ["dir/./file", "dir/."] { + assert!(WorkingDirectory::parse(path).is_err()); + } + assert!(WorkingDirectory::parse(".github/actions/check").is_ok()); + + let action = replace_once( + STANDALONE, + "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a", + "docker/build-push-action@floating-tag", + ); + assert!(parse_standalone_obligations(Path::new("standalone.tsv"), &action) + .unwrap_err() + .to_string() + .contains("lowercase hexadecimal")); + + let revision = "0123456789abcdef0123456789abcdef01234567"; + for repository in ["../repo", "owner/.."] { + assert!(validate_action_reference(&format!("{repository}@{revision}")).is_err()); + } + } + + #[test] + fn rejects_missing_extra_false_and_noncanonical_manifest_values() { + let path = Path::new("manifest.tsv"); + let missing = replace_once(MANIFEST, "build_pr_cells\t60\n", ""); + assert!(parse_manifest(path, &missing) + .unwrap_err() + .to_string() + .contains("missing manifest key `build_pr_cells`")); + + let extra = replace_once( + MANIFEST, + "generic_manual_miri_sets_equal\ttrue\n", + "generic_manual_miri_sets_equal\ttrue\nlegacy_extra\t1\n", + ); + assert!(parse_manifest(path, &extra) + .unwrap_err() + .to_string() + .contains("unknown manifest key `legacy_extra`")); + + let unequal = replace_once( + MANIFEST, + "generic_manual_build_sets_equal\ttrue", + "generic_manual_build_sets_equal\tfalse", + ); + assert!(parse_manifest(path, &unequal) + .unwrap_err() + .to_string() + .contains("not valid evidence")); + + let leading_zero = replace_once(MANIFEST, "build_pr_cells\t60", "build_pr_cells\t060"); + assert!(parse_manifest(path, &leading_zero) + .unwrap_err() + .to_string() + .contains("canonical unsigned decimal")); + } + + #[test] + fn rejects_malformed_and_noncanonical_json_fields() { + let path = Path::new("command-goldens.tsv"); + let environment = replace_once( + COMMAND_GOLDENS, + r#"{"RUSTFLAGS":"-C target-cpu=atmega328p"}"#, + r#"{"RUSTFLAGS":}"#, + ); + assert!(parse_command_goldens(path, &environment) + .unwrap_err() + .to_string() + .contains("environment_json")); + + let argv = replace_once( + COMMAND_GOLDENS, + r#"["./cargo.sh","+nightly","check","--target=avr-none","-Zbuild-std=core","--features","simd,simd-nightly,float-nightly,derive"]"#, + r#"["./cargo.sh",]"#, + ); + assert!(parse_command_goldens(path, &argv).unwrap_err().to_string().contains("payload")); + + let noncanonical = replace_once( + COMMAND_GOLDENS, + r#"{"MIRIFLAGS":" -Zmiri-strict-provenance -Zmiri-backtrace=full","RUSTDOCFLAGS":"-Dwarnings --cfg=zerocopy_unstable_ptr","RUSTFLAGS":"-Dwarnings -Zrandomize-layout"}"#, + r#"{"RUSTFLAGS":"-Dwarnings -Zrandomize-layout","MIRIFLAGS":" -Zmiri-strict-provenance -Zmiri-backtrace=full","RUSTDOCFLAGS":"-Dwarnings --cfg=zerocopy_unstable_ptr"}"#, + ); + assert!(parse_command_goldens(path, &noncanonical) + .unwrap_err() + .to_string() + .contains("compact canonical spelling")); + + let decoded_control = replace_once( + COMMAND_GOLDENS, + r#"{"RUSTFLAGS":"-C target-cpu=atmega328p"}"#, + r#"{"RUSTFLAGS":"\n"}"#, + ); + assert!(parse_command_goldens(path, &decoded_control) + .unwrap_err() + .to_string() + .contains("control character")); + + let decoded_escape = replace_once( + COMMAND_GOLDENS, + r#"{"RUSTFLAGS":"-C target-cpu=atmega328p"}"#, + r#"{"\u001b":"-C target-cpu=atmega328p"}"#, + ); + let diagnostic = parse_command_goldens(path, &decoded_escape).unwrap_err().to_string(); + assert!(!diagnostic.contains('\u{001b}')); + } + + #[test] + fn rejects_inconsistent_command_forms() { + let path = Path::new("command-goldens.tsv"); + let template_without_dynamic_value = replace_once( + COMMAND_GOLDENS, + "\targv\t[\"./cargo.sh\",\"+nightly\",\"check\",\"--target=avr-none\",\"-Zbuild-std=core\",\"--features\",\"simd,simd-nightly,float-nightly,derive\"]\t\t", + "\targv-template\t[\"./cargo.sh\",\"+nightly\",\"check\",\"--target=avr-none\",\"-Zbuild-std=core\",\"--features\",\"simd,simd-nightly,float-nightly,derive\"]\t\t", + ); + assert!(parse_command_goldens(path, &template_without_dynamic_value) + .unwrap_err() + .to_string() + .contains("dynamic_value")); + + let missing_template_placeholder = replace_once(COMMAND_GOLDENS, "\"<2*nproc>\"", "\"2\""); + assert!(parse_command_goldens(path, &missing_template_placeholder) + .unwrap_err() + .to_string() + .contains("exactly once")); + } + + #[test] + fn command_comparison_ignores_notes_but_not_behavior() { + let baseline = current_baseline(); + let changed_note = replace_once( + COMMAND_GOLDENS, + "The step-level RUSTFLAGS replaces the workflow value.", + "Equivalent command with clearer reviewer prose.", + ); + let changed_note = parse_command_goldens(Path::new("commands.tsv"), &changed_note).unwrap(); + let planned = CommandBehaviors::try_from_iter( + changed_note.values().map(|golden| golden.behavior.clone()), + ) + .unwrap(); + baseline.compare_command_goldens(&planned).unwrap(); + + let changed_environment = replace_once( + COMMAND_GOLDENS, + r#"{"RUSTFLAGS":"-C target-cpu=atmega328p"}"#, + r#"{"RUSTFLAGS":"-C target-cpu=atmega328"}"#, + ); + let changed_environment = + parse_command_goldens(Path::new("commands.tsv"), &changed_environment).unwrap(); + let planned = CommandBehaviors::try_from_iter( + changed_environment.values().map(|golden| golden.behavior.clone()), + ) + .unwrap(); + assert!(baseline.compare_command_goldens(&planned).is_err()); + } + + #[test] + fn rejects_noncanonical_logical_counts_and_sources() { + let path = Path::new("logical.tsv"); + let count = replace_once( + LOGICAL, + "avr-check\t\t\t\t\t\t1\t1\talways", + "avr-check\t\t\t\t\t\t01\t1\talways", + ); + assert!(parse_logical_obligations(path, &count) + .unwrap_err() + .to_string() + .contains("canonical unsigned decimal")); + + let sources = replace_once( + LOGICAL, + "check-all-toolchains-tested/Run check | run-git-hooks/Run dependency check", + "run-git-hooks/Run dependency check | check-all-toolchains-tested/Run check", + ); + assert!(parse_logical_obligations(path, &sources) + .unwrap_err() + .to_string() + .contains("sources must be unique and strictly sorted")); + } + + #[test] + fn rejects_conflicting_duplicate_semantic_keys() { + let logical_row = + "avr-check\t\t\t\t\t\t1\t1\talways\tcheck_avr_atmega/Check avr-none target\n"; + let conflicting_logical = format!( + "{logical_row}avr-check\t\t\t\t\t\t2\t1\talways\tcheck_avr_atmega/Check avr-none target\n" + ); + let logical = replace_once(LOGICAL, logical_row, &conflicting_logical); + assert!(parse_logical_obligations(Path::new("logical.tsv"), &logical) + .unwrap_err() + .to_string() + .contains("duplicate logical-obligation key")); + + let command_row = + COMMAND_GOLDENS.lines().find(|line| line.starts_with("avr-check\t")).unwrap(); + let conflicting_command = + command_row.replacen("\tcheck_avr_atmega\t", "\tdifferent_job\t", 1); + let commands = replace_once( + COMMAND_GOLDENS, + &format!("{command_row}\n"), + &format!("{command_row}\n{conflicting_command}\n"), + ); + assert!(parse_command_goldens(Path::new("commands.tsv"), &commands) + .unwrap_err() + .to_string() + .contains("command-golden name `avr-check` duplicates")); + + let id = |value: &str| value.parse::().unwrap(); + let source = ObligationSource::new(id("job"), "Step").unwrap(); + let obligation = |full_occurrences| { + LogicalObligation::new( + id("cargo-test"), + Some(id("zerocopy")), + Some(id("stable")), + Some(id("default")), + Some(id("x86_64-unknown-linux-gnu")), + None, + 1, + full_occurrences, + ObligationCondition::Always, + vec![source.clone()], + ) + .unwrap() + }; + assert!(LogicalObligations::try_from_iter([obligation(1), obligation(2)]).is_err()); + + let behavior = |argument: &str| { + CommandBehavior::new( + id("golden"), + id("job"), + "Step", + WorkingDirectory::RepositoryRoot, + BTreeMap::new(), + CommandPayload::Argv { argv: vec![argument.to_owned()], dynamic_value: None }, + ) + .unwrap() + }; + assert!(CommandBehaviors::try_from_iter([behavior("one"), behavior("two")]).is_err()); + } + + #[test] + fn small_json_parser_rejects_ambiguous_or_unsupported_forms() { + assert_eq!( + parse_canonical_json(r#"{"a":["b"],"c":{"d":"e"}}"#).unwrap(), + JsonValue::Object(BTreeMap::from([ + ("a".into(), JsonValue::Array(vec![JsonValue::String("b".into())])), + ( + "c".into(), + JsonValue::Object(BTreeMap::from([( + "d".into(), + JsonValue::String("e".into()) + )])) + ), + ])) + ); + + for invalid in [ + r#"{"b":"1","a":"2"}"#, + r#"{"a":"1","a":"2"}"#, + r#"{ "a":"1"}"#, + r#"["value",]"#, + r#""\u0061""#, + "true", + "1", + "null", + ] { + assert!( + parse_canonical_json(invalid).is_err(), + "unexpectedly accepted noncanonical JSON: {invalid}" + ); + } + } + + #[test] + fn public_constructors_recheck_string_bearing_values() { + let id = |value: &str| value.parse::().unwrap(); + assert!(MiriCell::new( + id("zerocopy"), + id("nightly"), + id("default"), + id("x86_64-unknown-linux-gnu"), + id("tree"), + vec!["-Zvalid".into(), "two words".into()], + ) + .is_err()); + + assert!(StandaloneObligation::new( + id("operation"), + StandaloneEvents::PullRequestAndFull, + id("job"), + "Step", + WorkingDirectory::Relative("../outside".into()), + StandaloneInvocation::Direct(vec!["command".into()]), + ) + .is_err()); + + let noncanonical_root = WorkingDirectory::Relative(".".into()); + let error = StandaloneObligation::new( + id("operation"), + StandaloneEvents::PullRequestAndFull, + id("job"), + "Step", + noncanonical_root.clone(), + StandaloneInvocation::Direct(vec!["command".into()]), + ) + .unwrap_err(); + assert!(error.contains("canonical enum representation")); + + let error = CommandBehavior::new( + id("golden"), + id("job"), + "Step", + noncanonical_root, + BTreeMap::new(), + CommandPayload::Argv { argv: vec!["command".into()], dynamic_value: None }, + ) + .unwrap_err(); + assert!(error.contains("canonical enum representation")); + + assert!(LogicalObligation::new( + id("cargo-test"), + Some(id("zerocopy")), + Some(id("stable")), + Some(id("default")), + Some(id("x86_64-unknown-linux-gnu")), + None, + 0, + 0, + ObligationCondition::Always, + vec![ObligationSource::new(id("job"), "Step").unwrap()], + ) + .is_err()); + + let malformed_inputs = + BTreeMap::from([("uses".into(), JsonValue::String("owner/repo@floating".into()))]); + assert!(CommandBehavior::new( + id("golden"), + id("job"), + "Step", + WorkingDirectory::RepositoryRoot, + BTreeMap::new(), + CommandPayload::ActionInputs { + inputs: malformed_inputs, + dynamic_value: "third-party implementation".into(), + }, + ) + .is_err()); + } +} diff --git a/tools/zc/src/identifier.rs b/tools/zc/src/identifier.rs new file mode 100644 index 0000000000..3be20ce9ca --- /dev/null +++ b/tools/zc/src/identifier.rs @@ -0,0 +1,53 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! The one stable-identifier contract shared by CI policy and baseline data. +//! +//! Policy references and frozen baseline rows cross the same JSON and GitHub +//! matrix boundaries. Keep both consumers on this complete validator so a +//! grammar or resource-bound change cannot silently make one input language +//! more permissive than the other. + +/// Maximum bytes in one stable identifier. +/// +/// CI planning clones identifiers while resolving selections. Aggregate count +/// limits bound the number of clones; this limit bounds the bytes in each one. +pub(crate) const MAX_ID_BYTES: usize = 256; + +/// Why text is not a stable CI identifier. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum IdentifierError { + /// The spelling exceeded [`MAX_ID_BYTES`]. + TooLong { + /// Observed byte length. + bytes: usize, + }, + /// The spelling did not use the canonical lowercase ASCII grammar. + InvalidSyntax, +} + +/// Validates the complete stable-identifier contract. +pub(crate) fn validate(value: &str) -> Result<(), IdentifierError> { + if value.len() > MAX_ID_BYTES { + return Err(IdentifierError::TooLong { bytes: value.len() }); + } + let valid = !value.is_empty() + && value.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'0'..=b'9' | b'_' => true, + b'-' | b'.' => index != 0, + _ => false, + }) + && !value.ends_with('-') + && !value.ends_with('.') + && value != "." + && value != ".."; + if valid { + Ok(()) + } else { + Err(IdentifierError::InvalidSyntax) + } +} diff --git a/tools/zc/src/lib.rs b/tools/zc/src/lib.rs index d9ceeaeae8..98d7f52a05 100644 --- a/tools/zc/src/lib.rs +++ b/tools/zc/src/lib.rs @@ -8,6 +8,8 @@ //! Shared, typed access to repository configuration and CI behavior. +pub mod baseline; +mod identifier; pub mod metadata; pub mod policy; pub mod workflow; diff --git a/tools/zc/src/policy.rs b/tools/zc/src/policy.rs index 2894fe0b67..95fad98190 100644 --- a/tools/zc/src/policy.rs +++ b/tools/zc/src/policy.rs @@ -31,6 +31,8 @@ use std::{ use serde::Deserialize; use thiserror::Error; +use crate::identifier::{self, IdentifierError, MAX_ID_BYTES}; + /// The only policy schema understood by this version of `zc`. pub const POLICY_SCHEMA_VERSION: u32 = 1; @@ -61,12 +63,6 @@ const MAX_TARGET_SET_DECLARATIONS: usize = MAX_PLAN_CELLS as usize; /// expansion limit. const MAX_TARGET_SET_MEMBERSHIPS: u64 = MAX_PLAN_CELLS; -/// Maximum bytes in one stable policy identifier. -/// -/// Target-set resolution clones identifiers. The aggregate membership bound -/// limits the number of clones, while this limit bounds the bytes in each one. -const MAX_ID_BYTES: usize = 256; - /// A conservative interpretation of GitHub's one-megabyte job-output limit. /// /// GitHub estimates output size using UTF-16. Keeping the configured ceiling at @@ -978,36 +974,27 @@ impl Validator { } fn id(&mut self, location: &str, value: &str) -> Option { - if value.len() > MAX_ID_BYTES { - self.error( - location, - format!( - "identifier is {} bytes, above the hard safety bound of {MAX_ID_BYTES}", - value.len() - ), - ); - return None; - } - let valid = !value.is_empty() - && value.bytes().enumerate().all(|(index, byte)| match byte { - b'a'..=b'z' | b'0'..=b'9' | b'_' => true, - b'-' | b'.' => index != 0, - _ => false, - }) - && !value.ends_with('-') - && !value.ends_with('.') - && value != "." - && value != ".."; - if !valid { - self.error( - location, - format!( - "`{value}` is not a stable identifier; use lowercase ASCII letters, digits, `_`, `-`, or `.`, and do not start or end with `-` or `.`" - ), - ); - return None; + match identifier::validate(value) { + Ok(()) => Some(Id(value.to_owned())), + Err(IdentifierError::TooLong { bytes }) => { + self.error( + location, + format!( + "identifier is {bytes} bytes, above the hard safety bound of {MAX_ID_BYTES}" + ), + ); + None + } + Err(IdentifierError::InvalidSyntax) => { + self.error( + location, + format!( + "`{value}` is not a stable identifier; use lowercase ASCII letters, digits, `_`, `-`, or `.`, and do not start or end with `-` or `.`" + ), + ); + None + } } - Some(Id(value.to_owned())) } fn id_set(&mut self, location: &str, values: &[String]) -> BTreeSet {