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 {
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}