Skip to main content

canic_host/
policy_gate.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 {
1328    use super::*;
1329    use crate::build_provenance::{
1330        ArtifactProvenanceV1, BuildProvenanceStatusV1, BuildScriptInputStateV1, CargoProvenanceV1,
1331        SourceProvenanceV1, SourceVcsV1,
1332    };
1333    use crate::evidence_envelope::{
1334        CommandProvenanceV1, EvidenceMessageSeverityV1, EvidenceMessageV1, EvidenceTargetKindV1,
1335        InputPathDisplayV1, PayloadSchemaStabilityV1, evidence_envelope_schema,
1336        policy_gate_report_schema,
1337    };
1338    use crate::test_support::temp_dir;
1339    use serde_json::json;
1340    use std::fs;
1341
1342    #[test]
1343    fn policy_parser_accepts_minimal_policy() {
1344        let policy = parse_ci_policy_v1(MINIMAL_POLICY).expect("parse policy");
1345
1346        assert_eq!(policy.schema_version, 1);
1347        assert_eq!(
1348            policy.envelope.required_schema,
1349            "canic.evidence_envelope.v1"
1350        );
1351        assert_eq!(policy.exit_class.allowed, vec![ExitClassV1::Success]);
1352    }
1353
1354    #[test]
1355    fn policy_parser_rejects_unknown_keys_and_empty_allow_lists() {
1356        let unknown = parse_ci_policy_v1(
1357            r#"
1358schema_version = 1
1359unexpected = true
1360
1361[envelope]
1362required_schema = "canic.evidence_envelope.v1"
1363
1364[exit_class]
1365allowed = ["success"]
1366"#,
1367        )
1368        .expect_err("unknown policy keys fail");
1369        assert!(unknown.to_string().contains("failed to parse policy TOML"));
1370
1371        let empty = parse_ci_policy_v1(
1372            r#"
1373schema_version = 1
1374
1375[envelope]
1376required_schema = "canic.evidence_envelope.v1"
1377
1378[exit_class]
1379allowed = []
1380"#,
1381        )
1382        .expect_err("empty allow list fails");
1383        assert!(empty.to_string().contains("exit_class.allowed"));
1384    }
1385
1386    #[test]
1387    fn policy_parser_accepts_build_provenance_rules() {
1388        let policy = parse_ci_policy_v1(BUILD_PROVENANCE_POLICY).expect("parse policy");
1389
1390        let rules = policy
1391            .build_provenance
1392            .expect("build provenance rules present");
1393        assert!(rules.is_enabled(PolicyBuildProvenanceRuleV1::CleanSource));
1394        assert!(rules.is_enabled(PolicyBuildProvenanceRuleV1::CargoLock));
1395        assert!(rules.is_enabled(PolicyBuildProvenanceRuleV1::WasmGzip));
1396        assert!(rules.is_enabled(PolicyBuildProvenanceRuleV1::Sha256));
1397        assert!(rules.is_enabled(PolicyBuildProvenanceRuleV1::PackageIdentityMatchesTarget));
1398    }
1399
1400    #[test]
1401    fn policy_parser_rejects_empty_build_provenance_rules() {
1402        let err = parse_ci_policy_v1(
1403            r#"
1404schema_version = 1
1405
1406[envelope]
1407required_schema = "canic.evidence_envelope.v1"
1408
1409[exit_class]
1410allowed = ["success"]
1411
1412[build_provenance]
1413"#,
1414        )
1415        .expect_err("empty build provenance rules fail");
1416
1417        assert!(err.to_string().contains("build_provenance"));
1418    }
1419
1420    #[test]
1421    fn policy_parser_rejects_unknown_build_provenance_keys() {
1422        let err = parse_ci_policy_v1(
1423            r#"
1424schema_version = 1
1425
1426[envelope]
1427required_schema = "canic.evidence_envelope.v1"
1428
1429[exit_class]
1430allowed = ["success"]
1431
1432[build_provenance]
1433require_magic = true
1434"#,
1435        )
1436        .expect_err("unknown build provenance keys fail");
1437
1438        assert!(err.to_string().contains("failed to parse policy TOML"));
1439    }
1440
1441    #[test]
1442    fn minimal_policy_passes_success_envelope() {
1443        let root = temp_dir("canic-policy-pass");
1444        fs::create_dir_all(&root).expect("create root");
1445        let policy_path = root.join("policy.toml");
1446        let envelope_path = root.join("envelope.json");
1447        fs::write(&policy_path, MINIMAL_POLICY).expect("write policy");
1448        fs::write(&envelope_path, "{}").expect("write envelope placeholder");
1449
1450        let report = evaluate_policy_gate(PolicyGateRequest {
1451            policy_source: MINIMAL_POLICY,
1452            policy_path: &policy_path,
1453            envelope_path: &envelope_path,
1454            fingerprint_root: &root,
1455            envelope: sample_envelope(),
1456        })
1457        .expect("evaluate policy");
1458
1459        fs::remove_dir_all(root).expect("clean");
1460        assert_eq!(report.policy_status, PolicyEvaluationStatusV1::Passed);
1461        assert_eq!(report.gate_exit_class, ExitClassV1::Success);
1462        assert!(report.findings.is_empty());
1463        assert_eq!(
1464            report.evaluated_payload_schema.id,
1465            "canic.build_provenance.v1"
1466        );
1467    }
1468
1469    #[test]
1470    fn policy_rejects_disallowed_exit_class_but_preserves_evaluated_class() {
1471        let mut envelope = sample_envelope();
1472        envelope.exit_class = ExitClassV1::SuccessWithWarnings;
1473
1474        let report = evaluate_policy_for_test(MINIMAL_POLICY, envelope);
1475
1476        assert_eq!(
1477            report.evaluated_envelope_exit_class,
1478            ExitClassV1::SuccessWithWarnings
1479        );
1480        assert_eq!(report.policy_status, PolicyEvaluationStatusV1::Failed);
1481        assert_eq!(report.gate_exit_class, ExitClassV1::BlockedByPolicy);
1482        assert_eq!(report.findings[0].code, "policy.exit_class.disallowed");
1483    }
1484
1485    #[test]
1486    fn policy_accepts_success_with_warnings_when_allowed() {
1487        let mut envelope = sample_envelope();
1488        envelope.exit_class = ExitClassV1::SuccessWithWarnings;
1489        envelope.summary.warnings.push(EvidenceMessageV1::new(
1490            "test.warning",
1491            "warning",
1492            EvidenceMessageSeverityV1::Warning,
1493        ));
1494
1495        let report = evaluate_policy_for_test(
1496            r#"
1497schema_version = 1
1498
1499[envelope]
1500required_schema = "canic.evidence_envelope.v1"
1501
1502[exit_class]
1503allowed = ["success", "success_with_warnings"]
1504"#,
1505            envelope,
1506        );
1507
1508        assert_eq!(report.policy_status, PolicyEvaluationStatusV1::Passed);
1509        assert_eq!(report.gate_exit_class, ExitClassV1::SuccessWithWarnings);
1510    }
1511
1512    #[test]
1513    fn summary_conflicts_and_missing_required_inputs_map_to_policy_exit_classes() {
1514        let mut conflict = sample_envelope();
1515        conflict
1516            .summary
1517            .evidence_conflicts
1518            .push(EvidenceMessageV1::new(
1519                "test.conflict",
1520                "conflict",
1521                EvidenceMessageSeverityV1::Error,
1522            ));
1523        let conflict_report = evaluate_policy_for_test(SUMMARY_POLICY, conflict);
1524
1525        assert_eq!(
1526            conflict_report.gate_exit_class,
1527            ExitClassV1::EvidenceConflict
1528        );
1529        assert_eq!(
1530            conflict_report.findings[0].code,
1531            "policy.summary.evidence_conflict"
1532        );
1533
1534        let missing_report = evaluate_policy_for_test(
1535            r#"
1536schema_version = 1
1537
1538[envelope]
1539required_schema = "canic.evidence_envelope.v1"
1540
1541[exit_class]
1542allowed = ["success"]
1543
1544[[required_input]]
1545kind = "canic_config"
1546schema = "canic.config.toml"
1547"#,
1548            sample_envelope(),
1549        );
1550
1551        assert_eq!(
1552            missing_report.gate_exit_class,
1553            ExitClassV1::MissingRequiredEvidence
1554        );
1555        assert_eq!(
1556            missing_report.findings[0].code,
1557            "policy.required_input.missing"
1558        );
1559    }
1560
1561    #[test]
1562    fn required_input_passes_on_matching_kind_and_schema() {
1563        let mut envelope = sample_envelope();
1564        envelope.inputs.push(InputFingerprintV1 {
1565            kind: "canic_config".to_string(),
1566            path: Some("canic.toml".to_string()),
1567            path_display: InputPathDisplayV1::Relative,
1568            sha256: None,
1569            size_bytes: None,
1570            modified_unix_secs: None,
1571            schema: Some(PayloadSchemaRefV1::stable("canic.config.toml", "1")),
1572            note: None,
1573        });
1574
1575        let report = evaluate_policy_for_test(
1576            r#"
1577schema_version = 1
1578
1579[envelope]
1580required_schema = "canic.evidence_envelope.v1"
1581
1582[exit_class]
1583allowed = ["success"]
1584
1585[[required_input]]
1586kind = "canic_config"
1587schema = "canic.config.toml"
1588"#,
1589            envelope,
1590        );
1591
1592        assert_eq!(report.policy_status, PolicyEvaluationStatusV1::Passed);
1593        assert_eq!(report.gate_exit_class, ExitClassV1::Success);
1594    }
1595
1596    #[test]
1597    fn build_provenance_policy_passes_matching_payload() {
1598        let report = evaluate_policy_for_test(BUILD_PROVENANCE_POLICY, sample_envelope());
1599
1600        assert_eq!(report.policy_status, PolicyEvaluationStatusV1::Passed);
1601        assert_eq!(report.gate_exit_class, ExitClassV1::Success);
1602        assert!(report.findings.is_empty());
1603        assert!(report.requirements.iter().any(
1604            |requirement| requirement.requirement_id == "build_provenance.require_clean_source"
1605        ));
1606    }
1607
1608    #[test]
1609    fn build_provenance_policy_rejects_dirty_or_unknown_source() {
1610        let mut dirty = sample_build_provenance_payload();
1611        dirty.source.dirty = Some(true);
1612        dirty.source.dirty_policy = SourceDirtyPolicyV1::DirtyRecorded;
1613        let dirty_report = evaluate_policy_for_test(
1614            BUILD_PROVENANCE_POLICY,
1615            sample_envelope_with_payload(serde_json::to_value(dirty).expect("payload json")),
1616        );
1617
1618        assert_eq!(dirty_report.gate_exit_class, ExitClassV1::BlockedByPolicy);
1619        assert!(
1620            dirty_report
1621                .findings
1622                .iter()
1623                .any(|finding| finding.code == "policy.build_provenance.source_not_clean")
1624        );
1625
1626        let mut unknown = sample_build_provenance_payload();
1627        unknown.source.vcs = SourceVcsV1::Unknown;
1628        unknown.source.dirty = None;
1629        unknown.source.dirty_policy = SourceDirtyPolicyV1::Unknown;
1630        let unknown_report = evaluate_policy_for_test(
1631            BUILD_PROVENANCE_POLICY,
1632            sample_envelope_with_payload(serde_json::to_value(unknown).expect("payload json")),
1633        );
1634
1635        assert_eq!(unknown_report.gate_exit_class, ExitClassV1::BlockedByPolicy);
1636    }
1637
1638    #[test]
1639    fn build_provenance_policy_requires_cargo_lock_and_gzip_wasm() {
1640        let mut no_lock = sample_build_provenance_payload();
1641        no_lock.cargo.cargo_lock_sha256 = None;
1642        let no_lock_report = evaluate_policy_for_test(
1643            BUILD_PROVENANCE_POLICY,
1644            sample_envelope_with_payload(serde_json::to_value(no_lock).expect("payload json")),
1645        );
1646
1647        assert_eq!(
1648            no_lock_report.gate_exit_class,
1649            ExitClassV1::MissingRequiredEvidence
1650        );
1651        assert!(
1652            no_lock_report
1653                .findings
1654                .iter()
1655                .any(|finding| finding.code == "policy.build_provenance.cargo_lock_missing")
1656        );
1657
1658        let mut no_gzip = sample_build_provenance_payload();
1659        no_gzip
1660            .artifacts
1661            .retain(|artifact| artifact.artifact_kind != ArtifactProvenanceKindV1::WasmGzip);
1662        let no_gzip_report = evaluate_policy_for_test(
1663            BUILD_PROVENANCE_POLICY,
1664            sample_envelope_with_payload(serde_json::to_value(no_gzip).expect("payload json")),
1665        );
1666
1667        assert_eq!(
1668            no_gzip_report.gate_exit_class,
1669            ExitClassV1::MissingRequiredEvidence
1670        );
1671        assert!(
1672            no_gzip_report
1673                .findings
1674                .iter()
1675                .any(|finding| finding.code == "policy.build_provenance.wasm_gzip_missing")
1676        );
1677    }
1678
1679    #[test]
1680    fn build_provenance_policy_requires_sha256_artifact_evidence() {
1681        let mut payload = sample_build_provenance_payload();
1682        payload.artifacts[0].sha256 = "not-a-sha".to_string();
1683        let report = evaluate_policy_for_test(
1684            BUILD_PROVENANCE_POLICY,
1685            sample_envelope_with_payload(serde_json::to_value(payload).expect("payload json")),
1686        );
1687
1688        assert_eq!(report.gate_exit_class, ExitClassV1::MissingRequiredEvidence);
1689        assert!(
1690            report
1691                .findings
1692                .iter()
1693                .any(|finding| finding.code == "policy.build_provenance.sha256_missing_or_invalid")
1694        );
1695    }
1696
1697    #[test]
1698    fn build_provenance_policy_requires_package_identity_to_match_target() {
1699        let mut payload = sample_build_provenance_payload();
1700        payload.cargo.package_metadata_role = "other".to_string();
1701        let report = evaluate_policy_for_test(
1702            BUILD_PROVENANCE_POLICY,
1703            sample_envelope_with_payload(serde_json::to_value(payload).expect("payload json")),
1704        );
1705
1706        assert_eq!(report.gate_exit_class, ExitClassV1::BlockedByPolicy);
1707        assert!(
1708            report
1709                .findings
1710                .iter()
1711                .any(|finding| finding.code == "policy.build_provenance.package_identity_mismatch")
1712        );
1713    }
1714
1715    #[test]
1716    fn build_provenance_policy_rejects_wrong_or_invalid_payload() {
1717        let mut wrong_schema = sample_envelope();
1718        wrong_schema.payload_schema = PayloadSchemaRefV1::stable("canic.adoption_report.v1", "1");
1719        let wrong_schema_report = evaluate_policy_for_test(BUILD_PROVENANCE_POLICY, wrong_schema);
1720
1721        assert_eq!(
1722            wrong_schema_report.gate_exit_class,
1723            ExitClassV1::BlockedByPolicy
1724        );
1725        assert!(
1726            wrong_schema_report
1727                .findings
1728                .iter()
1729                .any(|finding| finding.code == "policy.build_provenance.payload_schema")
1730        );
1731
1732        let invalid_report = evaluate_policy_for_test(
1733            BUILD_PROVENANCE_POLICY,
1734            sample_envelope_with_payload(json!({ "schema_version": 1 })),
1735        );
1736
1737        assert_eq!(invalid_report.gate_exit_class, ExitClassV1::BlockedByPolicy);
1738        assert!(
1739            invalid_report
1740                .findings
1741                .iter()
1742                .any(|finding| finding.code == "policy.build_provenance.invalid_payload")
1743        );
1744    }
1745
1746    #[test]
1747    fn project_evidence_manifest_gate_evaluates_required_envelope() {
1748        let root = temp_dir("canic-policy-manifest-pass");
1749        fs::create_dir_all(&root).expect("create root");
1750        let policy_path = root.join("policy.toml");
1751        let manifest_path = root.join("evidence.toml");
1752        let envelope_path = root.join("build.json");
1753        fs::write(&policy_path, BUILD_PROVENANCE_POLICY).expect("write policy");
1754        fs::write(
1755            &envelope_path,
1756            serde_json::to_vec(&sample_envelope()).expect("encode envelope"),
1757        )
1758        .expect("write envelope");
1759        let manifest_source = sample_manifest_source("build.json", true);
1760        fs::write(&manifest_path, &manifest_source).expect("write manifest");
1761
1762        let report = evaluate_project_evidence_manifest_gate(ProjectEvidenceManifestGateRequest {
1763            policy_source: BUILD_PROVENANCE_POLICY,
1764            policy_path: &policy_path,
1765            manifest_source: &manifest_source,
1766            manifest_path: &manifest_path,
1767            fingerprint_root: &root,
1768        })
1769        .expect("evaluate manifest gate");
1770
1771        fs::remove_dir_all(root).expect("clean");
1772        assert_eq!(report.policy_status, PolicyEvaluationStatusV1::Passed);
1773        assert_eq!(report.gate_exit_class, ExitClassV1::Success);
1774        assert_eq!(report.evidence.len(), 1);
1775        assert_eq!(report.evidence[0].status, PolicyEvaluationStatusV1::Passed);
1776        assert!(report.evidence[0].policy_report.is_some());
1777    }
1778
1779    #[test]
1780    fn project_evidence_manifest_gate_reports_missing_required_and_optional_evidence() {
1781        let required_report = evaluate_manifest_gate_for_test(
1782            &sample_manifest_source("missing.json", true),
1783            BUILD_PROVENANCE_POLICY,
1784        );
1785
1786        assert_eq!(
1787            required_report.gate_exit_class,
1788            ExitClassV1::MissingRequiredEvidence
1789        );
1790        assert_eq!(
1791            required_report.evidence[0].status,
1792            PolicyEvaluationStatusV1::Failed
1793        );
1794        assert_eq!(
1795            required_report.evidence[0].findings[0].code,
1796            "policy.manifest.required_evidence_missing"
1797        );
1798
1799        let optional_report = evaluate_manifest_gate_for_test(
1800            &sample_manifest_source("missing.json", false),
1801            BUILD_PROVENANCE_POLICY,
1802        );
1803
1804        assert_eq!(
1805            optional_report.gate_exit_class,
1806            ExitClassV1::SuccessWithWarnings
1807        );
1808        assert_eq!(
1809            optional_report.evidence[0].status,
1810            PolicyEvaluationStatusV1::Passed
1811        );
1812        assert_eq!(
1813            optional_report.evidence[0].findings[0].code,
1814            "policy.manifest.optional_evidence_missing"
1815        );
1816    }
1817
1818    #[test]
1819    fn project_evidence_manifest_gate_checks_target_and_payload_schema_expectations() {
1820        let mut wrong_schema = sample_envelope();
1821        wrong_schema.payload_schema = PayloadSchemaRefV1::stable("canic.other.v1", "1");
1822        let wrong_schema_report = evaluate_manifest_gate_with_envelope(
1823            &sample_manifest_source("build.json", true),
1824            wrong_schema,
1825        );
1826
1827        assert_eq!(
1828            wrong_schema_report.gate_exit_class,
1829            ExitClassV1::BlockedByPolicy
1830        );
1831        assert!(
1832            wrong_schema_report.evidence[0]
1833                .findings
1834                .iter()
1835                .any(|finding| finding.code == "policy.manifest.payload_schema_mismatch")
1836        );
1837
1838        let mut wrong_target = sample_envelope();
1839        wrong_target.target.role = Some("other".to_string());
1840        let wrong_target_report = evaluate_manifest_gate_with_envelope(
1841            &sample_manifest_source("build.json", true),
1842            wrong_target,
1843        );
1844
1845        assert_eq!(
1846            wrong_target_report.gate_exit_class,
1847            ExitClassV1::BlockedByPolicy
1848        );
1849        assert!(
1850            wrong_target_report.evidence[0]
1851                .findings
1852                .iter()
1853                .any(|finding| finding.code == "policy.manifest.target_mismatch")
1854        );
1855    }
1856
1857    #[test]
1858    fn project_evidence_manifest_rejects_duplicate_evidence_paths() {
1859        let manifest_source = r#"
1860schema_version = 1
1861
1862[project]
1863name = "demo"
1864root = "."
1865
1866[[evidence]]
1867kind = "build_provenance"
1868path = "build.json"
1869required = true
1870payload_schema = "canic.build_provenance.v1"
1871
1872[evidence.target]
1873fleet = "demo"
1874role = "app"
1875
1876[[evidence]]
1877kind = "deployment_check"
1878path = " ./build.json "
1879required = true
1880payload_schema = "canic.deployment_check.v1"
1881
1882[evidence.target]
1883deployment = "demo-staging"
1884"#;
1885
1886        let error = parse_project_evidence_manifest_v1(manifest_source)
1887            .expect_err("duplicate evidence path should fail");
1888
1889        assert!(matches!(error, PolicyGateError::InvalidPolicy(_)));
1890        assert!(
1891            error
1892                .to_string()
1893                .contains("duplicates an earlier evidence path")
1894        );
1895    }
1896
1897    #[test]
1898    fn policy_gate_report_schema_is_stable() {
1899        assert_eq!(
1900            policy_gate_report_schema(),
1901            PayloadSchemaRefV1 {
1902                id: "canic.policy_gate_report.v1".to_string(),
1903                version: "1".to_string(),
1904                stability: PayloadSchemaStabilityV1::Stable,
1905            }
1906        );
1907    }
1908
1909    fn evaluate_policy_for_test(
1910        policy_source: &str,
1911        envelope: EvidenceEnvelopeV1,
1912    ) -> PolicyGateReportV1 {
1913        let root = temp_dir("canic-policy-test");
1914        fs::create_dir_all(&root).expect("create root");
1915        let policy_path = root.join("policy.toml");
1916        let envelope_path = root.join("envelope.json");
1917        fs::write(&policy_path, policy_source).expect("write policy");
1918        fs::write(&envelope_path, "{}").expect("write envelope placeholder");
1919
1920        let report = evaluate_policy_gate(PolicyGateRequest {
1921            policy_source,
1922            policy_path: &policy_path,
1923            envelope_path: &envelope_path,
1924            fingerprint_root: &root,
1925            envelope,
1926        })
1927        .expect("evaluate policy");
1928
1929        fs::remove_dir_all(root).expect("clean");
1930        report
1931    }
1932
1933    fn evaluate_manifest_gate_for_test(
1934        manifest_source: &str,
1935        policy_source: &str,
1936    ) -> ProjectEvidenceGateReportV1 {
1937        let root = temp_dir("canic-policy-manifest-test");
1938        fs::create_dir_all(&root).expect("create root");
1939        let policy_path = root.join("policy.toml");
1940        let manifest_path = root.join("evidence.toml");
1941        fs::write(&policy_path, policy_source).expect("write policy");
1942        fs::write(&manifest_path, manifest_source).expect("write manifest");
1943
1944        let report = evaluate_project_evidence_manifest_gate(ProjectEvidenceManifestGateRequest {
1945            policy_source,
1946            policy_path: &policy_path,
1947            manifest_source,
1948            manifest_path: &manifest_path,
1949            fingerprint_root: &root,
1950        })
1951        .expect("evaluate manifest gate");
1952
1953        fs::remove_dir_all(root).expect("clean");
1954        report
1955    }
1956
1957    fn evaluate_manifest_gate_with_envelope(
1958        manifest_source: &str,
1959        envelope: EvidenceEnvelopeV1,
1960    ) -> ProjectEvidenceGateReportV1 {
1961        let root = temp_dir("canic-policy-manifest-envelope-test");
1962        fs::create_dir_all(&root).expect("create root");
1963        let policy_path = root.join("policy.toml");
1964        let manifest_path = root.join("evidence.toml");
1965        let envelope_path = root.join("build.json");
1966        fs::write(&policy_path, BUILD_PROVENANCE_POLICY).expect("write policy");
1967        fs::write(&manifest_path, manifest_source).expect("write manifest");
1968        fs::write(
1969            &envelope_path,
1970            serde_json::to_vec(&envelope).expect("encode envelope"),
1971        )
1972        .expect("write envelope");
1973
1974        let report = evaluate_project_evidence_manifest_gate(ProjectEvidenceManifestGateRequest {
1975            policy_source: BUILD_PROVENANCE_POLICY,
1976            policy_path: &policy_path,
1977            manifest_source,
1978            manifest_path: &manifest_path,
1979            fingerprint_root: &root,
1980        })
1981        .expect("evaluate manifest gate");
1982
1983        fs::remove_dir_all(root).expect("clean");
1984        report
1985    }
1986
1987    fn sample_envelope() -> EvidenceEnvelopeV1 {
1988        sample_envelope_with_payload(
1989            serde_json::to_value(sample_build_provenance_payload()).expect("payload json"),
1990        )
1991    }
1992
1993    fn sample_envelope_with_payload(payload: serde_json::Value) -> EvidenceEnvelopeV1 {
1994        EvidenceEnvelopeV1 {
1995            envelope_schema: evidence_envelope_schema(),
1996            canic_version: env!("CARGO_PKG_VERSION").to_string(),
1997            command: CommandProvenanceV1 {
1998                name: "canic build".to_string(),
1999                argv_normalized: Vec::new(),
2000                argv_redactions: Vec::new(),
2001                format: "envelope-json".to_string(),
2002            },
2003            target: EvidenceTargetV1 {
2004                kind: EvidenceTargetKindV1::Artifact,
2005                deployment: None,
2006                fleet: Some("demo".to_string()),
2007                role: Some("app".to_string()),
2008                profile: None,
2009                network: None,
2010            },
2011            generated_at: "unix:1".to_string(),
2012            source_config: None,
2013            inputs: Vec::new(),
2014            payload_schema: PayloadSchemaRefV1::stable("canic.build_provenance.v1", "1"),
2015            payload_sha256: Some("0".repeat(64)),
2016            payload,
2017            summary: EvidenceSummaryV1 {
2018                warnings: Vec::new(),
2019                blocked_actions: Vec::new(),
2020                missing_or_stale_evidence: Vec::new(),
2021                evidence_conflicts: Vec::new(),
2022            },
2023            exit_class: ExitClassV1::Success,
2024        }
2025    }
2026
2027    fn sample_build_provenance_payload() -> BuildProvenanceV1 {
2028        BuildProvenanceV1 {
2029            schema_version: 1,
2030            generated_at: "unix:1".to_string(),
2031            canic_version: env!("CARGO_PKG_VERSION").to_string(),
2032            command: CommandProvenanceV1 {
2033                name: "canic build".to_string(),
2034                argv_normalized: vec![
2035                    "canic".to_string(),
2036                    "build".to_string(),
2037                    "demo".to_string(),
2038                    "app".to_string(),
2039                ],
2040                argv_redactions: Vec::new(),
2041                format: "provenance".to_string(),
2042            },
2043            build_status: BuildProvenanceStatusV1::Success,
2044            source: SourceProvenanceV1 {
2045                schema_version: 1,
2046                vcs: SourceVcsV1::Git,
2047                revision: Some("abc123".to_string()),
2048                branch: Some("main".to_string()),
2049                dirty: Some(false),
2050                dirty_policy: SourceDirtyPolicyV1::Clean,
2051                dirty_summary_digest: None,
2052                dirty_summary_algorithm: None,
2053            },
2054            cargo: CargoProvenanceV1 {
2055                cargo_lock_sha256: Some("1".repeat(64)),
2056                package_manifest_sha256: Some("2".repeat(64)),
2057                package_name: "demo_app".to_string(),
2058                package_manifest: "fleets/demo/app/Cargo.toml".to_string(),
2059                package_metadata_fleet: "demo".to_string(),
2060                package_metadata_role: "app".to_string(),
2061                rustc_version: Some("rustc 1.88.0".to_string()),
2062                cargo_version: Some("cargo 1.88.0".to_string()),
2063                target: Some("wasm32-unknown-unknown".to_string()),
2064                profile: "fast".to_string(),
2065                features: Vec::new(),
2066                default_features: None,
2067                rustflags_digest: None,
2068                rustflags_digest_algorithm: None,
2069                cargo_config_fingerprints: Vec::new(),
2070                build_script_inputs: BuildScriptInputStateV1::NotRecorded,
2071            },
2072            artifacts: vec![
2073                sample_artifact(ArtifactProvenanceKindV1::Wasm, "a"),
2074                sample_artifact(ArtifactProvenanceKindV1::WasmGzip, "b"),
2075            ],
2076            warnings: Vec::new(),
2077        }
2078    }
2079
2080    fn sample_artifact(kind: ArtifactProvenanceKindV1, hash_char: &str) -> ArtifactProvenanceV1 {
2081        ArtifactProvenanceV1 {
2082            role: "app".to_string(),
2083            fleet: "demo".to_string(),
2084            artifact_kind: kind,
2085            path: Some("target/app.wasm.gz".to_string()),
2086            path_display: InputPathDisplayV1::Relative,
2087            hash_algorithm: "sha256".to_string(),
2088            sha256: hash_char.repeat(64),
2089            size_bytes: 123,
2090            produced_by: "canic build".to_string(),
2091        }
2092    }
2093
2094    fn sample_manifest_source(path: &str, required: bool) -> String {
2095        format!(
2096            r#"
2097schema_version = 1
2098
2099[project]
2100name = "demo"
2101root = "."
2102
2103[[evidence]]
2104kind = "build_provenance"
2105path = "{path}"
2106required = {required}
2107payload_schema = "canic.build_provenance.v1"
2108
2109[evidence.target]
2110fleet = "demo"
2111role = "app"
2112"#
2113        )
2114    }
2115
2116    const MINIMAL_POLICY: &str = r#"
2117schema_version = 1
2118
2119[envelope]
2120required_schema = "canic.evidence_envelope.v1"
2121
2122[exit_class]
2123allowed = ["success"]
2124"#;
2125
2126    const SUMMARY_POLICY: &str = r#"
2127schema_version = 1
2128
2129[envelope]
2130required_schema = "canic.evidence_envelope.v1"
2131
2132[exit_class]
2133allowed = ["success", "success_with_warnings"]
2134
2135[summary]
2136fail_on_evidence_conflicts = true
2137fail_on_blocked_actions = true
2138allow_missing_or_stale_evidence = false
2139"#;
2140
2141    const BUILD_PROVENANCE_POLICY: &str = r#"
2142schema_version = 1
2143
2144[envelope]
2145required_schema = "canic.evidence_envelope.v1"
2146
2147[exit_class]
2148allowed = ["success"]
2149
2150[build_provenance]
2151require_clean_source = true
2152require_cargo_lock = true
2153require_wasm_gzip = true
2154require_sha256 = true
2155require_package_identity_matches_target = true
2156"#;
2157}