1use super::*;
2use serde::Serialize;
3use std::collections::BTreeSet;
4
5#[derive(Serialize)]
6struct ExternalLifecyclePlanDigestInput<'a> {
7 lifecycle_authority_report_id: &'a str,
8 deployment_plan_id: &'a str,
9 deployment_plan_digest: &'a str,
10 inventory_id: &'a str,
11 lifecycle_authority_rows: &'a [LifecycleAuthorityV1],
12 directly_executable_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
13 proposed_external_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
14 blocked_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
15 dependency_blockers: &'a [String],
16 protected_call_implications: &'a [String],
17 residual_exposure: &'a [String],
18 status: ExternalLifecyclePlanStatusV1,
19}
20
21#[derive(Serialize)]
22struct ExternalUpgradeProposalDigestInput<'a> {
23 deployment_plan_id: &'a str,
24 deployment_plan_digest: &'a str,
25 lifecycle_plan_id: &'a str,
26 lifecycle_plan_digest: &'a str,
27 promotion_plan_id: &'a Option<String>,
28 promotion_plan_digest: &'a Option<String>,
29 promotion_provenance_id: &'a Option<String>,
30 promotion_provenance_digest: &'a Option<String>,
31 subject: &'a str,
32 canister_id: &'a Option<String>,
33 role: &'a Option<String>,
34 control_class: CanisterControlClassV1,
35 lifecycle_mode: LifecycleModeV1,
36 observed_before_digest: &'a str,
37 current_module_hash: &'a Option<String>,
38 current_canonical_embedded_config_sha256: &'a Option<String>,
39 target_wasm_sha256: &'a Option<String>,
40 target_wasm_gz_sha256: &'a Option<String>,
41 target_installed_module_hash: &'a Option<String>,
42 target_role_artifact_identity: &'a Option<String>,
43 target_canonical_embedded_config_sha256: &'a Option<String>,
44 root_trust_anchor: &'a Option<String>,
45 authority_profile_hash: &'a Option<String>,
46 required_external_action: &'a str,
47 consent_requirements: &'a [ConsentRequirementV1],
48 allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
49 verification_requirements: &'a [LifecycleVerificationRequirementV1],
50 expires_at: &'a Option<String>,
51 supersedes_proposal_id: &'a Option<String>,
52}
53
54#[derive(Serialize)]
55struct ExternalUpgradeReceiptDigestInput<'a> {
56 proposal_id: &'a str,
57 proposal_digest: &'a str,
58 subject: &'a str,
59 canister_id: &'a Option<String>,
60 role: &'a Option<String>,
61 consent_state: ExternalUpgradeConsentStateV1,
62 reported_by: &'a Option<String>,
63 observed_before_module_hash: &'a Option<String>,
64 observed_after_module_hash: &'a Option<String>,
65 observed_after_canonical_embedded_config_sha256: &'a Option<String>,
66 verification_result: ExternalUpgradeVerificationResultV1,
67 verification_notes: &'a [String],
68}
69
70#[derive(Serialize)]
71struct ObservedBeforeDigestInput<'a> {
72 subject: &'a str,
73 canister_id: &'a Option<String>,
74 role: &'a Option<String>,
75 observed_controllers: &'a [String],
76 current_module_hash: Option<&'a String>,
77 current_canonical_embedded_config_sha256: Option<&'a String>,
78}
79
80#[derive(Debug, Eq, thiserror::Error, PartialEq)]
84pub enum ExternalUpgradeReceiptError {
85 #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
86 SchemaVersionMismatch { expected: u32, actual: u32 },
87 #[error("external upgrade receipt field `{field}` is required")]
88 MissingRequiredField { field: &'static str },
89 #[error("external upgrade receipt verification result does not match observations")]
90 VerificationMismatch,
91 #[error("external upgrade receipt refused consent cannot be verified")]
92 RefusedConsentVerified,
93}
94
95#[must_use]
99pub fn lifecycle_authority_report_from_check(
100 report_id: impl Into<String>,
101 check: &DeploymentCheckV1,
102) -> LifecycleAuthorityReportV1 {
103 let mut authorities = Vec::new();
104 let mut seen_subjects = BTreeSet::new();
105
106 for expected in &check.plan.expected_canisters {
107 let observed = observed_canister_for_expected(&check.inventory, expected);
108 let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
109 seen_subjects.insert(authority.subject.clone());
110 authorities.push(authority);
111 }
112
113 for expected in &check.plan.expected_pool {
114 let observed = observed_pool_for_expected(&check.inventory, expected);
115 let authority = lifecycle_authority_for_expected_pool(expected, observed);
116 seen_subjects.insert(authority.subject.clone());
117 authorities.push(authority);
118 }
119
120 for observed in &check.inventory.observed_canisters {
121 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
122 if seen_subjects.contains(&subject) {
123 continue;
124 }
125 authorities.push(lifecycle_authority_for_unplanned_canister(observed));
126 }
127
128 for observed in &check.inventory.observed_pool {
129 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
130 if seen_subjects.contains(&subject) {
131 continue;
132 }
133 authorities.push(lifecycle_authority_for_unplanned_pool(observed));
134 }
135
136 authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
137 let external_action_required_count = authorities
138 .iter()
139 .filter(|authority| authority.external_action_required)
140 .count();
141 let blocked_count = authorities
142 .iter()
143 .filter(|authority| authority.blocked)
144 .count();
145
146 LifecycleAuthorityReportV1 {
147 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
148 report_id: report_id.into(),
149 check_id: check.check_id.clone(),
150 plan_id: check.plan.plan_id.clone(),
151 inventory_id: check.inventory.inventory_id.clone(),
152 authorities,
153 external_action_required_count,
154 blocked_count,
155 }
156}
157
158#[must_use]
164pub fn external_lifecycle_plan_from_check(
165 lifecycle_plan_id: impl Into<String>,
166 lifecycle_authority_report_id: impl Into<String>,
167 check: &DeploymentCheckV1,
168) -> ExternalLifecyclePlanV1 {
169 let lifecycle_authority_report =
170 lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
171 let lifecycle_authority_rows = lifecycle_authority_report.authorities;
172 let directly_executable_role_upgrades = lifecycle_authority_rows
173 .iter()
174 .filter(|authority| {
175 authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
176 && !authority.blocked
177 })
178 .map(external_lifecycle_role_upgrade)
179 .collect::<Vec<_>>();
180 let proposed_external_role_upgrades = lifecycle_authority_rows
181 .iter()
182 .filter(|authority| authority.external_action_required && !authority.blocked)
183 .map(external_lifecycle_role_upgrade)
184 .collect::<Vec<_>>();
185 let blocked_role_upgrades = lifecycle_authority_rows
186 .iter()
187 .filter(|authority| authority.blocked)
188 .map(external_lifecycle_role_upgrade)
189 .collect::<Vec<_>>();
190 let residual_exposure = proposed_external_role_upgrades
191 .iter()
192 .map(|upgrade| {
193 format!(
194 "{} remains pending external lifecycle action",
195 upgrade.subject
196 )
197 })
198 .collect::<Vec<_>>();
199 let status = if !blocked_role_upgrades.is_empty() {
200 ExternalLifecyclePlanStatusV1::Blocked
201 } else if !proposed_external_role_upgrades.is_empty() {
202 ExternalLifecyclePlanStatusV1::PendingExternalAction
203 } else {
204 ExternalLifecyclePlanStatusV1::Ready
205 };
206 let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
207 let mut plan = ExternalLifecyclePlanV1 {
208 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
209 lifecycle_plan_id: lifecycle_plan_id.into(),
210 lifecycle_plan_digest: String::new(),
211 lifecycle_authority_report_id: lifecycle_authority_report.report_id,
212 deployment_plan_id: check.plan.plan_id.clone(),
213 deployment_plan_digest,
214 inventory_id: check.inventory.inventory_id.clone(),
215 lifecycle_authority_rows,
216 directly_executable_role_upgrades,
217 proposed_external_role_upgrades,
218 blocked_role_upgrades,
219 dependency_blockers: Vec::new(),
220 protected_call_implications: protected_call_implications_for_check(check),
221 residual_exposure,
222 status,
223 };
224 plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
225 plan
226}
227
228#[must_use]
233pub fn external_upgrade_receipt_from_observation(
234 receipt_id: impl Into<String>,
235 proposal: &ExternalUpgradeProposalV1,
236 consent_state: ExternalUpgradeConsentStateV1,
237 reported_by: Option<String>,
238 observed_after: Option<&ObservedCanisterV1>,
239) -> ExternalUpgradeReceiptV1 {
240 let observed_after_module_hash =
241 observed_after.and_then(|observed| observed.module_hash.clone());
242 let observed_after_canonical_embedded_config_sha256 =
243 observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
244 let verification_result = external_upgrade_verification_result(
245 consent_state,
246 proposal,
247 observed_after_module_hash.as_deref(),
248 observed_after_canonical_embedded_config_sha256.as_deref(),
249 );
250 let verification_notes = external_upgrade_verification_notes(
251 verification_result,
252 proposal,
253 observed_after_module_hash.as_deref(),
254 observed_after_canonical_embedded_config_sha256.as_deref(),
255 );
256
257 let mut receipt = ExternalUpgradeReceiptV1 {
258 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
259 receipt_id: receipt_id.into(),
260 proposal_id: proposal.proposal_id.clone(),
261 proposal_digest: proposal.proposal_digest.clone(),
262 subject: proposal.subject.clone(),
263 canister_id: proposal.canister_id.clone(),
264 role: proposal.role.clone(),
265 consent_state,
266 reported_by,
267 observed_before_module_hash: proposal.current_module_hash.clone(),
268 observed_after_module_hash,
269 observed_after_canonical_embedded_config_sha256,
270 verification_result,
271 verification_notes,
272 receipt_digest: String::new(),
273 };
274 receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
275 receipt
276}
277
278pub fn validate_external_upgrade_receipt(
283 receipt: &ExternalUpgradeReceiptV1,
284) -> Result<(), ExternalUpgradeReceiptError> {
285 if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
286 return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
287 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
288 actual: receipt.schema_version,
289 });
290 }
291 ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
292 ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
293 ensure_external_receipt_field("subject", receipt.subject.as_str())?;
294
295 if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
296 && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
297 {
298 return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
299 }
300 let has_observation = receipt.observed_after_module_hash.is_some()
301 || receipt
302 .observed_after_canonical_embedded_config_sha256
303 .is_some();
304 if matches!(
305 receipt.verification_result,
306 ExternalUpgradeVerificationResultV1::Verified
307 | ExternalUpgradeVerificationResultV1::Mismatch
308 ) && !has_observation
309 {
310 return Err(ExternalUpgradeReceiptError::VerificationMismatch);
311 }
312 Ok(())
313}
314
315#[must_use]
320pub fn external_upgrade_proposal_report_from_lifecycle_plan(
321 report_id: impl Into<String>,
322 lifecycle_plan: &ExternalLifecyclePlanV1,
323 check: &DeploymentCheckV1,
324) -> ExternalUpgradeProposalReportV1 {
325 let report_id = report_id.into();
326 let mut proposals = Vec::new();
327 for authority in lifecycle_plan
328 .lifecycle_authority_rows
329 .iter()
330 .filter(|authority| authority.external_action_required && !authority.blocked)
331 {
332 proposals.push(external_upgrade_proposal(
333 &report_id,
334 lifecycle_plan,
335 check,
336 authority,
337 observed_canister_for_authority(&check.inventory, authority),
338 target_artifact_for_authority(&check.plan, authority),
339 ));
340 }
341
342 proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
343
344 ExternalUpgradeProposalReportV1 {
345 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
346 report_id,
347 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
348 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
349 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
350 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
351 inventory_id: check.inventory.inventory_id.clone(),
352 proposals,
353 blocked_subjects: lifecycle_plan
354 .blocked_role_upgrades
355 .iter()
356 .map(|upgrade| upgrade.subject.clone())
357 .collect(),
358 }
359}
360
361fn external_upgrade_proposal(
362 report_id: &str,
363 lifecycle_plan: &ExternalLifecyclePlanV1,
364 check: &DeploymentCheckV1,
365 authority: &LifecycleAuthorityV1,
366 observed: Option<&ObservedCanisterV1>,
367 target_artifact: Option<&RoleArtifactV1>,
368) -> ExternalUpgradeProposalV1 {
369 let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
370 let current_canonical_embedded_config_sha256 =
371 observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
372 let mut proposal = ExternalUpgradeProposalV1 {
373 proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
374 proposal_digest: String::new(),
375 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
376 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
377 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
378 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
379 promotion_plan_id: None,
380 promotion_plan_digest: None,
381 promotion_provenance_id: None,
382 promotion_provenance_digest: None,
383 subject: authority.subject.clone(),
384 canister_id: authority.canister_id.clone(),
385 role: authority.role.clone(),
386 control_class: authority.control_class,
387 lifecycle_mode: authority.lifecycle_mode,
388 observed_before_digest: observed_before_digest(
389 authority,
390 current_module_hash.as_ref(),
391 current_canonical_embedded_config_sha256.as_ref(),
392 ),
393 current_module_hash,
394 current_canonical_embedded_config_sha256,
395 target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
396 target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
397 target_installed_module_hash: target_artifact
398 .and_then(|artifact| artifact.installed_module_hash.clone()),
399 target_role_artifact_identity: target_artifact.map(role_artifact_identity),
400 target_canonical_embedded_config_sha256: target_artifact
401 .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
402 root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
403 authority_profile_hash: check
404 .plan
405 .deployment_identity
406 .authority_profile_hash
407 .clone(),
408 required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
409 consent_requirements: authority.consent_requirements.clone(),
410 allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
411 verification_requirements: authority.verification_requirements.clone(),
412 expires_at: None,
413 supersedes_proposal_id: None,
414 };
415 proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
416 proposal
417}
418
419fn lifecycle_authority_for_expected_canister(
420 plan: &DeploymentPlanV1,
421 expected: &ExpectedCanisterV1,
422 observed: Option<&ObservedCanisterV1>,
423) -> LifecycleAuthorityV1 {
424 let canister_id = expected
425 .canister_id
426 .clone()
427 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
428 let role = Some(expected.role.clone());
429 let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
430 let observed_controllers =
431 observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
432 lifecycle_authority(
433 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
434 canister_id,
435 role,
436 control_class,
437 observed_controllers,
438 &plan.authority_profile.expected_controllers,
439 plan.expected_verifier_readiness.required,
440 )
441}
442
443fn lifecycle_authority_for_expected_pool(
444 expected: &ExpectedPoolCanisterV1,
445 observed: Option<&ObservedPoolCanisterV1>,
446) -> LifecycleAuthorityV1 {
447 let canister_id = expected
448 .canister_id
449 .clone()
450 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
451 let role = expected
452 .role
453 .clone()
454 .or_else(|| observed.and_then(|observed| observed.role.clone()));
455 let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
456 observed.control_class
457 });
458 lifecycle_authority(
459 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
460 canister_id,
461 role,
462 control_class,
463 Vec::new(),
464 &[],
465 false,
466 )
467}
468
469fn lifecycle_authority_for_unplanned_canister(
470 observed: &ObservedCanisterV1,
471) -> LifecycleAuthorityV1 {
472 lifecycle_authority(
473 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
474 Some(observed.canister_id.clone()),
475 observed.role.clone(),
476 observed.control_class,
477 observed.controllers.clone(),
478 &[],
479 false,
480 )
481}
482
483fn lifecycle_authority_for_unplanned_pool(
484 observed: &ObservedPoolCanisterV1,
485) -> LifecycleAuthorityV1 {
486 lifecycle_authority(
487 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
488 Some(observed.canister_id.clone()),
489 observed.role.clone(),
490 observed.control_class,
491 Vec::new(),
492 &[],
493 false,
494 )
495}
496
497fn lifecycle_authority(
498 subject: String,
499 canister_id: Option<String>,
500 role: Option<String>,
501 control_class: CanisterControlClassV1,
502 observed_controllers: Vec<String>,
503 expected_controllers: &[String],
504 verifier_required: bool,
505) -> LifecycleAuthorityV1 {
506 let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
507 let external_controllers =
508 external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
509 let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
510 let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
511 let verification_requirements = lifecycle_verification_requirements(verifier_required);
512 let external_action_required = lifecycle_external_action_required(control_class);
513 let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
514 let lifecycle_mode = lifecycle_mode(control_class);
515 let blockers = lifecycle_blockers(control_class);
516 let warnings = lifecycle_warnings(control_class);
517 let reason = lifecycle_reason(control_class);
518 LifecycleAuthorityV1 {
519 subject,
520 canister_id,
521 role,
522 control_class,
523 lifecycle_mode,
524 observed_controllers,
525 expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
526 external_controllers,
527 required_controllers,
528 consent_requirements,
529 allowed_upgrade_modes,
530 verification_requirements,
531 external_action_required,
532 blocked,
533 blockers,
534 warnings,
535 reason,
536 }
537}
538
539fn required_lifecycle_controllers(
540 control_class: CanisterControlClassV1,
541 expected_controllers: &[String],
542) -> Vec<String> {
543 match control_class {
544 CanisterControlClassV1::DeploymentControlled
545 | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
546 CanisterControlClassV1::CanicManagedPool
547 | CanisterControlClassV1::ExternallyImported
548 | CanisterControlClassV1::UserControlled
549 | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
550 }
551}
552
553fn external_lifecycle_controllers(
554 control_class: CanisterControlClassV1,
555 observed_controllers: &[String],
556 required_controllers: &[String],
557) -> Vec<String> {
558 match control_class {
559 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
560 Vec::new()
561 }
562 CanisterControlClassV1::JointlyControlled => {
563 let required = required_controllers.iter().collect::<BTreeSet<_>>();
564 sorted_unique(
565 observed_controllers
566 .iter()
567 .filter(|controller| !required.contains(controller))
568 .cloned()
569 .collect(),
570 )
571 }
572 CanisterControlClassV1::CanicManagedPool
573 | CanisterControlClassV1::ExternallyImported
574 | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
575 }
576}
577
578fn lifecycle_consent_requirements(
579 control_class: CanisterControlClassV1,
580 external_controllers: &[String],
581) -> Vec<ConsentRequirementV1> {
582 if !lifecycle_external_action_required(control_class) {
583 return Vec::new();
584 }
585 vec![ConsentRequirementV1 {
586 consent_subject_kind: consent_subject_kind(control_class),
587 required_principals: sorted_unique(external_controllers.to_vec()),
588 required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
589 consent_channel_kind: consent_channel_kind(control_class),
590 required_action: required_consent_action(control_class),
591 }]
592}
593
594const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
595 match control_class {
596 CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
597 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
598 ConsentSubjectKindV1::CustomerController
599 }
600 CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
601 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
602 ConsentSubjectKindV1::UnknownExternalController
603 }
604 }
605}
606
607const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
608 match control_class {
609 CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
610 CanisterControlClassV1::ExternallyImported
611 | CanisterControlClassV1::JointlyControlled
612 | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
613 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
614 ConsentChannelKindV1::OutOfBand
615 }
616 }
617}
618
619const fn required_consent_action(
620 control_class: CanisterControlClassV1,
621) -> ExternalUpgradeAuthorizationModeV1 {
622 match control_class {
623 CanisterControlClassV1::JointlyControlled => {
624 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
625 }
626 CanisterControlClassV1::CanicManagedPool => {
627 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
628 }
629 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
630 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
631 }
632 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
633 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
634 }
635 }
636}
637
638const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
639 match control_class {
640 CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
641 CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
642 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
643 LifecycleModeV1::ExternalCompletionOnly
644 }
645 CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
646 CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
647 }
648}
649
650fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
651 if control_class == CanisterControlClassV1::UnknownUnsafe {
652 vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
653 } else {
654 Vec::new()
655 }
656}
657
658fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
659 match control_class {
660 CanisterControlClassV1::CanicManagedPool => {
661 vec!["pool-aware lifecycle policy is required before mutation".to_string()]
662 }
663 CanisterControlClassV1::ExternallyImported => {
664 vec!["external controller action or verification is required".to_string()]
665 }
666 CanisterControlClassV1::JointlyControlled => {
667 vec!["joint controller consent or delegation is required".to_string()]
668 }
669 CanisterControlClassV1::UserControlled => {
670 vec!["user or delegated lifecycle action is required".to_string()]
671 }
672 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
673 Vec::new()
674 }
675 }
676}
677
678fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
679 match control_class {
680 CanisterControlClassV1::DeploymentControlled => vec![
681 LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
682 LifecycleUpgradeModeV1::VerifyExternalCompletion,
683 ],
684 CanisterControlClassV1::CanicManagedPool
685 | CanisterControlClassV1::ExternallyImported
686 | CanisterControlClassV1::JointlyControlled
687 | CanisterControlClassV1::UserControlled => vec![
688 LifecycleUpgradeModeV1::ExternalProposal,
689 LifecycleUpgradeModeV1::ExternalExecution,
690 LifecycleUpgradeModeV1::VerifyExternalCompletion,
691 LifecycleUpgradeModeV1::ObserveOnly,
692 ],
693 CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
694 }
695}
696
697fn lifecycle_verification_requirements(
698 verifier_required: bool,
699) -> Vec<LifecycleVerificationRequirementV1> {
700 let mut requirements = vec![
701 LifecycleVerificationRequirementV1::LiveInventory,
702 LifecycleVerificationRequirementV1::ControllerObservation,
703 LifecycleVerificationRequirementV1::ModuleHash,
704 LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
705 ];
706 if verifier_required {
707 requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
708 }
709 requirements
710}
711
712const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
713 matches!(
714 control_class,
715 CanisterControlClassV1::CanicManagedPool
716 | CanisterControlClassV1::ExternallyImported
717 | CanisterControlClassV1::JointlyControlled
718 | CanisterControlClassV1::UserControlled
719 )
720}
721
722fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
723 match control_class {
724 CanisterControlClassV1::DeploymentControlled => {
725 "deployment authority can execute lifecycle directly".to_string()
726 }
727 CanisterControlClassV1::CanicManagedPool => {
728 "Canic-managed pool lifecycle requires pool-aware external action".to_string()
729 }
730 CanisterControlClassV1::ExternallyImported => {
731 "externally imported canister requires external controller action".to_string()
732 }
733 CanisterControlClassV1::JointlyControlled => {
734 "jointly controlled canister requires non-deployment-controller consent".to_string()
735 }
736 CanisterControlClassV1::UserControlled => {
737 "user-controlled canister requires user or delegated lifecycle action".to_string()
738 }
739 CanisterControlClassV1::UnknownUnsafe => {
740 "unknown or unsafe controller state blocks lifecycle action".to_string()
741 }
742 }
743}
744
745fn observed_canister_for_expected<'a>(
746 inventory: &'a DeploymentInventoryV1,
747 expected: &ExpectedCanisterV1,
748) -> Option<&'a ObservedCanisterV1> {
749 if let Some(canister_id) = &expected.canister_id
750 && let Some(observed) = inventory
751 .observed_canisters
752 .iter()
753 .find(|observed| &observed.canister_id == canister_id)
754 {
755 return Some(observed);
756 }
757 inventory
758 .observed_canisters
759 .iter()
760 .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
761}
762
763fn observed_pool_for_expected<'a>(
764 inventory: &'a DeploymentInventoryV1,
765 expected: &ExpectedPoolCanisterV1,
766) -> Option<&'a ObservedPoolCanisterV1> {
767 if let Some(canister_id) = &expected.canister_id
768 && let Some(observed) = inventory
769 .observed_pool
770 .iter()
771 .find(|observed| &observed.canister_id == canister_id)
772 {
773 return Some(observed);
774 }
775 inventory.observed_pool.iter().find(|observed| {
776 observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
777 })
778}
779
780fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
781 lifecycle_subject_for_parts(Some(canister_id), role)
782}
783
784fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
785 match (role, canister_id) {
786 (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
787 (Some(role), None) => format!("{role}:unassigned"),
788 (None, Some(canister_id)) => canister_id.to_string(),
789 (None, None) => "unknown".to_string(),
790 }
791}
792
793fn observed_canister_for_authority<'a>(
794 inventory: &'a DeploymentInventoryV1,
795 authority: &LifecycleAuthorityV1,
796) -> Option<&'a ObservedCanisterV1> {
797 if let Some(canister_id) = &authority.canister_id
798 && let Some(observed) = inventory
799 .observed_canisters
800 .iter()
801 .find(|observed| &observed.canister_id == canister_id)
802 {
803 return Some(observed);
804 }
805 inventory
806 .observed_canisters
807 .iter()
808 .find(|observed| observed.role == authority.role)
809}
810
811fn target_artifact_for_authority<'a>(
812 plan: &'a DeploymentPlanV1,
813 authority: &LifecycleAuthorityV1,
814) -> Option<&'a RoleArtifactV1> {
815 let role = authority.role.as_ref()?;
816 plan.role_artifacts
817 .iter()
818 .find(|artifact| &artifact.role == role)
819}
820
821fn external_lifecycle_role_upgrade(
822 authority: &LifecycleAuthorityV1,
823) -> ExternalLifecycleRoleUpgradeV1 {
824 ExternalLifecycleRoleUpgradeV1 {
825 subject: authority.subject.clone(),
826 canister_id: authority.canister_id.clone(),
827 role: authority.role.clone(),
828 control_class: authority.control_class,
829 lifecycle_mode: authority.lifecycle_mode,
830 required_external_action: authority
831 .external_action_required
832 .then(|| required_external_action(authority.lifecycle_mode).to_string()),
833 blockers: authority.blockers.clone(),
834 warnings: authority.warnings.clone(),
835 }
836}
837
838fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
839 if check.plan.expected_verifier_readiness.required {
840 vec!["protected-call verifier readiness must be checked before completion".to_string()]
841 } else {
842 Vec::new()
843 }
844}
845
846const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
847 match lifecycle_mode {
848 LifecycleModeV1::DirectDeploymentAuthority => "none",
849 LifecycleModeV1::ProposalRequired => "proposal_and_consent",
850 LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
851 LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
852 LifecycleModeV1::VerifyOnly => "verify_external_completion",
853 LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
854 }
855}
856
857fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
858 stable_json_sha256_hex(&(
859 artifact.role.as_str(),
860 artifact.wasm_sha256.as_deref(),
861 artifact.wasm_gz_sha256.as_deref(),
862 artifact.installed_module_hash.as_deref(),
863 artifact.candid_sha256.as_deref(),
864 artifact.canonical_embedded_config_sha256.as_deref(),
865 ))
866}
867
868fn external_upgrade_authorization_modes(
869 control_class: CanisterControlClassV1,
870) -> Vec<ExternalUpgradeAuthorizationModeV1> {
871 match control_class {
872 CanisterControlClassV1::JointlyControlled => vec![
873 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
874 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
875 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
876 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
877 ],
878 CanisterControlClassV1::CanicManagedPool
879 | CanisterControlClassV1::ExternallyImported
880 | CanisterControlClassV1::UserControlled => vec![
881 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
882 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
883 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
884 ],
885 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
886 Vec::new()
887 }
888 }
889}
890
891fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
892 let subject = subject.replace([':', '/'], "-");
893 format!("{report_id}:{subject}")
894}
895
896fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
897 stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
898 lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
899 deployment_plan_id: &plan.deployment_plan_id,
900 deployment_plan_digest: &plan.deployment_plan_digest,
901 inventory_id: &plan.inventory_id,
902 lifecycle_authority_rows: &plan.lifecycle_authority_rows,
903 directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
904 proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
905 blocked_role_upgrades: &plan.blocked_role_upgrades,
906 dependency_blockers: &plan.dependency_blockers,
907 protected_call_implications: &plan.protected_call_implications,
908 residual_exposure: &plan.residual_exposure,
909 status: plan.status,
910 })
911}
912
913fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
914 stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
915 deployment_plan_id: &proposal.deployment_plan_id,
916 deployment_plan_digest: &proposal.deployment_plan_digest,
917 lifecycle_plan_id: &proposal.lifecycle_plan_id,
918 lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
919 promotion_plan_id: &proposal.promotion_plan_id,
920 promotion_plan_digest: &proposal.promotion_plan_digest,
921 promotion_provenance_id: &proposal.promotion_provenance_id,
922 promotion_provenance_digest: &proposal.promotion_provenance_digest,
923 subject: &proposal.subject,
924 canister_id: &proposal.canister_id,
925 role: &proposal.role,
926 control_class: proposal.control_class,
927 lifecycle_mode: proposal.lifecycle_mode,
928 observed_before_digest: &proposal.observed_before_digest,
929 current_module_hash: &proposal.current_module_hash,
930 current_canonical_embedded_config_sha256: &proposal
931 .current_canonical_embedded_config_sha256,
932 target_wasm_sha256: &proposal.target_wasm_sha256,
933 target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
934 target_installed_module_hash: &proposal.target_installed_module_hash,
935 target_role_artifact_identity: &proposal.target_role_artifact_identity,
936 target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
937 root_trust_anchor: &proposal.root_trust_anchor,
938 authority_profile_hash: &proposal.authority_profile_hash,
939 required_external_action: &proposal.required_external_action,
940 consent_requirements: &proposal.consent_requirements,
941 allowed_authorization_modes: &proposal.allowed_authorization_modes,
942 verification_requirements: &proposal.verification_requirements,
943 expires_at: &proposal.expires_at,
944 supersedes_proposal_id: &proposal.supersedes_proposal_id,
945 })
946}
947
948fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
949 stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
950 proposal_id: &receipt.proposal_id,
951 proposal_digest: &receipt.proposal_digest,
952 subject: &receipt.subject,
953 canister_id: &receipt.canister_id,
954 role: &receipt.role,
955 consent_state: receipt.consent_state,
956 reported_by: &receipt.reported_by,
957 observed_before_module_hash: &receipt.observed_before_module_hash,
958 observed_after_module_hash: &receipt.observed_after_module_hash,
959 observed_after_canonical_embedded_config_sha256: &receipt
960 .observed_after_canonical_embedded_config_sha256,
961 verification_result: receipt.verification_result,
962 verification_notes: &receipt.verification_notes,
963 })
964}
965
966fn observed_before_digest(
967 authority: &LifecycleAuthorityV1,
968 current_module_hash: Option<&String>,
969 current_config_hash: Option<&String>,
970) -> String {
971 stable_json_sha256_hex(&ObservedBeforeDigestInput {
972 subject: &authority.subject,
973 canister_id: &authority.canister_id,
974 role: &authority.role,
975 observed_controllers: &authority.observed_controllers,
976 current_module_hash,
977 current_canonical_embedded_config_sha256: current_config_hash,
978 })
979}
980
981fn external_upgrade_verification_result(
982 consent_state: ExternalUpgradeConsentStateV1,
983 proposal: &ExternalUpgradeProposalV1,
984 observed_after_module_hash: Option<&str>,
985 observed_after_config: Option<&str>,
986) -> ExternalUpgradeVerificationResultV1 {
987 match consent_state {
988 ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
989 ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
990 ExternalUpgradeConsentStateV1::Delegated
991 | ExternalUpgradeConsentStateV1::ExecutedExternally => {
992 if external_upgrade_observation_matches(
993 proposal.target_installed_module_hash.as_deref(),
994 observed_after_module_hash,
995 ) && external_upgrade_observation_matches(
996 proposal.target_canonical_embedded_config_sha256.as_deref(),
997 observed_after_config,
998 ) {
999 ExternalUpgradeVerificationResultV1::Verified
1000 } else {
1001 ExternalUpgradeVerificationResultV1::Mismatch
1002 }
1003 }
1004 }
1005}
1006
1007fn external_upgrade_verification_notes(
1008 verification_result: ExternalUpgradeVerificationResultV1,
1009 proposal: &ExternalUpgradeProposalV1,
1010 observed_after_module_hash: Option<&str>,
1011 observed_after_config: Option<&str>,
1012) -> Vec<String> {
1013 let mut notes = Vec::new();
1014 if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
1015 if !external_upgrade_observation_matches(
1016 proposal.target_installed_module_hash.as_deref(),
1017 observed_after_module_hash,
1018 ) {
1019 notes.push("observed module hash does not match proposal target".to_string());
1020 }
1021 if !external_upgrade_observation_matches(
1022 proposal.target_canonical_embedded_config_sha256.as_deref(),
1023 observed_after_config,
1024 ) {
1025 notes.push("observed embedded config does not match proposal target".to_string());
1026 }
1027 }
1028 notes
1029}
1030
1031fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
1032 expected.is_none_or(|expected| observed == Some(expected))
1033}
1034
1035fn ensure_external_receipt_field(
1036 field: &'static str,
1037 value: &str,
1038) -> Result<(), ExternalUpgradeReceiptError> {
1039 if value.trim().is_empty() {
1040 return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
1041 }
1042 Ok(())
1043}
1044
1045fn sorted_unique(values: Vec<String>) -> Vec<String> {
1046 values
1047 .into_iter()
1048 .collect::<BTreeSet<_>>()
1049 .into_iter()
1050 .collect()
1051}