1use super::{
2 DEPLOYMENT_TRUTH_SCHEMA_VERSION, DeploymentPlanV1, PromotionArtifactLevelV1,
3 PromotionReadinessStatusV1, PromotionReadinessV1, RoleArtifactSourceKindV1,
4 RoleArtifactSourceV1, RoleArtifactV1, RolePromotionInputV1, RolePromotionReadinessV1,
5 SafetyFindingV1, SafetySeverityV1,
6};
7use thiserror::Error as ThisError;
8
9#[derive(Debug, ThisError)]
13pub enum PromotionArtifactSourceError {
14 #[error("promotion artifact source is missing required field: {field}")]
15 MissingRequiredField { field: &'static str },
16 #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
17 InvalidSha256Digest { field: &'static str },
18 #[error("promotion artifact source kind {kind:?} requires a digest pin")]
19 MissingDigestPin { kind: RoleArtifactSourceKindV1 },
20 #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
21 UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
22 #[error(
23 "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
24 )]
25 MissingPreviousReceiptKind,
26}
27
28#[derive(Debug, ThisError)]
32pub enum PromotionReadinessError {
33 #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
34 SchemaVersionMismatch { expected: u32, found: u32 },
35 #[error("promotion readiness is missing required field: {field}")]
36 MissingRequiredField { field: &'static str },
37 #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
38 StatusBlockerMismatch {
39 status: PromotionReadinessStatusV1,
40 blocker_count: usize,
41 },
42 #[error("promotion readiness contains duplicate role: {role}")]
43 DuplicateRole { role: String },
44 #[error("promotion readiness role {role} has inconsistent restage state")]
45 RestageStateMismatch { role: String },
46 #[error("promotion readiness finding in {field} has severity {severity:?}")]
47 FindingSeverityMismatch {
48 field: &'static str,
49 severity: SafetySeverityV1,
50 },
51 #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
52 InvalidSha256Digest { field: &'static str },
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct PromotionReadinessRequest {
60 pub readiness_id: String,
61 pub target_plan: DeploymentPlanV1,
62 pub inputs: Vec<RolePromotionInputV1>,
63}
64
65pub fn check_promotion_readiness(
66 request: &PromotionReadinessRequest,
67) -> Result<PromotionReadinessV1, PromotionReadinessError> {
68 ensure_readiness_field("readiness_id", &request.readiness_id)?;
69 let readiness = promotion_readiness_from_inputs(
70 &request.readiness_id,
71 &request.target_plan,
72 &request.inputs,
73 );
74 validate_promotion_readiness(&readiness)?;
75 Ok(readiness)
76}
77
78#[must_use]
79pub fn promotion_readiness_from_inputs(
80 readiness_id: impl Into<String>,
81 target_plan: &DeploymentPlanV1,
82 inputs: &[RolePromotionInputV1],
83) -> PromotionReadinessV1 {
84 let mut roles = Vec::with_capacity(inputs.len());
85 let mut blockers = Vec::new();
86 let mut warnings = Vec::new();
87
88 for input in inputs {
89 let target_artifact = target_plan
90 .role_artifacts
91 .iter()
92 .find(|artifact| artifact.role == input.role);
93 let Some(target_artifact) = target_artifact else {
94 blockers.push(promotion_finding(
95 "promotion_target_role_missing",
96 format!("target plan does not contain role {}", input.role),
97 SafetySeverityV1::HardFailure,
98 &input.role,
99 ));
100 continue;
101 };
102
103 let role_readiness = role_promotion_readiness(input, target_artifact);
104 collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
105 roles.push(role_readiness);
106 }
107
108 let status = if blockers.is_empty() {
109 PromotionReadinessStatusV1::Ready
110 } else {
111 PromotionReadinessStatusV1::Blocked
112 };
113
114 PromotionReadinessV1 {
115 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
116 readiness_id: readiness_id.into(),
117 target_plan_id: target_plan.plan_id.clone(),
118 status,
119 roles,
120 blockers,
121 warnings,
122 }
123}
124
125pub fn validate_promotion_readiness(
126 readiness: &PromotionReadinessV1,
127) -> Result<(), PromotionReadinessError> {
128 if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
129 return Err(PromotionReadinessError::SchemaVersionMismatch {
130 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
131 found: readiness.schema_version,
132 });
133 }
134 ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
135 ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
136 ensure_readiness_status_matches_blockers(readiness)?;
137 ensure_unique_readiness_roles(&readiness.roles)?;
138 for role in &readiness.roles {
139 validate_role_readiness(role)?;
140 }
141 validate_readiness_findings(
142 "blockers",
143 &readiness.blockers,
144 SafetySeverityV1::HardFailure,
145 )?;
146 validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
147 Ok(())
148}
149
150pub fn validate_role_artifact_source(
151 source: &RoleArtifactSourceV1,
152) -> Result<(), PromotionArtifactSourceError> {
153 ensure_field("role", &source.role)?;
154 ensure_locator_requirement(source)?;
155 ensure_previous_receipt_requirement(source)?;
156 ensure_digest_requirement(source)?;
157 ensure_optional_sha256(
158 "expected_wasm_sha256",
159 source.expected_wasm_sha256.as_deref(),
160 )?;
161 ensure_optional_sha256(
162 "expected_wasm_gz_sha256",
163 source.expected_wasm_gz_sha256.as_deref(),
164 )?;
165 ensure_optional_sha256(
166 "expected_candid_sha256",
167 source.expected_candid_sha256.as_deref(),
168 )?;
169 ensure_optional_sha256(
170 "expected_canonical_embedded_config_sha256",
171 source.expected_canonical_embedded_config_sha256.as_deref(),
172 )?;
173 Ok(())
174}
175
176fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
177 ensure_readiness_field("role", &role.role)?;
178 ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
179 ensure_readiness_optional_sha256(
180 "source_wasm_gz_sha256",
181 role.source_wasm_gz_sha256.as_deref(),
182 )?;
183 ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
184 ensure_readiness_optional_sha256(
185 "target_wasm_gz_sha256",
186 role.target_wasm_gz_sha256.as_deref(),
187 )?;
188 ensure_readiness_optional_sha256(
189 "source_canonical_embedded_config_sha256",
190 role.source_canonical_embedded_config_sha256.as_deref(),
191 )?;
192 ensure_readiness_optional_sha256(
193 "target_canonical_embedded_config_sha256",
194 role.target_canonical_embedded_config_sha256.as_deref(),
195 )?;
196 if role.restage_required != (role.target_store_has_artifact == Some(false)) {
197 return Err(PromotionReadinessError::RestageStateMismatch {
198 role: role.role.clone(),
199 });
200 }
201 Ok(())
202}
203
204fn role_promotion_readiness(
205 input: &RolePromotionInputV1,
206 target_artifact: &RoleArtifactV1,
207) -> RolePromotionReadinessV1 {
208 let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
209 let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
210 let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
211 let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
212 let byte_identical_wasm =
213 matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
214 || {
215 matching_optional_digest(
216 source_wasm_gz_sha256.as_ref(),
217 target_wasm_gz_sha256.as_ref(),
218 )
219 },
220 );
221 let embedded_config_identical = matching_optional_digest(
222 input
223 .source
224 .expected_canonical_embedded_config_sha256
225 .as_ref(),
226 target_artifact.canonical_embedded_config_sha256.as_ref(),
227 );
228
229 RolePromotionReadinessV1 {
230 role: input.role.clone(),
231 promotion_level: input.promotion_level,
232 source_kind: input.source.kind,
233 source_locator: input.source.locator.clone(),
234 source_wasm_sha256,
235 source_wasm_gz_sha256,
236 target_wasm_sha256,
237 target_wasm_gz_sha256,
238 source_canonical_embedded_config_sha256: input
239 .source
240 .expected_canonical_embedded_config_sha256
241 .clone(),
242 target_canonical_embedded_config_sha256: target_artifact
243 .canonical_embedded_config_sha256
244 .clone(),
245 byte_identical_wasm,
246 embedded_config_identical,
247 target_store_has_artifact: input.target_store_has_artifact,
248 restage_required: input.target_store_has_artifact == Some(false),
249 }
250}
251
252fn collect_role_findings(
253 input: &RolePromotionInputV1,
254 readiness: &RolePromotionReadinessV1,
255 blockers: &mut Vec<SafetyFindingV1>,
256 warnings: &mut Vec<SafetyFindingV1>,
257) {
258 if let Err(err) = validate_role_artifact_source(&input.source) {
259 blockers.push(promotion_finding(
260 "promotion_artifact_source_invalid",
261 err.to_string(),
262 SafetySeverityV1::HardFailure,
263 &input.role,
264 ));
265 }
266
267 if input.role != input.source.role {
268 blockers.push(promotion_finding(
269 "promotion_source_role_mismatch",
270 format!(
271 "promotion input role {} does not match artifact source role {}",
272 input.role, input.source.role
273 ),
274 SafetySeverityV1::HardFailure,
275 &input.role,
276 ));
277 }
278
279 if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
280 blockers.push(promotion_finding(
281 "promotion_wasm_digest_mismatch",
282 "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
283 SafetySeverityV1::HardFailure,
284 &input.role,
285 ));
286 }
287
288 if input.require_target_embedded_config
289 && readiness
290 .target_canonical_embedded_config_sha256
291 .as_deref()
292 .is_none_or(str::is_empty)
293 {
294 blockers.push(promotion_finding(
295 "promotion_target_embedded_config_missing",
296 "promotion requires target canonical embedded config but target plan has no digest",
297 SafetySeverityV1::HardFailure,
298 &input.role,
299 ));
300 }
301
302 if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
303 && readiness.embedded_config_identical != Some(true)
304 {
305 blockers.push(promotion_finding(
306 "promotion_sealed_wasm_embedded_config_mismatch",
307 "sealed wasm promotion requires embedded config identity to be acceptable for the target",
308 SafetySeverityV1::HardFailure,
309 &input.role,
310 ));
311 }
312
313 if readiness.restage_required {
314 warnings.push(promotion_finding(
315 "promotion_target_store_restage_required",
316 "target artifact store does not already contain the artifact; restaging is required",
317 SafetySeverityV1::Warning,
318 &input.role,
319 ));
320 }
321}
322
323fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
324 match (left.map(String::as_str), right.map(String::as_str)) {
325 (Some(left), Some(right)) => Some(left == right),
326 _ => None,
327 }
328}
329
330fn promotion_finding(
331 code: impl Into<String>,
332 message: impl Into<String>,
333 severity: SafetySeverityV1,
334 role: &str,
335) -> SafetyFindingV1 {
336 SafetyFindingV1 {
337 code: code.into(),
338 message: message.into(),
339 severity,
340 subject: Some(role.to_string()),
341 }
342}
343
344fn ensure_locator_requirement(
345 source: &RoleArtifactSourceV1,
346) -> Result<(), PromotionArtifactSourceError> {
347 match source.kind {
348 RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
349 _ => ensure_option_field("locator", source.locator.as_deref()),
350 }
351}
352
353const fn ensure_previous_receipt_requirement(
354 source: &RoleArtifactSourceV1,
355) -> Result<(), PromotionArtifactSourceError> {
356 match (source.kind, source.previous_receipt_kind) {
357 (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
358 (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
359 Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
360 }
361 (_, Some(_)) => {
362 Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
363 }
364 (_, None) => Ok(()),
365 }
366}
367
368const fn ensure_digest_requirement(
369 source: &RoleArtifactSourceV1,
370) -> Result<(), PromotionArtifactSourceError> {
371 let has_digest =
372 source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
373 match source.kind {
374 RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
375 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
376 }
377 RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
378 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
379 }
380 RoleArtifactSourceKindV1::PublishedPackage
381 | RoleArtifactSourceKindV1::PreviousReceiptArtifact
382 if !has_digest =>
383 {
384 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
385 }
386 _ => Ok(()),
387 }
388}
389
390fn ensure_option_field(
391 field: &'static str,
392 value: Option<&str>,
393) -> Result<(), PromotionArtifactSourceError> {
394 match value {
395 Some(value) => ensure_field(field, value),
396 None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
397 }
398}
399
400fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
401 if value.trim().is_empty() {
402 return Err(PromotionArtifactSourceError::MissingRequiredField { field });
403 }
404 Ok(())
405}
406
407fn ensure_optional_sha256(
408 field: &'static str,
409 value: Option<&str>,
410) -> Result<(), PromotionArtifactSourceError> {
411 let Some(value) = value else {
412 return Ok(());
413 };
414 if is_lower_hex_sha256(value) {
415 Ok(())
416 } else {
417 Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
418 }
419}
420
421fn is_lower_hex_sha256(value: &str) -> bool {
422 value.len() == 64
423 && value
424 .bytes()
425 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
426}
427
428const fn ensure_readiness_status_matches_blockers(
429 readiness: &PromotionReadinessV1,
430) -> Result<(), PromotionReadinessError> {
431 match (readiness.status, readiness.blockers.is_empty()) {
432 (PromotionReadinessStatusV1::Ready, false)
433 | (PromotionReadinessStatusV1::Blocked, true) => {
434 Err(PromotionReadinessError::StatusBlockerMismatch {
435 status: readiness.status,
436 blocker_count: readiness.blockers.len(),
437 })
438 }
439 _ => Ok(()),
440 }
441}
442
443fn ensure_unique_readiness_roles(
444 roles: &[RolePromotionReadinessV1],
445) -> Result<(), PromotionReadinessError> {
446 let mut seen = std::collections::BTreeSet::new();
447 for role in roles {
448 if !seen.insert(role.role.as_str()) {
449 return Err(PromotionReadinessError::DuplicateRole {
450 role: role.role.clone(),
451 });
452 }
453 }
454 Ok(())
455}
456
457fn validate_readiness_findings(
458 field: &'static str,
459 findings: &[SafetyFindingV1],
460 expected_severity: SafetySeverityV1,
461) -> Result<(), PromotionReadinessError> {
462 for finding in findings {
463 ensure_readiness_field("finding.code", &finding.code)?;
464 ensure_readiness_field("finding.message", &finding.message)?;
465 if finding.severity != expected_severity {
466 return Err(PromotionReadinessError::FindingSeverityMismatch {
467 field,
468 severity: finding.severity,
469 });
470 }
471 }
472 Ok(())
473}
474
475fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
476 if value.trim().is_empty() {
477 return Err(PromotionReadinessError::MissingRequiredField { field });
478 }
479 Ok(())
480}
481
482fn ensure_readiness_optional_sha256(
483 field: &'static str,
484 value: Option<&str>,
485) -> Result<(), PromotionReadinessError> {
486 let Some(value) = value else {
487 return Ok(());
488 };
489 if is_lower_hex_sha256(value) {
490 Ok(())
491 } else {
492 Err(PromotionReadinessError::InvalidSha256Digest { field })
493 }
494}