1use 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#[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#[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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
67#[serde(deny_unknown_fields)]
68pub struct PolicyExitClassRulesV1 {
69 pub allowed: Vec<ExitClassV1>,
70}
71
72#[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
89pub struct PolicyBuildProvenanceRulesV1 {
90 rules: Vec<PolicyBuildProvenanceRuleV1>,
91}
92
93#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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;