From 5e05b1f5bff239604f26bb089d72fe35010182bf Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:29:27 +0300 Subject: [PATCH 01/13] feat(gate): add CF-13 evidence models --- crates/commandf-pkg/src/gate_model.rs | 121 ++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 crates/commandf-pkg/src/gate_model.rs diff --git a/crates/commandf-pkg/src/gate_model.rs b/crates/commandf-pkg/src/gate_model.rs new file mode 100644 index 00000000..967e344d --- /dev/null +++ b/crates/commandf-pkg/src/gate_model.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; + +use crate::{CheckReport, PackageEvidence}; + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +pub struct FindingFingerprint { + pub schema: u32, + pub digest: String, +} + +impl FindingFingerprint { + pub const SCHEMA_V1: u32 = 1; +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +pub struct GateSuppression { + pub finding_fingerprint: FindingFingerprint, + pub rationale: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reference: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GateSuppressions { + pub schema: u32, + pub suppressions: Vec, +} + +impl GateSuppressions { + pub const SCHEMA_V1: u32 = 1; + + pub fn from_json_slice(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + + pub fn to_json_bytes(&self) -> Result, serde_json::Error> { + let mut bytes = serde_json::to_vec_pretty(self)?; + bytes.push(b'\n'); + Ok(bytes) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QualityGateDisposition { + New, + Baseline, + Suppressed, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QualityGateFinding { + pub finding_index: usize, + pub fingerprint: FindingFingerprint, + pub disposition: QualityGateDisposition, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_suppression: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QualityGateBaselineEvidence { + pub canonical_sha256: String, + pub fingerprint_schema: u32, + pub package_name: String, + pub ruleset: String, + pub before: PackageEvidence, + pub after: PackageEvidence, + pub finding_count: usize, + pub fingerprints: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QualityGateSuppressionEvidence { + pub canonical_sha256: String, + pub schema: u32, + pub fingerprint_schema: u32, + pub entry_count: usize, + pub suppressions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QualityGateDecision { + pub passed: bool, + pub total_findings: usize, + pub selected_findings: usize, + pub new_findings: usize, + pub baseline_findings: usize, + pub suppressed_findings: usize, + pub new_selected_breaking_findings: usize, + pub new_selected_risky_findings: usize, + pub new_selected_additive_findings: usize, + pub blocking_findings: usize, + pub unused_suppressions: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QualityGateReport { + pub schema: u32, + pub current: CheckReport, + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suppression_evidence: Option, + pub findings: Vec, + pub unused_suppressions: Vec, + pub decision: QualityGateDecision, +} + +impl QualityGateReport { + pub const SCHEMA_V1: u32 = 1; + + pub fn from_json_slice(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + + pub fn to_json_bytes(&self) -> Result, serde_json::Error> { + let mut bytes = serde_json::to_vec_pretty(self)?; + bytes.push(b'\n'); + Ok(bytes) + } +} From ef61840f9a5688bce51ff82fc1d3e17622f63f9e Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:29:38 +0300 Subject: [PATCH 02/13] feat(gate): add fail-closed CF-13 errors --- crates/commandf-pkg/src/gate_error.rs | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 crates/commandf-pkg/src/gate_error.rs diff --git a/crates/commandf-pkg/src/gate_error.rs b/crates/commandf-pkg/src/gate_error.rs new file mode 100644 index 00000000..ffe79ea7 --- /dev/null +++ b/crates/commandf-pkg/src/gate_error.rs @@ -0,0 +1,41 @@ +use thiserror::Error; + +use crate::CheckError; + +#[derive(Debug, Error)] +pub enum QualityGateError { + #[error("unsupported CF-13 quality-gate schema {found}; expected {expected}")] + UnsupportedGateSchema { found: u32, expected: u32 }, + #[error("unsupported CF-13 suppression schema {found}; expected {expected}")] + UnsupportedSuppressionSchema { found: u32, expected: u32 }, + #[error("unsupported CF-13 fingerprint schema {found}; expected {expected}")] + UnsupportedFingerprintSchema { found: u32, expected: u32 }, + #[error("malformed CF-13 SHA-256 identity {value:?}")] + MalformedSha256Identity { value: String }, + #[error("baseline package {baseline:?} does not match current package {current:?}")] + BaselinePackageMismatch { current: String, baseline: String }, + #[error("baseline ruleset {baseline:?} does not match current ruleset {current:?}")] + BaselineRulesetMismatch { current: String, baseline: String }, + #[error("duplicate current finding fingerprint {fingerprint}")] + DuplicateCurrentFingerprint { fingerprint: String }, + #[error("duplicate baseline finding fingerprint {fingerprint}")] + DuplicateBaselineFingerprint { fingerprint: String }, + #[error("duplicate suppression fingerprint {fingerprint}")] + DuplicateSuppressionFingerprint { fingerprint: String }, + #[error("suppression count {found} exceeds maximum {maximum}")] + TooManySuppressions { found: usize, maximum: usize }, + #[error("suppression {field} length {found} exceeds maximum {maximum}")] + SuppressionStringTooLong { + field: &'static str, + found: usize, + maximum: usize, + }, + #[error("suppression rationale must not be empty after trimming")] + EmptySuppressionRationale, + #[error("persisted CF-13 report is inconsistent: {reason}")] + InconsistentReport { reason: &'static str }, + #[error("CF-05 validation failed: {0}")] + Check(#[from] CheckError), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} From 7d8ba5ee0137a89b47010c10c9df848bf9a765b4 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:30:42 +0300 Subject: [PATCH 03/13] feat(gate): implement deterministic CF-13 evaluation --- crates/commandf-pkg/src/gate.rs | 567 ++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 crates/commandf-pkg/src/gate.rs diff --git a/crates/commandf-pkg/src/gate.rs b/crates/commandf-pkg/src/gate.rs new file mode 100644 index 00000000..d91401d7 --- /dev/null +++ b/crates/commandf-pkg/src/gate.rs @@ -0,0 +1,567 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::check::{direction_selected, severity_blocks}; +use crate::{ + validate_check_report, CheckReport, CompatibilityFinding, CompatibilitySeverity, + FindingFingerprint, GateSuppression, GateSuppressions, QualityGateBaselineEvidence, + QualityGateDecision, QualityGateDisposition, QualityGateError, QualityGateFinding, + QualityGateReport, QualityGateSuppressionEvidence, +}; + +pub const MAX_GATE_SUPPRESSIONS: usize = 4_096; +pub const MAX_GATE_SUPPRESSION_RATIONALE_CHARS: usize = 4_096; +pub const MAX_GATE_SUPPRESSION_REFERENCE_CHARS: usize = 4_096; + +#[derive(Serialize)] +struct FindingFingerprintKey { + schema: u32, + ruleset: String, + rule_id: String, + severity: crate::CompatibilitySeverity, + direction: crate::CompatibilityDirection, + source_kind: crate::StructuralChangeKind, + resource: crate::ResourceKey, + before_filename: Option, + after_filename: Option, + view: Option, + element_id: Option, + field: Option, + before: Option, + after: Option, +} + +pub fn finding_fingerprint_v1( + ruleset: &str, + finding: &CompatibilityFinding, +) -> Result { + let key = FindingFingerprintKey { + schema: FindingFingerprint::SCHEMA_V1, + ruleset: ruleset.to_owned(), + rule_id: finding.rule_id.clone(), + severity: finding.severity, + direction: finding.direction, + source_kind: finding.source_kind, + resource: finding.resource.clone(), + before_filename: finding.before_filename.clone(), + after_filename: finding.after_filename.clone(), + view: finding.view, + element_id: finding.element_id.clone(), + field: finding.field.clone(), + before: finding.before.clone().map(canonicalize_json_value), + after: finding.after.clone().map(canonicalize_json_value), + }; + let bytes = canonical_json_bytes(&key)?; + Ok(FindingFingerprint { + schema: FindingFingerprint::SCHEMA_V1, + digest: sha256_identity(&bytes), + }) +} + +pub fn evaluate_quality_gate( + current: &CheckReport, + baseline: Option<&CheckReport>, + suppressions: Option<&GateSuppressions>, +) -> Result { + validate_check_report(current)?; + + let current_fingerprints = current_fingerprints(current)?; + let baseline_evidence = baseline + .map(|baseline| normalize_baseline(current, baseline)) + .transpose()?; + let suppression_evidence = suppressions.map(normalize_suppressions).transpose()?; + + let baseline_members = baseline_evidence + .as_ref() + .map(|evidence| evidence.fingerprints.iter().cloned().collect::>()) + .unwrap_or_default(); + let suppression_map = suppression_evidence + .as_ref() + .map(|evidence| { + evidence + .suppressions + .iter() + .cloned() + .map(|entry| (entry.finding_fingerprint.clone(), entry)) + .collect::>() + }) + .unwrap_or_default(); + + let mut findings = Vec::with_capacity(current_fingerprints.len()); + for (finding_index, fingerprint) in current_fingerprints.iter().cloned().enumerate() { + let (disposition, matched_suppression) = + if let Some(suppression) = suppression_map.get(&fingerprint) { + ( + QualityGateDisposition::Suppressed, + Some(suppression.clone()), + ) + } else if baseline_members.contains(&fingerprint) { + (QualityGateDisposition::Baseline, None) + } else { + (QualityGateDisposition::New, None) + }; + findings.push(QualityGateFinding { + finding_index, + fingerprint, + disposition, + matched_suppression, + }); + } + + let current_members = current_fingerprints.into_iter().collect::>(); + let unused_suppressions = suppression_map + .keys() + .filter(|fingerprint| !current_members.contains(*fingerprint)) + .cloned() + .collect::>(); + let decision = build_quality_gate_decision(current, &findings, unused_suppressions.len()); + + let report = QualityGateReport { + schema: QualityGateReport::SCHEMA_V1, + current: current.clone(), + baseline: baseline_evidence, + suppression_evidence, + findings, + unused_suppressions, + decision, + }; + validate_quality_gate_report(&report)?; + Ok(report) +} + +pub fn validate_quality_gate_report(report: &QualityGateReport) -> Result<(), QualityGateError> { + if report.schema != QualityGateReport::SCHEMA_V1 { + return Err(QualityGateError::UnsupportedGateSchema { + found: report.schema, + expected: QualityGateReport::SCHEMA_V1, + }); + } + validate_check_report(&report.current)?; + + let current_fingerprints = current_fingerprints(&report.current)?; + let baseline_members = match report.baseline.as_ref() { + Some(evidence) => validate_baseline_evidence(&report.current, evidence)?, + None => BTreeSet::new(), + }; + let suppression_map = match report.suppression_evidence.as_ref() { + Some(evidence) => validate_suppression_evidence(evidence)?, + None => BTreeMap::new(), + }; + + if report.findings.len() != current_fingerprints.len() { + return Err(inconsistent("finding count does not match current CF-05 evidence")); + } + + for (index, ((gate_finding, current_fingerprint), _current_finding)) in report + .findings + .iter() + .zip(current_fingerprints.iter()) + .zip(report.current.compatibility.findings.iter()) + .enumerate() + { + if gate_finding.finding_index != index { + return Err(inconsistent("finding_index does not match current finding order")); + } + validate_fingerprint(&gate_finding.fingerprint)?; + if &gate_finding.fingerprint != current_fingerprint { + return Err(inconsistent("persisted finding fingerprint does not match current evidence")); + } + + let expected_suppression = suppression_map.get(current_fingerprint); + let expected_disposition = if expected_suppression.is_some() { + QualityGateDisposition::Suppressed + } else if baseline_members.contains(current_fingerprint) { + QualityGateDisposition::Baseline + } else { + QualityGateDisposition::New + }; + if gate_finding.disposition != expected_disposition { + return Err(inconsistent("finding disposition does not match retained membership evidence")); + } + match (expected_suppression, gate_finding.matched_suppression.as_ref()) { + (Some(expected), Some(found)) if expected == found => {} + (None, None) => {} + _ => { + return Err(inconsistent( + "matched suppression metadata does not match retained suppression evidence", + )); + } + } + } + + let current_members = current_fingerprints.into_iter().collect::>(); + let expected_unused = suppression_map + .keys() + .filter(|fingerprint| !current_members.contains(*fingerprint)) + .cloned() + .collect::>(); + for fingerprint in &report.unused_suppressions { + validate_fingerprint(fingerprint)?; + } + if report.unused_suppressions != expected_unused { + return Err(inconsistent("unused suppressions do not match retained suppression evidence")); + } + + let expected_decision = + build_quality_gate_decision(&report.current, &report.findings, expected_unused.len()); + if report.decision != expected_decision { + return Err(inconsistent("quality-gate decision does not match current policy and dispositions")); + } + Ok(()) +} + +fn normalize_baseline( + current: &CheckReport, + baseline: &CheckReport, +) -> Result { + validate_check_report(baseline)?; + if baseline.compatibility.package_name != current.compatibility.package_name { + return Err(QualityGateError::BaselinePackageMismatch { + current: current.compatibility.package_name.clone(), + baseline: baseline.compatibility.package_name.clone(), + }); + } + if baseline.compatibility.ruleset != current.compatibility.ruleset { + return Err(QualityGateError::BaselineRulesetMismatch { + current: current.compatibility.ruleset.clone(), + baseline: baseline.compatibility.ruleset.clone(), + }); + } + + let mut seen = BTreeSet::new(); + let mut fingerprints = Vec::with_capacity(baseline.compatibility.findings.len()); + for finding in &baseline.compatibility.findings { + let fingerprint = finding_fingerprint_v1(&baseline.compatibility.ruleset, finding)?; + if !seen.insert(fingerprint.clone()) { + return Err(QualityGateError::DuplicateBaselineFingerprint { + fingerprint: fingerprint.digest, + }); + } + fingerprints.push(fingerprint); + } + fingerprints.sort(); + + let canonical_sha256 = sha256_identity(&canonical_json_bytes(baseline)?); + Ok(QualityGateBaselineEvidence { + canonical_sha256, + fingerprint_schema: FindingFingerprint::SCHEMA_V1, + package_name: baseline.compatibility.package_name.clone(), + ruleset: baseline.compatibility.ruleset.clone(), + before: baseline.compatibility.before.clone(), + after: baseline.compatibility.after.clone(), + finding_count: fingerprints.len(), + fingerprints, + }) +} + +fn normalize_suppressions( + suppressions: &GateSuppressions, +) -> Result { + if suppressions.schema != GateSuppressions::SCHEMA_V1 { + return Err(QualityGateError::UnsupportedSuppressionSchema { + found: suppressions.schema, + expected: GateSuppressions::SCHEMA_V1, + }); + } + if suppressions.suppressions.len() > MAX_GATE_SUPPRESSIONS { + return Err(QualityGateError::TooManySuppressions { + found: suppressions.suppressions.len(), + maximum: MAX_GATE_SUPPRESSIONS, + }); + } + + let mut normalized = suppressions.suppressions.clone(); + normalized.sort_by(|left, right| left.finding_fingerprint.cmp(&right.finding_fingerprint)); + let mut previous: Option<&FindingFingerprint> = None; + for suppression in &normalized { + validate_suppression(suppression)?; + if previous == Some(&suppression.finding_fingerprint) { + return Err(QualityGateError::DuplicateSuppressionFingerprint { + fingerprint: suppression.finding_fingerprint.digest.clone(), + }); + } + previous = Some(&suppression.finding_fingerprint); + } + + let normalized_input = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: normalized.clone(), + }; + let canonical_sha256 = sha256_identity(&canonical_json_bytes(&normalized_input)?); + Ok(QualityGateSuppressionEvidence { + canonical_sha256, + schema: GateSuppressions::SCHEMA_V1, + fingerprint_schema: FindingFingerprint::SCHEMA_V1, + entry_count: normalized.len(), + suppressions: normalized, + }) +} + +fn validate_baseline_evidence( + current: &CheckReport, + evidence: &QualityGateBaselineEvidence, +) -> Result, QualityGateError> { + if evidence.fingerprint_schema != FindingFingerprint::SCHEMA_V1 { + return Err(QualityGateError::UnsupportedFingerprintSchema { + found: evidence.fingerprint_schema, + expected: FindingFingerprint::SCHEMA_V1, + }); + } + validate_sha256_identity(&evidence.canonical_sha256)?; + validate_raw_sha256(&evidence.before.archive_sha256)?; + validate_raw_sha256(&evidence.after.archive_sha256)?; + if evidence.package_name != current.compatibility.package_name { + return Err(inconsistent("baseline evidence package does not match current package")); + } + if evidence.ruleset != current.compatibility.ruleset { + return Err(inconsistent("baseline evidence ruleset does not match current ruleset")); + } + if evidence.finding_count != evidence.fingerprints.len() { + return Err(inconsistent("baseline evidence finding count is inconsistent")); + } + validate_sorted_unique_fingerprints(&evidence.fingerprints, "baseline fingerprints are not sorted and unique")?; + Ok(evidence.fingerprints.iter().cloned().collect()) +} + +fn validate_suppression_evidence( + evidence: &QualityGateSuppressionEvidence, +) -> Result, QualityGateError> { + if evidence.schema != GateSuppressions::SCHEMA_V1 { + return Err(QualityGateError::UnsupportedSuppressionSchema { + found: evidence.schema, + expected: GateSuppressions::SCHEMA_V1, + }); + } + if evidence.fingerprint_schema != FindingFingerprint::SCHEMA_V1 { + return Err(QualityGateError::UnsupportedFingerprintSchema { + found: evidence.fingerprint_schema, + expected: FindingFingerprint::SCHEMA_V1, + }); + } + validate_sha256_identity(&evidence.canonical_sha256)?; + if evidence.entry_count != evidence.suppressions.len() { + return Err(inconsistent("suppression evidence entry count is inconsistent")); + } + if evidence.suppressions.len() > MAX_GATE_SUPPRESSIONS { + return Err(QualityGateError::TooManySuppressions { + found: evidence.suppressions.len(), + maximum: MAX_GATE_SUPPRESSIONS, + }); + } + + let mut map = BTreeMap::new(); + let mut previous: Option<&FindingFingerprint> = None; + for suppression in &evidence.suppressions { + validate_suppression(suppression)?; + if let Some(previous) = previous { + if previous >= &suppression.finding_fingerprint { + return Err(inconsistent("suppression evidence is not sorted and unique")); + } + } + previous = Some(&suppression.finding_fingerprint); + map.insert( + suppression.finding_fingerprint.clone(), + suppression.clone(), + ); + } + + let normalized_input = GateSuppressions { + schema: evidence.schema, + suppressions: evidence.suppressions.clone(), + }; + let expected_digest = sha256_identity(&canonical_json_bytes(&normalized_input)?); + if evidence.canonical_sha256 != expected_digest { + return Err(inconsistent("suppression canonical digest does not match retained evidence")); + } + Ok(map) +} + +fn current_fingerprints(current: &CheckReport) -> Result, QualityGateError> { + let mut seen = BTreeSet::new(); + let mut fingerprints = Vec::with_capacity(current.compatibility.findings.len()); + for finding in ¤t.compatibility.findings { + let fingerprint = finding_fingerprint_v1(¤t.compatibility.ruleset, finding)?; + if !seen.insert(fingerprint.clone()) { + return Err(QualityGateError::DuplicateCurrentFingerprint { + fingerprint: fingerprint.digest, + }); + } + fingerprints.push(fingerprint); + } + Ok(fingerprints) +} + +fn build_quality_gate_decision( + current: &CheckReport, + findings: &[QualityGateFinding], + unused_suppressions: usize, +) -> QualityGateDecision { + let mut selected_findings = 0usize; + let mut new_findings = 0usize; + let mut baseline_findings = 0usize; + let mut suppressed_findings = 0usize; + let mut new_selected_breaking_findings = 0usize; + let mut new_selected_risky_findings = 0usize; + let mut new_selected_additive_findings = 0usize; + let mut blocking_findings = 0usize; + + for (finding, gate_finding) in current.compatibility.findings.iter().zip(findings) { + let selected = direction_selected(current.policy.direction, finding.direction); + if selected { + selected_findings += 1; + } + match gate_finding.disposition { + QualityGateDisposition::New => { + new_findings += 1; + if selected { + match finding.severity { + CompatibilitySeverity::Breaking => new_selected_breaking_findings += 1, + CompatibilitySeverity::Risky => new_selected_risky_findings += 1, + CompatibilitySeverity::Additive => new_selected_additive_findings += 1, + } + if severity_blocks(current.policy.fail_on, finding.severity) { + blocking_findings += 1; + } + } + } + QualityGateDisposition::Baseline => baseline_findings += 1, + QualityGateDisposition::Suppressed => suppressed_findings += 1, + } + } + + QualityGateDecision { + passed: blocking_findings == 0, + total_findings: current.compatibility.findings.len(), + selected_findings, + new_findings, + baseline_findings, + suppressed_findings, + new_selected_breaking_findings, + new_selected_risky_findings, + new_selected_additive_findings, + blocking_findings, + unused_suppressions, + } +} + +fn validate_suppression(suppression: &GateSuppression) -> Result<(), QualityGateError> { + validate_fingerprint(&suppression.finding_fingerprint)?; + if suppression.rationale.trim().is_empty() { + return Err(QualityGateError::EmptySuppressionRationale); + } + let rationale_chars = suppression.rationale.chars().count(); + if rationale_chars > MAX_GATE_SUPPRESSION_RATIONALE_CHARS { + return Err(QualityGateError::SuppressionStringTooLong { + field: "rationale", + found: rationale_chars, + maximum: MAX_GATE_SUPPRESSION_RATIONALE_CHARS, + }); + } + if let Some(reference) = suppression.reference.as_ref() { + let reference_chars = reference.chars().count(); + if reference_chars > MAX_GATE_SUPPRESSION_REFERENCE_CHARS { + return Err(QualityGateError::SuppressionStringTooLong { + field: "reference", + found: reference_chars, + maximum: MAX_GATE_SUPPRESSION_REFERENCE_CHARS, + }); + } + } + Ok(()) +} + +fn validate_sorted_unique_fingerprints( + fingerprints: &[FindingFingerprint], + reason: &'static str, +) -> Result<(), QualityGateError> { + let mut previous: Option<&FindingFingerprint> = None; + for fingerprint in fingerprints { + validate_fingerprint(fingerprint)?; + if let Some(previous) = previous { + if previous >= fingerprint { + return Err(inconsistent(reason)); + } + } + previous = Some(fingerprint); + } + Ok(()) +} + +fn validate_fingerprint(fingerprint: &FindingFingerprint) -> Result<(), QualityGateError> { + if fingerprint.schema != FindingFingerprint::SCHEMA_V1 { + return Err(QualityGateError::UnsupportedFingerprintSchema { + found: fingerprint.schema, + expected: FindingFingerprint::SCHEMA_V1, + }); + } + validate_sha256_identity(&fingerprint.digest) +} + +fn validate_sha256_identity(value: &str) -> Result<(), QualityGateError> { + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(QualityGateError::MalformedSha256Identity { + value: value.to_owned(), + }); + }; + if !is_lower_hex_64(hex) { + return Err(QualityGateError::MalformedSha256Identity { + value: value.to_owned(), + }); + } + Ok(()) +} + +fn validate_raw_sha256(value: &str) -> Result<(), QualityGateError> { + if !is_lower_hex_64(value) { + return Err(QualityGateError::MalformedSha256Identity { + value: value.to_owned(), + }); + } + Ok(()) +} + +fn is_lower_hex_64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn canonical_json_bytes(value: &T) -> Result, serde_json::Error> { + let value = serde_json::to_value(value)?; + serde_json::to_vec(&canonicalize_json_value(value)) +} + +fn canonicalize_json_value(value: Value) -> Value { + match value { + Value::Object(object) => { + let mut entries = object.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut canonical = Map::new(); + for (key, value) in entries { + canonical.insert(key, canonicalize_json_value(value)); + } + Value::Object(canonical) + } + Value::Array(values) => Value::Array( + values + .into_iter() + .map(canonicalize_json_value) + .collect(), + ), + scalar => scalar, + } +} + +fn sha256_identity(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("sha256:{:x}", hasher.finalize()) +} + +fn inconsistent(reason: &'static str) -> QualityGateError { + QualityGateError::InconsistentReport { reason } +} From d23ed9b4670e30de45919028c9f5e7291a30b8e2 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:30:56 +0300 Subject: [PATCH 04/13] refactor(check): expose severity policy helper internally --- crates/commandf-pkg/src/check.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/commandf-pkg/src/check.rs b/crates/commandf-pkg/src/check.rs index d145fd86..e238247a 100644 --- a/crates/commandf-pkg/src/check.rs +++ b/crates/commandf-pkg/src/check.rs @@ -90,7 +90,7 @@ pub(crate) fn direction_selected(policy: CheckDirection, finding: CompatibilityD } } -fn severity_blocks(policy: CheckFailOn, severity: CompatibilitySeverity) -> bool { +pub(crate) fn severity_blocks(policy: CheckFailOn, severity: CompatibilitySeverity) -> bool { match policy { CheckFailOn::Breaking => severity == CompatibilitySeverity::Breaking, CheckFailOn::Risky => matches!( From fd227294410dbf9057813c065bf0a6166d4126e0 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:31:15 +0300 Subject: [PATCH 05/13] feat(gate): export CF-13 library surface --- crates/commandf-pkg/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/commandf-pkg/src/lib.rs b/crates/commandf-pkg/src/lib.rs index ed0c0105..51f206d4 100644 --- a/crates/commandf-pkg/src/lib.rs +++ b/crates/commandf-pkg/src/lib.rs @@ -23,6 +23,9 @@ mod context; mod context_error; mod context_model; mod error; +mod gate; +mod gate_error; +mod gate_model; mod impact; mod impact_error; mod impact_model; @@ -77,6 +80,17 @@ pub use context_model::{ ContextPackageDependencyEdge, ContextPackageIdentity, ContextPackageNode, }; pub use error::PackageError; +pub use gate::{ + evaluate_quality_gate, finding_fingerprint_v1, validate_quality_gate_report, + MAX_GATE_SUPPRESSIONS, MAX_GATE_SUPPRESSION_RATIONALE_CHARS, + MAX_GATE_SUPPRESSION_REFERENCE_CHARS, +}; +pub use gate_error::QualityGateError; +pub use gate_model::{ + FindingFingerprint, GateSuppression, GateSuppressions, QualityGateBaselineEvidence, + QualityGateDecision, QualityGateDisposition, QualityGateFinding, QualityGateReport, + QualityGateSuppressionEvidence, +}; pub use impact::build_impact_report; pub use impact_error::ImpactError; pub use impact_model::{ From 68819a62180329fc680aa0a5fcd850fad46e7670 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:32:28 +0300 Subject: [PATCH 06/13] test(gate): prove CF-13 library contracts and tamper resistance --- crates/commandf-pkg/tests/quality_gate.rs | 548 ++++++++++++++++++++++ 1 file changed, 548 insertions(+) create mode 100644 crates/commandf-pkg/tests/quality_gate.rs diff --git a/crates/commandf-pkg/tests/quality_gate.rs b/crates/commandf-pkg/tests/quality_gate.rs new file mode 100644 index 00000000..8bb73df4 --- /dev/null +++ b/crates/commandf-pkg/tests/quality_gate.rs @@ -0,0 +1,548 @@ +use commandf_pkg::{ + evaluate_compatibility_policy, evaluate_quality_gate, finding_fingerprint_v1, + validate_quality_gate_report, CheckDirection, CheckFailOn, CheckPolicy, CompatibilityDirection, + CompatibilityFinding, CompatibilityReport, CompatibilitySeverity, ElementView, FindingFingerprint, + GateSuppression, GateSuppressions, PackageEvidence, QualityGateDisposition, QualityGateError, + ResourceKey, ResourceKeyKind, StructuralChangeKind, MAX_GATE_SUPPRESSION_RATIONALE_CHARS, +}; +use serde_json::{json, Value}; + +fn finding( + rule_id: &str, + severity: CompatibilitySeverity, + direction: CompatibilityDirection, + before: Value, + after: Value, +) -> CompatibilityFinding { + CompatibilityFinding { + rule_id: rule_id.to_owned(), + severity, + direction, + source_kind: StructuralChangeKind::ElementFieldChanged, + message: format!("{rule_id} finding"), + resource: ResourceKey { + kind: ResourceKeyKind::Canonical, + value: "http://example.org/StructureDefinition/example".to_owned(), + }, + before_filename: Some("StructureDefinition-example.json".to_owned()), + after_filename: Some("StructureDefinition-example.json".to_owned()), + view: Some(ElementView::Snapshot), + element_id: Some("Observation.status".to_owned()), + field: Some("min".to_owned()), + before: Some(before), + after: Some(after), + } +} + +fn compatibility(findings: Vec, before_version: &str, after_version: &str) -> CompatibilityReport { + CompatibilityReport { + schema: CompatibilityReport::SCHEMA_V1, + ruleset: CompatibilityReport::RULESET_V1.to_owned(), + package_name: "example.package".to_owned(), + before: PackageEvidence { + version: before_version.to_owned(), + archive_sha256: "a".repeat(64), + }, + after: PackageEvidence { + version: after_version.to_owned(), + archive_sha256: "b".repeat(64), + }, + findings, + } +} + +fn check( + findings: Vec, + before_version: &str, + after_version: &str, + policy: CheckPolicy, +) -> commandf_pkg::CheckReport { + evaluate_compatibility_policy( + &compatibility(findings, before_version, after_version), + policy, + ) + .unwrap() +} + +fn default_policy() -> CheckPolicy { + CheckPolicy { + direction: CheckDirection::Both, + fail_on: CheckFailOn::Breaking, + } +} + +fn breaking() -> CompatibilityFinding { + finding( + "CF04-TEST-001", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + json!(0), + json!(1), + ) +} + +#[test] +fn no_baseline_selected_breaking_finding_is_new_and_blocks() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let report = evaluate_quality_gate(¤t, None, None).unwrap(); + + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].disposition, QualityGateDisposition::New); + assert!(report.findings[0].matched_suppression.is_none()); + assert_eq!(report.decision.total_findings, 1); + assert_eq!(report.decision.selected_findings, 1); + assert_eq!(report.decision.new_findings, 1); + assert_eq!(report.decision.new_selected_breaking_findings, 1); + assert_eq!(report.decision.blocking_findings, 1); + assert!(!report.decision.passed); + validate_quality_gate_report(&report).unwrap(); +} + +#[test] +fn baseline_membership_is_non_blocking_across_package_versions() { + let baseline = check(vec![breaking()], "0.8.0", "0.9.0", default_policy()); + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let report = evaluate_quality_gate(¤t, Some(&baseline), None).unwrap(); + + assert_eq!(report.findings[0].disposition, QualityGateDisposition::Baseline); + assert_eq!(report.decision.baseline_findings, 1); + assert_eq!(report.decision.blocking_findings, 0); + assert!(report.decision.passed); + + let evidence = report.baseline.as_ref().unwrap(); + assert_eq!(evidence.before.version, "0.8.0"); + assert_eq!(evidence.after.version, "0.9.0"); + assert_eq!(evidence.finding_count, 1); + assert_eq!(evidence.fingerprints, vec![report.findings[0].fingerprint.clone()]); + validate_quality_gate_report(&report).unwrap(); +} + +#[test] +fn exact_suppression_precedes_baseline_and_unused_suppressions_are_retained() { + let baseline = check(vec![breaking()], "0.8.0", "0.9.0", default_policy()); + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let unused = FindingFingerprint { + schema: FindingFingerprint::SCHEMA_V1, + digest: format!("sha256:{}", "c".repeat(64)), + }; + let suppressions = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: vec![ + GateSuppression { + finding_fingerprint: unused.clone(), + rationale: "Stale historical waiver".to_owned(), + reference: None, + }, + GateSuppression { + finding_fingerprint: fingerprint.clone(), + rationale: "Accepted interoperability exception".to_owned(), + reference: Some("INT-123".to_owned()), + }, + ], + }; + + let report = evaluate_quality_gate(¤t, Some(&baseline), Some(&suppressions)).unwrap(); + assert_eq!(report.findings[0].disposition, QualityGateDisposition::Suppressed); + assert_eq!( + report.findings[0] + .matched_suppression + .as_ref() + .unwrap() + .rationale, + "Accepted interoperability exception" + ); + assert_eq!(report.decision.suppressed_findings, 1); + assert_eq!(report.decision.baseline_findings, 0); + assert_eq!(report.decision.blocking_findings, 0); + assert!(report.decision.passed); + assert_eq!(report.unused_suppressions, vec![unused]); + validate_quality_gate_report(&report).unwrap(); +} + +#[test] +fn direction_and_threshold_semantics_match_cf05_for_new_findings() { + let findings = vec![ + finding( + "CF04-BREAK", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + json!(0), + json!(1), + ), + finding( + "CF04-RISK", + CompatibilitySeverity::Risky, + CompatibilityDirection::Consumer, + json!("a"), + json!("b"), + ), + ]; + + let consumer_breaking = check( + findings.clone(), + "1.0.0", + "1.1.0", + CheckPolicy { + direction: CheckDirection::Consumer, + fail_on: CheckFailOn::Breaking, + }, + ); + let gate = evaluate_quality_gate(&consumer_breaking, None, None).unwrap(); + assert_eq!(gate.decision.selected_findings, consumer_breaking.decision.selected_findings); + assert_eq!(gate.decision.blocking_findings, 0); + assert!(gate.decision.passed); + + let consumer_risky = check( + findings.clone(), + "1.0.0", + "1.1.0", + CheckPolicy { + direction: CheckDirection::Consumer, + fail_on: CheckFailOn::Risky, + }, + ); + let gate = evaluate_quality_gate(&consumer_risky, None, None).unwrap(); + assert_eq!(gate.decision.blocking_findings, consumer_risky.decision.blocking_findings); + assert_eq!(gate.decision.blocking_findings, 1); + + let none = check( + findings, + "1.0.0", + "1.1.0", + CheckPolicy { + direction: CheckDirection::Both, + fail_on: CheckFailOn::None, + }, + ); + let gate = evaluate_quality_gate(&none, None, None).unwrap(); + assert_eq!(gate.decision.blocking_findings, 0); + assert!(gate.decision.passed); +} + +#[test] +fn fingerprint_canonicalizes_nested_object_keys_but_preserves_array_order() { + let before_a: Value = serde_json::from_str(r#"{"z":1,"a":{"y":2,"x":3}}"#).unwrap(); + let before_b: Value = serde_json::from_str(r#"{"a":{"x":3,"y":2},"z":1}"#).unwrap(); + let after_a: Value = serde_json::from_str(r#"{"items":[1,2,3]}"#).unwrap(); + let after_b: Value = serde_json::from_str(r#"{"items":[3,2,1]}"#).unwrap(); + + let left = finding( + "CF04-CANON", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + before_a, + after_a.clone(), + ); + let right = finding( + "CF04-CANON", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + before_b, + after_a, + ); + assert_eq!( + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &left).unwrap(), + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &right).unwrap() + ); + + let array_changed = finding( + "CF04-CANON", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + serde_json::from_str(r#"{"a":{"x":3,"y":2},"z":1}"#).unwrap(), + after_b, + ); + assert_ne!( + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &left).unwrap(), + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &array_changed).unwrap() + ); +} + +#[test] +fn message_only_change_preserves_fingerprint_but_semantic_change_does_not() { + let original = breaking(); + let mut wording = original.clone(); + wording.message = "different human wording".to_owned(); + assert_eq!( + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &original).unwrap(), + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &wording).unwrap() + ); + + let mut severity = original.clone(); + severity.severity = CompatibilitySeverity::Risky; + assert_ne!( + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &original).unwrap(), + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &severity).unwrap() + ); +} + +#[test] +fn baseline_canonical_digest_is_invariant_to_nested_object_key_order() { + let left = finding( + "CF04-CANON", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + serde_json::from_str(r#"{"z":1,"a":{"y":2,"x":3}}"#).unwrap(), + json!(1), + ); + let right = finding( + "CF04-CANON", + CompatibilitySeverity::Breaking, + CompatibilityDirection::Producer, + serde_json::from_str(r#"{"a":{"x":3,"y":2},"z":1}"#).unwrap(), + json!(1), + ); + let baseline_left = check(vec![left.clone()], "0.8.0", "0.9.0", default_policy()); + let baseline_right = check(vec![right.clone()], "0.8.0", "0.9.0", default_policy()); + let current_left = check(vec![left], "1.0.0", "1.1.0", default_policy()); + let current_right = check(vec![right], "1.0.0", "1.1.0", default_policy()); + + let report_left = evaluate_quality_gate(¤t_left, Some(&baseline_left), None).unwrap(); + let report_right = evaluate_quality_gate(¤t_right, Some(&baseline_right), None).unwrap(); + assert_eq!( + report_left.baseline.unwrap().canonical_sha256, + report_right.baseline.unwrap().canonical_sha256 + ); +} + +#[test] +fn suppression_order_is_canonical_and_report_bytes_are_repeatable() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let current_fp = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let unused_fp = FindingFingerprint { + schema: 1, + digest: format!("sha256:{}", "d".repeat(64)), + }; + let a = GateSuppression { + finding_fingerprint: current_fp, + rationale: "accepted".to_owned(), + reference: None, + }; + let b = GateSuppression { + finding_fingerprint: unused_fp, + rationale: "unused".to_owned(), + reference: Some("REF".to_owned()), + }; + let first = GateSuppressions { + schema: 1, + suppressions: vec![a.clone(), b.clone()], + }; + let second = GateSuppressions { + schema: 1, + suppressions: vec![b, a], + }; + + let report_a = evaluate_quality_gate(¤t, None, Some(&first)).unwrap(); + let report_b = evaluate_quality_gate(¤t, None, Some(&second)).unwrap(); + assert_eq!( + report_a.suppression_evidence.as_ref().unwrap().canonical_sha256, + report_b.suppression_evidence.as_ref().unwrap().canonical_sha256 + ); + assert_eq!(report_a.to_json_bytes().unwrap(), report_b.to_json_bytes().unwrap()); + let repeated = evaluate_quality_gate(¤t, None, Some(&first)).unwrap(); + assert_eq!(report_a.to_json_bytes().unwrap(), repeated.to_json_bytes().unwrap()); +} + +#[test] +fn invalid_or_ambiguous_suppression_state_fails_closed() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + + let unsupported = GateSuppressions { + schema: 1, + suppressions: vec![GateSuppression { + finding_fingerprint: FindingFingerprint { + schema: 2, + digest: fingerprint.digest.clone(), + }, + rationale: "reason".to_owned(), + reference: None, + }], + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&unsupported)), + Err(QualityGateError::UnsupportedFingerprintSchema { found: 2, expected: 1 }) + )); + + let malformed = GateSuppressions { + schema: 1, + suppressions: vec![GateSuppression { + finding_fingerprint: FindingFingerprint { + schema: 1, + digest: "sha256:ABC".to_owned(), + }, + rationale: "reason".to_owned(), + reference: None, + }], + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&malformed)), + Err(QualityGateError::MalformedSha256Identity { .. }) + )); + + let duplicate = GateSuppression { + finding_fingerprint: fingerprint, + rationale: "reason".to_owned(), + reference: None, + }; + let duplicates = GateSuppressions { + schema: 1, + suppressions: vec![duplicate.clone(), duplicate], + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&duplicates)), + Err(QualityGateError::DuplicateSuppressionFingerprint { .. }) + )); + + let oversized = GateSuppressions { + schema: 1, + suppressions: vec![GateSuppression { + finding_fingerprint: finding_fingerprint_v1( + CompatibilityReport::RULESET_V1, + &breaking(), + ) + .unwrap(), + rationale: "x".repeat(MAX_GATE_SUPPRESSION_RATIONALE_CHARS + 1), + reference: None, + }], + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&oversized)), + Err(QualityGateError::SuppressionStringTooLong { field: "rationale", .. }) + )); +} + +#[test] +fn duplicate_baseline_or_current_fingerprints_fail_closed() { + let duplicated = vec![breaking(), breaking()]; + let baseline = check(duplicated.clone(), "0.8.0", "0.9.0", default_policy()); + let current_single = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + assert!(matches!( + evaluate_quality_gate(¤t_single, Some(&baseline), None), + Err(QualityGateError::DuplicateBaselineFingerprint { .. }) + )); + + let current_duplicate = check(duplicated, "1.0.0", "1.1.0", default_policy()); + assert!(matches!( + evaluate_quality_gate(¤t_duplicate, None, None), + Err(QualityGateError::DuplicateCurrentFingerprint { .. }) + )); +} + +#[test] +fn baseline_package_mismatch_fails_closed() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let mut baseline = check(vec![breaking()], "0.8.0", "0.9.0", default_policy()); + baseline.compatibility.package_name = "other.package".to_owned(); + assert!(matches!( + evaluate_quality_gate(¤t, Some(&baseline), None), + Err(QualityGateError::BaselinePackageMismatch { .. }) + )); +} + +#[test] +fn persisted_report_validation_rejects_forged_baseline_disposition() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let report = evaluate_quality_gate(¤t, None, None).unwrap(); + validate_quality_gate_report(&report).unwrap(); + + let mut forged = report; + forged.findings[0].disposition = QualityGateDisposition::Baseline; + forged.decision.passed = true; + forged.decision.new_findings = 0; + forged.decision.baseline_findings = 1; + forged.decision.new_selected_breaking_findings = 0; + forged.decision.blocking_findings = 0; + assert!(matches!( + validate_quality_gate_report(&forged), + Err(QualityGateError::InconsistentReport { .. }) + )); +} + +#[test] +fn persisted_report_validation_rejects_altered_fingerprint_and_decision() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let report = evaluate_quality_gate(¤t, None, None).unwrap(); + + let mut fingerprint_tamper = report.clone(); + fingerprint_tamper.findings[0].fingerprint.digest = format!("sha256:{}", "0".repeat(64)); + assert!(matches!( + validate_quality_gate_report(&fingerprint_tamper), + Err(QualityGateError::InconsistentReport { .. }) + )); + + let mut decision_tamper = report; + decision_tamper.decision.blocking_findings = 0; + decision_tamper.decision.passed = true; + assert!(matches!( + validate_quality_gate_report(&decision_tamper), + Err(QualityGateError::InconsistentReport { .. }) + )); +} + +#[test] +fn persisted_report_validation_rejects_missing_baseline_membership() { + let baseline = check(vec![breaking()], "0.8.0", "0.9.0", default_policy()); + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let mut report = evaluate_quality_gate(¤t, Some(&baseline), None).unwrap(); + validate_quality_gate_report(&report).unwrap(); + + let evidence = report.baseline.as_mut().unwrap(); + evidence.fingerprints.clear(); + evidence.finding_count = 0; + assert!(matches!( + validate_quality_gate_report(&report), + Err(QualityGateError::InconsistentReport { .. }) + )); +} + +#[test] +fn persisted_report_validation_rejects_suppression_metadata_tampering() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let suppressions = GateSuppressions { + schema: 1, + suppressions: vec![GateSuppression { + finding_fingerprint: fingerprint, + rationale: "approved".to_owned(), + reference: Some("INT-123".to_owned()), + }], + }; + let report = evaluate_quality_gate(¤t, None, Some(&suppressions)).unwrap(); + validate_quality_gate_report(&report).unwrap(); + + let mut matched_tamper = report.clone(); + matched_tamper.findings[0] + .matched_suppression + .as_mut() + .unwrap() + .rationale = "forged".to_owned(); + assert!(matches!( + validate_quality_gate_report(&matched_tamper), + Err(QualityGateError::InconsistentReport { .. }) + )); + + let mut evidence_tamper = report; + evidence_tamper + .suppression_evidence + .as_mut() + .unwrap() + .suppressions[0] + .rationale = "forged".to_owned(); + assert!(matches!( + validate_quality_gate_report(&evidence_tamper), + Err(QualityGateError::InconsistentReport { .. }) + )); +} + +#[test] +fn persisted_report_rejects_unknown_fingerprint_version() { + let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); + let mut report = evaluate_quality_gate(¤t, None, None).unwrap(); + report.findings[0].fingerprint.schema = 2; + assert!(matches!( + validate_quality_gate_report(&report), + Err(QualityGateError::UnsupportedFingerprintSchema { found: 2, expected: 1 }) + )); +} From 74f14c0f5e072404a889bced706beb20949832f8 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:36:27 +0300 Subject: [PATCH 07/13] style(gate): apply canonical Rust formatting --- crates/commandf-pkg/src/gate.rs | 89 ++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 29 deletions(-) diff --git a/crates/commandf-pkg/src/gate.rs b/crates/commandf-pkg/src/gate.rs index d91401d7..ec19c77f 100644 --- a/crates/commandf-pkg/src/gate.rs +++ b/crates/commandf-pkg/src/gate.rs @@ -76,7 +76,13 @@ pub fn evaluate_quality_gate( let baseline_members = baseline_evidence .as_ref() - .map(|evidence| evidence.fingerprints.iter().cloned().collect::>()) + .map(|evidence| { + evidence + .fingerprints + .iter() + .cloned() + .collect::>() + }) .unwrap_or_default(); let suppression_map = suppression_evidence .as_ref() @@ -152,22 +158,27 @@ pub fn validate_quality_gate_report(report: &QualityGateReport) -> Result<(), Qu }; if report.findings.len() != current_fingerprints.len() { - return Err(inconsistent("finding count does not match current CF-05 evidence")); + return Err(inconsistent( + "finding count does not match current CF-05 evidence", + )); } - for (index, ((gate_finding, current_fingerprint), _current_finding)) in report + for (index, (gate_finding, current_fingerprint)) in report .findings .iter() .zip(current_fingerprints.iter()) - .zip(report.current.compatibility.findings.iter()) .enumerate() { if gate_finding.finding_index != index { - return Err(inconsistent("finding_index does not match current finding order")); + return Err(inconsistent( + "finding_index does not match current finding order", + )); } validate_fingerprint(&gate_finding.fingerprint)?; if &gate_finding.fingerprint != current_fingerprint { - return Err(inconsistent("persisted finding fingerprint does not match current evidence")); + return Err(inconsistent( + "persisted finding fingerprint does not match current evidence", + )); } let expected_suppression = suppression_map.get(current_fingerprint); @@ -179,9 +190,14 @@ pub fn validate_quality_gate_report(report: &QualityGateReport) -> Result<(), Qu QualityGateDisposition::New }; if gate_finding.disposition != expected_disposition { - return Err(inconsistent("finding disposition does not match retained membership evidence")); + return Err(inconsistent( + "finding disposition does not match retained membership evidence", + )); } - match (expected_suppression, gate_finding.matched_suppression.as_ref()) { + match ( + expected_suppression, + gate_finding.matched_suppression.as_ref(), + ) { (Some(expected), Some(found)) if expected == found => {} (None, None) => {} _ => { @@ -202,13 +218,17 @@ pub fn validate_quality_gate_report(report: &QualityGateReport) -> Result<(), Qu validate_fingerprint(fingerprint)?; } if report.unused_suppressions != expected_unused { - return Err(inconsistent("unused suppressions do not match retained suppression evidence")); + return Err(inconsistent( + "unused suppressions do not match retained suppression evidence", + )); } let expected_decision = build_quality_gate_decision(&report.current, &report.findings, expected_unused.len()); if report.decision != expected_decision { - return Err(inconsistent("quality-gate decision does not match current policy and dispositions")); + return Err(inconsistent( + "quality-gate decision does not match current policy and dispositions", + )); } Ok(()) } @@ -314,15 +334,24 @@ fn validate_baseline_evidence( validate_raw_sha256(&evidence.before.archive_sha256)?; validate_raw_sha256(&evidence.after.archive_sha256)?; if evidence.package_name != current.compatibility.package_name { - return Err(inconsistent("baseline evidence package does not match current package")); + return Err(inconsistent( + "baseline evidence package does not match current package", + )); } if evidence.ruleset != current.compatibility.ruleset { - return Err(inconsistent("baseline evidence ruleset does not match current ruleset")); + return Err(inconsistent( + "baseline evidence ruleset does not match current ruleset", + )); } if evidence.finding_count != evidence.fingerprints.len() { - return Err(inconsistent("baseline evidence finding count is inconsistent")); - } - validate_sorted_unique_fingerprints(&evidence.fingerprints, "baseline fingerprints are not sorted and unique")?; + return Err(inconsistent( + "baseline evidence finding count is inconsistent", + )); + } + validate_sorted_unique_fingerprints( + &evidence.fingerprints, + "baseline fingerprints are not sorted and unique", + )?; Ok(evidence.fingerprints.iter().cloned().collect()) } @@ -343,7 +372,9 @@ fn validate_suppression_evidence( } validate_sha256_identity(&evidence.canonical_sha256)?; if evidence.entry_count != evidence.suppressions.len() { - return Err(inconsistent("suppression evidence entry count is inconsistent")); + return Err(inconsistent( + "suppression evidence entry count is inconsistent", + )); } if evidence.suppressions.len() > MAX_GATE_SUPPRESSIONS { return Err(QualityGateError::TooManySuppressions { @@ -358,14 +389,13 @@ fn validate_suppression_evidence( validate_suppression(suppression)?; if let Some(previous) = previous { if previous >= &suppression.finding_fingerprint { - return Err(inconsistent("suppression evidence is not sorted and unique")); + return Err(inconsistent( + "suppression evidence is not sorted and unique", + )); } } previous = Some(&suppression.finding_fingerprint); - map.insert( - suppression.finding_fingerprint.clone(), - suppression.clone(), - ); + map.insert(suppression.finding_fingerprint.clone(), suppression.clone()); } let normalized_input = GateSuppressions { @@ -374,12 +404,16 @@ fn validate_suppression_evidence( }; let expected_digest = sha256_identity(&canonical_json_bytes(&normalized_input)?); if evidence.canonical_sha256 != expected_digest { - return Err(inconsistent("suppression canonical digest does not match retained evidence")); + return Err(inconsistent( + "suppression canonical digest does not match retained evidence", + )); } Ok(map) } -fn current_fingerprints(current: &CheckReport) -> Result, QualityGateError> { +fn current_fingerprints( + current: &CheckReport, +) -> Result, QualityGateError> { let mut seen = BTreeSet::new(); let mut fingerprints = Vec::with_capacity(current.compatibility.findings.len()); for finding in ¤t.compatibility.findings { @@ -546,12 +580,9 @@ fn canonicalize_json_value(value: Value) -> Value { } Value::Object(canonical) } - Value::Array(values) => Value::Array( - values - .into_iter() - .map(canonicalize_json_value) - .collect(), - ), + Value::Array(values) => { + Value::Array(values.into_iter().map(canonicalize_json_value).collect()) + } scalar => scalar, } } From ec1aaf6bea9e9754dcf6ac4cd024197d10117737 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:37:25 +0300 Subject: [PATCH 08/13] style(gate): format CF-13 contract tests --- crates/commandf-pkg/tests/quality_gate.rs | 89 +++++++++++++++++------ 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/crates/commandf-pkg/tests/quality_gate.rs b/crates/commandf-pkg/tests/quality_gate.rs index 8bb73df4..4832eb64 100644 --- a/crates/commandf-pkg/tests/quality_gate.rs +++ b/crates/commandf-pkg/tests/quality_gate.rs @@ -1,9 +1,10 @@ use commandf_pkg::{ evaluate_compatibility_policy, evaluate_quality_gate, finding_fingerprint_v1, validate_quality_gate_report, CheckDirection, CheckFailOn, CheckPolicy, CompatibilityDirection, - CompatibilityFinding, CompatibilityReport, CompatibilitySeverity, ElementView, FindingFingerprint, - GateSuppression, GateSuppressions, PackageEvidence, QualityGateDisposition, QualityGateError, - ResourceKey, ResourceKeyKind, StructuralChangeKind, MAX_GATE_SUPPRESSION_RATIONALE_CHARS, + CompatibilityFinding, CompatibilityReport, CompatibilitySeverity, ElementView, + FindingFingerprint, GateSuppression, GateSuppressions, PackageEvidence, QualityGateDisposition, + QualityGateError, ResourceKey, ResourceKeyKind, StructuralChangeKind, + MAX_GATE_SUPPRESSION_RATIONALE_CHARS, }; use serde_json::{json, Value}; @@ -34,7 +35,11 @@ fn finding( } } -fn compatibility(findings: Vec, before_version: &str, after_version: &str) -> CompatibilityReport { +fn compatibility( + findings: Vec, + before_version: &str, + after_version: &str, +) -> CompatibilityReport { CompatibilityReport { schema: CompatibilityReport::SCHEMA_V1, ruleset: CompatibilityReport::RULESET_V1.to_owned(), @@ -104,7 +109,10 @@ fn baseline_membership_is_non_blocking_across_package_versions() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); let report = evaluate_quality_gate(¤t, Some(&baseline), None).unwrap(); - assert_eq!(report.findings[0].disposition, QualityGateDisposition::Baseline); + assert_eq!( + report.findings[0].disposition, + QualityGateDisposition::Baseline + ); assert_eq!(report.decision.baseline_findings, 1); assert_eq!(report.decision.blocking_findings, 0); assert!(report.decision.passed); @@ -113,7 +121,10 @@ fn baseline_membership_is_non_blocking_across_package_versions() { assert_eq!(evidence.before.version, "0.8.0"); assert_eq!(evidence.after.version, "0.9.0"); assert_eq!(evidence.finding_count, 1); - assert_eq!(evidence.fingerprints, vec![report.findings[0].fingerprint.clone()]); + assert_eq!( + evidence.fingerprints, + vec![report.findings[0].fingerprint.clone()] + ); validate_quality_gate_report(&report).unwrap(); } @@ -121,7 +132,8 @@ fn baseline_membership_is_non_blocking_across_package_versions() { fn exact_suppression_precedes_baseline_and_unused_suppressions_are_retained() { let baseline = check(vec![breaking()], "0.8.0", "0.9.0", default_policy()); let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let fingerprint = + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let unused = FindingFingerprint { schema: FindingFingerprint::SCHEMA_V1, digest: format!("sha256:{}", "c".repeat(64)), @@ -135,7 +147,7 @@ fn exact_suppression_precedes_baseline_and_unused_suppressions_are_retained() { reference: None, }, GateSuppression { - finding_fingerprint: fingerprint.clone(), + finding_fingerprint: fingerprint, rationale: "Accepted interoperability exception".to_owned(), reference: Some("INT-123".to_owned()), }, @@ -143,7 +155,10 @@ fn exact_suppression_precedes_baseline_and_unused_suppressions_are_retained() { }; let report = evaluate_quality_gate(¤t, Some(&baseline), Some(&suppressions)).unwrap(); - assert_eq!(report.findings[0].disposition, QualityGateDisposition::Suppressed); + assert_eq!( + report.findings[0].disposition, + QualityGateDisposition::Suppressed + ); assert_eq!( report.findings[0] .matched_suppression @@ -189,7 +204,10 @@ fn direction_and_threshold_semantics_match_cf05_for_new_findings() { }, ); let gate = evaluate_quality_gate(&consumer_breaking, None, None).unwrap(); - assert_eq!(gate.decision.selected_findings, consumer_breaking.decision.selected_findings); + assert_eq!( + gate.decision.selected_findings, + consumer_breaking.decision.selected_findings + ); assert_eq!(gate.decision.blocking_findings, 0); assert!(gate.decision.passed); @@ -203,7 +221,10 @@ fn direction_and_threshold_semantics_match_cf05_for_new_findings() { }, ); let gate = evaluate_quality_gate(&consumer_risky, None, None).unwrap(); - assert_eq!(gate.decision.blocking_findings, consumer_risky.decision.blocking_findings); + assert_eq!( + gate.decision.blocking_findings, + consumer_risky.decision.blocking_findings + ); assert_eq!(gate.decision.blocking_findings, 1); let none = check( @@ -309,7 +330,8 @@ fn baseline_canonical_digest_is_invariant_to_nested_object_key_order() { #[test] fn suppression_order_is_canonical_and_report_bytes_are_repeatable() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let current_fp = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let current_fp = + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let unused_fp = FindingFingerprint { schema: 1, digest: format!("sha256:{}", "d".repeat(64)), @@ -336,18 +358,33 @@ fn suppression_order_is_canonical_and_report_bytes_are_repeatable() { let report_a = evaluate_quality_gate(¤t, None, Some(&first)).unwrap(); let report_b = evaluate_quality_gate(¤t, None, Some(&second)).unwrap(); assert_eq!( - report_a.suppression_evidence.as_ref().unwrap().canonical_sha256, - report_b.suppression_evidence.as_ref().unwrap().canonical_sha256 + report_a + .suppression_evidence + .as_ref() + .unwrap() + .canonical_sha256, + report_b + .suppression_evidence + .as_ref() + .unwrap() + .canonical_sha256 + ); + assert_eq!( + report_a.to_json_bytes().unwrap(), + report_b.to_json_bytes().unwrap() ); - assert_eq!(report_a.to_json_bytes().unwrap(), report_b.to_json_bytes().unwrap()); let repeated = evaluate_quality_gate(¤t, None, Some(&first)).unwrap(); - assert_eq!(report_a.to_json_bytes().unwrap(), repeated.to_json_bytes().unwrap()); + assert_eq!( + report_a.to_json_bytes().unwrap(), + repeated.to_json_bytes().unwrap() + ); } #[test] fn invalid_or_ambiguous_suppression_state_fails_closed() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let fingerprint = + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let unsupported = GateSuppressions { schema: 1, @@ -362,7 +399,10 @@ fn invalid_or_ambiguous_suppression_state_fails_closed() { }; assert!(matches!( evaluate_quality_gate(¤t, None, Some(&unsupported)), - Err(QualityGateError::UnsupportedFingerprintSchema { found: 2, expected: 1 }) + Err(QualityGateError::UnsupportedFingerprintSchema { + found: 2, + expected: 1 + }) )); let malformed = GateSuppressions { @@ -409,7 +449,10 @@ fn invalid_or_ambiguous_suppression_state_fails_closed() { }; assert!(matches!( evaluate_quality_gate(¤t, None, Some(&oversized)), - Err(QualityGateError::SuppressionStringTooLong { field: "rationale", .. }) + Err(QualityGateError::SuppressionStringTooLong { + field: "rationale", + .. + }) )); } @@ -500,7 +543,8 @@ fn persisted_report_validation_rejects_missing_baseline_membership() { #[test] fn persisted_report_validation_rejects_suppression_metadata_tampering() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let fingerprint = + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let suppressions = GateSuppressions { schema: 1, suppressions: vec![GateSuppression { @@ -543,6 +587,9 @@ fn persisted_report_rejects_unknown_fingerprint_version() { report.findings[0].fingerprint.schema = 2; assert!(matches!( validate_quality_gate_report(&report), - Err(QualityGateError::UnsupportedFingerprintSchema { found: 2, expected: 1 }) + Err(QualityGateError::UnsupportedFingerprintSchema { + found: 2, + expected: 1 + }) )); } From b1ccfa5ac65d88ce3e5498b68d95622d8178a403 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:38:59 +0300 Subject: [PATCH 09/13] style(gate): finish rustfmt normalization --- crates/commandf-pkg/tests/quality_gate.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/commandf-pkg/tests/quality_gate.rs b/crates/commandf-pkg/tests/quality_gate.rs index 4832eb64..0b8d075a 100644 --- a/crates/commandf-pkg/tests/quality_gate.rs +++ b/crates/commandf-pkg/tests/quality_gate.rs @@ -132,8 +132,7 @@ fn baseline_membership_is_non_blocking_across_package_versions() { fn exact_suppression_precedes_baseline_and_unused_suppressions_are_retained() { let baseline = check(vec![breaking()], "0.8.0", "0.9.0", default_policy()); let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let fingerprint = - finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let unused = FindingFingerprint { schema: FindingFingerprint::SCHEMA_V1, digest: format!("sha256:{}", "c".repeat(64)), @@ -330,8 +329,7 @@ fn baseline_canonical_digest_is_invariant_to_nested_object_key_order() { #[test] fn suppression_order_is_canonical_and_report_bytes_are_repeatable() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let current_fp = - finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let current_fp = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let unused_fp = FindingFingerprint { schema: 1, digest: format!("sha256:{}", "d".repeat(64)), @@ -383,8 +381,7 @@ fn suppression_order_is_canonical_and_report_bytes_are_repeatable() { #[test] fn invalid_or_ambiguous_suppression_state_fails_closed() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let fingerprint = - finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let unsupported = GateSuppressions { schema: 1, @@ -543,8 +540,7 @@ fn persisted_report_validation_rejects_missing_baseline_membership() { #[test] fn persisted_report_validation_rejects_suppression_metadata_tampering() { let current = check(vec![breaking()], "1.0.0", "1.1.0", default_policy()); - let fingerprint = - finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); + let fingerprint = finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap(); let suppressions = GateSuppressions { schema: 1, suppressions: vec![GateSuppression { From 7d84f91cd6ba124bfc5a52892ad5d17d01fb1bbf Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:42:45 +0300 Subject: [PATCH 10/13] test(gate): complete fail-closed validation matrix --- .../tests/quality_gate_validation.rs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 crates/commandf-pkg/tests/quality_gate_validation.rs diff --git a/crates/commandf-pkg/tests/quality_gate_validation.rs b/crates/commandf-pkg/tests/quality_gate_validation.rs new file mode 100644 index 00000000..328d211a --- /dev/null +++ b/crates/commandf-pkg/tests/quality_gate_validation.rs @@ -0,0 +1,181 @@ +use commandf_pkg::{ + evaluate_compatibility_policy, evaluate_quality_gate, finding_fingerprint_v1, + validate_quality_gate_report, CheckPolicy, CompatibilityDirection, CompatibilityFinding, + CompatibilityReport, CompatibilitySeverity, ElementView, FindingFingerprint, GateSuppression, + GateSuppressions, PackageEvidence, QualityGateError, QualityGateReport, ResourceKey, + ResourceKeyKind, StructuralChangeKind, MAX_GATE_SUPPRESSIONS, + MAX_GATE_SUPPRESSION_REFERENCE_CHARS, +}; +use serde_json::json; + +fn breaking() -> CompatibilityFinding { + CompatibilityFinding { + rule_id: "CF04-TEST-VALIDATION".to_owned(), + severity: CompatibilitySeverity::Breaking, + direction: CompatibilityDirection::Producer, + source_kind: StructuralChangeKind::ElementFieldChanged, + message: "validation finding".to_owned(), + resource: ResourceKey { + kind: ResourceKeyKind::Canonical, + value: "http://example.org/StructureDefinition/example".to_owned(), + }, + before_filename: Some("StructureDefinition-example.json".to_owned()), + after_filename: Some("StructureDefinition-example.json".to_owned()), + view: Some(ElementView::Snapshot), + element_id: Some("Observation.status".to_owned()), + field: Some("min".to_owned()), + before: Some(json!(0)), + after: Some(json!(1)), + } +} + +fn check(before_version: &str, after_version: &str) -> commandf_pkg::CheckReport { + evaluate_compatibility_policy( + &CompatibilityReport { + schema: CompatibilityReport::SCHEMA_V1, + ruleset: CompatibilityReport::RULESET_V1.to_owned(), + package_name: "example.package".to_owned(), + before: PackageEvidence { + version: before_version.to_owned(), + archive_sha256: "a".repeat(64), + }, + after: PackageEvidence { + version: after_version.to_owned(), + archive_sha256: "b".repeat(64), + }, + findings: vec![breaking()], + }, + CheckPolicy::default(), + ) + .unwrap() +} + +fn fingerprint() -> FindingFingerprint { + finding_fingerprint_v1(CompatibilityReport::RULESET_V1, &breaking()).unwrap() +} + +#[test] +fn suppression_schema_and_empty_rationale_fail_closed() { + let current = check("1.0.0", "1.1.0"); + let unsupported_schema = GateSuppressions { + schema: 2, + suppressions: Vec::new(), + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&unsupported_schema)), + Err(QualityGateError::UnsupportedSuppressionSchema { + found: 2, + expected: 1 + }) + )); + + let empty_rationale = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: vec![GateSuppression { + finding_fingerprint: fingerprint(), + rationale: " \n\t ".to_owned(), + reference: None, + }], + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&empty_rationale)), + Err(QualityGateError::EmptySuppressionRationale) + )); +} + +#[test] +fn suppression_entry_and_reference_bounds_fail_closed() { + let current = check("1.0.0", "1.1.0"); + let suppressions = (0..=MAX_GATE_SUPPRESSIONS) + .map(|index| GateSuppression { + finding_fingerprint: FindingFingerprint { + schema: FindingFingerprint::SCHEMA_V1, + digest: format!("sha256:{index:064x}"), + }, + rationale: "bounded".to_owned(), + reference: None, + }) + .collect(); + assert!(matches!( + evaluate_quality_gate( + ¤t, + None, + Some(&GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions, + }) + ), + Err(QualityGateError::TooManySuppressions { + found, + maximum + }) if found == MAX_GATE_SUPPRESSIONS + 1 && maximum == MAX_GATE_SUPPRESSIONS + )); + + let oversized_reference = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: vec![GateSuppression { + finding_fingerprint: fingerprint(), + rationale: "approved".to_owned(), + reference: Some("x".repeat(MAX_GATE_SUPPRESSION_REFERENCE_CHARS + 1)), + }], + }; + assert!(matches!( + evaluate_quality_gate(¤t, None, Some(&oversized_reference)), + Err(QualityGateError::SuppressionStringTooLong { + field: "reference", + .. + }) + )); +} + +#[test] +fn persisted_report_rejects_report_and_membership_count_tampering() { + let baseline = check("0.8.0", "0.9.0"); + let current = check("1.0.0", "1.1.0"); + let mut report = evaluate_quality_gate(¤t, Some(&baseline), None).unwrap(); + + report.schema = 2; + assert!(matches!( + validate_quality_gate_report(&report), + Err(QualityGateError::UnsupportedGateSchema { + found: 2, + expected: 1 + }) + )); + + let mut baseline_count = evaluate_quality_gate(¤t, Some(&baseline), None).unwrap(); + baseline_count.baseline.as_mut().unwrap().finding_count += 1; + assert!(matches!( + validate_quality_gate_report(&baseline_count), + Err(QualityGateError::InconsistentReport { .. }) + )); + + let suppression = GateSuppressions { + schema: GateSuppressions::SCHEMA_V1, + suppressions: vec![GateSuppression { + finding_fingerprint: fingerprint(), + rationale: "approved".to_owned(), + reference: None, + }], + }; + let mut suppression_count = evaluate_quality_gate(¤t, None, Some(&suppression)).unwrap(); + suppression_count + .suppression_evidence + .as_mut() + .unwrap() + .entry_count += 1; + assert!(matches!( + validate_quality_gate_report(&suppression_count), + Err(QualityGateError::InconsistentReport { .. }) + )); +} + +#[test] +fn persisted_report_decoder_rejects_unknown_disposition_value() { + let current = check("1.0.0", "1.1.0"); + let report = evaluate_quality_gate(¤t, None, None).unwrap(); + let json = String::from_utf8(report.to_json_bytes().unwrap()).unwrap(); + let tampered = json.replacen("\"disposition\": \"new\"", "\"disposition\": \"future\"", 1); + + assert!(QualityGateReport::from_json_slice(tampered.as_bytes()).is_err()); +} From 50f9afd2987c2d31d8c914199f4f166744ce0f7e Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:54:23 +0300 Subject: [PATCH 11/13] fix(cf13): reject unknown gate evidence fields --- crates/commandf-pkg/src/gate_model.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/commandf-pkg/src/gate_model.rs b/crates/commandf-pkg/src/gate_model.rs index 967e344d..27375767 100644 --- a/crates/commandf-pkg/src/gate_model.rs +++ b/crates/commandf-pkg/src/gate_model.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::{CheckReport, PackageEvidence}; #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FindingFingerprint { pub schema: u32, pub digest: String, @@ -13,6 +14,7 @@ impl FindingFingerprint { } #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GateSuppression { pub finding_fingerprint: FindingFingerprint, pub rationale: String, @@ -21,6 +23,7 @@ pub struct GateSuppression { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GateSuppressions { pub schema: u32, pub suppressions: Vec, @@ -49,6 +52,7 @@ pub enum QualityGateDisposition { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct QualityGateFinding { pub finding_index: usize, pub fingerprint: FindingFingerprint, @@ -58,6 +62,7 @@ pub struct QualityGateFinding { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct QualityGateBaselineEvidence { pub canonical_sha256: String, pub fingerprint_schema: u32, @@ -70,6 +75,7 @@ pub struct QualityGateBaselineEvidence { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct QualityGateSuppressionEvidence { pub canonical_sha256: String, pub schema: u32, @@ -79,6 +85,7 @@ pub struct QualityGateSuppressionEvidence { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct QualityGateDecision { pub passed: bool, pub total_findings: usize, @@ -94,6 +101,7 @@ pub struct QualityGateDecision { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct QualityGateReport { pub schema: u32, pub current: CheckReport, From 8bc855945d2c119e61cb136ea948a28b3bc91ea0 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:54:52 +0300 Subject: [PATCH 12/13] test(cf13): reject unknown gate policy fields --- .../tests/quality_gate_validation.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/commandf-pkg/tests/quality_gate_validation.rs b/crates/commandf-pkg/tests/quality_gate_validation.rs index 328d211a..7415c7f8 100644 --- a/crates/commandf-pkg/tests/quality_gate_validation.rs +++ b/crates/commandf-pkg/tests/quality_gate_validation.rs @@ -179,3 +179,36 @@ fn persisted_report_decoder_rejects_unknown_disposition_value() { assert!(QualityGateReport::from_json_slice(tampered.as_bytes()).is_err()); } + +#[test] +fn suppression_decoder_rejects_unknown_policy_fields() { + let payload = json!({ + "schema": 1, + "suppressions": [{ + "finding_fingerprint": { + "schema": 1, + "digest": fingerprint().digest, + "algorithm": "sha256" + }, + "rationale": "approved", + "expires_at": "2099-01-01T00:00:00Z" + }] + }); + let bytes = serde_json::to_vec(&payload).unwrap(); + + assert!(GateSuppressions::from_json_slice(&bytes).is_err()); +} + +#[test] +fn persisted_report_decoder_rejects_unknown_gate_fields() { + let current = check("1.0.0", "1.1.0"); + let report = evaluate_quality_gate(¤t, None, None).unwrap(); + let mut value = serde_json::to_value(&report).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("future_policy".to_owned(), json!(true)); + let bytes = serde_json::to_vec(&value).unwrap(); + + assert!(QualityGateReport::from_json_slice(&bytes).is_err()); +} From 8bdca1bc66539058310249f5841ece9fca2a437a Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 12:57:50 +0300 Subject: [PATCH 13/13] test(cf13): prove baseline ruleset fails closed --- .../tests/quality_gate_validation.rs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/commandf-pkg/tests/quality_gate_validation.rs b/crates/commandf-pkg/tests/quality_gate_validation.rs index 7415c7f8..3e3184bf 100644 --- a/crates/commandf-pkg/tests/quality_gate_validation.rs +++ b/crates/commandf-pkg/tests/quality_gate_validation.rs @@ -1,9 +1,9 @@ use commandf_pkg::{ evaluate_compatibility_policy, evaluate_quality_gate, finding_fingerprint_v1, - validate_quality_gate_report, CheckPolicy, CompatibilityDirection, CompatibilityFinding, - CompatibilityReport, CompatibilitySeverity, ElementView, FindingFingerprint, GateSuppression, - GateSuppressions, PackageEvidence, QualityGateError, QualityGateReport, ResourceKey, - ResourceKeyKind, StructuralChangeKind, MAX_GATE_SUPPRESSIONS, + validate_quality_gate_report, CheckError, CheckPolicy, CompatibilityDirection, + CompatibilityFinding, CompatibilityReport, CompatibilitySeverity, ElementView, + FindingFingerprint, GateSuppression, GateSuppressions, PackageEvidence, QualityGateError, + QualityGateReport, ResourceKey, ResourceKeyKind, StructuralChangeKind, MAX_GATE_SUPPRESSIONS, MAX_GATE_SUPPRESSION_REFERENCE_CHARS, }; use serde_json::json; @@ -128,6 +128,20 @@ fn suppression_entry_and_reference_bounds_fail_closed() { )); } +#[test] +fn baseline_with_unsupported_ruleset_fails_through_cf05_authority() { + let current = check("1.0.0", "1.1.0"); + let mut baseline = check("0.8.0", "0.9.0"); + baseline.compatibility.ruleset = "cf04-rules-v2".to_owned(); + + assert!(matches!( + evaluate_quality_gate(¤t, Some(&baseline), None), + Err(QualityGateError::Check( + CheckError::UnsupportedCompatibilityRuleset { found, expected } + )) if found == "cf04-rules-v2" && expected == CompatibilityReport::RULESET_V1 + )); +} + #[test] fn persisted_report_rejects_report_and_membership_count_tampering() { let baseline = check("0.8.0", "0.9.0");