1use super::*;
2use serde::Serialize;
3use std::collections::BTreeSet;
4
5#[derive(Serialize)]
6struct LifecycleAuthorityReportDigestInput<'a> {
7 report_id: &'a str,
8 check_id: &'a str,
9 plan_id: &'a str,
10 inventory_id: &'a str,
11 authorities: &'a [LifecycleAuthorityV1],
12 external_action_required_count: usize,
13 blocked_count: usize,
14}
15
16#[derive(Serialize)]
17struct ExternalLifecyclePlanDigestInput<'a> {
18 lifecycle_authority_report_id: &'a str,
19 deployment_plan_id: &'a str,
20 deployment_plan_digest: &'a str,
21 inventory_id: &'a str,
22 lifecycle_authority_rows: &'a [LifecycleAuthorityV1],
23 directly_executable_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
24 proposed_external_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
25 blocked_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
26 dependency_blockers: &'a [String],
27 protected_call_implications: &'a [String],
28 residual_exposure: &'a [String],
29 status: ExternalLifecyclePlanStatusV1,
30}
31
32#[derive(Serialize)]
33struct ExternalUpgradeProposalReportDigestInput<'a> {
34 report_id: &'a str,
35 lifecycle_plan_id: &'a str,
36 lifecycle_plan_digest: &'a str,
37 deployment_plan_id: &'a str,
38 deployment_plan_digest: &'a str,
39 inventory_id: &'a str,
40 proposals: &'a [ExternalUpgradeProposalV1],
41 blocked_subjects: &'a [String],
42}
43
44#[derive(Serialize)]
45struct ExternalUpgradeProposalDigestInput<'a> {
46 deployment_plan_id: &'a str,
47 deployment_plan_digest: &'a str,
48 lifecycle_plan_id: &'a str,
49 lifecycle_plan_digest: &'a str,
50 promotion_plan_id: &'a Option<String>,
51 promotion_plan_digest: &'a Option<String>,
52 promotion_provenance_id: &'a Option<String>,
53 promotion_provenance_digest: &'a Option<String>,
54 subject: &'a str,
55 canister_id: &'a Option<String>,
56 role: &'a Option<String>,
57 control_class: CanisterControlClassV1,
58 lifecycle_mode: LifecycleModeV1,
59 observed_before_digest: &'a str,
60 current_module_hash: &'a Option<String>,
61 current_canonical_embedded_config_sha256: &'a Option<String>,
62 target_wasm_sha256: &'a Option<String>,
63 target_wasm_gz_sha256: &'a Option<String>,
64 target_installed_module_hash: &'a Option<String>,
65 target_role_artifact_identity: &'a Option<String>,
66 target_canonical_embedded_config_sha256: &'a Option<String>,
67 root_trust_anchor: &'a Option<String>,
68 authority_profile_hash: &'a Option<String>,
69 required_external_action: &'a str,
70 consent_requirements: &'a [ConsentRequirementV1],
71 allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
72 verification_requirements: &'a [LifecycleVerificationRequirementV1],
73 expires_at: &'a Option<String>,
74 supersedes_proposal_id: &'a Option<String>,
75}
76
77#[derive(Serialize)]
78struct ExternalUpgradeReceiptDigestInput<'a> {
79 proposal_id: &'a str,
80 proposal_digest: &'a str,
81 subject: &'a str,
82 canister_id: &'a Option<String>,
83 role: &'a Option<String>,
84 consent_state: ExternalUpgradeConsentStateV1,
85 reported_by: &'a Option<String>,
86 observed_before_module_hash: &'a Option<String>,
87 observed_after_module_hash: &'a Option<String>,
88 observed_after_canonical_embedded_config_sha256: &'a Option<String>,
89 verification_result: ExternalUpgradeVerificationResultV1,
90 verification_notes: &'a [String],
91}
92
93#[derive(Serialize)]
94struct ObservedBeforeDigestInput<'a> {
95 subject: &'a str,
96 canister_id: &'a Option<String>,
97 role: &'a Option<String>,
98 observed_controllers: &'a [String],
99 current_module_hash: Option<&'a String>,
100 current_canonical_embedded_config_sha256: Option<&'a String>,
101}
102
103#[derive(Debug, Eq, thiserror::Error, PartialEq)]
107pub enum ExternalUpgradeReceiptError {
108 #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
109 SchemaVersionMismatch { expected: u32, actual: u32 },
110 #[error("external upgrade receipt field `{field}` is required")]
111 MissingRequiredField { field: &'static str },
112 #[error("external upgrade receipt field `{field}` digest is stale")]
113 DigestMismatch { field: &'static str },
114 #[error("external upgrade receipt verification result does not match observations")]
115 VerificationMismatch,
116 #[error("external upgrade receipt refused consent cannot be verified")]
117 RefusedConsentVerified,
118}
119
120#[derive(Debug, Eq, thiserror::Error, PartialEq)]
124pub enum LifecycleAuthorityReportError {
125 #[error(
126 "lifecycle authority report schema version {actual} does not match expected {expected}"
127 )]
128 SchemaVersionMismatch { expected: u32, actual: u32 },
129 #[error("lifecycle authority report field `{field}` is required")]
130 MissingRequiredField { field: &'static str },
131 #[error("lifecycle authority report field `{field}` digest is stale")]
132 DigestMismatch { field: &'static str },
133 #[error("lifecycle authority report contains duplicate subject `{subject}`")]
134 DuplicateSubject { subject: String },
135 #[error("lifecycle authority report counters do not match authority rows")]
136 CountMismatch,
137}
138
139#[derive(Debug, Eq, thiserror::Error, PartialEq)]
143pub enum ExternalLifecyclePlanError {
144 #[error("external lifecycle plan schema version {actual} does not match expected {expected}")]
145 SchemaVersionMismatch { expected: u32, actual: u32 },
146 #[error("external lifecycle plan field `{field}` is required")]
147 MissingRequiredField { field: &'static str },
148 #[error("external lifecycle plan field `{field}` digest is stale")]
149 DigestMismatch { field: &'static str },
150 #[error("external lifecycle plan field `{field}` does not match deployment truth source")]
151 SourceMismatch { field: &'static str },
152 #[error("external lifecycle plan status does not match role partitioning")]
153 StatusMismatch,
154 #[error("external lifecycle plan contains duplicate subject `{subject}`")]
155 DuplicateSubject { subject: String },
156}
157
158#[derive(Debug, Eq, thiserror::Error, PartialEq)]
162pub enum ExternalUpgradeProposalReportError {
163 #[error(
164 "external upgrade proposal report schema version {actual} does not match expected {expected}"
165 )]
166 SchemaVersionMismatch { expected: u32, actual: u32 },
167 #[error("external upgrade proposal report field `{field}` is required")]
168 MissingRequiredField { field: &'static str },
169 #[error("external upgrade proposal report field `{field}` digest is stale")]
170 DigestMismatch { field: &'static str },
171 #[error("external upgrade proposal report field `{field}` does not match lifecycle source")]
172 SourceMismatch { field: &'static str },
173 #[error(
174 "external upgrade proposal report contains proposal for directly controlled row `{subject}`"
175 )]
176 DirectLifecycleProposal { subject: String },
177 #[error("external upgrade proposal report contains duplicate subject `{subject}`")]
178 DuplicateSubject { subject: String },
179}
180
181#[must_use]
185pub fn lifecycle_authority_report_from_check(
186 report_id: impl Into<String>,
187 check: &DeploymentCheckV1,
188) -> LifecycleAuthorityReportV1 {
189 let mut authorities = Vec::new();
190 let mut seen_subjects = BTreeSet::new();
191
192 for expected in &check.plan.expected_canisters {
193 let observed = observed_canister_for_expected(&check.inventory, expected);
194 let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
195 seen_subjects.insert(authority.subject.clone());
196 authorities.push(authority);
197 }
198
199 for expected in &check.plan.expected_pool {
200 let observed = observed_pool_for_expected(&check.inventory, expected);
201 let authority = lifecycle_authority_for_expected_pool(expected, observed);
202 seen_subjects.insert(authority.subject.clone());
203 authorities.push(authority);
204 }
205
206 for observed in &check.inventory.observed_canisters {
207 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
208 if seen_subjects.contains(&subject) {
209 continue;
210 }
211 authorities.push(lifecycle_authority_for_unplanned_canister(observed));
212 }
213
214 for observed in &check.inventory.observed_pool {
215 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
216 if seen_subjects.contains(&subject) {
217 continue;
218 }
219 authorities.push(lifecycle_authority_for_unplanned_pool(observed));
220 }
221
222 authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
223 let external_action_required_count = authorities
224 .iter()
225 .filter(|authority| authority.external_action_required)
226 .count();
227 let blocked_count = authorities
228 .iter()
229 .filter(|authority| authority.blocked)
230 .count();
231
232 let mut report = LifecycleAuthorityReportV1 {
233 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
234 report_id: report_id.into(),
235 report_digest: String::new(),
236 check_id: check.check_id.clone(),
237 plan_id: check.plan.plan_id.clone(),
238 inventory_id: check.inventory.inventory_id.clone(),
239 authorities,
240 external_action_required_count,
241 blocked_count,
242 };
243 report.report_digest = lifecycle_authority_report_digest(&report);
244 report
245}
246
247pub fn validate_lifecycle_authority_report(
249 report: &LifecycleAuthorityReportV1,
250) -> Result<(), LifecycleAuthorityReportError> {
251 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
252 return Err(LifecycleAuthorityReportError::SchemaVersionMismatch {
253 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
254 actual: report.schema_version,
255 });
256 }
257 ensure_lifecycle_authority_report_field("report_id", report.report_id.as_str())?;
258 ensure_lifecycle_authority_report_field("report_digest", report.report_digest.as_str())?;
259 ensure_lifecycle_authority_report_field("check_id", report.check_id.as_str())?;
260 ensure_lifecycle_authority_report_field("plan_id", report.plan_id.as_str())?;
261 ensure_lifecycle_authority_report_field("inventory_id", report.inventory_id.as_str())?;
262 ensure_unique_authority_subjects(&report.authorities)?;
263 if report.external_action_required_count
264 != report
265 .authorities
266 .iter()
267 .filter(|authority| authority.external_action_required)
268 .count()
269 || report.blocked_count
270 != report
271 .authorities
272 .iter()
273 .filter(|authority| authority.blocked)
274 .count()
275 {
276 return Err(LifecycleAuthorityReportError::CountMismatch);
277 }
278 if report.report_digest != lifecycle_authority_report_digest(report) {
279 return Err(LifecycleAuthorityReportError::DigestMismatch {
280 field: "report_digest",
281 });
282 }
283 Ok(())
284}
285
286#[must_use]
292pub fn external_lifecycle_plan_from_check(
293 lifecycle_plan_id: impl Into<String>,
294 lifecycle_authority_report_id: impl Into<String>,
295 check: &DeploymentCheckV1,
296) -> ExternalLifecyclePlanV1 {
297 let lifecycle_authority_report =
298 lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
299 let lifecycle_authority_rows = lifecycle_authority_report.authorities;
300 let directly_executable_role_upgrades = lifecycle_authority_rows
301 .iter()
302 .filter(|authority| {
303 authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
304 && !authority.blocked
305 })
306 .map(external_lifecycle_role_upgrade)
307 .collect::<Vec<_>>();
308 let proposed_external_role_upgrades = lifecycle_authority_rows
309 .iter()
310 .filter(|authority| authority.external_action_required && !authority.blocked)
311 .map(external_lifecycle_role_upgrade)
312 .collect::<Vec<_>>();
313 let blocked_role_upgrades = lifecycle_authority_rows
314 .iter()
315 .filter(|authority| authority.blocked)
316 .map(external_lifecycle_role_upgrade)
317 .collect::<Vec<_>>();
318 let residual_exposure = proposed_external_role_upgrades
319 .iter()
320 .map(|upgrade| {
321 format!(
322 "{} remains pending external lifecycle action",
323 upgrade.subject
324 )
325 })
326 .collect::<Vec<_>>();
327 let status = if !blocked_role_upgrades.is_empty() {
328 ExternalLifecyclePlanStatusV1::Blocked
329 } else if !proposed_external_role_upgrades.is_empty() {
330 ExternalLifecyclePlanStatusV1::PendingExternalAction
331 } else {
332 ExternalLifecyclePlanStatusV1::Ready
333 };
334 let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
335 let mut plan = ExternalLifecyclePlanV1 {
336 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
337 lifecycle_plan_id: lifecycle_plan_id.into(),
338 lifecycle_plan_digest: String::new(),
339 lifecycle_authority_report_id: lifecycle_authority_report.report_id,
340 deployment_plan_id: check.plan.plan_id.clone(),
341 deployment_plan_digest,
342 inventory_id: check.inventory.inventory_id.clone(),
343 lifecycle_authority_rows,
344 directly_executable_role_upgrades,
345 proposed_external_role_upgrades,
346 blocked_role_upgrades,
347 dependency_blockers: Vec::new(),
348 protected_call_implications: protected_call_implications_for_check(check),
349 residual_exposure,
350 status,
351 };
352 plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
353 plan
354}
355
356pub fn validate_external_lifecycle_plan(
358 plan: &ExternalLifecyclePlanV1,
359) -> Result<(), ExternalLifecyclePlanError> {
360 if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
361 return Err(ExternalLifecyclePlanError::SchemaVersionMismatch {
362 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
363 actual: plan.schema_version,
364 });
365 }
366 ensure_external_lifecycle_plan_field("lifecycle_plan_id", plan.lifecycle_plan_id.as_str())?;
367 ensure_external_lifecycle_plan_field(
368 "lifecycle_authority_report_id",
369 plan.lifecycle_authority_report_id.as_str(),
370 )?;
371 ensure_external_lifecycle_plan_field("deployment_plan_id", plan.deployment_plan_id.as_str())?;
372 ensure_external_lifecycle_plan_field("inventory_id", plan.inventory_id.as_str())?;
373 if plan.lifecycle_plan_digest != external_lifecycle_plan_digest(plan) {
374 return Err(ExternalLifecyclePlanError::DigestMismatch {
375 field: "lifecycle_plan_digest",
376 });
377 }
378 if plan.status != expected_lifecycle_plan_status(plan) {
379 return Err(ExternalLifecyclePlanError::StatusMismatch);
380 }
381 ensure_unique_lifecycle_subjects(&plan.lifecycle_authority_rows)?;
382 ensure_unique_role_upgrade_subjects(&plan.directly_executable_role_upgrades)?;
383 ensure_unique_role_upgrade_subjects(&plan.proposed_external_role_upgrades)?;
384 ensure_unique_role_upgrade_subjects(&plan.blocked_role_upgrades)?;
385 Ok(())
386}
387
388pub fn validate_external_lifecycle_plan_for_check(
391 plan: &ExternalLifecyclePlanV1,
392 check: &DeploymentCheckV1,
393) -> Result<(), ExternalLifecyclePlanError> {
394 validate_external_lifecycle_plan(plan)?;
395 let expected = external_lifecycle_plan_from_check(
396 plan.lifecycle_plan_id.clone(),
397 plan.lifecycle_authority_report_id.clone(),
398 check,
399 );
400 if plan != &expected {
401 return Err(ExternalLifecyclePlanError::SourceMismatch {
402 field: "deployment_check",
403 });
404 }
405 Ok(())
406}
407
408#[must_use]
413pub fn external_upgrade_receipt_from_observation(
414 receipt_id: impl Into<String>,
415 proposal: &ExternalUpgradeProposalV1,
416 consent_state: ExternalUpgradeConsentStateV1,
417 reported_by: Option<String>,
418 observed_after: Option<&ObservedCanisterV1>,
419) -> ExternalUpgradeReceiptV1 {
420 let observed_after_module_hash =
421 observed_after.and_then(|observed| observed.module_hash.clone());
422 let observed_after_canonical_embedded_config_sha256 =
423 observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
424 let verification_result = external_upgrade_verification_result(
425 consent_state,
426 proposal,
427 observed_after_module_hash.as_deref(),
428 observed_after_canonical_embedded_config_sha256.as_deref(),
429 );
430 let verification_notes = external_upgrade_verification_notes(
431 verification_result,
432 proposal,
433 observed_after_module_hash.as_deref(),
434 observed_after_canonical_embedded_config_sha256.as_deref(),
435 );
436
437 let mut receipt = ExternalUpgradeReceiptV1 {
438 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
439 receipt_id: receipt_id.into(),
440 proposal_id: proposal.proposal_id.clone(),
441 proposal_digest: proposal.proposal_digest.clone(),
442 subject: proposal.subject.clone(),
443 canister_id: proposal.canister_id.clone(),
444 role: proposal.role.clone(),
445 consent_state,
446 reported_by,
447 observed_before_module_hash: proposal.current_module_hash.clone(),
448 observed_after_module_hash,
449 observed_after_canonical_embedded_config_sha256,
450 verification_result,
451 verification_notes,
452 receipt_digest: String::new(),
453 };
454 receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
455 receipt
456}
457
458pub fn validate_external_upgrade_receipt(
463 receipt: &ExternalUpgradeReceiptV1,
464) -> Result<(), ExternalUpgradeReceiptError> {
465 if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
466 return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
467 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
468 actual: receipt.schema_version,
469 });
470 }
471 ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
472 ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
473 ensure_external_receipt_field("proposal_digest", receipt.proposal_digest.as_str())?;
474 ensure_external_receipt_field("subject", receipt.subject.as_str())?;
475 ensure_external_receipt_field("receipt_digest", receipt.receipt_digest.as_str())?;
476
477 if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
478 && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
479 {
480 return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
481 }
482 let has_observation = receipt.observed_after_module_hash.is_some()
483 || receipt
484 .observed_after_canonical_embedded_config_sha256
485 .is_some();
486 if matches!(
487 receipt.verification_result,
488 ExternalUpgradeVerificationResultV1::Verified
489 | ExternalUpgradeVerificationResultV1::Mismatch
490 ) && !has_observation
491 {
492 return Err(ExternalUpgradeReceiptError::VerificationMismatch);
493 }
494 if receipt.receipt_digest != external_upgrade_receipt_digest(receipt) {
495 return Err(ExternalUpgradeReceiptError::DigestMismatch {
496 field: "receipt_digest",
497 });
498 }
499 Ok(())
500}
501
502#[must_use]
507pub fn external_upgrade_proposal_report_from_lifecycle_plan(
508 report_id: impl Into<String>,
509 lifecycle_plan: &ExternalLifecyclePlanV1,
510 check: &DeploymentCheckV1,
511) -> ExternalUpgradeProposalReportV1 {
512 let report_id = report_id.into();
513 let mut proposals = Vec::new();
514 for authority in lifecycle_plan
515 .lifecycle_authority_rows
516 .iter()
517 .filter(|authority| authority.external_action_required && !authority.blocked)
518 {
519 proposals.push(external_upgrade_proposal(
520 &report_id,
521 lifecycle_plan,
522 check,
523 authority,
524 observed_canister_for_authority(&check.inventory, authority),
525 target_artifact_for_authority(&check.plan, authority),
526 ));
527 }
528
529 proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
530
531 let mut report = ExternalUpgradeProposalReportV1 {
532 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
533 report_id,
534 report_digest: String::new(),
535 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
536 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
537 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
538 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
539 inventory_id: check.inventory.inventory_id.clone(),
540 proposals,
541 blocked_subjects: lifecycle_plan
542 .blocked_role_upgrades
543 .iter()
544 .map(|upgrade| upgrade.subject.clone())
545 .collect(),
546 };
547 report.report_digest = external_upgrade_proposal_report_digest(&report);
548 report
549}
550
551pub fn validate_external_upgrade_proposal_report(
553 report: &ExternalUpgradeProposalReportV1,
554) -> Result<(), ExternalUpgradeProposalReportError> {
555 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
556 return Err(ExternalUpgradeProposalReportError::SchemaVersionMismatch {
557 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
558 actual: report.schema_version,
559 });
560 }
561 ensure_external_proposal_report_field("report_id", report.report_id.as_str())?;
562 ensure_external_proposal_report_field("report_digest", report.report_digest.as_str())?;
563 ensure_external_proposal_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
564 ensure_external_proposal_report_field(
565 "lifecycle_plan_digest",
566 report.lifecycle_plan_digest.as_str(),
567 )?;
568 ensure_external_proposal_report_field(
569 "deployment_plan_id",
570 report.deployment_plan_id.as_str(),
571 )?;
572 ensure_external_proposal_report_field(
573 "deployment_plan_digest",
574 report.deployment_plan_digest.as_str(),
575 )?;
576 ensure_external_proposal_report_field("inventory_id", report.inventory_id.as_str())?;
577
578 let mut subjects = BTreeSet::new();
579 for proposal in &report.proposals {
580 if !subjects.insert(proposal.subject.clone()) {
581 return Err(ExternalUpgradeProposalReportError::DuplicateSubject {
582 subject: proposal.subject.clone(),
583 });
584 }
585 validate_external_upgrade_proposal(proposal)?;
586 }
587 if report.report_digest != external_upgrade_proposal_report_digest(report) {
588 return Err(ExternalUpgradeProposalReportError::DigestMismatch {
589 field: "report_digest",
590 });
591 }
592 Ok(())
593}
594
595pub fn validate_external_upgrade_proposal_report_for_lifecycle_plan(
598 report: &ExternalUpgradeProposalReportV1,
599 lifecycle_plan: &ExternalLifecyclePlanV1,
600 check: &DeploymentCheckV1,
601) -> Result<(), ExternalUpgradeProposalReportError> {
602 validate_external_upgrade_proposal_report(report)?;
603 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
604 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
605 field: "lifecycle_plan_id",
606 });
607 }
608 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
609 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
610 field: "lifecycle_plan_digest",
611 });
612 }
613 let expected = external_upgrade_proposal_report_from_lifecycle_plan(
614 report.report_id.clone(),
615 lifecycle_plan,
616 check,
617 );
618 if report != &expected {
619 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
620 field: "deployment_check",
621 });
622 }
623 Ok(())
624}
625
626fn external_upgrade_proposal(
627 report_id: &str,
628 lifecycle_plan: &ExternalLifecyclePlanV1,
629 check: &DeploymentCheckV1,
630 authority: &LifecycleAuthorityV1,
631 observed: Option<&ObservedCanisterV1>,
632 target_artifact: Option<&RoleArtifactV1>,
633) -> ExternalUpgradeProposalV1 {
634 let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
635 let current_canonical_embedded_config_sha256 =
636 observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
637 let mut proposal = ExternalUpgradeProposalV1 {
638 proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
639 proposal_digest: String::new(),
640 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
641 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
642 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
643 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
644 promotion_plan_id: None,
645 promotion_plan_digest: None,
646 promotion_provenance_id: None,
647 promotion_provenance_digest: None,
648 subject: authority.subject.clone(),
649 canister_id: authority.canister_id.clone(),
650 role: authority.role.clone(),
651 control_class: authority.control_class,
652 lifecycle_mode: authority.lifecycle_mode,
653 observed_before_digest: observed_before_digest(
654 authority,
655 current_module_hash.as_ref(),
656 current_canonical_embedded_config_sha256.as_ref(),
657 ),
658 current_module_hash,
659 current_canonical_embedded_config_sha256,
660 target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
661 target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
662 target_installed_module_hash: target_artifact
663 .and_then(|artifact| artifact.installed_module_hash.clone()),
664 target_role_artifact_identity: target_artifact.map(role_artifact_identity),
665 target_canonical_embedded_config_sha256: target_artifact
666 .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
667 root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
668 authority_profile_hash: check
669 .plan
670 .deployment_identity
671 .authority_profile_hash
672 .clone(),
673 required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
674 consent_requirements: authority.consent_requirements.clone(),
675 allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
676 verification_requirements: authority.verification_requirements.clone(),
677 expires_at: None,
678 supersedes_proposal_id: None,
679 };
680 proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
681 proposal
682}
683
684fn validate_external_upgrade_proposal(
685 proposal: &ExternalUpgradeProposalV1,
686) -> Result<(), ExternalUpgradeProposalReportError> {
687 ensure_external_proposal_report_field("proposal_id", proposal.proposal_id.as_str())?;
688 ensure_external_proposal_report_field("proposal_digest", proposal.proposal_digest.as_str())?;
689 ensure_external_proposal_report_field(
690 "proposal.deployment_plan_id",
691 proposal.deployment_plan_id.as_str(),
692 )?;
693 ensure_external_proposal_report_field(
694 "proposal.deployment_plan_digest",
695 proposal.deployment_plan_digest.as_str(),
696 )?;
697 ensure_external_proposal_report_field(
698 "proposal.lifecycle_plan_id",
699 proposal.lifecycle_plan_id.as_str(),
700 )?;
701 ensure_external_proposal_report_field(
702 "proposal.lifecycle_plan_digest",
703 proposal.lifecycle_plan_digest.as_str(),
704 )?;
705 ensure_external_proposal_report_field(
706 "proposal.observed_before_digest",
707 proposal.observed_before_digest.as_str(),
708 )?;
709 ensure_external_proposal_report_field("proposal.subject", proposal.subject.as_str())?;
710 if proposal.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority {
711 return Err(
712 ExternalUpgradeProposalReportError::DirectLifecycleProposal {
713 subject: proposal.subject.clone(),
714 },
715 );
716 }
717 if proposal.proposal_digest != external_upgrade_proposal_digest(proposal) {
718 return Err(ExternalUpgradeProposalReportError::DigestMismatch {
719 field: "proposal_digest",
720 });
721 }
722 Ok(())
723}
724
725fn lifecycle_authority_for_expected_canister(
726 plan: &DeploymentPlanV1,
727 expected: &ExpectedCanisterV1,
728 observed: Option<&ObservedCanisterV1>,
729) -> LifecycleAuthorityV1 {
730 let canister_id = expected
731 .canister_id
732 .clone()
733 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
734 let role = Some(expected.role.clone());
735 let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
736 let observed_controllers =
737 observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
738 lifecycle_authority(
739 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
740 canister_id,
741 role,
742 control_class,
743 observed_controllers,
744 &plan.authority_profile.expected_controllers,
745 plan.expected_verifier_readiness.required,
746 )
747}
748
749fn lifecycle_authority_for_expected_pool(
750 expected: &ExpectedPoolCanisterV1,
751 observed: Option<&ObservedPoolCanisterV1>,
752) -> LifecycleAuthorityV1 {
753 let canister_id = expected
754 .canister_id
755 .clone()
756 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
757 let role = expected
758 .role
759 .clone()
760 .or_else(|| observed.and_then(|observed| observed.role.clone()));
761 let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
762 observed.control_class
763 });
764 lifecycle_authority(
765 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
766 canister_id,
767 role,
768 control_class,
769 Vec::new(),
770 &[],
771 false,
772 )
773}
774
775fn lifecycle_authority_for_unplanned_canister(
776 observed: &ObservedCanisterV1,
777) -> LifecycleAuthorityV1 {
778 lifecycle_authority(
779 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
780 Some(observed.canister_id.clone()),
781 observed.role.clone(),
782 observed.control_class,
783 observed.controllers.clone(),
784 &[],
785 false,
786 )
787}
788
789fn lifecycle_authority_for_unplanned_pool(
790 observed: &ObservedPoolCanisterV1,
791) -> LifecycleAuthorityV1 {
792 lifecycle_authority(
793 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
794 Some(observed.canister_id.clone()),
795 observed.role.clone(),
796 observed.control_class,
797 Vec::new(),
798 &[],
799 false,
800 )
801}
802
803fn lifecycle_authority(
804 subject: String,
805 canister_id: Option<String>,
806 role: Option<String>,
807 control_class: CanisterControlClassV1,
808 observed_controllers: Vec<String>,
809 expected_controllers: &[String],
810 verifier_required: bool,
811) -> LifecycleAuthorityV1 {
812 let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
813 let external_controllers =
814 external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
815 let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
816 let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
817 let verification_requirements = lifecycle_verification_requirements(verifier_required);
818 let external_action_required = lifecycle_external_action_required(control_class);
819 let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
820 let lifecycle_mode = lifecycle_mode(control_class);
821 let blockers = lifecycle_blockers(control_class);
822 let warnings = lifecycle_warnings(control_class);
823 let reason = lifecycle_reason(control_class);
824 LifecycleAuthorityV1 {
825 subject,
826 canister_id,
827 role,
828 control_class,
829 lifecycle_mode,
830 observed_controllers,
831 expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
832 external_controllers,
833 required_controllers,
834 consent_requirements,
835 allowed_upgrade_modes,
836 verification_requirements,
837 external_action_required,
838 blocked,
839 blockers,
840 warnings,
841 reason,
842 }
843}
844
845fn required_lifecycle_controllers(
846 control_class: CanisterControlClassV1,
847 expected_controllers: &[String],
848) -> Vec<String> {
849 match control_class {
850 CanisterControlClassV1::DeploymentControlled
851 | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
852 CanisterControlClassV1::CanicManagedPool
853 | CanisterControlClassV1::ExternallyImported
854 | CanisterControlClassV1::UserControlled
855 | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
856 }
857}
858
859fn external_lifecycle_controllers(
860 control_class: CanisterControlClassV1,
861 observed_controllers: &[String],
862 required_controllers: &[String],
863) -> Vec<String> {
864 match control_class {
865 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
866 Vec::new()
867 }
868 CanisterControlClassV1::JointlyControlled => {
869 let required = required_controllers.iter().collect::<BTreeSet<_>>();
870 sorted_unique(
871 observed_controllers
872 .iter()
873 .filter(|controller| !required.contains(controller))
874 .cloned()
875 .collect(),
876 )
877 }
878 CanisterControlClassV1::CanicManagedPool
879 | CanisterControlClassV1::ExternallyImported
880 | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
881 }
882}
883
884fn lifecycle_consent_requirements(
885 control_class: CanisterControlClassV1,
886 external_controllers: &[String],
887) -> Vec<ConsentRequirementV1> {
888 if !lifecycle_external_action_required(control_class) {
889 return Vec::new();
890 }
891 vec![ConsentRequirementV1 {
892 consent_subject_kind: consent_subject_kind(control_class),
893 required_principals: sorted_unique(external_controllers.to_vec()),
894 required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
895 consent_channel_kind: consent_channel_kind(control_class),
896 required_action: required_consent_action(control_class),
897 }]
898}
899
900const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
901 match control_class {
902 CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
903 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
904 ConsentSubjectKindV1::CustomerController
905 }
906 CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
907 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
908 ConsentSubjectKindV1::UnknownExternalController
909 }
910 }
911}
912
913const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
914 match control_class {
915 CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
916 CanisterControlClassV1::ExternallyImported
917 | CanisterControlClassV1::JointlyControlled
918 | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
919 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
920 ConsentChannelKindV1::OutOfBand
921 }
922 }
923}
924
925const fn required_consent_action(
926 control_class: CanisterControlClassV1,
927) -> ExternalUpgradeAuthorizationModeV1 {
928 match control_class {
929 CanisterControlClassV1::JointlyControlled => {
930 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
931 }
932 CanisterControlClassV1::CanicManagedPool => {
933 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
934 }
935 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
936 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
937 }
938 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
939 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
940 }
941 }
942}
943
944const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
945 match control_class {
946 CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
947 CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
948 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
949 LifecycleModeV1::ExternalCompletionOnly
950 }
951 CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
952 CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
953 }
954}
955
956fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
957 if control_class == CanisterControlClassV1::UnknownUnsafe {
958 vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
959 } else {
960 Vec::new()
961 }
962}
963
964fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
965 match control_class {
966 CanisterControlClassV1::CanicManagedPool => {
967 vec!["pool-aware lifecycle policy is required before mutation".to_string()]
968 }
969 CanisterControlClassV1::ExternallyImported => {
970 vec!["external controller action or verification is required".to_string()]
971 }
972 CanisterControlClassV1::JointlyControlled => {
973 vec!["joint controller consent or delegation is required".to_string()]
974 }
975 CanisterControlClassV1::UserControlled => {
976 vec!["user or delegated lifecycle action is required".to_string()]
977 }
978 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
979 Vec::new()
980 }
981 }
982}
983
984fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
985 match control_class {
986 CanisterControlClassV1::DeploymentControlled => vec![
987 LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
988 LifecycleUpgradeModeV1::VerifyExternalCompletion,
989 ],
990 CanisterControlClassV1::CanicManagedPool
991 | CanisterControlClassV1::ExternallyImported
992 | CanisterControlClassV1::JointlyControlled
993 | CanisterControlClassV1::UserControlled => vec![
994 LifecycleUpgradeModeV1::ExternalProposal,
995 LifecycleUpgradeModeV1::ExternalExecution,
996 LifecycleUpgradeModeV1::VerifyExternalCompletion,
997 LifecycleUpgradeModeV1::ObserveOnly,
998 ],
999 CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
1000 }
1001}
1002
1003fn lifecycle_verification_requirements(
1004 verifier_required: bool,
1005) -> Vec<LifecycleVerificationRequirementV1> {
1006 let mut requirements = vec![
1007 LifecycleVerificationRequirementV1::LiveInventory,
1008 LifecycleVerificationRequirementV1::ControllerObservation,
1009 LifecycleVerificationRequirementV1::ModuleHash,
1010 LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
1011 ];
1012 if verifier_required {
1013 requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
1014 }
1015 requirements
1016}
1017
1018const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
1019 matches!(
1020 control_class,
1021 CanisterControlClassV1::CanicManagedPool
1022 | CanisterControlClassV1::ExternallyImported
1023 | CanisterControlClassV1::JointlyControlled
1024 | CanisterControlClassV1::UserControlled
1025 )
1026}
1027
1028fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
1029 match control_class {
1030 CanisterControlClassV1::DeploymentControlled => {
1031 "deployment authority can execute lifecycle directly".to_string()
1032 }
1033 CanisterControlClassV1::CanicManagedPool => {
1034 "Canic-managed pool lifecycle requires pool-aware external action".to_string()
1035 }
1036 CanisterControlClassV1::ExternallyImported => {
1037 "externally imported canister requires external controller action".to_string()
1038 }
1039 CanisterControlClassV1::JointlyControlled => {
1040 "jointly controlled canister requires non-deployment-controller consent".to_string()
1041 }
1042 CanisterControlClassV1::UserControlled => {
1043 "user-controlled canister requires user or delegated lifecycle action".to_string()
1044 }
1045 CanisterControlClassV1::UnknownUnsafe => {
1046 "unknown or unsafe controller state blocks lifecycle action".to_string()
1047 }
1048 }
1049}
1050
1051fn observed_canister_for_expected<'a>(
1052 inventory: &'a DeploymentInventoryV1,
1053 expected: &ExpectedCanisterV1,
1054) -> Option<&'a ObservedCanisterV1> {
1055 if let Some(canister_id) = &expected.canister_id
1056 && let Some(observed) = inventory
1057 .observed_canisters
1058 .iter()
1059 .find(|observed| &observed.canister_id == canister_id)
1060 {
1061 return Some(observed);
1062 }
1063 inventory
1064 .observed_canisters
1065 .iter()
1066 .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
1067}
1068
1069fn observed_pool_for_expected<'a>(
1070 inventory: &'a DeploymentInventoryV1,
1071 expected: &ExpectedPoolCanisterV1,
1072) -> Option<&'a ObservedPoolCanisterV1> {
1073 if let Some(canister_id) = &expected.canister_id
1074 && let Some(observed) = inventory
1075 .observed_pool
1076 .iter()
1077 .find(|observed| &observed.canister_id == canister_id)
1078 {
1079 return Some(observed);
1080 }
1081 inventory.observed_pool.iter().find(|observed| {
1082 observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
1083 })
1084}
1085
1086fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
1087 lifecycle_subject_for_parts(Some(canister_id), role)
1088}
1089
1090fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
1091 match (role, canister_id) {
1092 (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
1093 (Some(role), None) => format!("{role}:unassigned"),
1094 (None, Some(canister_id)) => canister_id.to_string(),
1095 (None, None) => "unknown".to_string(),
1096 }
1097}
1098
1099fn observed_canister_for_authority<'a>(
1100 inventory: &'a DeploymentInventoryV1,
1101 authority: &LifecycleAuthorityV1,
1102) -> Option<&'a ObservedCanisterV1> {
1103 if let Some(canister_id) = &authority.canister_id
1104 && let Some(observed) = inventory
1105 .observed_canisters
1106 .iter()
1107 .find(|observed| &observed.canister_id == canister_id)
1108 {
1109 return Some(observed);
1110 }
1111 inventory
1112 .observed_canisters
1113 .iter()
1114 .find(|observed| observed.role == authority.role)
1115}
1116
1117fn target_artifact_for_authority<'a>(
1118 plan: &'a DeploymentPlanV1,
1119 authority: &LifecycleAuthorityV1,
1120) -> Option<&'a RoleArtifactV1> {
1121 let role = authority.role.as_ref()?;
1122 plan.role_artifacts
1123 .iter()
1124 .find(|artifact| &artifact.role == role)
1125}
1126
1127fn external_lifecycle_role_upgrade(
1128 authority: &LifecycleAuthorityV1,
1129) -> ExternalLifecycleRoleUpgradeV1 {
1130 ExternalLifecycleRoleUpgradeV1 {
1131 subject: authority.subject.clone(),
1132 canister_id: authority.canister_id.clone(),
1133 role: authority.role.clone(),
1134 control_class: authority.control_class,
1135 lifecycle_mode: authority.lifecycle_mode,
1136 required_external_action: authority
1137 .external_action_required
1138 .then(|| required_external_action(authority.lifecycle_mode).to_string()),
1139 blockers: authority.blockers.clone(),
1140 warnings: authority.warnings.clone(),
1141 }
1142}
1143
1144fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
1145 if check.plan.expected_verifier_readiness.required {
1146 vec!["protected-call verifier readiness must be checked before completion".to_string()]
1147 } else {
1148 Vec::new()
1149 }
1150}
1151
1152const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
1153 match lifecycle_mode {
1154 LifecycleModeV1::DirectDeploymentAuthority => "none",
1155 LifecycleModeV1::ProposalRequired => "proposal_and_consent",
1156 LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
1157 LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
1158 LifecycleModeV1::VerifyOnly => "verify_external_completion",
1159 LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
1160 }
1161}
1162
1163fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
1164 stable_json_sha256_hex(&(
1165 artifact.role.as_str(),
1166 artifact.wasm_sha256.as_deref(),
1167 artifact.wasm_gz_sha256.as_deref(),
1168 artifact.installed_module_hash.as_deref(),
1169 artifact.candid_sha256.as_deref(),
1170 artifact.canonical_embedded_config_sha256.as_deref(),
1171 ))
1172}
1173
1174fn external_upgrade_authorization_modes(
1175 control_class: CanisterControlClassV1,
1176) -> Vec<ExternalUpgradeAuthorizationModeV1> {
1177 match control_class {
1178 CanisterControlClassV1::JointlyControlled => vec![
1179 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
1180 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1181 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1182 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1183 ],
1184 CanisterControlClassV1::CanicManagedPool
1185 | CanisterControlClassV1::ExternallyImported
1186 | CanisterControlClassV1::UserControlled => vec![
1187 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1188 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1189 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1190 ],
1191 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1192 Vec::new()
1193 }
1194 }
1195}
1196
1197fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
1198 let subject = subject.replace([':', '/'], "-");
1199 format!("{report_id}:{subject}")
1200}
1201
1202fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
1203 stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
1204 lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
1205 deployment_plan_id: &plan.deployment_plan_id,
1206 deployment_plan_digest: &plan.deployment_plan_digest,
1207 inventory_id: &plan.inventory_id,
1208 lifecycle_authority_rows: &plan.lifecycle_authority_rows,
1209 directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
1210 proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
1211 blocked_role_upgrades: &plan.blocked_role_upgrades,
1212 dependency_blockers: &plan.dependency_blockers,
1213 protected_call_implications: &plan.protected_call_implications,
1214 residual_exposure: &plan.residual_exposure,
1215 status: plan.status,
1216 })
1217}
1218
1219fn lifecycle_authority_report_digest(report: &LifecycleAuthorityReportV1) -> String {
1220 stable_json_sha256_hex(&LifecycleAuthorityReportDigestInput {
1221 report_id: &report.report_id,
1222 check_id: &report.check_id,
1223 plan_id: &report.plan_id,
1224 inventory_id: &report.inventory_id,
1225 authorities: &report.authorities,
1226 external_action_required_count: report.external_action_required_count,
1227 blocked_count: report.blocked_count,
1228 })
1229}
1230
1231const fn expected_lifecycle_plan_status(
1232 plan: &ExternalLifecyclePlanV1,
1233) -> ExternalLifecyclePlanStatusV1 {
1234 if !plan.blocked_role_upgrades.is_empty() {
1235 ExternalLifecyclePlanStatusV1::Blocked
1236 } else if !plan.proposed_external_role_upgrades.is_empty() {
1237 ExternalLifecyclePlanStatusV1::PendingExternalAction
1238 } else {
1239 ExternalLifecyclePlanStatusV1::Ready
1240 }
1241}
1242
1243fn ensure_unique_lifecycle_subjects(
1244 rows: &[LifecycleAuthorityV1],
1245) -> Result<(), ExternalLifecyclePlanError> {
1246 let mut subjects = BTreeSet::new();
1247 for row in rows {
1248 if !subjects.insert(row.subject.clone()) {
1249 return Err(ExternalLifecyclePlanError::DuplicateSubject {
1250 subject: row.subject.clone(),
1251 });
1252 }
1253 }
1254 Ok(())
1255}
1256
1257fn ensure_unique_authority_subjects(
1258 rows: &[LifecycleAuthorityV1],
1259) -> Result<(), LifecycleAuthorityReportError> {
1260 let mut subjects = BTreeSet::new();
1261 for row in rows {
1262 if !subjects.insert(row.subject.clone()) {
1263 return Err(LifecycleAuthorityReportError::DuplicateSubject {
1264 subject: row.subject.clone(),
1265 });
1266 }
1267 }
1268 Ok(())
1269}
1270
1271fn ensure_unique_role_upgrade_subjects(
1272 rows: &[ExternalLifecycleRoleUpgradeV1],
1273) -> Result<(), ExternalLifecyclePlanError> {
1274 let mut subjects = BTreeSet::new();
1275 for row in rows {
1276 if !subjects.insert(row.subject.clone()) {
1277 return Err(ExternalLifecyclePlanError::DuplicateSubject {
1278 subject: row.subject.clone(),
1279 });
1280 }
1281 }
1282 Ok(())
1283}
1284
1285fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
1286 stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
1287 deployment_plan_id: &proposal.deployment_plan_id,
1288 deployment_plan_digest: &proposal.deployment_plan_digest,
1289 lifecycle_plan_id: &proposal.lifecycle_plan_id,
1290 lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
1291 promotion_plan_id: &proposal.promotion_plan_id,
1292 promotion_plan_digest: &proposal.promotion_plan_digest,
1293 promotion_provenance_id: &proposal.promotion_provenance_id,
1294 promotion_provenance_digest: &proposal.promotion_provenance_digest,
1295 subject: &proposal.subject,
1296 canister_id: &proposal.canister_id,
1297 role: &proposal.role,
1298 control_class: proposal.control_class,
1299 lifecycle_mode: proposal.lifecycle_mode,
1300 observed_before_digest: &proposal.observed_before_digest,
1301 current_module_hash: &proposal.current_module_hash,
1302 current_canonical_embedded_config_sha256: &proposal
1303 .current_canonical_embedded_config_sha256,
1304 target_wasm_sha256: &proposal.target_wasm_sha256,
1305 target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
1306 target_installed_module_hash: &proposal.target_installed_module_hash,
1307 target_role_artifact_identity: &proposal.target_role_artifact_identity,
1308 target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
1309 root_trust_anchor: &proposal.root_trust_anchor,
1310 authority_profile_hash: &proposal.authority_profile_hash,
1311 required_external_action: &proposal.required_external_action,
1312 consent_requirements: &proposal.consent_requirements,
1313 allowed_authorization_modes: &proposal.allowed_authorization_modes,
1314 verification_requirements: &proposal.verification_requirements,
1315 expires_at: &proposal.expires_at,
1316 supersedes_proposal_id: &proposal.supersedes_proposal_id,
1317 })
1318}
1319
1320fn external_upgrade_proposal_report_digest(report: &ExternalUpgradeProposalReportV1) -> String {
1321 stable_json_sha256_hex(&ExternalUpgradeProposalReportDigestInput {
1322 report_id: &report.report_id,
1323 lifecycle_plan_id: &report.lifecycle_plan_id,
1324 lifecycle_plan_digest: &report.lifecycle_plan_digest,
1325 deployment_plan_id: &report.deployment_plan_id,
1326 deployment_plan_digest: &report.deployment_plan_digest,
1327 inventory_id: &report.inventory_id,
1328 proposals: &report.proposals,
1329 blocked_subjects: &report.blocked_subjects,
1330 })
1331}
1332
1333fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
1334 stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
1335 proposal_id: &receipt.proposal_id,
1336 proposal_digest: &receipt.proposal_digest,
1337 subject: &receipt.subject,
1338 canister_id: &receipt.canister_id,
1339 role: &receipt.role,
1340 consent_state: receipt.consent_state,
1341 reported_by: &receipt.reported_by,
1342 observed_before_module_hash: &receipt.observed_before_module_hash,
1343 observed_after_module_hash: &receipt.observed_after_module_hash,
1344 observed_after_canonical_embedded_config_sha256: &receipt
1345 .observed_after_canonical_embedded_config_sha256,
1346 verification_result: receipt.verification_result,
1347 verification_notes: &receipt.verification_notes,
1348 })
1349}
1350
1351fn observed_before_digest(
1352 authority: &LifecycleAuthorityV1,
1353 current_module_hash: Option<&String>,
1354 current_config_hash: Option<&String>,
1355) -> String {
1356 stable_json_sha256_hex(&ObservedBeforeDigestInput {
1357 subject: &authority.subject,
1358 canister_id: &authority.canister_id,
1359 role: &authority.role,
1360 observed_controllers: &authority.observed_controllers,
1361 current_module_hash,
1362 current_canonical_embedded_config_sha256: current_config_hash,
1363 })
1364}
1365
1366fn external_upgrade_verification_result(
1367 consent_state: ExternalUpgradeConsentStateV1,
1368 proposal: &ExternalUpgradeProposalV1,
1369 observed_after_module_hash: Option<&str>,
1370 observed_after_config: Option<&str>,
1371) -> ExternalUpgradeVerificationResultV1 {
1372 match consent_state {
1373 ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
1374 ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
1375 ExternalUpgradeConsentStateV1::Delegated
1376 | ExternalUpgradeConsentStateV1::ExecutedExternally => {
1377 if external_upgrade_observation_matches(
1378 proposal.target_installed_module_hash.as_deref(),
1379 observed_after_module_hash,
1380 ) && external_upgrade_observation_matches(
1381 proposal.target_canonical_embedded_config_sha256.as_deref(),
1382 observed_after_config,
1383 ) {
1384 ExternalUpgradeVerificationResultV1::Verified
1385 } else {
1386 ExternalUpgradeVerificationResultV1::Mismatch
1387 }
1388 }
1389 }
1390}
1391
1392fn external_upgrade_verification_notes(
1393 verification_result: ExternalUpgradeVerificationResultV1,
1394 proposal: &ExternalUpgradeProposalV1,
1395 observed_after_module_hash: Option<&str>,
1396 observed_after_config: Option<&str>,
1397) -> Vec<String> {
1398 let mut notes = Vec::new();
1399 if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
1400 if !external_upgrade_observation_matches(
1401 proposal.target_installed_module_hash.as_deref(),
1402 observed_after_module_hash,
1403 ) {
1404 notes.push("observed module hash does not match proposal target".to_string());
1405 }
1406 if !external_upgrade_observation_matches(
1407 proposal.target_canonical_embedded_config_sha256.as_deref(),
1408 observed_after_config,
1409 ) {
1410 notes.push("observed embedded config does not match proposal target".to_string());
1411 }
1412 }
1413 notes
1414}
1415
1416fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
1417 expected.is_none_or(|expected| observed == Some(expected))
1418}
1419
1420fn ensure_external_receipt_field(
1421 field: &'static str,
1422 value: &str,
1423) -> Result<(), ExternalUpgradeReceiptError> {
1424 if value.trim().is_empty() {
1425 return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
1426 }
1427 Ok(())
1428}
1429
1430fn ensure_external_lifecycle_plan_field(
1431 field: &'static str,
1432 value: &str,
1433) -> Result<(), ExternalLifecyclePlanError> {
1434 if value.trim().is_empty() {
1435 return Err(ExternalLifecyclePlanError::MissingRequiredField { field });
1436 }
1437 Ok(())
1438}
1439
1440fn ensure_external_proposal_report_field(
1441 field: &'static str,
1442 value: &str,
1443) -> Result<(), ExternalUpgradeProposalReportError> {
1444 if value.trim().is_empty() {
1445 return Err(ExternalUpgradeProposalReportError::MissingRequiredField { field });
1446 }
1447 Ok(())
1448}
1449
1450fn ensure_lifecycle_authority_report_field(
1451 field: &'static str,
1452 value: &str,
1453) -> Result<(), LifecycleAuthorityReportError> {
1454 if value.trim().is_empty() {
1455 return Err(LifecycleAuthorityReportError::MissingRequiredField { field });
1456 }
1457 Ok(())
1458}
1459
1460fn sorted_unique(values: Vec<String>) -> Vec<String> {
1461 values
1462 .into_iter()
1463 .collect::<BTreeSet<_>>()
1464 .into_iter()
1465 .collect()
1466}