Skip to main content

canic_host/policy_gate/
mod.rs

1//! Passive CI policy gates over stable evidence envelopes.
2
3use crate::build_provenance::{
4    ArtifactProvenanceKindV1, BUILD_PROVENANCE_SCHEMA_ID, BuildProvenanceV1, SourceDirtyPolicyV1,
5};
6use crate::evidence_envelope::{
7    EvidenceEnvelopeV1, EvidenceSummaryV1, EvidenceTargetV1, ExitClassV1, InputFingerprintV1,
8    PayloadSchemaRefV1, PayloadSchemaStabilityV1, combine_exit_classes, evidence_envelope_schema,
9    file_input_fingerprint, project_evidence_manifest_schema,
10};
11use serde::{Deserialize, Serialize, de};
12use std::{
13    collections::{BTreeMap, BTreeSet},
14    fs,
15    path::{Component, Path, PathBuf},
16};
17use thiserror::Error as ThisError;
18
19///
20/// PolicyGateError
21///
22#[derive(Debug, ThisError)]
23pub enum PolicyGateError {
24    #[error("invalid policy: {0}")]
25    InvalidPolicy(String),
26
27    #[error("failed to parse policy TOML: {0}")]
28    Toml(#[from] toml::de::Error),
29
30    #[error("failed to parse evidence envelope JSON: {0}")]
31    Json(#[from] serde_json::Error),
32
33    #[error("failed to fingerprint policy input: {0}")]
34    Io(#[from] std::io::Error),
35}
36
37///
38/// CiPolicyV1
39///
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
41#[serde(deny_unknown_fields)]
42pub struct CiPolicyV1 {
43    pub schema_version: u32,
44    pub envelope: PolicyEnvelopeRulesV1,
45    pub exit_class: PolicyExitClassRulesV1,
46    pub summary: Option<PolicySummaryRulesV1>,
47    pub build_provenance: Option<PolicyBuildProvenanceRulesV1>,
48    #[serde(default)]
49    pub required_input: Vec<PolicyRequiredInputRuleV1>,
50}
51
52///
53/// PolicyEnvelopeRulesV1
54///
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
56#[serde(deny_unknown_fields)]
57pub struct PolicyEnvelopeRulesV1 {
58    pub required_schema: String,
59    pub allowed_payload_schemas: Option<Vec<String>>,
60    pub allowed_payload_stability: Option<Vec<PayloadSchemaStabilityV1>>,
61}
62
63///
64/// PolicyExitClassRulesV1
65///
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
67#[serde(deny_unknown_fields)]
68pub struct PolicyExitClassRulesV1 {
69    pub allowed: Vec<ExitClassV1>,
70}
71
72///
73/// PolicySummaryRulesV1
74///
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
76#[serde(deny_unknown_fields)]
77pub struct PolicySummaryRulesV1 {
78    #[serde(default)]
79    pub fail_on_evidence_conflicts: bool,
80    #[serde(default)]
81    pub fail_on_blocked_actions: bool,
82    pub allow_missing_or_stale_evidence: Option<bool>,
83}
84
85///
86/// PolicyBuildProvenanceRulesV1
87///
88#[derive(Clone, Debug, Default, Eq, PartialEq)]
89pub struct PolicyBuildProvenanceRulesV1 {
90    rules: Vec<PolicyBuildProvenanceRuleV1>,
91}
92
93///
94/// PolicyBuildProvenanceRuleV1
95///
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97enum PolicyBuildProvenanceRuleV1 {
98    CleanSource,
99    CargoLock,
100    WasmGzip,
101    Sha256,
102    PackageIdentityMatchesTarget,
103}
104
105impl PolicyBuildProvenanceRulesV1 {
106    fn is_enabled(&self, rule: PolicyBuildProvenanceRuleV1) -> bool {
107        self.rules.contains(&rule)
108    }
109}
110
111impl<'de> Deserialize<'de> for PolicyBuildProvenanceRulesV1 {
112    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113    where
114        D: de::Deserializer<'de>,
115    {
116        const FIELDS: &[&str] = &[
117            "require_clean_source",
118            "require_cargo_lock",
119            "require_wasm_gzip",
120            "require_sha256",
121            "require_package_identity_matches_target",
122        ];
123        let values = BTreeMap::<String, bool>::deserialize(deserializer)?;
124        let mut rules = Vec::new();
125        for (key, enabled) in values {
126            let rule = match key.as_str() {
127                "require_clean_source" => PolicyBuildProvenanceRuleV1::CleanSource,
128                "require_cargo_lock" => PolicyBuildProvenanceRuleV1::CargoLock,
129                "require_wasm_gzip" => PolicyBuildProvenanceRuleV1::WasmGzip,
130                "require_sha256" => PolicyBuildProvenanceRuleV1::Sha256,
131                "require_package_identity_matches_target" => {
132                    PolicyBuildProvenanceRuleV1::PackageIdentityMatchesTarget
133                }
134                unknown => return Err(de::Error::unknown_field(unknown, FIELDS)),
135            };
136            if enabled {
137                rules.push(rule);
138            }
139        }
140        Ok(Self { rules })
141    }
142}
143
144///
145/// PolicyRequiredInputRuleV1
146///
147#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
148#[serde(deny_unknown_fields)]
149pub struct PolicyRequiredInputRuleV1 {
150    pub kind: String,
151    pub schema: Option<String>,
152}
153
154///
155/// PolicyGateRequest
156///
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct PolicyGateRequest<'a> {
159    pub policy_source: &'a str,
160    pub policy_path: &'a Path,
161    pub envelope_path: &'a Path,
162    pub fingerprint_root: &'a Path,
163    pub envelope: EvidenceEnvelopeV1,
164}
165
166///
167/// ProjectEvidenceManifestGateRequest
168///
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct ProjectEvidenceManifestGateRequest<'a> {
171    pub policy_source: &'a str,
172    pub policy_path: &'a Path,
173    pub manifest_source: &'a str,
174    pub manifest_path: &'a Path,
175    pub fingerprint_root: &'a Path,
176}
177
178///
179/// ProjectEvidenceManifestV1
180///
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
182#[serde(deny_unknown_fields)]
183pub struct ProjectEvidenceManifestV1 {
184    pub schema_version: u32,
185    pub project: ProjectEvidenceManifestProjectV1,
186    pub evidence: Vec<ProjectEvidenceManifestEntryV1>,
187}
188
189///
190/// ProjectEvidenceManifestProjectV1
191///
192#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
193#[serde(deny_unknown_fields)]
194pub struct ProjectEvidenceManifestProjectV1 {
195    pub name: String,
196    pub root: String,
197}
198
199///
200/// ProjectEvidenceManifestEntryV1
201///
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
203#[serde(deny_unknown_fields)]
204pub struct ProjectEvidenceManifestEntryV1 {
205    pub kind: String,
206    pub path: String,
207    pub required: bool,
208    pub payload_schema: String,
209    pub target: ProjectEvidenceManifestTargetV1,
210}
211
212///
213/// ProjectEvidenceManifestTargetV1
214///
215#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
216#[serde(deny_unknown_fields)]
217pub struct ProjectEvidenceManifestTargetV1 {
218    pub deployment: Option<String>,
219    pub fleet: Option<String>,
220    pub role: Option<String>,
221    pub profile: Option<String>,
222    pub network: Option<String>,
223}
224
225///
226/// PolicyGateReportV1
227///
228#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
229pub struct PolicyGateReportV1 {
230    pub schema_version: u32,
231    pub policy_schema_version: u32,
232    pub policy_file_fingerprint: InputFingerprintV1,
233    pub evaluated_envelope_fingerprint: InputFingerprintV1,
234    pub evaluated_envelope_exit_class: ExitClassV1,
235    pub evaluated_payload_schema: PayloadSchemaRefV1,
236    pub evaluated_target: EvidenceTargetV1,
237    pub policy_status: PolicyEvaluationStatusV1,
238    pub gate_exit_class: ExitClassV1,
239    pub requirements: Vec<PolicyRequirementV1>,
240    pub findings: Vec<PolicyFindingV1>,
241}
242
243///
244/// ProjectEvidenceGateReportV1
245///
246#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
247pub struct ProjectEvidenceGateReportV1 {
248    pub schema_version: u32,
249    pub manifest_schema_version: u32,
250    pub project_name: String,
251    pub policy_file_fingerprint: InputFingerprintV1,
252    pub manifest_file_fingerprint: InputFingerprintV1,
253    pub policy_status: PolicyEvaluationStatusV1,
254    pub gate_exit_class: ExitClassV1,
255    pub evidence: Vec<ProjectEvidenceGateEntryReportV1>,
256}
257
258///
259/// ProjectEvidenceGateEntryReportV1
260///
261#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
262pub struct ProjectEvidenceGateEntryReportV1 {
263    pub kind: String,
264    pub path: String,
265    pub required: bool,
266    pub expected_payload_schema: String,
267    pub expected_target: ProjectEvidenceManifestTargetV1,
268    pub status: PolicyEvaluationStatusV1,
269    pub gate_exit_class: ExitClassV1,
270    pub evaluated_envelope_fingerprint: Option<InputFingerprintV1>,
271    pub policy_report: Option<PolicyGateReportV1>,
272    pub findings: Vec<PolicyFindingV1>,
273}
274
275///
276/// PolicyRequirementV1
277///
278#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
279pub struct PolicyRequirementV1 {
280    pub requirement_id: String,
281    pub status: PolicyEvaluationStatusV1,
282    pub exit_class: ExitClassV1,
283    pub finding_codes: Vec<String>,
284}
285
286///
287/// PolicyFindingV1
288///
289#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
290pub struct PolicyFindingV1 {
291    pub code: String,
292    pub severity: PolicyFindingSeverityV1,
293    pub message: String,
294    pub requirement_id: Option<String>,
295    pub subject: Option<String>,
296    pub expected: Option<serde_json::Value>,
297    pub actual: Option<serde_json::Value>,
298    pub evidence_path: Option<String>,
299    pub target: Option<EvidenceTargetV1>,
300    pub related_input: Option<String>,
301}
302
303///
304/// PolicyFindingSeverityV1
305///
306#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
307#[serde(rename_all = "snake_case")]
308pub enum PolicyFindingSeverityV1 {
309    Info,
310    Warning,
311    Error,
312}
313
314///
315/// PolicyEvaluationStatusV1
316///
317#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
318#[serde(rename_all = "snake_case")]
319pub enum PolicyEvaluationStatusV1 {
320    Passed,
321    Failed,
322}
323
324pub fn parse_ci_policy_v1(source: &str) -> Result<CiPolicyV1, PolicyGateError> {
325    let policy = toml::from_str::<CiPolicyV1>(source)?;
326    validate_ci_policy_v1(&policy)?;
327    Ok(policy)
328}
329
330pub fn parse_project_evidence_manifest_v1(
331    source: &str,
332) -> Result<ProjectEvidenceManifestV1, PolicyGateError> {
333    let manifest = toml::from_str::<ProjectEvidenceManifestV1>(source)?;
334    validate_project_evidence_manifest_v1(&manifest)?;
335    Ok(manifest)
336}
337
338pub fn evaluate_policy_gate(
339    request: PolicyGateRequest<'_>,
340) -> Result<PolicyGateReportV1, PolicyGateError> {
341    let policy = parse_ci_policy_v1(request.policy_source)?;
342    let policy_file_fingerprint = file_input_fingerprint(
343        "ci_policy",
344        request.policy_path,
345        request.fingerprint_root,
346        None,
347        None,
348    )?;
349    let evaluated_envelope_fingerprint = file_input_fingerprint(
350        "evidence_envelope",
351        request.envelope_path,
352        request.fingerprint_root,
353        Some(evidence_envelope_schema()),
354        None,
355    )?;
356    Ok(evaluate_policy(
357        &policy,
358        policy_file_fingerprint,
359        evaluated_envelope_fingerprint,
360        request.envelope,
361    ))
362}
363
364pub fn evaluate_project_evidence_manifest_gate(
365    request: ProjectEvidenceManifestGateRequest<'_>,
366) -> Result<ProjectEvidenceGateReportV1, PolicyGateError> {
367    let policy = parse_ci_policy_v1(request.policy_source)?;
368    let manifest = parse_project_evidence_manifest_v1(request.manifest_source)?;
369    let policy_file_fingerprint = file_input_fingerprint(
370        "ci_policy",
371        request.policy_path,
372        request.fingerprint_root,
373        None,
374        None,
375    )?;
376    let manifest_file_fingerprint = file_input_fingerprint(
377        "project_evidence_manifest",
378        request.manifest_path,
379        request.fingerprint_root,
380        Some(project_evidence_manifest_schema()),
381        None,
382    )?;
383    let project_root = manifest_project_root(request.manifest_path, &manifest.project.root);
384    let mut evidence = Vec::new();
385
386    for entry in &manifest.evidence {
387        evidence.push(evaluate_manifest_entry(
388            &policy,
389            &policy_file_fingerprint,
390            &project_root,
391            entry,
392        )?);
393    }
394
395    let has_failures = evidence
396        .iter()
397        .any(|entry| entry.status == PolicyEvaluationStatusV1::Failed);
398    let gate_exit_class = combine_exit_classes(evidence.iter().map(|entry| entry.gate_exit_class));
399
400    Ok(ProjectEvidenceGateReportV1 {
401        schema_version: 1,
402        manifest_schema_version: manifest.schema_version,
403        project_name: manifest.project.name,
404        policy_file_fingerprint,
405        manifest_file_fingerprint,
406        policy_status: if has_failures {
407            PolicyEvaluationStatusV1::Failed
408        } else {
409            PolicyEvaluationStatusV1::Passed
410        },
411        gate_exit_class,
412        evidence,
413    })
414}
415
416fn validate_ci_policy_v1(policy: &CiPolicyV1) -> Result<(), PolicyGateError> {
417    if policy.schema_version != 1 {
418        return Err(PolicyGateError::InvalidPolicy(format!(
419            "unsupported schema_version {}; expected 1",
420            policy.schema_version
421        )));
422    }
423    ensure_nonempty("envelope.required_schema", &policy.envelope.required_schema)?;
424    ensure_optional_allow_list(
425        "envelope.allowed_payload_schemas",
426        policy.envelope.allowed_payload_schemas.as_deref(),
427    )?;
428    ensure_optional_allow_list(
429        "envelope.allowed_payload_stability",
430        policy.envelope.allowed_payload_stability.as_deref(),
431    )?;
432    if policy.exit_class.allowed.is_empty() {
433        return Err(PolicyGateError::InvalidPolicy(
434            "exit_class.allowed must not be empty".to_string(),
435        ));
436    }
437    if policy
438        .build_provenance
439        .as_ref()
440        .is_some_and(|rules| rules.rules.is_empty())
441    {
442        return Err(PolicyGateError::InvalidPolicy(
443            "build_provenance must enable at least one rule".to_string(),
444        ));
445    }
446    for (index, rule) in policy.required_input.iter().enumerate() {
447        ensure_nonempty(&format!("required_input[{index}].kind"), &rule.kind)?;
448        if let Some(schema) = &rule.schema {
449            ensure_nonempty(&format!("required_input[{index}].schema"), schema)?;
450        }
451    }
452    Ok(())
453}
454
455fn validate_project_evidence_manifest_v1(
456    manifest: &ProjectEvidenceManifestV1,
457) -> Result<(), PolicyGateError> {
458    if manifest.schema_version != 1 {
459        return Err(PolicyGateError::InvalidPolicy(format!(
460            "unsupported project evidence manifest schema_version {}; expected 1",
461            manifest.schema_version
462        )));
463    }
464    ensure_nonempty("project.name", &manifest.project.name)?;
465    ensure_nonempty("project.root", &manifest.project.root)?;
466    if manifest.evidence.is_empty() {
467        return Err(PolicyGateError::InvalidPolicy(
468            "evidence must not be empty".to_string(),
469        ));
470    }
471    let mut seen_paths = BTreeSet::new();
472    for (index, entry) in manifest.evidence.iter().enumerate() {
473        ensure_nonempty(&format!("evidence[{index}].kind"), &entry.kind)?;
474        ensure_nonempty(&format!("evidence[{index}].path"), &entry.path)?;
475        let path_key = manifest_evidence_path_key(&entry.path);
476        if !seen_paths.insert(path_key.clone()) {
477            return Err(PolicyGateError::InvalidPolicy(format!(
478                "evidence[{index}].path duplicates an earlier evidence path after normalization: {path_key}"
479            )));
480        }
481        ensure_nonempty(
482            &format!("evidence[{index}].payload_schema"),
483            &entry.payload_schema,
484        )?;
485        if !entry.target.has_selector() {
486            return Err(PolicyGateError::InvalidPolicy(format!(
487                "evidence[{index}].target must include at least one target field"
488            )));
489        }
490    }
491    Ok(())
492}
493
494fn manifest_evidence_path_key(path: &str) -> String {
495    let mut components = Vec::new();
496
497    for component in Path::new(path.trim()).components() {
498        match component {
499            Component::Prefix(prefix) => {
500                components.push(prefix.as_os_str().to_string_lossy().to_string());
501            }
502            Component::RootDir => components.push(String::new()),
503            Component::CurDir => {}
504            Component::ParentDir => {
505                if components
506                    .last()
507                    .is_some_and(|component| !component.is_empty() && component != "..")
508                {
509                    components.pop();
510                } else {
511                    components.push("..".to_string());
512                }
513            }
514            Component::Normal(segment) => {
515                components.push(segment.to_string_lossy().to_string());
516            }
517        }
518    }
519
520    if components.is_empty() {
521        ".".to_string()
522    } else {
523        components.join("/")
524    }
525}
526
527fn ensure_optional_allow_list<T>(field: &str, value: Option<&[T]>) -> Result<(), PolicyGateError> {
528    if value.is_some_and(<[T]>::is_empty) {
529        return Err(PolicyGateError::InvalidPolicy(format!(
530            "{field} must not be empty when present"
531        )));
532    }
533    Ok(())
534}
535
536fn ensure_nonempty(field: &str, value: &str) -> Result<(), PolicyGateError> {
537    if value.trim().is_empty() {
538        return Err(PolicyGateError::InvalidPolicy(format!(
539            "{field} must not be empty"
540        )));
541    }
542    Ok(())
543}
544
545impl ProjectEvidenceManifestTargetV1 {
546    const fn has_selector(&self) -> bool {
547        self.deployment.is_some()
548            || self.fleet.is_some()
549            || self.role.is_some()
550            || self.profile.is_some()
551            || self.network.is_some()
552    }
553
554    fn matches_envelope_target(&self, target: &EvidenceTargetV1) -> bool {
555        self.deployment
556            .as_ref()
557            .is_none_or(|expected| target.deployment.as_ref() == Some(expected))
558            && self
559                .fleet
560                .as_ref()
561                .is_none_or(|expected| target.fleet.as_ref() == Some(expected))
562            && self
563                .role
564                .as_ref()
565                .is_none_or(|expected| target.role.as_ref() == Some(expected))
566            && self
567                .profile
568                .as_ref()
569                .is_none_or(|expected| target.profile.as_ref() == Some(expected))
570            && self
571                .network
572                .as_ref()
573                .is_none_or(|expected| target.network.as_ref() == Some(expected))
574    }
575}
576
577fn evaluate_policy(
578    policy: &CiPolicyV1,
579    policy_file_fingerprint: InputFingerprintV1,
580    evaluated_envelope_fingerprint: InputFingerprintV1,
581    envelope: EvidenceEnvelopeV1,
582) -> PolicyGateReportV1 {
583    let mut builder = PolicyReportBuilder::default();
584    builder.evaluate_envelope_schema(policy, &envelope);
585    builder.evaluate_payload_schema(policy, &envelope);
586    builder.evaluate_payload_stability(policy, &envelope);
587    builder.evaluate_exit_class(policy, &envelope);
588    builder.evaluate_summary(policy.summary.as_ref(), &envelope.summary);
589    builder.evaluate_build_provenance(policy.build_provenance.as_ref(), &envelope);
590    builder.evaluate_required_inputs(&policy.required_input, &envelope.inputs);
591
592    let has_failures = builder
593        .requirements
594        .iter()
595        .any(|requirement| requirement.status == PolicyEvaluationStatusV1::Failed);
596    let gate_exit_class = if has_failures {
597        combine_exit_classes(builder.findings.iter().map(PolicyFindingV1::exit_class))
598    } else if envelope.exit_class == ExitClassV1::SuccessWithWarnings
599        || !envelope.summary.warnings.is_empty()
600        || !envelope.summary.missing_or_stale_evidence.is_empty()
601    {
602        ExitClassV1::SuccessWithWarnings
603    } else {
604        ExitClassV1::Success
605    };
606
607    PolicyGateReportV1 {
608        schema_version: 1,
609        policy_schema_version: policy.schema_version,
610        policy_file_fingerprint,
611        evaluated_envelope_fingerprint,
612        evaluated_envelope_exit_class: envelope.exit_class,
613        evaluated_payload_schema: envelope.payload_schema,
614        evaluated_target: envelope.target,
615        policy_status: if has_failures {
616            PolicyEvaluationStatusV1::Failed
617        } else {
618            PolicyEvaluationStatusV1::Passed
619        },
620        gate_exit_class,
621        requirements: builder.requirements,
622        findings: builder.findings,
623    }
624}
625
626fn evaluate_manifest_entry(
627    policy: &CiPolicyV1,
628    policy_file_fingerprint: &InputFingerprintV1,
629    project_root: &Path,
630    entry: &ProjectEvidenceManifestEntryV1,
631) -> Result<ProjectEvidenceGateEntryReportV1, PolicyGateError> {
632    let evidence_path = resolve_manifest_entry_path(project_root, &entry.path);
633    if !evidence_path.is_file() {
634        return Ok(missing_manifest_entry_report(entry));
635    }
636
637    let envelope_source = fs::read_to_string(&evidence_path)?;
638    let envelope = serde_json::from_str::<EvidenceEnvelopeV1>(&envelope_source)?;
639    let evaluated_envelope_fingerprint = file_input_fingerprint(
640        "evidence_envelope",
641        &evidence_path,
642        project_root,
643        Some(evidence_envelope_schema()),
644        None,
645    )?;
646    let mut policy_report = evaluate_policy(
647        policy,
648        policy_file_fingerprint.clone(),
649        evaluated_envelope_fingerprint.clone(),
650        envelope.clone(),
651    );
652    let mut findings = Vec::new();
653    let mut gate_exit_classes = vec![policy_report.gate_exit_class];
654
655    if envelope.payload_schema.id != entry.payload_schema {
656        let finding = PolicyFindingV1::error(
657            "policy.manifest.payload_schema_mismatch",
658            "manifest evidence payload schema does not match the evaluated envelope",
659            "manifest.evidence.payload_schema",
660            ExitClassV1::BlockedByPolicy,
661        )
662        .expected(serde_json::json!(entry.payload_schema))
663        .actual(serde_json::json!(envelope.payload_schema.id));
664        gate_exit_classes.push(finding.exit_class());
665        findings.push(finding);
666    }
667
668    if !entry.target.matches_envelope_target(&envelope.target) {
669        let finding = PolicyFindingV1::error(
670            "policy.manifest.target_mismatch",
671            "manifest evidence target does not match the evaluated envelope target",
672            "manifest.evidence.target",
673            ExitClassV1::BlockedByPolicy,
674        )
675        .expected(serde_json::json!(entry.target))
676        .actual(serde_json::json!(envelope.target));
677        gate_exit_classes.push(finding.exit_class());
678        findings.push(finding);
679    }
680
681    policy_report.findings.extend(findings.clone());
682    let gate_exit_class = combine_exit_classes(gate_exit_classes);
683    policy_report.gate_exit_class = gate_exit_class;
684    if !findings.is_empty() {
685        policy_report.policy_status = PolicyEvaluationStatusV1::Failed;
686    }
687
688    Ok(ProjectEvidenceGateEntryReportV1 {
689        kind: entry.kind.clone(),
690        path: entry.path.clone(),
691        required: entry.required,
692        expected_payload_schema: entry.payload_schema.clone(),
693        expected_target: entry.target.clone(),
694        status: if policy_report.policy_status == PolicyEvaluationStatusV1::Failed {
695            PolicyEvaluationStatusV1::Failed
696        } else {
697            PolicyEvaluationStatusV1::Passed
698        },
699        gate_exit_class,
700        evaluated_envelope_fingerprint: Some(evaluated_envelope_fingerprint),
701        policy_report: Some(policy_report),
702        findings,
703    })
704}
705
706fn missing_manifest_entry_report(
707    entry: &ProjectEvidenceManifestEntryV1,
708) -> ProjectEvidenceGateEntryReportV1 {
709    let (status, gate_exit_class, findings) = if entry.required {
710        (
711            PolicyEvaluationStatusV1::Failed,
712            ExitClassV1::MissingRequiredEvidence,
713            vec![
714                PolicyFindingV1::error(
715                    "policy.manifest.required_evidence_missing",
716                    "required manifest evidence file is missing",
717                    "manifest.evidence.path",
718                    ExitClassV1::MissingRequiredEvidence,
719                )
720                .expected(serde_json::json!(entry.path)),
721            ],
722        )
723    } else {
724        (
725            PolicyEvaluationStatusV1::Passed,
726            ExitClassV1::SuccessWithWarnings,
727            vec![
728                PolicyFindingV1::warning(
729                    "policy.manifest.optional_evidence_missing",
730                    "optional manifest evidence file is missing",
731                    "manifest.evidence.path",
732                )
733                .expected(serde_json::json!(entry.path)),
734            ],
735        )
736    };
737
738    ProjectEvidenceGateEntryReportV1 {
739        kind: entry.kind.clone(),
740        path: entry.path.clone(),
741        required: entry.required,
742        expected_payload_schema: entry.payload_schema.clone(),
743        expected_target: entry.target.clone(),
744        status,
745        gate_exit_class,
746        evaluated_envelope_fingerprint: None,
747        policy_report: None,
748        findings,
749    }
750}
751
752fn manifest_project_root(manifest_path: &Path, root: &str) -> PathBuf {
753    let root_path = PathBuf::from(root);
754    if root_path.is_absolute() {
755        return root_path;
756    }
757    manifest_path
758        .parent()
759        .unwrap_or_else(|| Path::new("."))
760        .join(root_path)
761}
762
763fn resolve_manifest_entry_path(project_root: &Path, path: &str) -> PathBuf {
764    let path = PathBuf::from(path);
765    if path.is_absolute() {
766        path
767    } else {
768        project_root.join(path)
769    }
770}
771
772#[derive(Default)]
773struct PolicyReportBuilder {
774    requirements: Vec<PolicyRequirementV1>,
775    findings: Vec<PolicyFindingV1>,
776}
777
778impl PolicyReportBuilder {
779    fn evaluate_envelope_schema(&mut self, policy: &CiPolicyV1, envelope: &EvidenceEnvelopeV1) {
780        let requirement_id = "envelope.required_schema";
781        let actual = envelope.envelope_schema.id.clone();
782        if actual == policy.envelope.required_schema {
783            self.pass(requirement_id);
784            return;
785        }
786        self.fail(
787            requirement_id,
788            PolicyFindingV1::error(
789                "policy.envelope_schema.mismatch",
790                format!(
791                    "evidence envelope schema '{}' does not match required schema '{}'",
792                    actual, policy.envelope.required_schema
793                ),
794                requirement_id,
795                ExitClassV1::BlockedByPolicy,
796            )
797            .expected(serde_json::json!(policy.envelope.required_schema))
798            .actual(serde_json::json!(actual)),
799        );
800    }
801
802    fn evaluate_payload_schema(&mut self, policy: &CiPolicyV1, envelope: &EvidenceEnvelopeV1) {
803        let requirement_id = "envelope.allowed_payload_schemas";
804        let Some(allowed) = policy.envelope.allowed_payload_schemas.as_ref() else {
805            return;
806        };
807        let actual = envelope.payload_schema.id.clone();
808        if allowed.contains(&actual) {
809            self.pass(requirement_id);
810            return;
811        }
812        self.fail(
813            requirement_id,
814            PolicyFindingV1::error(
815                "policy.payload_schema.disallowed",
816                format!("payload schema '{actual}' is not allowed by policy"),
817                requirement_id,
818                ExitClassV1::BlockedByPolicy,
819            )
820            .expected(serde_json::json!(allowed))
821            .actual(serde_json::json!(actual)),
822        );
823    }
824
825    fn evaluate_payload_stability(&mut self, policy: &CiPolicyV1, envelope: &EvidenceEnvelopeV1) {
826        let requirement_id = "envelope.allowed_payload_stability";
827        let Some(allowed) = policy.envelope.allowed_payload_stability.as_ref() else {
828            return;
829        };
830        let actual = envelope.payload_schema.stability;
831        if allowed.contains(&actual) {
832            self.pass(requirement_id);
833            return;
834        }
835        self.fail(
836            requirement_id,
837            PolicyFindingV1::error(
838                "policy.payload_stability.disallowed",
839                "payload schema stability is not allowed by policy",
840                requirement_id,
841                ExitClassV1::BlockedByPolicy,
842            )
843            .expected(serde_json::json!(allowed))
844            .actual(serde_json::json!(actual)),
845        );
846    }
847
848    fn evaluate_exit_class(&mut self, policy: &CiPolicyV1, envelope: &EvidenceEnvelopeV1) {
849        let requirement_id = "exit_class.allowed";
850        let actual = envelope.exit_class;
851        if policy.exit_class.allowed.contains(&actual) && is_success_exit_class(actual) {
852            self.pass(requirement_id);
853            return;
854        }
855
856        let exit_class = match actual {
857            ExitClassV1::EvidenceConflict => ExitClassV1::EvidenceConflict,
858            ExitClassV1::MissingRequiredEvidence => ExitClassV1::MissingRequiredEvidence,
859            _ => ExitClassV1::BlockedByPolicy,
860        };
861        self.fail(
862            requirement_id,
863            PolicyFindingV1::error(
864                "policy.exit_class.disallowed",
865                format!("evidence exit class '{actual:?}' is not allowed by policy"),
866                requirement_id,
867                exit_class,
868            )
869            .expected(serde_json::json!(policy.exit_class.allowed))
870            .actual(serde_json::json!(actual)),
871        );
872    }
873
874    fn evaluate_summary(
875        &mut self,
876        policy: Option<&PolicySummaryRulesV1>,
877        summary: &EvidenceSummaryV1,
878    ) {
879        let Some(policy) = policy else {
880            return;
881        };
882
883        if policy.fail_on_evidence_conflicts {
884            let requirement_id = "summary.fail_on_evidence_conflicts";
885            if summary.evidence_conflicts.is_empty() {
886                self.pass(requirement_id);
887            } else {
888                self.fail(
889                    requirement_id,
890                    PolicyFindingV1::error(
891                        "policy.summary.evidence_conflict",
892                        "evidence summary contains conflicts",
893                        requirement_id,
894                        ExitClassV1::EvidenceConflict,
895                    )
896                    .actual(serde_json::json!(message_codes(
897                        &summary.evidence_conflicts
898                    ))),
899                );
900            }
901        }
902
903        if policy.fail_on_blocked_actions {
904            let requirement_id = "summary.fail_on_blocked_actions";
905            if summary.blocked_actions.is_empty() {
906                self.pass(requirement_id);
907            } else {
908                self.fail(
909                    requirement_id,
910                    PolicyFindingV1::error(
911                        "policy.summary.blocked_action",
912                        "evidence summary contains blocked actions",
913                        requirement_id,
914                        ExitClassV1::BlockedByPolicy,
915                    )
916                    .actual(serde_json::json!(message_codes(&summary.blocked_actions))),
917                );
918            }
919        }
920
921        if policy.allow_missing_or_stale_evidence == Some(false) {
922            let requirement_id = "summary.allow_missing_or_stale_evidence";
923            if summary.missing_or_stale_evidence.is_empty() {
924                self.pass(requirement_id);
925            } else {
926                self.fail(
927                    requirement_id,
928                    PolicyFindingV1::error(
929                        "policy.summary.missing_or_stale_evidence",
930                        "evidence summary contains missing or stale evidence",
931                        requirement_id,
932                        ExitClassV1::MissingRequiredEvidence,
933                    )
934                    .actual(serde_json::json!(message_codes(
935                        &summary.missing_or_stale_evidence
936                    ))),
937                );
938            }
939        }
940    }
941
942    fn evaluate_required_inputs(
943        &mut self,
944        rules: &[PolicyRequiredInputRuleV1],
945        inputs: &[InputFingerprintV1],
946    ) {
947        for (index, rule) in rules.iter().enumerate() {
948            let requirement_id = format!("required_input.{index}");
949            let kind_matches = inputs
950                .iter()
951                .filter(|input| input.kind == rule.kind)
952                .collect::<Vec<_>>();
953            let matched = kind_matches.iter().any(|input| {
954                rule.schema.as_ref().is_none_or(|schema| {
955                    input
956                        .schema
957                        .as_ref()
958                        .is_some_and(|input_schema| input_schema.id == *schema)
959                })
960            });
961            if matched {
962                self.pass(&requirement_id);
963                continue;
964            }
965
966            let actual = if kind_matches.is_empty() {
967                serde_json::json!([])
968            } else {
969                serde_json::json!(
970                    kind_matches
971                        .iter()
972                        .map(|input| input.schema.as_ref().map(|schema| schema.id.clone()))
973                        .collect::<Vec<_>>()
974                )
975            };
976            self.fail(
977                &requirement_id,
978                PolicyFindingV1::error(
979                    "policy.required_input.missing",
980                    format!("required input '{}' was not found", rule.kind),
981                    &requirement_id,
982                    ExitClassV1::MissingRequiredEvidence,
983                )
984                .expected(serde_json::json!({
985                    "kind": rule.kind,
986                    "schema": rule.schema,
987                }))
988                .actual(actual),
989            );
990        }
991    }
992
993    fn evaluate_build_provenance(
994        &mut self,
995        rules: Option<&PolicyBuildProvenanceRulesV1>,
996        envelope: &EvidenceEnvelopeV1,
997    ) {
998        let Some(rules) = rules else {
999            return;
1000        };
1001
1002        if envelope.payload_schema.id.as_str() != BUILD_PROVENANCE_SCHEMA_ID {
1003            self.fail_enabled_build_provenance_rules(
1004                rules,
1005                "policy.build_provenance.payload_schema",
1006                "build-provenance policy rules require a canic.build_provenance.v1 payload",
1007                ExitClassV1::BlockedByPolicy,
1008                serde_json::json!(BUILD_PROVENANCE_SCHEMA_ID),
1009                serde_json::json!(envelope.payload_schema.id.clone()),
1010            );
1011            return;
1012        }
1013
1014        let provenance = match serde_json::from_value::<BuildProvenanceV1>(envelope.payload.clone())
1015        {
1016            Ok(provenance) => provenance,
1017            Err(err) => {
1018                self.fail_enabled_build_provenance_rules(
1019                    rules,
1020                    "policy.build_provenance.invalid_payload",
1021                    "build-provenance policy rules could not decode the envelope payload",
1022                    ExitClassV1::BlockedByPolicy,
1023                    serde_json::json!("BuildProvenanceV1"),
1024                    serde_json::json!(err.to_string()),
1025                );
1026                return;
1027            }
1028        };
1029
1030        if rules.is_enabled(PolicyBuildProvenanceRuleV1::CleanSource) {
1031            self.evaluate_clean_source(&provenance);
1032        }
1033        if rules.is_enabled(PolicyBuildProvenanceRuleV1::CargoLock) {
1034            self.evaluate_cargo_lock(&provenance);
1035        }
1036        if rules.is_enabled(PolicyBuildProvenanceRuleV1::WasmGzip) {
1037            self.evaluate_wasm_gzip(&provenance);
1038        }
1039        if rules.is_enabled(PolicyBuildProvenanceRuleV1::Sha256) {
1040            self.evaluate_sha256(&provenance);
1041        }
1042        if rules.is_enabled(PolicyBuildProvenanceRuleV1::PackageIdentityMatchesTarget) {
1043            self.evaluate_package_identity_matches_target(&provenance, &envelope.target);
1044        }
1045    }
1046
1047    fn evaluate_clean_source(&mut self, provenance: &BuildProvenanceV1) {
1048        let requirement_id = "build_provenance.require_clean_source";
1049        if provenance.source.dirty == Some(false)
1050            && provenance.source.dirty_policy == SourceDirtyPolicyV1::Clean
1051        {
1052            self.pass(requirement_id);
1053            return;
1054        }
1055
1056        self.fail(
1057            requirement_id,
1058            PolicyFindingV1::error(
1059                "policy.build_provenance.source_not_clean",
1060                "build provenance does not prove a clean source checkout",
1061                requirement_id,
1062                ExitClassV1::BlockedByPolicy,
1063            )
1064            .expected(serde_json::json!({
1065                "dirty": false,
1066                "dirty_policy": SourceDirtyPolicyV1::Clean,
1067            }))
1068            .actual(serde_json::json!({
1069                "dirty": provenance.source.dirty,
1070                "dirty_policy": provenance.source.dirty_policy,
1071            })),
1072        );
1073    }
1074
1075    fn evaluate_cargo_lock(&mut self, provenance: &BuildProvenanceV1) {
1076        let requirement_id = "build_provenance.require_cargo_lock";
1077        if provenance.cargo.cargo_lock_sha256.is_some() {
1078            self.pass(requirement_id);
1079            return;
1080        }
1081
1082        self.fail(
1083            requirement_id,
1084            PolicyFindingV1::error(
1085                "policy.build_provenance.cargo_lock_missing",
1086                "build provenance does not include Cargo.lock evidence",
1087                requirement_id,
1088                ExitClassV1::MissingRequiredEvidence,
1089            ),
1090        );
1091    }
1092
1093    fn evaluate_wasm_gzip(&mut self, provenance: &BuildProvenanceV1) {
1094        let requirement_id = "build_provenance.require_wasm_gzip";
1095        if provenance
1096            .artifacts
1097            .iter()
1098            .any(|artifact| artifact.artifact_kind == ArtifactProvenanceKindV1::WasmGzip)
1099        {
1100            self.pass(requirement_id);
1101            return;
1102        }
1103
1104        self.fail(
1105            requirement_id,
1106            PolicyFindingV1::error(
1107                "policy.build_provenance.wasm_gzip_missing",
1108                "build provenance does not include a gzip Wasm artifact",
1109                requirement_id,
1110                ExitClassV1::MissingRequiredEvidence,
1111            ),
1112        );
1113    }
1114
1115    fn evaluate_sha256(&mut self, provenance: &BuildProvenanceV1) {
1116        let requirement_id = "build_provenance.require_sha256";
1117        if !provenance.artifacts.is_empty()
1118            && provenance.artifacts.iter().all(|artifact| {
1119                artifact.hash_algorithm == "sha256" && is_sha256_hex(&artifact.sha256)
1120            })
1121        {
1122            self.pass(requirement_id);
1123            return;
1124        }
1125
1126        self.fail(
1127            requirement_id,
1128            PolicyFindingV1::error(
1129                "policy.build_provenance.sha256_missing_or_invalid",
1130                "build provenance has missing or invalid artifact SHA-256 evidence",
1131                requirement_id,
1132                ExitClassV1::MissingRequiredEvidence,
1133            )
1134            .actual(serde_json::json!(
1135                provenance
1136                    .artifacts
1137                    .iter()
1138                    .map(|artifact| serde_json::json!({
1139                        "artifact_kind": artifact.artifact_kind,
1140                        "hash_algorithm": artifact.hash_algorithm,
1141                        "sha256": artifact.sha256,
1142                    }))
1143                    .collect::<Vec<_>>()
1144            )),
1145        );
1146    }
1147
1148    fn evaluate_package_identity_matches_target(
1149        &mut self,
1150        provenance: &BuildProvenanceV1,
1151        target: &EvidenceTargetV1,
1152    ) {
1153        let requirement_id = "build_provenance.require_package_identity_matches_target";
1154        let target_fleet = target.fleet.as_deref();
1155        let target_role = target.role.as_deref();
1156        let package_fleet = provenance.cargo.package_metadata_fleet.as_str();
1157        let package_role = provenance.cargo.package_metadata_role.as_str();
1158
1159        if target_fleet == Some(package_fleet) && target_role == Some(package_role) {
1160            self.pass(requirement_id);
1161            return;
1162        }
1163
1164        let exit_class = if target_fleet.is_none() || target_role.is_none() {
1165            ExitClassV1::MissingRequiredEvidence
1166        } else {
1167            ExitClassV1::BlockedByPolicy
1168        };
1169        self.fail(
1170            requirement_id,
1171            PolicyFindingV1::error(
1172                "policy.build_provenance.package_identity_mismatch",
1173                "build provenance package metadata does not match the envelope target",
1174                requirement_id,
1175                exit_class,
1176            )
1177            .expected(serde_json::json!({
1178                "target_fleet": target_fleet,
1179                "target_role": target_role,
1180            }))
1181            .actual(serde_json::json!({
1182                "package_metadata_fleet": package_fleet,
1183                "package_metadata_role": package_role,
1184            })),
1185        );
1186    }
1187
1188    fn fail_enabled_build_provenance_rules(
1189        &mut self,
1190        rules: &PolicyBuildProvenanceRulesV1,
1191        code: &str,
1192        message: &str,
1193        exit_class: ExitClassV1,
1194        expected: serde_json::Value,
1195        actual: serde_json::Value,
1196    ) {
1197        for requirement_id in build_provenance_requirement_ids(rules) {
1198            self.fail(
1199                requirement_id,
1200                PolicyFindingV1::error(code, message, requirement_id, exit_class)
1201                    .expected(expected.clone())
1202                    .actual(actual.clone()),
1203            );
1204        }
1205    }
1206
1207    fn pass(&mut self, requirement_id: &str) {
1208        self.requirements.push(PolicyRequirementV1 {
1209            requirement_id: requirement_id.to_string(),
1210            status: PolicyEvaluationStatusV1::Passed,
1211            exit_class: ExitClassV1::Success,
1212            finding_codes: Vec::new(),
1213        });
1214    }
1215
1216    fn fail(&mut self, requirement_id: &str, finding: PolicyFindingV1) {
1217        let finding_code = finding.code.clone();
1218        let exit_class = finding.exit_class();
1219        self.findings.push(finding);
1220        self.requirements.push(PolicyRequirementV1 {
1221            requirement_id: requirement_id.to_string(),
1222            status: PolicyEvaluationStatusV1::Failed,
1223            exit_class,
1224            finding_codes: vec![finding_code],
1225        });
1226    }
1227}
1228
1229impl PolicyFindingV1 {
1230    fn error(
1231        code: &str,
1232        message: impl Into<String>,
1233        requirement_id: &str,
1234        exit_class: ExitClassV1,
1235    ) -> Self {
1236        Self {
1237            code: code.to_string(),
1238            severity: PolicyFindingSeverityV1::Error,
1239            message: message.into(),
1240            requirement_id: Some(requirement_id.to_string()),
1241            subject: Some(exit_class_subject(exit_class).to_string()),
1242            expected: None,
1243            actual: None,
1244            evidence_path: None,
1245            target: None,
1246            related_input: None,
1247        }
1248    }
1249
1250    fn warning(code: &str, message: impl Into<String>, requirement_id: &str) -> Self {
1251        Self {
1252            code: code.to_string(),
1253            severity: PolicyFindingSeverityV1::Warning,
1254            message: message.into(),
1255            requirement_id: Some(requirement_id.to_string()),
1256            subject: Some("success_with_warnings".to_string()),
1257            expected: None,
1258            actual: None,
1259            evidence_path: None,
1260            target: None,
1261            related_input: None,
1262        }
1263    }
1264
1265    fn expected(mut self, expected: serde_json::Value) -> Self {
1266        self.expected = Some(expected);
1267        self
1268    }
1269
1270    fn actual(mut self, actual: serde_json::Value) -> Self {
1271        self.actual = Some(actual);
1272        self
1273    }
1274
1275    fn exit_class(&self) -> ExitClassV1 {
1276        match self.subject.as_deref() {
1277            Some("evidence_conflict") => ExitClassV1::EvidenceConflict,
1278            Some("missing_required_evidence") => ExitClassV1::MissingRequiredEvidence,
1279            _ => ExitClassV1::BlockedByPolicy,
1280        }
1281    }
1282}
1283
1284const fn exit_class_subject(exit_class: ExitClassV1) -> &'static str {
1285    match exit_class {
1286        ExitClassV1::EvidenceConflict => "evidence_conflict",
1287        ExitClassV1::MissingRequiredEvidence => "missing_required_evidence",
1288        _ => "blocked_by_policy",
1289    }
1290}
1291
1292const fn is_success_exit_class(exit_class: ExitClassV1) -> bool {
1293    matches!(
1294        exit_class,
1295        ExitClassV1::Success | ExitClassV1::SuccessWithWarnings
1296    )
1297}
1298
1299fn message_codes(messages: &[crate::evidence_envelope::EvidenceMessageV1]) -> Vec<String> {
1300    messages
1301        .iter()
1302        .map(|message| message.code.clone())
1303        .collect()
1304}
1305
1306fn build_provenance_requirement_ids(rules: &PolicyBuildProvenanceRulesV1) -> Vec<&'static str> {
1307    rules
1308        .rules
1309        .iter()
1310        .map(|rule| match rule {
1311            PolicyBuildProvenanceRuleV1::CleanSource => "build_provenance.require_clean_source",
1312            PolicyBuildProvenanceRuleV1::CargoLock => "build_provenance.require_cargo_lock",
1313            PolicyBuildProvenanceRuleV1::WasmGzip => "build_provenance.require_wasm_gzip",
1314            PolicyBuildProvenanceRuleV1::Sha256 => "build_provenance.require_sha256",
1315            PolicyBuildProvenanceRuleV1::PackageIdentityMatchesTarget => {
1316                "build_provenance.require_package_identity_matches_target"
1317            }
1318        })
1319        .collect()
1320}
1321
1322fn is_sha256_hex(value: &str) -> bool {
1323    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
1324}
1325
1326#[cfg(test)]
1327mod tests;