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!( diff --git a/crates/commandf-pkg/src/gate.rs b/crates/commandf-pkg/src/gate.rs new file mode 100644 index 00000000..ec19c77f --- /dev/null +++ b/crates/commandf-pkg/src/gate.rs @@ -0,0 +1,598 @@ +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)) in report + .findings + .iter() + .zip(current_fingerprints.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 } +} 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), +} diff --git a/crates/commandf-pkg/src/gate_model.rs b/crates/commandf-pkg/src/gate_model.rs new file mode 100644 index 00000000..27375767 --- /dev/null +++ b/crates/commandf-pkg/src/gate_model.rs @@ -0,0 +1,129 @@ +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, +} + +impl FindingFingerprint { + pub const SCHEMA_V1: u32 = 1; +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +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)] +#[serde(deny_unknown_fields)] +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)] +#[serde(deny_unknown_fields)] +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)] +#[serde(deny_unknown_fields)] +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)] +#[serde(deny_unknown_fields)] +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)] +#[serde(deny_unknown_fields)] +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)] +#[serde(deny_unknown_fields)] +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) + } +} 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::{ diff --git a/crates/commandf-pkg/tests/quality_gate.rs b/crates/commandf-pkg/tests/quality_gate.rs new file mode 100644 index 00000000..0b8d075a --- /dev/null +++ b/crates/commandf-pkg/tests/quality_gate.rs @@ -0,0 +1,591 @@ +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, + 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 + }) + )); +} 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..3e3184bf --- /dev/null +++ b/crates/commandf-pkg/tests/quality_gate_validation.rs @@ -0,0 +1,228 @@ +use commandf_pkg::{ + evaluate_compatibility_policy, evaluate_quality_gate, finding_fingerprint_v1, + 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; + +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 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"); + 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()); +} + +#[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()); +}