1use super::{
2 ArtifactSourceV1, BuildMaterializationEvidenceV1, BuildMaterializationInputV1,
3 BuildMaterializationResultV1, BuildRecipeIdentityV1, DEPLOYMENT_TRUTH_SCHEMA_VERSION,
4 DeploymentPlanV1, PromotionArtifactIdentityGroupV1, PromotionArtifactIdentityKindV1,
5 PromotionArtifactIdentityReportV1, PromotionArtifactLevelV1, PromotionPlanTransformEvidenceV1,
6 PromotionPlanTransformV1, PromotionReadinessStatusV1, PromotionReadinessV1,
7 RoleArtifactSourceKindV1, RoleArtifactSourceV1, RoleArtifactV1,
8 RolePromotionArtifactIdentityV1, RolePromotionInputV1, RolePromotionPlanTransformV1,
9 RolePromotionReadinessV1, SafetyFindingV1, SafetySeverityV1, stable_json_sha256_hex,
10};
11use std::collections::{BTreeMap, BTreeSet};
12use thiserror::Error as ThisError;
13
14#[derive(Debug, ThisError)]
18pub enum PromotionArtifactSourceError {
19 #[error("promotion artifact source is missing required field: {field}")]
20 MissingRequiredField { field: &'static str },
21 #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
22 InvalidSha256Digest { field: &'static str },
23 #[error("promotion artifact source kind {kind:?} requires a digest pin")]
24 MissingDigestPin { kind: RoleArtifactSourceKindV1 },
25 #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
26 UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
27 #[error(
28 "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
29 )]
30 MissingPreviousReceiptKind,
31}
32
33#[derive(Debug, ThisError)]
37pub enum PromotionReadinessError {
38 #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
39 SchemaVersionMismatch { expected: u32, found: u32 },
40 #[error("promotion readiness is missing required field: {field}")]
41 MissingRequiredField { field: &'static str },
42 #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
43 StatusBlockerMismatch {
44 status: PromotionReadinessStatusV1,
45 blocker_count: usize,
46 },
47 #[error("promotion readiness contains duplicate role: {role}")]
48 DuplicateRole { role: String },
49 #[error("promotion readiness role {role} has inconsistent restage state")]
50 RestageStateMismatch { role: String },
51 #[error("promotion readiness finding in {field} has severity {severity:?}")]
52 FindingSeverityMismatch {
53 field: &'static str,
54 severity: SafetySeverityV1,
55 },
56 #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
57 InvalidSha256Digest { field: &'static str },
58}
59
60#[derive(Debug, ThisError)]
64pub enum PromotionPlanTransformError {
65 #[error("promotion plan transform schema mismatch: expected {expected}, found {found}")]
66 SchemaVersionMismatch { expected: u32, found: u32 },
67 #[error("promotion plan transform is missing required field: {field}")]
68 MissingRequiredField { field: &'static str },
69 #[error("promotion readiness validation failed: {0}")]
70 Readiness(#[from] PromotionReadinessError),
71 #[error("promotion readiness is blocked with {blocker_count} blocker(s)")]
72 ReadinessBlocked { blocker_count: usize },
73 #[error("promotion target plan is missing role: {role}")]
74 TargetRoleMissing { role: String },
75 #[error("promotion transform contains duplicate role: {role}")]
76 DuplicateRole { role: String },
77 #[error("promotion transform promoted plan id mismatch: expected {expected}, found {found}")]
78 PromotedPlanIdMismatch { expected: String, found: String },
79 #[error("promotion transform role {role} is missing from promoted plan")]
80 PromotedRoleMissing { role: String },
81 #[error("promotion transform role {role} has inconsistent field {field}")]
82 RoleStateMismatch { role: String, field: &'static str },
83}
84
85#[derive(Debug, ThisError)]
89pub enum PromotionPlanTransformEvidenceError {
90 #[error(
91 "promotion plan transform evidence schema mismatch: expected {expected}, found {found}"
92 )]
93 SchemaVersionMismatch { expected: u32, found: u32 },
94 #[error("promotion plan transform evidence is missing required field: {field}")]
95 MissingRequiredField { field: &'static str },
96 #[error("promotion plan transform evidence has invalid transform: {0}")]
97 Transform(#[from] PromotionPlanTransformError),
98}
99
100#[derive(Debug, ThisError)]
104pub enum PromotionArtifactIdentityReportError {
105 #[error(
106 "promotion artifact identity report schema mismatch: expected {expected}, found {found}"
107 )]
108 SchemaVersionMismatch { expected: u32, found: u32 },
109 #[error("promotion artifact identity report is missing required field: {field}")]
110 MissingRequiredField { field: &'static str },
111 #[error(
112 "promotion artifact identity report status {status:?} does not match blocker count {blocker_count}"
113 )]
114 StatusBlockerMismatch {
115 status: PromotionReadinessStatusV1,
116 blocker_count: usize,
117 },
118 #[error("promotion artifact identity report contains duplicate role: {role}")]
119 DuplicateRole { role: String },
120 #[error("promotion artifact identity report contains duplicate identity group: {identity_key}")]
121 DuplicateIdentityGroup { identity_key: String },
122 #[error("promotion artifact identity report identity group {identity_key} has no roles")]
123 EmptyIdentityGroup { identity_key: String },
124 #[error("promotion artifact identity report identity group contains unknown role: {role}")]
125 UnknownGroupedRole { role: String },
126 #[error("promotion artifact identity report groups role {role} more than once")]
127 DuplicateGroupedRole { role: String },
128 #[error("promotion artifact identity report does not group role: {role}")]
129 MissingGroupedRole { role: String },
130 #[error(
131 "promotion artifact identity report role {role} belongs to identity group {expected}, found {found}"
132 )]
133 IdentityGroupRoleMismatch {
134 role: String,
135 expected: String,
136 found: String,
137 },
138 #[error(
139 "promotion artifact identity report identity group key mismatch: expected {expected}, found {found}"
140 )]
141 IdentityGroupKeyMismatch { expected: String, found: String },
142 #[error(
143 "promotion artifact identity report field {field} must be a lowercase sha256 hex digest"
144 )]
145 InvalidSha256Digest { field: &'static str },
146 #[error("promotion artifact identity report blocker has severity {severity:?}")]
147 BlockerSeverityMismatch { severity: SafetySeverityV1 },
148}
149
150#[derive(Debug, ThisError)]
154pub enum PromotionMaterializationIdentityError {
155 #[error(
156 "promotion materialization identity schema mismatch: expected {expected}, found {found}"
157 )]
158 SchemaVersionMismatch { expected: u32, found: u32 },
159 #[error("promotion materialization identity is missing required field: {field}")]
160 MissingRequiredField { field: &'static str },
161 #[error(
162 "promotion materialization identity field {field} must be a lowercase sha256 hex digest"
163 )]
164 InvalidSha256Digest { field: &'static str },
165 #[error("promotion materialization identity field {field} is inconsistent")]
166 LinkageMismatch { field: &'static str },
167 #[error(
168 "promotion materialization identity digest mismatch for {field}: expected {expected}, found {found}"
169 )]
170 DigestMismatch {
171 field: &'static str,
172 expected: String,
173 found: String,
174 },
175}
176
177#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct PromotionReadinessRequest {
182 pub readiness_id: String,
183 pub target_plan: DeploymentPlanV1,
184 pub inputs: Vec<RolePromotionInputV1>,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct PromotionPlanTransformRequest {
192 pub promoted_plan_id: String,
193 pub target_plan: DeploymentPlanV1,
194 pub inputs: Vec<RolePromotionInputV1>,
195}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct PromotionPlanTransformEvidenceRequest {
202 pub evidence_id: String,
203 pub generated_at: String,
204 pub transform: PromotionPlanTransformV1,
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct PromotionArtifactIdentityReportRequest {
212 pub report_id: String,
213 pub inputs: Vec<RolePromotionInputV1>,
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct BuildMaterializationEvidenceRequest {
221 pub evidence_id: String,
222 pub recipe: BuildRecipeIdentityV1,
223 pub materialization_input: BuildMaterializationInputV1,
224 pub materialization_result: BuildMaterializationResultV1,
225}
226
227pub fn promoted_deployment_plan_from_inputs(
228 request: &PromotionPlanTransformRequest,
229) -> Result<DeploymentPlanV1, PromotionPlanTransformError> {
230 Ok(promoted_deployment_plan_transform_from_inputs(request)?.promoted_plan)
231}
232
233pub fn promoted_deployment_plan_transform_from_inputs(
234 request: &PromotionPlanTransformRequest,
235) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
236 ensure_transform_field("promoted_plan_id", &request.promoted_plan_id)?;
237 let readiness = promotion_readiness_from_inputs(
238 &request.promoted_plan_id,
239 &request.target_plan,
240 &request.inputs,
241 );
242 validate_promotion_readiness(&readiness)?;
243 if readiness.status == PromotionReadinessStatusV1::Blocked {
244 return Err(PromotionPlanTransformError::ReadinessBlocked {
245 blocker_count: readiness.blockers.len(),
246 });
247 }
248
249 let mut promoted_plan = request.target_plan.clone();
250 promoted_plan.plan_id.clone_from(&request.promoted_plan_id);
251 for input in &request.inputs {
252 let Some(role_artifact) = promoted_plan
253 .role_artifacts
254 .iter_mut()
255 .find(|artifact| artifact.role == input.role)
256 else {
257 return Err(PromotionPlanTransformError::TargetRoleMissing {
258 role: input.role.clone(),
259 });
260 };
261 apply_promotion_input_to_role_artifact(role_artifact, input);
262 }
263 let transform =
264 promotion_plan_transform_from_parts(&request.target_plan, promoted_plan, &request.inputs);
265 validate_promotion_plan_transform(&transform)?;
266 Ok(transform)
267}
268
269pub fn check_promotion_readiness(
270 request: &PromotionReadinessRequest,
271) -> Result<PromotionReadinessV1, PromotionReadinessError> {
272 ensure_readiness_field("readiness_id", &request.readiness_id)?;
273 let readiness = promotion_readiness_from_inputs(
274 &request.readiness_id,
275 &request.target_plan,
276 &request.inputs,
277 );
278 validate_promotion_readiness(&readiness)?;
279 Ok(readiness)
280}
281
282pub fn promotion_artifact_identity_report_from_inputs(
283 request: PromotionArtifactIdentityReportRequest,
284) -> Result<PromotionArtifactIdentityReportV1, PromotionArtifactIdentityReportError> {
285 ensure_identity_report_field("report_id", &request.report_id)?;
286 let report = promotion_artifact_identity_report(&request.report_id, &request.inputs);
287 validate_promotion_artifact_identity_report(&report)?;
288 Ok(report)
289}
290
291#[must_use]
292pub fn promotion_artifact_identity_report(
293 report_id: impl Into<String>,
294 inputs: &[RolePromotionInputV1],
295) -> PromotionArtifactIdentityReportV1 {
296 let mut roles = Vec::with_capacity(inputs.len());
297 let mut blockers = Vec::new();
298 for input in inputs {
299 if let Err(err) = validate_role_artifact_source(&input.source) {
300 blockers.push(promotion_finding(
301 "promotion_artifact_source_invalid",
302 err.to_string(),
303 SafetySeverityV1::HardFailure,
304 &input.role,
305 ));
306 }
307 if input.role != input.source.role {
308 blockers.push(promotion_finding(
309 "promotion_source_role_mismatch",
310 format!(
311 "promotion input role {} does not match artifact source role {}",
312 input.role, input.source.role
313 ),
314 SafetySeverityV1::HardFailure,
315 &input.role,
316 ));
317 }
318 roles.push(role_promotion_artifact_identity(input));
319 }
320
321 PromotionArtifactIdentityReportV1 {
322 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
323 report_id: report_id.into(),
324 status: if blockers.is_empty() {
325 PromotionReadinessStatusV1::Ready
326 } else {
327 PromotionReadinessStatusV1::Blocked
328 },
329 identity_groups: promotion_artifact_identity_groups(&roles),
330 roles,
331 blockers,
332 }
333}
334
335pub fn validate_promotion_artifact_identity_report(
336 report: &PromotionArtifactIdentityReportV1,
337) -> Result<(), PromotionArtifactIdentityReportError> {
338 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
339 return Err(
340 PromotionArtifactIdentityReportError::SchemaVersionMismatch {
341 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
342 found: report.schema_version,
343 },
344 );
345 }
346 ensure_identity_report_field("report_id", &report.report_id)?;
347 ensure_identity_report_status_matches_blockers(report)?;
348 ensure_unique_artifact_identity_roles(&report.roles)?;
349 for role in &report.roles {
350 validate_role_artifact_identity(role)?;
351 }
352 validate_artifact_identity_groups(&report.roles, &report.identity_groups)?;
353 validate_identity_report_blockers(&report.blockers)?;
354 Ok(())
355}
356
357pub fn promotion_plan_transform_evidence(
358 request: PromotionPlanTransformEvidenceRequest,
359) -> Result<PromotionPlanTransformEvidenceV1, PromotionPlanTransformEvidenceError> {
360 ensure_evidence_field("evidence_id", &request.evidence_id)?;
361 ensure_evidence_field("generated_at", &request.generated_at)?;
362 validate_promotion_plan_transform(&request.transform)?;
363 let evidence = PromotionPlanTransformEvidenceV1 {
364 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
365 evidence_id: request.evidence_id,
366 generated_at: request.generated_at,
367 transform: request.transform,
368 };
369 validate_promotion_plan_transform_evidence(&evidence)?;
370 Ok(evidence)
371}
372
373pub fn validate_promotion_plan_transform_evidence(
374 evidence: &PromotionPlanTransformEvidenceV1,
375) -> Result<(), PromotionPlanTransformEvidenceError> {
376 if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
377 return Err(PromotionPlanTransformEvidenceError::SchemaVersionMismatch {
378 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
379 found: evidence.schema_version,
380 });
381 }
382 ensure_evidence_field("evidence_id", &evidence.evidence_id)?;
383 ensure_evidence_field("generated_at", &evidence.generated_at)?;
384 validate_promotion_plan_transform(&evidence.transform)?;
385 Ok(())
386}
387
388pub fn validate_promotion_plan_transform(
389 transform: &PromotionPlanTransformV1,
390) -> Result<(), PromotionPlanTransformError> {
391 if transform.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
392 return Err(PromotionPlanTransformError::SchemaVersionMismatch {
393 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
394 found: transform.schema_version,
395 });
396 }
397 ensure_transform_field("transform_id", &transform.transform_id)?;
398 ensure_transform_field("target_plan_id", &transform.target_plan_id)?;
399 ensure_transform_field("promoted_plan_id", &transform.promoted_plan_id)?;
400 ensure_transform_field("promoted_plan.plan_id", &transform.promoted_plan.plan_id)?;
401 if transform.promoted_plan.plan_id != transform.promoted_plan_id {
402 return Err(PromotionPlanTransformError::PromotedPlanIdMismatch {
403 expected: transform.promoted_plan_id.clone(),
404 found: transform.promoted_plan.plan_id.clone(),
405 });
406 }
407 ensure_unique_transform_roles(&transform.roles)?;
408 for role in &transform.roles {
409 validate_role_plan_transform(role, &transform.promoted_plan)?;
410 }
411 Ok(())
412}
413
414#[must_use]
415pub fn promotion_readiness_from_inputs(
416 readiness_id: impl Into<String>,
417 target_plan: &DeploymentPlanV1,
418 inputs: &[RolePromotionInputV1],
419) -> PromotionReadinessV1 {
420 let mut roles = Vec::with_capacity(inputs.len());
421 let mut blockers = Vec::new();
422 let mut warnings = Vec::new();
423
424 for input in inputs {
425 let target_artifact = target_plan
426 .role_artifacts
427 .iter()
428 .find(|artifact| artifact.role == input.role);
429 let Some(target_artifact) = target_artifact else {
430 blockers.push(promotion_finding(
431 "promotion_target_role_missing",
432 format!("target plan does not contain role {}", input.role),
433 SafetySeverityV1::HardFailure,
434 &input.role,
435 ));
436 continue;
437 };
438
439 let role_readiness = role_promotion_readiness(input, target_artifact);
440 collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
441 roles.push(role_readiness);
442 }
443
444 let status = if blockers.is_empty() {
445 PromotionReadinessStatusV1::Ready
446 } else {
447 PromotionReadinessStatusV1::Blocked
448 };
449
450 PromotionReadinessV1 {
451 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
452 readiness_id: readiness_id.into(),
453 target_plan_id: target_plan.plan_id.clone(),
454 status,
455 roles,
456 blockers,
457 warnings,
458 }
459}
460
461pub fn validate_promotion_readiness(
462 readiness: &PromotionReadinessV1,
463) -> Result<(), PromotionReadinessError> {
464 if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
465 return Err(PromotionReadinessError::SchemaVersionMismatch {
466 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
467 found: readiness.schema_version,
468 });
469 }
470 ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
471 ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
472 ensure_readiness_status_matches_blockers(readiness)?;
473 ensure_unique_readiness_roles(&readiness.roles)?;
474 for role in &readiness.roles {
475 validate_role_readiness(role)?;
476 }
477 validate_readiness_findings(
478 "blockers",
479 &readiness.blockers,
480 SafetySeverityV1::HardFailure,
481 )?;
482 validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
483 Ok(())
484}
485
486pub fn validate_role_artifact_source(
487 source: &RoleArtifactSourceV1,
488) -> Result<(), PromotionArtifactSourceError> {
489 ensure_field("role", &source.role)?;
490 ensure_locator_requirement(source)?;
491 ensure_previous_receipt_requirement(source)?;
492 ensure_digest_requirement(source)?;
493 ensure_optional_sha256(
494 "expected_wasm_sha256",
495 source.expected_wasm_sha256.as_deref(),
496 )?;
497 ensure_optional_sha256(
498 "expected_wasm_gz_sha256",
499 source.expected_wasm_gz_sha256.as_deref(),
500 )?;
501 ensure_optional_sha256(
502 "expected_candid_sha256",
503 source.expected_candid_sha256.as_deref(),
504 )?;
505 ensure_optional_sha256(
506 "expected_canonical_embedded_config_sha256",
507 source.expected_canonical_embedded_config_sha256.as_deref(),
508 )?;
509 Ok(())
510}
511
512pub fn build_materialization_evidence(
513 request: BuildMaterializationEvidenceRequest,
514) -> Result<BuildMaterializationEvidenceV1, PromotionMaterializationIdentityError> {
515 ensure_materialization_field("evidence_id", &request.evidence_id)?;
516 validate_build_recipe_identity(&request.recipe)?;
517 validate_build_materialization_input(&request.materialization_input)?;
518 validate_build_materialization_result(&request.materialization_result)?;
519 let computed_materialization_input_digest =
520 build_materialization_input_digest(&request.materialization_input);
521 let evidence = BuildMaterializationEvidenceV1 {
522 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
523 evidence_id: request.evidence_id,
524 recipe_id_matches_input: request.recipe.recipe_id
525 == request.materialization_input.build_recipe_id,
526 recipe_id_matches_result: request.recipe.recipe_id
527 == request.materialization_result.build_recipe_id,
528 materialization_input_digest_matches_result: computed_materialization_input_digest
529 == request.materialization_result.materialization_input_digest,
530 computed_materialization_input_digest,
531 recipe: request.recipe,
532 materialization_input: request.materialization_input,
533 materialization_result: request.materialization_result,
534 };
535 validate_build_materialization_evidence(&evidence)?;
536 Ok(evidence)
537}
538
539pub fn validate_build_materialization_evidence(
540 evidence: &BuildMaterializationEvidenceV1,
541) -> Result<(), PromotionMaterializationIdentityError> {
542 if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
543 return Err(
544 PromotionMaterializationIdentityError::SchemaVersionMismatch {
545 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
546 found: evidence.schema_version,
547 },
548 );
549 }
550 ensure_materialization_field("evidence_id", &evidence.evidence_id)?;
551 validate_build_recipe_identity(&evidence.recipe)?;
552 validate_build_materialization_input(&evidence.materialization_input)?;
553 validate_build_materialization_result(&evidence.materialization_result)?;
554 ensure_materialization_sha256(
555 "computed_materialization_input_digest",
556 &evidence.computed_materialization_input_digest,
557 )?;
558 ensure_materialization_link(
559 "recipe_id_matches_input",
560 evidence.recipe_id_matches_input
561 == (evidence.recipe.recipe_id == evidence.materialization_input.build_recipe_id),
562 )?;
563 ensure_materialization_link("recipe_id_matches_input", evidence.recipe_id_matches_input)?;
564 ensure_materialization_link(
565 "recipe_id_matches_result",
566 evidence.recipe_id_matches_result
567 == (evidence.recipe.recipe_id == evidence.materialization_result.build_recipe_id),
568 )?;
569 ensure_materialization_link(
570 "recipe_id_matches_result",
571 evidence.recipe_id_matches_result,
572 )?;
573 let computed = build_materialization_input_digest(&evidence.materialization_input);
574 if computed != evidence.computed_materialization_input_digest {
575 return Err(PromotionMaterializationIdentityError::DigestMismatch {
576 field: "computed_materialization_input_digest",
577 expected: computed,
578 found: evidence.computed_materialization_input_digest.clone(),
579 });
580 }
581 ensure_materialization_link(
582 "materialization_input_digest_matches_result",
583 evidence.materialization_input_digest_matches_result
584 == (evidence.computed_materialization_input_digest
585 == evidence.materialization_result.materialization_input_digest),
586 )?;
587 ensure_materialization_link(
588 "materialization_input_digest_matches_result",
589 evidence.materialization_input_digest_matches_result,
590 )?;
591 Ok(())
592}
593
594#[must_use]
595pub fn build_materialization_input_digest(input: &BuildMaterializationInputV1) -> String {
596 stable_json_sha256_hex(input)
597}
598
599pub fn validate_build_recipe_identity(
600 recipe: &BuildRecipeIdentityV1,
601) -> Result<(), PromotionMaterializationIdentityError> {
602 ensure_materialization_field("recipe_id", &recipe.recipe_id)?;
603 ensure_materialization_field("source_revision", &recipe.source_revision)?;
604 ensure_materialization_field("package_or_role_selector", &recipe.package_or_role_selector)?;
605 ensure_materialization_field("cargo_profile", &recipe.cargo_profile)?;
606 ensure_materialization_sha256("cargo_features_digest", &recipe.cargo_features_digest)?;
607 ensure_materialization_sha256("cargo_lock_digest", &recipe.cargo_lock_digest)?;
608 ensure_materialization_field("rust_toolchain", &recipe.rust_toolchain)?;
609 ensure_materialization_field("builder_version", &recipe.builder_version)?;
610 ensure_materialization_field("target_triple", &recipe.target_triple)?;
611 ensure_materialization_field("linker_identity", &recipe.linker_identity)?;
612 ensure_materialization_field("deterministic_build_mode", &recipe.deterministic_build_mode)?;
613 ensure_materialization_field("wasm_opt_version", &recipe.wasm_opt_version)?;
614 ensure_materialization_field("compression_identity", &recipe.compression_identity)?;
615 Ok(())
616}
617
618pub fn validate_build_materialization_input(
619 input: &BuildMaterializationInputV1,
620) -> Result<(), PromotionMaterializationIdentityError> {
621 ensure_materialization_field("materialization_input_id", &input.materialization_input_id)?;
622 ensure_materialization_field("build_recipe_id", &input.build_recipe_id)?;
623 ensure_materialization_sha256(
624 "canonical_embedded_config_sha256",
625 &input.canonical_embedded_config_sha256,
626 )?;
627 ensure_materialization_field("network", &input.network)?;
628 ensure_materialization_field("root_trust_anchor", &input.root_trust_anchor)?;
629 ensure_materialization_field("runtime_variant", &input.runtime_variant)?;
630 Ok(())
631}
632
633pub fn validate_build_materialization_result(
634 result: &BuildMaterializationResultV1,
635) -> Result<(), PromotionMaterializationIdentityError> {
636 ensure_materialization_field(
637 "materialization_result_id",
638 &result.materialization_result_id,
639 )?;
640 ensure_materialization_field("build_recipe_id", &result.build_recipe_id)?;
641 ensure_materialization_sha256(
642 "materialization_input_digest",
643 &result.materialization_input_digest,
644 )?;
645 ensure_materialization_sha256("wasm_sha256", &result.wasm_sha256)?;
646 ensure_materialization_sha256("wasm_gz_sha256", &result.wasm_gz_sha256)?;
647 ensure_materialization_sha256("installed_module_hash", &result.installed_module_hash)?;
648 ensure_materialization_sha256("candid_sha256", &result.candid_sha256)?;
649 Ok(())
650}
651
652fn apply_promotion_input_to_role_artifact(
653 role_artifact: &mut RoleArtifactV1,
654 input: &RolePromotionInputV1,
655) {
656 match input.promotion_level {
657 PromotionArtifactLevelV1::SealedWasm => {
658 role_artifact.source = artifact_source_for_promotion_source(input.source.kind);
659 apply_promotion_source_locator(role_artifact, &input.source);
660 role_artifact
661 .wasm_sha256
662 .clone_from(&input.source.expected_wasm_sha256);
663 role_artifact
664 .wasm_gz_sha256
665 .clone_from(&input.source.expected_wasm_gz_sha256);
666 role_artifact
667 .candid_sha256
668 .clone_from(&input.source.expected_candid_sha256);
669 role_artifact
670 .canonical_embedded_config_sha256
671 .clone_from(&input.source.expected_canonical_embedded_config_sha256);
672 }
673 PromotionArtifactLevelV1::SourceBuild => {}
674 }
675}
676
677const fn artifact_source_for_promotion_source(kind: RoleArtifactSourceKindV1) -> ArtifactSourceV1 {
678 match kind {
679 RoleArtifactSourceKindV1::WorkspacePackage => ArtifactSourceV1::LocalBuild,
680 RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => ArtifactSourceV1::WasmStore,
681 RoleArtifactSourceKindV1::PublishedPackage
682 | RoleArtifactSourceKindV1::LocalWasm
683 | RoleArtifactSourceKindV1::LocalWasmGz
684 | RoleArtifactSourceKindV1::PreviousReceiptArtifact => ArtifactSourceV1::External,
685 }
686}
687
688fn apply_promotion_source_locator(
689 role_artifact: &mut RoleArtifactV1,
690 source: &RoleArtifactSourceV1,
691) {
692 match source.kind {
693 RoleArtifactSourceKindV1::LocalWasm => {
694 role_artifact.wasm_path.clone_from(&source.locator);
695 }
696 RoleArtifactSourceKindV1::LocalWasmGz => {
697 role_artifact.wasm_gz_path.clone_from(&source.locator);
698 }
699 _ => {}
700 }
701}
702
703fn promotion_plan_transform_from_parts(
704 target_plan: &DeploymentPlanV1,
705 promoted_plan: DeploymentPlanV1,
706 inputs: &[RolePromotionInputV1],
707) -> PromotionPlanTransformV1 {
708 let roles = inputs
709 .iter()
710 .filter_map(|input| {
711 let before = target_plan
712 .role_artifacts
713 .iter()
714 .find(|artifact| artifact.role == input.role)?;
715 let after = promoted_plan
716 .role_artifacts
717 .iter()
718 .find(|artifact| artifact.role == input.role)?;
719 Some(role_plan_transform(input, before, after))
720 })
721 .collect();
722
723 PromotionPlanTransformV1 {
724 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
725 transform_id: format!("promotion-transform:{}", promoted_plan.plan_id),
726 target_plan_id: target_plan.plan_id.clone(),
727 promoted_plan_id: promoted_plan.plan_id.clone(),
728 promoted_plan,
729 roles,
730 }
731}
732
733fn role_plan_transform(
734 input: &RolePromotionInputV1,
735 before: &RoleArtifactV1,
736 after: &RoleArtifactV1,
737) -> RolePromotionPlanTransformV1 {
738 RolePromotionPlanTransformV1 {
739 role: input.role.clone(),
740 promotion_level: input.promotion_level,
741 source_kind: input.source.kind,
742 source_locator: input.source.locator.clone(),
743 artifact_source_before: before.source,
744 artifact_source_after: after.source,
745 wasm_sha256_before: before.wasm_sha256.clone(),
746 wasm_sha256_after: after.wasm_sha256.clone(),
747 wasm_gz_sha256_before: before.wasm_gz_sha256.clone(),
748 wasm_gz_sha256_after: after.wasm_gz_sha256.clone(),
749 candid_sha256_before: before.candid_sha256.clone(),
750 candid_sha256_after: after.candid_sha256.clone(),
751 canonical_embedded_config_sha256_before: before.canonical_embedded_config_sha256.clone(),
752 canonical_embedded_config_sha256_after: after.canonical_embedded_config_sha256.clone(),
753 artifact_identity_changed: artifact_identity_changed(before, after),
754 embedded_config_changed: before.canonical_embedded_config_sha256
755 != after.canonical_embedded_config_sha256,
756 target_materialization_preserved: input.promotion_level
757 == PromotionArtifactLevelV1::SourceBuild
758 && role_materialization_identity_matches(before, after),
759 }
760}
761
762fn artifact_identity_changed(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
763 before.source != after.source
764 || before.wasm_path != after.wasm_path
765 || before.wasm_gz_path != after.wasm_gz_path
766 || before.wasm_sha256 != after.wasm_sha256
767 || before.wasm_gz_sha256 != after.wasm_gz_sha256
768 || before.candid_path != after.candid_path
769 || before.candid_sha256 != after.candid_sha256
770}
771
772fn role_materialization_identity_matches(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
773 before.source == after.source
774 && before.wasm_path == after.wasm_path
775 && before.wasm_gz_path == after.wasm_gz_path
776 && before.wasm_sha256 == after.wasm_sha256
777 && before.wasm_gz_sha256 == after.wasm_gz_sha256
778 && before.candid_path == after.candid_path
779 && before.candid_sha256 == after.candid_sha256
780 && before.canonical_embedded_config_sha256 == after.canonical_embedded_config_sha256
781}
782
783fn role_promotion_artifact_identity(
784 input: &RolePromotionInputV1,
785) -> RolePromotionArtifactIdentityV1 {
786 let wasm_sha256 = input.source.expected_wasm_sha256.clone();
787 let wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
788 RolePromotionArtifactIdentityV1 {
789 role: input.role.clone(),
790 promotion_level: input.promotion_level,
791 source_kind: input.source.kind,
792 source_locator: input.source.locator.clone(),
793 identity_kind: promotion_artifact_identity_kind(input.promotion_level, &input.source),
794 digest_pinned: wasm_sha256.is_some() || wasm_gz_sha256.is_some(),
795 wasm_sha256,
796 wasm_gz_sha256,
797 candid_sha256: input.source.expected_candid_sha256.clone(),
798 canonical_embedded_config_sha256: input
799 .source
800 .expected_canonical_embedded_config_sha256
801 .clone(),
802 }
803}
804
805fn promotion_artifact_identity_groups(
806 roles: &[RolePromotionArtifactIdentityV1],
807) -> Vec<PromotionArtifactIdentityGroupV1> {
808 let mut groups = BTreeMap::<String, PromotionArtifactIdentityGroupV1>::new();
809 for role in roles {
810 let identity_key = artifact_identity_key_for_role(role);
811 let group = groups.entry(identity_key.clone()).or_insert_with(|| {
812 PromotionArtifactIdentityGroupV1 {
813 identity_key,
814 identity_kind: role.identity_kind,
815 roles: Vec::new(),
816 source_kinds: Vec::new(),
817 source_locators: Vec::new(),
818 digest_pinned: role.digest_pinned,
819 wasm_sha256: role.wasm_sha256.clone(),
820 wasm_gz_sha256: role.wasm_gz_sha256.clone(),
821 candid_sha256: role.candid_sha256.clone(),
822 canonical_embedded_config_sha256: role.canonical_embedded_config_sha256.clone(),
823 }
824 });
825 if !group.source_kinds.contains(&role.source_kind) {
826 group.source_kinds.push(role.source_kind);
827 }
828 if let Some(locator) = &role.source_locator
829 && !group.source_locators.contains(locator)
830 {
831 group.source_locators.push(locator.clone());
832 }
833 group.roles.push(role.role.clone());
834 }
835 groups.into_values().collect()
836}
837
838const fn promotion_artifact_identity_kind(
839 promotion_level: PromotionArtifactLevelV1,
840 source: &RoleArtifactSourceV1,
841) -> PromotionArtifactIdentityKindV1 {
842 if matches!(promotion_level, PromotionArtifactLevelV1::SourceBuild) {
843 return PromotionArtifactIdentityKindV1::SourceBuild;
844 }
845 match (
846 source.expected_wasm_sha256.is_some(),
847 source.expected_wasm_gz_sha256.is_some(),
848 ) {
849 (true, true) => PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm,
850 (true, false) => PromotionArtifactIdentityKindV1::SealedWasm,
851 (false, true) => PromotionArtifactIdentityKindV1::SealedCompressedWasm,
852 (false, false) => PromotionArtifactIdentityKindV1::Deferred,
853 }
854}
855
856fn artifact_identity_key_for_role(role: &RolePromotionArtifactIdentityV1) -> String {
857 match role.identity_kind {
858 PromotionArtifactIdentityKindV1::SealedWasm
859 | PromotionArtifactIdentityKindV1::SealedCompressedWasm
860 | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
861 role.wasm_sha256.as_deref(),
862 role.wasm_gz_sha256.as_deref(),
863 role.candid_sha256.as_deref(),
864 role.canonical_embedded_config_sha256.as_deref(),
865 ),
866 PromotionArtifactIdentityKindV1::SourceBuild => format!(
867 "source_build:source_kind={:?}:locator={}:candid={}:config={}",
868 role.source_kind,
869 optional_identity_part(role.source_locator.as_deref()),
870 optional_identity_part(role.candid_sha256.as_deref()),
871 optional_identity_part(role.canonical_embedded_config_sha256.as_deref())
872 ),
873 PromotionArtifactIdentityKindV1::Deferred => format!(
874 "deferred:source_kind={:?}:locator={}",
875 role.source_kind,
876 optional_identity_part(role.source_locator.as_deref())
877 ),
878 }
879}
880
881fn artifact_identity_key_for_group(group: &PromotionArtifactIdentityGroupV1) -> String {
882 match group.identity_kind {
883 PromotionArtifactIdentityKindV1::SealedWasm
884 | PromotionArtifactIdentityKindV1::SealedCompressedWasm
885 | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
886 group.wasm_sha256.as_deref(),
887 group.wasm_gz_sha256.as_deref(),
888 group.candid_sha256.as_deref(),
889 group.canonical_embedded_config_sha256.as_deref(),
890 ),
891 PromotionArtifactIdentityKindV1::SourceBuild => format!(
892 "source_build:source_kind={}:locator={}:candid={}:config={}",
893 source_kind_identity_part(single_group_source_kind(group)),
894 optional_identity_part(single_group_source_locator(group)),
895 optional_identity_part(group.candid_sha256.as_deref()),
896 optional_identity_part(group.canonical_embedded_config_sha256.as_deref())
897 ),
898 PromotionArtifactIdentityKindV1::Deferred => format!(
899 "deferred:source_kind={}:locator={}",
900 source_kind_identity_part(single_group_source_kind(group)),
901 optional_identity_part(single_group_source_locator(group))
902 ),
903 }
904}
905
906fn source_kind_identity_part(kind: Option<RoleArtifactSourceKindV1>) -> String {
907 kind.map_or_else(|| "not-recorded".to_string(), |kind| format!("{kind:?}"))
908}
909
910fn single_group_source_kind(
911 group: &PromotionArtifactIdentityGroupV1,
912) -> Option<RoleArtifactSourceKindV1> {
913 group.source_kinds.first().copied()
914}
915
916fn single_group_source_locator(group: &PromotionArtifactIdentityGroupV1) -> Option<&str> {
917 group.source_locators.first().map(String::as_str)
918}
919
920fn sealed_identity_key(
921 wasm_sha256: Option<&str>,
922 wasm_gz_sha256: Option<&str>,
923 candid_sha256: Option<&str>,
924 canonical_embedded_config_sha256: Option<&str>,
925) -> String {
926 format!(
927 "sealed:wasm={}:wasm_gz={}:candid={}:config={}",
928 optional_identity_part(wasm_sha256),
929 optional_identity_part(wasm_gz_sha256),
930 optional_identity_part(candid_sha256),
931 optional_identity_part(canonical_embedded_config_sha256)
932 )
933}
934
935const fn optional_identity_part(value: Option<&str>) -> &str {
936 match value {
937 Some(value) => value,
938 None => "not-recorded",
939 }
940}
941
942fn validate_role_artifact_identity(
943 role: &RolePromotionArtifactIdentityV1,
944) -> Result<(), PromotionArtifactIdentityReportError> {
945 ensure_identity_report_field("role", &role.role)?;
946 ensure_identity_optional_sha256("wasm_sha256", role.wasm_sha256.as_deref())?;
947 ensure_identity_optional_sha256("wasm_gz_sha256", role.wasm_gz_sha256.as_deref())?;
948 ensure_identity_optional_sha256("candid_sha256", role.candid_sha256.as_deref())?;
949 ensure_identity_optional_sha256(
950 "canonical_embedded_config_sha256",
951 role.canonical_embedded_config_sha256.as_deref(),
952 )?;
953 Ok(())
954}
955
956fn validate_artifact_identity_groups(
957 roles: &[RolePromotionArtifactIdentityV1],
958 groups: &[PromotionArtifactIdentityGroupV1],
959) -> Result<(), PromotionArtifactIdentityReportError> {
960 let role_names = roles
961 .iter()
962 .map(|role| role.role.as_str())
963 .collect::<BTreeSet<_>>();
964 let mut grouped_roles = BTreeSet::new();
965 let mut group_keys = BTreeSet::new();
966 for group in groups {
967 validate_artifact_identity_group(group)?;
968 if !group_keys.insert(group.identity_key.as_str()) {
969 return Err(
970 PromotionArtifactIdentityReportError::DuplicateIdentityGroup {
971 identity_key: group.identity_key.clone(),
972 },
973 );
974 }
975 if group.roles.is_empty() {
976 return Err(PromotionArtifactIdentityReportError::EmptyIdentityGroup {
977 identity_key: group.identity_key.clone(),
978 });
979 }
980 for role in &group.roles {
981 if !role_names.contains(role.as_str()) {
982 return Err(PromotionArtifactIdentityReportError::UnknownGroupedRole {
983 role: role.clone(),
984 });
985 }
986 if !grouped_roles.insert(role.as_str()) {
987 return Err(PromotionArtifactIdentityReportError::DuplicateGroupedRole {
988 role: role.clone(),
989 });
990 }
991 let role_identity = roles
992 .iter()
993 .find(|candidate| candidate.role == *role)
994 .expect("known role should be present");
995 let expected = artifact_identity_key_for_role(role_identity);
996 if expected != group.identity_key {
997 return Err(
998 PromotionArtifactIdentityReportError::IdentityGroupRoleMismatch {
999 role: role.clone(),
1000 expected,
1001 found: group.identity_key.clone(),
1002 },
1003 );
1004 }
1005 }
1006 }
1007 for role in roles {
1008 if !grouped_roles.contains(role.role.as_str()) {
1009 return Err(PromotionArtifactIdentityReportError::MissingGroupedRole {
1010 role: role.role.clone(),
1011 });
1012 }
1013 }
1014 Ok(())
1015}
1016
1017fn validate_artifact_identity_group(
1018 group: &PromotionArtifactIdentityGroupV1,
1019) -> Result<(), PromotionArtifactIdentityReportError> {
1020 ensure_identity_report_field("identity_group.identity_key", &group.identity_key)?;
1021 if group.source_kinds.is_empty() {
1022 return Err(PromotionArtifactIdentityReportError::MissingRequiredField {
1023 field: "identity_group.source_kinds",
1024 });
1025 }
1026 ensure_identity_optional_sha256("identity_group.wasm_sha256", group.wasm_sha256.as_deref())?;
1027 ensure_identity_optional_sha256(
1028 "identity_group.wasm_gz_sha256",
1029 group.wasm_gz_sha256.as_deref(),
1030 )?;
1031 ensure_identity_optional_sha256(
1032 "identity_group.candid_sha256",
1033 group.candid_sha256.as_deref(),
1034 )?;
1035 ensure_identity_optional_sha256(
1036 "identity_group.canonical_embedded_config_sha256",
1037 group.canonical_embedded_config_sha256.as_deref(),
1038 )?;
1039 let expected = artifact_identity_key_for_group(group);
1040 if expected != group.identity_key {
1041 return Err(
1042 PromotionArtifactIdentityReportError::IdentityGroupKeyMismatch {
1043 expected,
1044 found: group.identity_key.clone(),
1045 },
1046 );
1047 }
1048 Ok(())
1049}
1050
1051fn validate_role_plan_transform(
1052 role: &RolePromotionPlanTransformV1,
1053 promoted_plan: &DeploymentPlanV1,
1054) -> Result<(), PromotionPlanTransformError> {
1055 ensure_transform_field("role", &role.role)?;
1056 let Some(promoted_role) = promoted_plan
1057 .role_artifacts
1058 .iter()
1059 .find(|artifact| artifact.role == role.role)
1060 else {
1061 return Err(PromotionPlanTransformError::PromotedRoleMissing {
1062 role: role.role.clone(),
1063 });
1064 };
1065 ensure_role_matches_promoted_artifact(role, promoted_role)?;
1066 ensure_role_transform_flags_are_consistent(role)?;
1067 Ok(())
1068}
1069
1070fn ensure_role_matches_promoted_artifact(
1071 role: &RolePromotionPlanTransformV1,
1072 promoted_role: &RoleArtifactV1,
1073) -> Result<(), PromotionPlanTransformError> {
1074 ensure_role_field_matches(
1075 role,
1076 "artifact_source_after",
1077 role.artifact_source_after == promoted_role.source,
1078 )?;
1079 ensure_role_field_matches(
1080 role,
1081 "wasm_sha256_after",
1082 role.wasm_sha256_after == promoted_role.wasm_sha256,
1083 )?;
1084 ensure_role_field_matches(
1085 role,
1086 "wasm_gz_sha256_after",
1087 role.wasm_gz_sha256_after == promoted_role.wasm_gz_sha256,
1088 )?;
1089 ensure_role_field_matches(
1090 role,
1091 "candid_sha256_after",
1092 role.candid_sha256_after == promoted_role.candid_sha256,
1093 )?;
1094 ensure_role_field_matches(
1095 role,
1096 "canonical_embedded_config_sha256_after",
1097 role.canonical_embedded_config_sha256_after
1098 == promoted_role.canonical_embedded_config_sha256,
1099 )
1100}
1101
1102fn ensure_role_transform_flags_are_consistent(
1103 role: &RolePromotionPlanTransformV1,
1104) -> Result<(), PromotionPlanTransformError> {
1105 ensure_role_field_matches(
1106 role,
1107 "artifact_identity_changed",
1108 role.artifact_identity_changed == role_summary_artifact_identity_changed(role),
1109 )?;
1110 ensure_role_field_matches(
1111 role,
1112 "embedded_config_changed",
1113 role.embedded_config_changed
1114 == (role.canonical_embedded_config_sha256_before
1115 != role.canonical_embedded_config_sha256_after),
1116 )?;
1117 if role.target_materialization_preserved {
1118 ensure_role_field_matches(
1119 role,
1120 "target_materialization_preserved",
1121 role.promotion_level == PromotionArtifactLevelV1::SourceBuild
1122 && !role.artifact_identity_changed
1123 && !role.embedded_config_changed,
1124 )?;
1125 }
1126 Ok(())
1127}
1128
1129fn role_summary_artifact_identity_changed(role: &RolePromotionPlanTransformV1) -> bool {
1130 role.artifact_source_before != role.artifact_source_after
1131 || role.wasm_sha256_before != role.wasm_sha256_after
1132 || role.wasm_gz_sha256_before != role.wasm_gz_sha256_after
1133 || role.candid_sha256_before != role.candid_sha256_after
1134}
1135
1136fn ensure_role_field_matches(
1137 role: &RolePromotionPlanTransformV1,
1138 field: &'static str,
1139 matches: bool,
1140) -> Result<(), PromotionPlanTransformError> {
1141 if matches {
1142 Ok(())
1143 } else {
1144 Err(PromotionPlanTransformError::RoleStateMismatch {
1145 role: role.role.clone(),
1146 field,
1147 })
1148 }
1149}
1150
1151fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
1152 ensure_readiness_field("role", &role.role)?;
1153 ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
1154 ensure_readiness_optional_sha256(
1155 "source_wasm_gz_sha256",
1156 role.source_wasm_gz_sha256.as_deref(),
1157 )?;
1158 ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
1159 ensure_readiness_optional_sha256(
1160 "target_wasm_gz_sha256",
1161 role.target_wasm_gz_sha256.as_deref(),
1162 )?;
1163 ensure_readiness_optional_sha256(
1164 "source_canonical_embedded_config_sha256",
1165 role.source_canonical_embedded_config_sha256.as_deref(),
1166 )?;
1167 ensure_readiness_optional_sha256(
1168 "target_canonical_embedded_config_sha256",
1169 role.target_canonical_embedded_config_sha256.as_deref(),
1170 )?;
1171 if role.restage_required != (role.target_store_has_artifact == Some(false)) {
1172 return Err(PromotionReadinessError::RestageStateMismatch {
1173 role: role.role.clone(),
1174 });
1175 }
1176 Ok(())
1177}
1178
1179fn role_promotion_readiness(
1180 input: &RolePromotionInputV1,
1181 target_artifact: &RoleArtifactV1,
1182) -> RolePromotionReadinessV1 {
1183 let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
1184 let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
1185 let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
1186 let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
1187 let byte_identical_wasm =
1188 matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
1189 || {
1190 matching_optional_digest(
1191 source_wasm_gz_sha256.as_ref(),
1192 target_wasm_gz_sha256.as_ref(),
1193 )
1194 },
1195 );
1196 let embedded_config_identical = matching_optional_digest(
1197 input
1198 .source
1199 .expected_canonical_embedded_config_sha256
1200 .as_ref(),
1201 target_artifact.canonical_embedded_config_sha256.as_ref(),
1202 );
1203
1204 RolePromotionReadinessV1 {
1205 role: input.role.clone(),
1206 promotion_level: input.promotion_level,
1207 source_kind: input.source.kind,
1208 source_locator: input.source.locator.clone(),
1209 source_wasm_sha256,
1210 source_wasm_gz_sha256,
1211 target_wasm_sha256,
1212 target_wasm_gz_sha256,
1213 source_canonical_embedded_config_sha256: input
1214 .source
1215 .expected_canonical_embedded_config_sha256
1216 .clone(),
1217 target_canonical_embedded_config_sha256: target_artifact
1218 .canonical_embedded_config_sha256
1219 .clone(),
1220 byte_identical_wasm,
1221 embedded_config_identical,
1222 target_store_has_artifact: input.target_store_has_artifact,
1223 restage_required: input.target_store_has_artifact == Some(false),
1224 }
1225}
1226
1227fn collect_role_findings(
1228 input: &RolePromotionInputV1,
1229 readiness: &RolePromotionReadinessV1,
1230 blockers: &mut Vec<SafetyFindingV1>,
1231 warnings: &mut Vec<SafetyFindingV1>,
1232) {
1233 if let Err(err) = validate_role_artifact_source(&input.source) {
1234 blockers.push(promotion_finding(
1235 "promotion_artifact_source_invalid",
1236 err.to_string(),
1237 SafetySeverityV1::HardFailure,
1238 &input.role,
1239 ));
1240 }
1241
1242 if input.role != input.source.role {
1243 blockers.push(promotion_finding(
1244 "promotion_source_role_mismatch",
1245 format!(
1246 "promotion input role {} does not match artifact source role {}",
1247 input.role, input.source.role
1248 ),
1249 SafetySeverityV1::HardFailure,
1250 &input.role,
1251 ));
1252 }
1253
1254 if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
1255 blockers.push(promotion_finding(
1256 "promotion_wasm_digest_mismatch",
1257 "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
1258 SafetySeverityV1::HardFailure,
1259 &input.role,
1260 ));
1261 }
1262
1263 if input.require_target_embedded_config
1264 && readiness
1265 .target_canonical_embedded_config_sha256
1266 .as_deref()
1267 .is_none_or(str::is_empty)
1268 {
1269 blockers.push(promotion_finding(
1270 "promotion_target_embedded_config_missing",
1271 "promotion requires target canonical embedded config but target plan has no digest",
1272 SafetySeverityV1::HardFailure,
1273 &input.role,
1274 ));
1275 }
1276
1277 if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
1278 && readiness.embedded_config_identical != Some(true)
1279 {
1280 blockers.push(promotion_finding(
1281 "promotion_sealed_wasm_embedded_config_mismatch",
1282 "sealed wasm promotion requires embedded config identity to be acceptable for the target",
1283 SafetySeverityV1::HardFailure,
1284 &input.role,
1285 ));
1286 }
1287
1288 if readiness.restage_required {
1289 warnings.push(promotion_finding(
1290 "promotion_target_store_restage_required",
1291 "target artifact store does not already contain the artifact; restaging is required",
1292 SafetySeverityV1::Warning,
1293 &input.role,
1294 ));
1295 }
1296}
1297
1298fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
1299 match (left.map(String::as_str), right.map(String::as_str)) {
1300 (Some(left), Some(right)) => Some(left == right),
1301 _ => None,
1302 }
1303}
1304
1305fn promotion_finding(
1306 code: impl Into<String>,
1307 message: impl Into<String>,
1308 severity: SafetySeverityV1,
1309 role: &str,
1310) -> SafetyFindingV1 {
1311 SafetyFindingV1 {
1312 code: code.into(),
1313 message: message.into(),
1314 severity,
1315 subject: Some(role.to_string()),
1316 }
1317}
1318
1319fn ensure_locator_requirement(
1320 source: &RoleArtifactSourceV1,
1321) -> Result<(), PromotionArtifactSourceError> {
1322 match source.kind {
1323 RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
1324 _ => ensure_option_field("locator", source.locator.as_deref()),
1325 }
1326}
1327
1328const fn ensure_previous_receipt_requirement(
1329 source: &RoleArtifactSourceV1,
1330) -> Result<(), PromotionArtifactSourceError> {
1331 match (source.kind, source.previous_receipt_kind) {
1332 (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
1333 (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
1334 Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
1335 }
1336 (_, Some(_)) => {
1337 Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
1338 }
1339 (_, None) => Ok(()),
1340 }
1341}
1342
1343const fn ensure_digest_requirement(
1344 source: &RoleArtifactSourceV1,
1345) -> Result<(), PromotionArtifactSourceError> {
1346 let has_digest =
1347 source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
1348 match source.kind {
1349 RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
1350 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
1351 }
1352 RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
1353 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
1354 }
1355 RoleArtifactSourceKindV1::PublishedPackage
1356 | RoleArtifactSourceKindV1::PreviousReceiptArtifact
1357 if !has_digest =>
1358 {
1359 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
1360 }
1361 _ => Ok(()),
1362 }
1363}
1364
1365fn ensure_option_field(
1366 field: &'static str,
1367 value: Option<&str>,
1368) -> Result<(), PromotionArtifactSourceError> {
1369 match value {
1370 Some(value) => ensure_field(field, value),
1371 None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
1372 }
1373}
1374
1375fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
1376 if value.trim().is_empty() {
1377 return Err(PromotionArtifactSourceError::MissingRequiredField { field });
1378 }
1379 Ok(())
1380}
1381
1382fn ensure_optional_sha256(
1383 field: &'static str,
1384 value: Option<&str>,
1385) -> Result<(), PromotionArtifactSourceError> {
1386 let Some(value) = value else {
1387 return Ok(());
1388 };
1389 if is_lower_hex_sha256(value) {
1390 Ok(())
1391 } else {
1392 Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
1393 }
1394}
1395
1396fn is_lower_hex_sha256(value: &str) -> bool {
1397 value.len() == 64
1398 && value
1399 .bytes()
1400 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1401}
1402
1403const fn ensure_readiness_status_matches_blockers(
1404 readiness: &PromotionReadinessV1,
1405) -> Result<(), PromotionReadinessError> {
1406 match (readiness.status, readiness.blockers.is_empty()) {
1407 (PromotionReadinessStatusV1::Ready, false)
1408 | (PromotionReadinessStatusV1::Blocked, true) => {
1409 Err(PromotionReadinessError::StatusBlockerMismatch {
1410 status: readiness.status,
1411 blocker_count: readiness.blockers.len(),
1412 })
1413 }
1414 _ => Ok(()),
1415 }
1416}
1417
1418fn ensure_unique_readiness_roles(
1419 roles: &[RolePromotionReadinessV1],
1420) -> Result<(), PromotionReadinessError> {
1421 let mut seen = std::collections::BTreeSet::new();
1422 for role in roles {
1423 if !seen.insert(role.role.as_str()) {
1424 return Err(PromotionReadinessError::DuplicateRole {
1425 role: role.role.clone(),
1426 });
1427 }
1428 }
1429 Ok(())
1430}
1431
1432fn ensure_unique_transform_roles(
1433 roles: &[RolePromotionPlanTransformV1],
1434) -> Result<(), PromotionPlanTransformError> {
1435 let mut seen = std::collections::BTreeSet::new();
1436 for role in roles {
1437 if !seen.insert(role.role.as_str()) {
1438 return Err(PromotionPlanTransformError::DuplicateRole {
1439 role: role.role.clone(),
1440 });
1441 }
1442 }
1443 Ok(())
1444}
1445
1446const fn ensure_identity_report_status_matches_blockers(
1447 report: &PromotionArtifactIdentityReportV1,
1448) -> Result<(), PromotionArtifactIdentityReportError> {
1449 match (report.status, report.blockers.is_empty()) {
1450 (PromotionReadinessStatusV1::Ready, false)
1451 | (PromotionReadinessStatusV1::Blocked, true) => Err(
1452 PromotionArtifactIdentityReportError::StatusBlockerMismatch {
1453 status: report.status,
1454 blocker_count: report.blockers.len(),
1455 },
1456 ),
1457 _ => Ok(()),
1458 }
1459}
1460
1461fn ensure_unique_artifact_identity_roles(
1462 roles: &[RolePromotionArtifactIdentityV1],
1463) -> Result<(), PromotionArtifactIdentityReportError> {
1464 let mut seen = std::collections::BTreeSet::new();
1465 for role in roles {
1466 if !seen.insert(role.role.as_str()) {
1467 return Err(PromotionArtifactIdentityReportError::DuplicateRole {
1468 role: role.role.clone(),
1469 });
1470 }
1471 }
1472 Ok(())
1473}
1474
1475fn validate_identity_report_blockers(
1476 blockers: &[SafetyFindingV1],
1477) -> Result<(), PromotionArtifactIdentityReportError> {
1478 for blocker in blockers {
1479 ensure_identity_report_field("blocker.code", &blocker.code)?;
1480 ensure_identity_report_field("blocker.message", &blocker.message)?;
1481 if blocker.severity != SafetySeverityV1::HardFailure {
1482 return Err(
1483 PromotionArtifactIdentityReportError::BlockerSeverityMismatch {
1484 severity: blocker.severity,
1485 },
1486 );
1487 }
1488 }
1489 Ok(())
1490}
1491
1492fn validate_readiness_findings(
1493 field: &'static str,
1494 findings: &[SafetyFindingV1],
1495 expected_severity: SafetySeverityV1,
1496) -> Result<(), PromotionReadinessError> {
1497 for finding in findings {
1498 ensure_readiness_field("finding.code", &finding.code)?;
1499 ensure_readiness_field("finding.message", &finding.message)?;
1500 if finding.severity != expected_severity {
1501 return Err(PromotionReadinessError::FindingSeverityMismatch {
1502 field,
1503 severity: finding.severity,
1504 });
1505 }
1506 }
1507 Ok(())
1508}
1509
1510fn ensure_identity_report_field(
1511 field: &'static str,
1512 value: &str,
1513) -> Result<(), PromotionArtifactIdentityReportError> {
1514 if value.trim().is_empty() {
1515 return Err(PromotionArtifactIdentityReportError::MissingRequiredField { field });
1516 }
1517 Ok(())
1518}
1519
1520fn ensure_identity_optional_sha256(
1521 field: &'static str,
1522 value: Option<&str>,
1523) -> Result<(), PromotionArtifactIdentityReportError> {
1524 let Some(value) = value else {
1525 return Ok(());
1526 };
1527 if is_lower_hex_sha256(value) {
1528 Ok(())
1529 } else {
1530 Err(PromotionArtifactIdentityReportError::InvalidSha256Digest { field })
1531 }
1532}
1533
1534fn ensure_materialization_field(
1535 field: &'static str,
1536 value: &str,
1537) -> Result<(), PromotionMaterializationIdentityError> {
1538 if value.trim().is_empty() {
1539 return Err(PromotionMaterializationIdentityError::MissingRequiredField { field });
1540 }
1541 Ok(())
1542}
1543
1544fn ensure_materialization_sha256(
1545 field: &'static str,
1546 value: &str,
1547) -> Result<(), PromotionMaterializationIdentityError> {
1548 ensure_materialization_field(field, value)?;
1549 if is_lower_hex_sha256(value) {
1550 Ok(())
1551 } else {
1552 Err(PromotionMaterializationIdentityError::InvalidSha256Digest { field })
1553 }
1554}
1555
1556const fn ensure_materialization_link(
1557 field: &'static str,
1558 valid: bool,
1559) -> Result<(), PromotionMaterializationIdentityError> {
1560 if valid {
1561 Ok(())
1562 } else {
1563 Err(PromotionMaterializationIdentityError::LinkageMismatch { field })
1564 }
1565}
1566
1567fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
1568 if value.trim().is_empty() {
1569 return Err(PromotionReadinessError::MissingRequiredField { field });
1570 }
1571 Ok(())
1572}
1573
1574fn ensure_readiness_optional_sha256(
1575 field: &'static str,
1576 value: Option<&str>,
1577) -> Result<(), PromotionReadinessError> {
1578 let Some(value) = value else {
1579 return Ok(());
1580 };
1581 if is_lower_hex_sha256(value) {
1582 Ok(())
1583 } else {
1584 Err(PromotionReadinessError::InvalidSha256Digest { field })
1585 }
1586}
1587
1588fn ensure_transform_field(
1589 field: &'static str,
1590 value: &str,
1591) -> Result<(), PromotionPlanTransformError> {
1592 if value.trim().is_empty() {
1593 return Err(PromotionPlanTransformError::MissingRequiredField { field });
1594 }
1595 Ok(())
1596}
1597
1598fn ensure_evidence_field(
1599 field: &'static str,
1600 value: &str,
1601) -> Result<(), PromotionPlanTransformEvidenceError> {
1602 if value.trim().is_empty() {
1603 return Err(PromotionPlanTransformEvidenceError::MissingRequiredField { field });
1604 }
1605 Ok(())
1606}