1use super::*;
2use serde::Serialize;
3use std::collections::{BTreeMap, 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 ExternalLifecyclePendingReportDigestInput<'a> {
46 report_id: &'a str,
47 lifecycle_plan_id: &'a str,
48 lifecycle_plan_digest: &'a str,
49 proposal_report_id: &'a str,
50 proposal_report_digest: &'a str,
51 deployment_plan_id: &'a str,
52 deployment_plan_digest: &'a str,
53 inventory_id: &'a str,
54 direct_upgrade_count: usize,
55 pending_external_count: usize,
56 blocked_count: usize,
57 pending_external_actions: &'a [ExternalLifecyclePendingActionV1],
58 blocked_subjects: &'a [String],
59 residual_exposure: &'a [String],
60 status: ExternalLifecyclePlanStatusV1,
61}
62
63#[derive(Serialize)]
64struct ExternalLifecycleCheckDigestInput<'a> {
65 check_id: &'a str,
66 lifecycle_plan_id: &'a str,
67 lifecycle_plan_digest: &'a str,
68 proposal_report_id: &'a str,
69 proposal_report_digest: &'a str,
70 pending_report_id: &'a str,
71 pending_report_digest: &'a str,
72 deployment_plan_id: &'a str,
73 deployment_plan_digest: &'a str,
74 inventory_id: &'a str,
75 status: ExternalLifecyclePlanStatusV1,
76 direct_upgrade_count: usize,
77 pending_external_count: usize,
78 blocked_count: usize,
79 residual_exposure_count: usize,
80 summary: &'a str,
81 next_actions: &'a [String],
82}
83
84#[derive(Serialize)]
85struct ExternalLifecycleHandoffDigestInput<'a> {
86 handoff_id: &'a str,
87 lifecycle_check_id: &'a str,
88 lifecycle_check_digest: &'a str,
89 pending_report_id: &'a str,
90 pending_report_digest: &'a str,
91 proposal_report_id: &'a str,
92 proposal_report_digest: &'a str,
93 deployment_plan_id: &'a str,
94 deployment_plan_digest: &'a str,
95 inventory_id: &'a str,
96 status: ExternalLifecyclePlanStatusV1,
97 handoff_actions: &'a [ExternalLifecycleHandoffActionV1],
98 blocked_subjects: &'a [String],
99 residual_exposure: &'a [String],
100 operator_summary: &'a str,
101}
102
103#[derive(Serialize)]
104struct CriticalExternalFixReportDigestInput<'a> {
105 report_id: &'a str,
106 fix_id: &'a str,
107 severity: &'a str,
108 lifecycle_plan_id: &'a str,
109 lifecycle_plan_digest: &'a str,
110 pending_report_id: &'a str,
111 pending_report_digest: &'a str,
112 deployment_plan_id: &'a str,
113 deployment_plan_digest: &'a str,
114 inventory_id: &'a str,
115 affected_roles: &'a [String],
116 affected_canisters: &'a [String],
117 directly_patchable_roles: &'a [String],
118 externally_blocked_roles: &'a [String],
119 dependency_blocked_roles: &'a [String],
120 required_external_actions: &'a [String],
121 protected_call_implications: &'a [String],
122 residual_exposure: &'a [String],
123 operator_next_steps: &'a [String],
124}
125
126#[derive(Serialize)]
127struct ExternalUpgradeProposalDigestInput<'a> {
128 deployment_plan_id: &'a str,
129 deployment_plan_digest: &'a str,
130 lifecycle_plan_id: &'a str,
131 lifecycle_plan_digest: &'a str,
132 promotion_plan_id: &'a Option<String>,
133 promotion_plan_digest: &'a Option<String>,
134 promotion_provenance_id: &'a Option<String>,
135 promotion_provenance_digest: &'a Option<String>,
136 subject: &'a str,
137 canister_id: &'a Option<String>,
138 role: &'a Option<String>,
139 control_class: CanisterControlClassV1,
140 lifecycle_mode: LifecycleModeV1,
141 observed_before_digest: &'a str,
142 current_module_hash: &'a Option<String>,
143 current_canonical_embedded_config_sha256: &'a Option<String>,
144 target_wasm_sha256: &'a Option<String>,
145 target_wasm_gz_sha256: &'a Option<String>,
146 target_installed_module_hash: &'a Option<String>,
147 target_role_artifact_identity: &'a Option<String>,
148 target_canonical_embedded_config_sha256: &'a Option<String>,
149 root_trust_anchor: &'a Option<String>,
150 authority_profile_hash: &'a Option<String>,
151 required_external_action: &'a str,
152 consent_requirements: &'a [ConsentRequirementV1],
153 allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
154 verification_requirements: &'a [LifecycleVerificationRequirementV1],
155 expires_at: &'a Option<String>,
156 supersedes_proposal_id: &'a Option<String>,
157}
158
159#[derive(Serialize)]
160struct ExternalUpgradeReceiptDigestInput<'a> {
161 proposal_id: &'a str,
162 proposal_digest: &'a str,
163 subject: &'a str,
164 canister_id: &'a Option<String>,
165 role: &'a Option<String>,
166 consent_state: ExternalUpgradeConsentStateV1,
167 reported_by: &'a Option<String>,
168 observed_before_module_hash: &'a Option<String>,
169 observed_after_module_hash: &'a Option<String>,
170 observed_after_canonical_embedded_config_sha256: &'a Option<String>,
171 verification_result: ExternalUpgradeVerificationResultV1,
172 verification_notes: &'a [String],
173}
174
175#[derive(Serialize)]
176struct ExternalUpgradeConsentEvidenceDigestInput<'a> {
177 evidence_id: &'a str,
178 proposal_id: &'a str,
179 proposal_digest: &'a str,
180 receipt_id: &'a str,
181 receipt_digest: &'a str,
182 subject: &'a str,
183 canister_id: &'a Option<String>,
184 role: &'a Option<String>,
185 consent_state: ExternalUpgradeConsentStateV1,
186 reported_by: &'a Option<String>,
187 consent_requirements: &'a [ConsentRequirementV1],
188 allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
189 status_summary: &'a str,
190}
191
192#[derive(Serialize)]
193struct ExternalUpgradeVerificationReportDigestInput<'a> {
194 report_id: &'a str,
195 proposal_id: &'a str,
196 proposal_digest: &'a str,
197 receipt_id: &'a str,
198 receipt_digest: &'a str,
199 subject: &'a str,
200 canister_id: &'a Option<String>,
201 role: &'a Option<String>,
202 verification_result: ExternalUpgradeVerificationResultV1,
203 verification_notes: &'a [String],
204 live_inventory_required: bool,
205 status_summary: &'a str,
206}
207
208#[derive(Serialize)]
209struct ObservedBeforeDigestInput<'a> {
210 subject: &'a str,
211 canister_id: &'a Option<String>,
212 role: &'a Option<String>,
213 observed_controllers: &'a [String],
214 current_module_hash: Option<&'a String>,
215 current_canonical_embedded_config_sha256: Option<&'a String>,
216}
217
218#[derive(Debug, Eq, thiserror::Error, PartialEq)]
222pub enum ExternalUpgradeReceiptError {
223 #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
224 SchemaVersionMismatch { expected: u32, actual: u32 },
225 #[error("external upgrade receipt field `{field}` is required")]
226 MissingRequiredField { field: &'static str },
227 #[error("external upgrade receipt field `{field}` digest is stale")]
228 DigestMismatch { field: &'static str },
229 #[error("external upgrade receipt field `{field}` does not match proposal source")]
230 SourceMismatch { field: &'static str },
231 #[error("external upgrade receipt verification result does not match observations")]
232 VerificationMismatch,
233 #[error("external upgrade receipt refused consent cannot be verified")]
234 RefusedConsentVerified,
235}
236
237#[derive(Debug, Eq, thiserror::Error, PartialEq)]
241pub enum ExternalUpgradeConsentEvidenceError {
242 #[error(
243 "external upgrade consent evidence schema version {actual} does not match expected {expected}"
244 )]
245 SchemaVersionMismatch { expected: u32, actual: u32 },
246 #[error("external upgrade consent evidence field `{field}` is required")]
247 MissingRequiredField { field: &'static str },
248 #[error("external upgrade consent evidence field `{field}` digest is stale")]
249 DigestMismatch { field: &'static str },
250 #[error("external upgrade consent evidence field `{field}` no longer matches source receipt")]
251 SourceMismatch { field: &'static str },
252 #[error(transparent)]
253 Receipt(#[from] ExternalUpgradeReceiptError),
254}
255
256#[derive(Debug, Eq, thiserror::Error, PartialEq)]
260pub enum ExternalUpgradeVerificationReportError {
261 #[error(
262 "external upgrade verification report schema version {actual} does not match expected {expected}"
263 )]
264 SchemaVersionMismatch { expected: u32, actual: u32 },
265 #[error("external upgrade verification report field `{field}` is required")]
266 MissingRequiredField { field: &'static str },
267 #[error("external upgrade verification report field `{field}` digest is stale")]
268 DigestMismatch { field: &'static str },
269 #[error("external upgrade verification report field `{field}` does not match source evidence")]
270 SourceMismatch { field: &'static str },
271 #[error(transparent)]
272 Receipt(#[from] ExternalUpgradeReceiptError),
273}
274
275#[derive(Debug, Eq, thiserror::Error, PartialEq)]
279pub enum LifecycleAuthorityReportError {
280 #[error(
281 "lifecycle authority report schema version {actual} does not match expected {expected}"
282 )]
283 SchemaVersionMismatch { expected: u32, actual: u32 },
284 #[error("lifecycle authority report field `{field}` is required")]
285 MissingRequiredField { field: &'static str },
286 #[error("lifecycle authority report field `{field}` digest is stale")]
287 DigestMismatch { field: &'static str },
288 #[error("lifecycle authority report contains duplicate subject `{subject}`")]
289 DuplicateSubject { subject: String },
290 #[error("lifecycle authority report counters do not match authority rows")]
291 CountMismatch,
292}
293
294#[derive(Debug, Eq, thiserror::Error, PartialEq)]
298pub enum ExternalLifecyclePlanError {
299 #[error("external lifecycle plan schema version {actual} does not match expected {expected}")]
300 SchemaVersionMismatch { expected: u32, actual: u32 },
301 #[error("external lifecycle plan field `{field}` is required")]
302 MissingRequiredField { field: &'static str },
303 #[error("external lifecycle plan field `{field}` digest is stale")]
304 DigestMismatch { field: &'static str },
305 #[error("external lifecycle plan field `{field}` does not match deployment truth source")]
306 SourceMismatch { field: &'static str },
307 #[error("external lifecycle plan status does not match role partitioning")]
308 StatusMismatch,
309 #[error("external lifecycle plan contains duplicate subject `{subject}`")]
310 DuplicateSubject { subject: String },
311}
312
313#[derive(Debug, Eq, thiserror::Error, PartialEq)]
317pub enum ExternalUpgradeProposalReportError {
318 #[error(
319 "external upgrade proposal report schema version {actual} does not match expected {expected}"
320 )]
321 SchemaVersionMismatch { expected: u32, actual: u32 },
322 #[error("external upgrade proposal report field `{field}` is required")]
323 MissingRequiredField { field: &'static str },
324 #[error("external upgrade proposal report field `{field}` digest is stale")]
325 DigestMismatch { field: &'static str },
326 #[error("external upgrade proposal report field `{field}` does not match lifecycle source")]
327 SourceMismatch { field: &'static str },
328 #[error(
329 "external upgrade proposal report contains proposal for directly controlled row `{subject}`"
330 )]
331 DirectLifecycleProposal { subject: String },
332 #[error("external upgrade proposal report contains duplicate subject `{subject}`")]
333 DuplicateSubject { subject: String },
334}
335
336#[derive(Debug, Eq, thiserror::Error, PartialEq)]
340pub enum ExternalLifecyclePendingReportError {
341 #[error(
342 "external lifecycle pending report schema version {actual} does not match expected {expected}"
343 )]
344 SchemaVersionMismatch { expected: u32, actual: u32 },
345 #[error("external lifecycle pending report field `{field}` is required")]
346 MissingRequiredField { field: &'static str },
347 #[error("external lifecycle pending report field `{field}` digest is stale")]
348 DigestMismatch { field: &'static str },
349 #[error("external lifecycle pending report field `{field}` does not match lifecycle source")]
350 SourceMismatch { field: &'static str },
351 #[error("external lifecycle pending report counters do not match action rows")]
352 CountMismatch,
353 #[error("external lifecycle pending report contains duplicate subject `{subject}`")]
354 DuplicateSubject { subject: String },
355}
356
357#[derive(Debug, Eq, thiserror::Error, PartialEq)]
361pub enum ExternalLifecycleCheckError {
362 #[error("external lifecycle check schema version {actual} does not match expected {expected}")]
363 SchemaVersionMismatch { expected: u32, actual: u32 },
364 #[error("external lifecycle check field `{field}` is required")]
365 MissingRequiredField { field: &'static str },
366 #[error("external lifecycle check field `{field}` digest is stale")]
367 DigestMismatch { field: &'static str },
368 #[error("external lifecycle check field `{field}` does not match lifecycle source")]
369 SourceMismatch { field: &'static str },
370 #[error("external lifecycle check counters do not match source reports")]
371 CountMismatch,
372}
373
374#[derive(Debug, Eq, thiserror::Error, PartialEq)]
378pub enum ExternalLifecycleHandoffError {
379 #[error(
380 "external lifecycle handoff schema version {actual} does not match expected {expected}"
381 )]
382 SchemaVersionMismatch { expected: u32, actual: u32 },
383 #[error("external lifecycle handoff field `{field}` is required")]
384 MissingRequiredField { field: &'static str },
385 #[error("external lifecycle handoff field `{field}` digest is stale")]
386 DigestMismatch { field: &'static str },
387 #[error("external lifecycle handoff field `{field}` does not match lifecycle source")]
388 SourceMismatch { field: &'static str },
389 #[error("external lifecycle handoff contains duplicate subject `{subject}`")]
390 DuplicateSubject { subject: String },
391}
392
393#[derive(Debug, Eq, thiserror::Error, PartialEq)]
397pub enum CriticalExternalFixReportError {
398 #[error(
399 "critical external fix report schema version {actual} does not match expected {expected}"
400 )]
401 SchemaVersionMismatch { expected: u32, actual: u32 },
402 #[error("critical external fix report field `{field}` is required")]
403 MissingRequiredField { field: &'static str },
404 #[error("critical external fix report field `{field}` digest is stale")]
405 DigestMismatch { field: &'static str },
406 #[error("critical external fix report field `{field}` does not match lifecycle source")]
407 SourceMismatch { field: &'static str },
408}
409
410#[must_use]
414pub fn lifecycle_authority_report_from_check(
415 report_id: impl Into<String>,
416 check: &DeploymentCheckV1,
417) -> LifecycleAuthorityReportV1 {
418 let mut authorities = Vec::new();
419 let mut seen_subjects = BTreeSet::new();
420
421 for expected in &check.plan.expected_canisters {
422 let observed = observed_canister_for_expected(&check.inventory, expected);
423 let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
424 seen_subjects.insert(authority.subject.clone());
425 authorities.push(authority);
426 }
427
428 for expected in &check.plan.expected_pool {
429 let observed = observed_pool_for_expected(&check.inventory, expected);
430 let authority = lifecycle_authority_for_expected_pool(expected, observed);
431 seen_subjects.insert(authority.subject.clone());
432 authorities.push(authority);
433 }
434
435 for observed in &check.inventory.observed_canisters {
436 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
437 if seen_subjects.contains(&subject) {
438 continue;
439 }
440 authorities.push(lifecycle_authority_for_unplanned_canister(observed));
441 }
442
443 for observed in &check.inventory.observed_pool {
444 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
445 if seen_subjects.contains(&subject) {
446 continue;
447 }
448 authorities.push(lifecycle_authority_for_unplanned_pool(observed));
449 }
450
451 authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
452 let external_action_required_count = authorities
453 .iter()
454 .filter(|authority| authority.external_action_required)
455 .count();
456 let blocked_count = authorities
457 .iter()
458 .filter(|authority| authority.blocked)
459 .count();
460
461 let mut report = LifecycleAuthorityReportV1 {
462 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
463 report_id: report_id.into(),
464 report_digest: String::new(),
465 check_id: check.check_id.clone(),
466 plan_id: check.plan.plan_id.clone(),
467 inventory_id: check.inventory.inventory_id.clone(),
468 authorities,
469 external_action_required_count,
470 blocked_count,
471 };
472 report.report_digest = lifecycle_authority_report_digest(&report);
473 report
474}
475
476pub fn validate_lifecycle_authority_report(
478 report: &LifecycleAuthorityReportV1,
479) -> Result<(), LifecycleAuthorityReportError> {
480 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
481 return Err(LifecycleAuthorityReportError::SchemaVersionMismatch {
482 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
483 actual: report.schema_version,
484 });
485 }
486 ensure_lifecycle_authority_report_field("report_id", report.report_id.as_str())?;
487 ensure_lifecycle_authority_report_field("report_digest", report.report_digest.as_str())?;
488 ensure_lifecycle_authority_report_field("check_id", report.check_id.as_str())?;
489 ensure_lifecycle_authority_report_field("plan_id", report.plan_id.as_str())?;
490 ensure_lifecycle_authority_report_field("inventory_id", report.inventory_id.as_str())?;
491 ensure_unique_authority_subjects(&report.authorities)?;
492 if report.external_action_required_count
493 != report
494 .authorities
495 .iter()
496 .filter(|authority| authority.external_action_required)
497 .count()
498 || report.blocked_count
499 != report
500 .authorities
501 .iter()
502 .filter(|authority| authority.blocked)
503 .count()
504 {
505 return Err(LifecycleAuthorityReportError::CountMismatch);
506 }
507 if report.report_digest != lifecycle_authority_report_digest(report) {
508 return Err(LifecycleAuthorityReportError::DigestMismatch {
509 field: "report_digest",
510 });
511 }
512 Ok(())
513}
514
515#[must_use]
521pub fn external_lifecycle_plan_from_check(
522 lifecycle_plan_id: impl Into<String>,
523 lifecycle_authority_report_id: impl Into<String>,
524 check: &DeploymentCheckV1,
525) -> ExternalLifecyclePlanV1 {
526 let lifecycle_authority_report =
527 lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
528 let lifecycle_authority_rows = lifecycle_authority_report.authorities;
529 let directly_executable_role_upgrades = lifecycle_authority_rows
530 .iter()
531 .filter(|authority| {
532 authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
533 && !authority.blocked
534 })
535 .map(external_lifecycle_role_upgrade)
536 .collect::<Vec<_>>();
537 let proposed_external_role_upgrades = lifecycle_authority_rows
538 .iter()
539 .filter(|authority| authority.external_action_required && !authority.blocked)
540 .map(external_lifecycle_role_upgrade)
541 .collect::<Vec<_>>();
542 let blocked_role_upgrades = lifecycle_authority_rows
543 .iter()
544 .filter(|authority| authority.blocked)
545 .map(external_lifecycle_role_upgrade)
546 .collect::<Vec<_>>();
547 let residual_exposure = proposed_external_role_upgrades
548 .iter()
549 .map(|upgrade| {
550 format!(
551 "{} remains pending external lifecycle action",
552 upgrade.subject
553 )
554 })
555 .collect::<Vec<_>>();
556 let status = if !blocked_role_upgrades.is_empty() {
557 ExternalLifecyclePlanStatusV1::Blocked
558 } else if !proposed_external_role_upgrades.is_empty() {
559 ExternalLifecyclePlanStatusV1::PendingExternalAction
560 } else {
561 ExternalLifecyclePlanStatusV1::Ready
562 };
563 let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
564 let mut plan = ExternalLifecyclePlanV1 {
565 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
566 lifecycle_plan_id: lifecycle_plan_id.into(),
567 lifecycle_plan_digest: String::new(),
568 lifecycle_authority_report_id: lifecycle_authority_report.report_id,
569 deployment_plan_id: check.plan.plan_id.clone(),
570 deployment_plan_digest,
571 inventory_id: check.inventory.inventory_id.clone(),
572 lifecycle_authority_rows,
573 directly_executable_role_upgrades,
574 proposed_external_role_upgrades,
575 blocked_role_upgrades,
576 dependency_blockers: Vec::new(),
577 protected_call_implications: protected_call_implications_for_check(check),
578 residual_exposure,
579 status,
580 };
581 plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
582 plan
583}
584
585pub fn validate_external_lifecycle_plan(
587 plan: &ExternalLifecyclePlanV1,
588) -> Result<(), ExternalLifecyclePlanError> {
589 if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
590 return Err(ExternalLifecyclePlanError::SchemaVersionMismatch {
591 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
592 actual: plan.schema_version,
593 });
594 }
595 ensure_external_lifecycle_plan_field("lifecycle_plan_id", plan.lifecycle_plan_id.as_str())?;
596 ensure_external_lifecycle_plan_field(
597 "lifecycle_authority_report_id",
598 plan.lifecycle_authority_report_id.as_str(),
599 )?;
600 ensure_external_lifecycle_plan_field("deployment_plan_id", plan.deployment_plan_id.as_str())?;
601 ensure_external_lifecycle_plan_field("inventory_id", plan.inventory_id.as_str())?;
602 if plan.lifecycle_plan_digest != external_lifecycle_plan_digest(plan) {
603 return Err(ExternalLifecyclePlanError::DigestMismatch {
604 field: "lifecycle_plan_digest",
605 });
606 }
607 if plan.status != expected_lifecycle_plan_status(plan) {
608 return Err(ExternalLifecyclePlanError::StatusMismatch);
609 }
610 ensure_unique_lifecycle_subjects(&plan.lifecycle_authority_rows)?;
611 ensure_unique_role_upgrade_subjects(&plan.directly_executable_role_upgrades)?;
612 ensure_unique_role_upgrade_subjects(&plan.proposed_external_role_upgrades)?;
613 ensure_unique_role_upgrade_subjects(&plan.blocked_role_upgrades)?;
614 Ok(())
615}
616
617pub fn validate_external_lifecycle_plan_for_check(
620 plan: &ExternalLifecyclePlanV1,
621 check: &DeploymentCheckV1,
622) -> Result<(), ExternalLifecyclePlanError> {
623 validate_external_lifecycle_plan(plan)?;
624 let expected = external_lifecycle_plan_from_check(
625 plan.lifecycle_plan_id.clone(),
626 plan.lifecycle_authority_report_id.clone(),
627 check,
628 );
629 if plan != &expected {
630 return Err(ExternalLifecyclePlanError::SourceMismatch {
631 field: "deployment_check",
632 });
633 }
634 Ok(())
635}
636
637#[must_use]
642pub fn external_upgrade_receipt_from_observation(
643 receipt_id: impl Into<String>,
644 proposal: &ExternalUpgradeProposalV1,
645 consent_state: ExternalUpgradeConsentStateV1,
646 reported_by: Option<String>,
647 observed_after: Option<&ObservedCanisterV1>,
648) -> ExternalUpgradeReceiptV1 {
649 let observed_after_module_hash =
650 observed_after.and_then(|observed| observed.module_hash.clone());
651 let observed_after_canonical_embedded_config_sha256 =
652 observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
653 let verification_result = external_upgrade_verification_result(
654 consent_state,
655 proposal,
656 observed_after_module_hash.as_deref(),
657 observed_after_canonical_embedded_config_sha256.as_deref(),
658 );
659 let verification_notes = external_upgrade_verification_notes(
660 verification_result,
661 proposal,
662 observed_after_module_hash.as_deref(),
663 observed_after_canonical_embedded_config_sha256.as_deref(),
664 );
665
666 let mut receipt = ExternalUpgradeReceiptV1 {
667 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
668 receipt_id: receipt_id.into(),
669 proposal_id: proposal.proposal_id.clone(),
670 proposal_digest: proposal.proposal_digest.clone(),
671 subject: proposal.subject.clone(),
672 canister_id: proposal.canister_id.clone(),
673 role: proposal.role.clone(),
674 consent_state,
675 reported_by,
676 observed_before_module_hash: proposal.current_module_hash.clone(),
677 observed_after_module_hash,
678 observed_after_canonical_embedded_config_sha256,
679 verification_result,
680 verification_notes,
681 receipt_digest: String::new(),
682 };
683 receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
684 receipt
685}
686
687pub fn validate_external_upgrade_receipt(
692 receipt: &ExternalUpgradeReceiptV1,
693) -> Result<(), ExternalUpgradeReceiptError> {
694 if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
695 return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
696 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
697 actual: receipt.schema_version,
698 });
699 }
700 ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
701 ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
702 ensure_external_receipt_field("proposal_digest", receipt.proposal_digest.as_str())?;
703 ensure_external_receipt_field("subject", receipt.subject.as_str())?;
704 ensure_external_receipt_field("receipt_digest", receipt.receipt_digest.as_str())?;
705
706 if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
707 && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
708 {
709 return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
710 }
711 let has_observation = receipt.observed_after_module_hash.is_some()
712 || receipt
713 .observed_after_canonical_embedded_config_sha256
714 .is_some();
715 if matches!(
716 receipt.verification_result,
717 ExternalUpgradeVerificationResultV1::Verified
718 | ExternalUpgradeVerificationResultV1::Mismatch
719 ) && !has_observation
720 {
721 return Err(ExternalUpgradeReceiptError::VerificationMismatch);
722 }
723 if receipt.receipt_digest != external_upgrade_receipt_digest(receipt) {
724 return Err(ExternalUpgradeReceiptError::DigestMismatch {
725 field: "receipt_digest",
726 });
727 }
728 Ok(())
729}
730
731pub fn validate_external_upgrade_receipt_for_proposal(
738 receipt: &ExternalUpgradeReceiptV1,
739 proposal: &ExternalUpgradeProposalV1,
740) -> Result<(), ExternalUpgradeReceiptError> {
741 validate_external_upgrade_receipt(receipt)?;
742 ensure_external_receipt_matches_proposal(
743 "proposal_id",
744 receipt.proposal_id.as_str(),
745 proposal.proposal_id.as_str(),
746 )?;
747 ensure_external_receipt_matches_proposal(
748 "proposal_digest",
749 receipt.proposal_digest.as_str(),
750 proposal.proposal_digest.as_str(),
751 )?;
752 ensure_external_receipt_matches_proposal(
753 "subject",
754 receipt.subject.as_str(),
755 proposal.subject.as_str(),
756 )?;
757 ensure_external_receipt_option_matches_proposal(
758 "canister_id",
759 receipt.canister_id.as_deref(),
760 proposal.canister_id.as_deref(),
761 )?;
762 ensure_external_receipt_option_matches_proposal(
763 "role",
764 receipt.role.as_deref(),
765 proposal.role.as_deref(),
766 )?;
767 ensure_external_receipt_option_matches_proposal(
768 "observed_before_module_hash",
769 receipt.observed_before_module_hash.as_deref(),
770 proposal.current_module_hash.as_deref(),
771 )?;
772
773 let expected_result = external_upgrade_verification_result(
774 receipt.consent_state,
775 proposal,
776 receipt.observed_after_module_hash.as_deref(),
777 receipt
778 .observed_after_canonical_embedded_config_sha256
779 .as_deref(),
780 );
781 if receipt.verification_result != expected_result {
782 return Err(ExternalUpgradeReceiptError::VerificationMismatch);
783 }
784 let expected_notes = external_upgrade_verification_notes(
785 expected_result,
786 proposal,
787 receipt.observed_after_module_hash.as_deref(),
788 receipt
789 .observed_after_canonical_embedded_config_sha256
790 .as_deref(),
791 );
792 if receipt.verification_notes != expected_notes {
793 return Err(ExternalUpgradeReceiptError::SourceMismatch {
794 field: "verification_notes",
795 });
796 }
797
798 Ok(())
799}
800
801pub fn external_upgrade_consent_evidence_from_receipt(
807 evidence_id: impl Into<String>,
808 proposal: &ExternalUpgradeProposalV1,
809 receipt: &ExternalUpgradeReceiptV1,
810) -> Result<ExternalUpgradeConsentEvidenceV1, ExternalUpgradeReceiptError> {
811 validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
812 let consent_state = receipt.consent_state;
813 let mut evidence = ExternalUpgradeConsentEvidenceV1 {
814 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
815 evidence_id: evidence_id.into(),
816 evidence_digest: String::new(),
817 proposal_id: proposal.proposal_id.clone(),
818 proposal_digest: proposal.proposal_digest.clone(),
819 receipt_id: receipt.receipt_id.clone(),
820 receipt_digest: receipt.receipt_digest.clone(),
821 subject: proposal.subject.clone(),
822 canister_id: proposal.canister_id.clone(),
823 role: proposal.role.clone(),
824 consent_state,
825 reported_by: receipt.reported_by.clone(),
826 consent_requirements: proposal.consent_requirements.clone(),
827 allowed_authorization_modes: proposal.allowed_authorization_modes.clone(),
828 status_summary: external_upgrade_consent_summary(consent_state).to_string(),
829 };
830 evidence.evidence_digest = external_upgrade_consent_evidence_digest(&evidence);
831 Ok(evidence)
832}
833
834pub fn validate_external_upgrade_consent_evidence(
836 evidence: &ExternalUpgradeConsentEvidenceV1,
837) -> Result<(), ExternalUpgradeConsentEvidenceError> {
838 if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
839 return Err(ExternalUpgradeConsentEvidenceError::SchemaVersionMismatch {
840 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
841 actual: evidence.schema_version,
842 });
843 }
844 ensure_external_consent_evidence_field("evidence_id", evidence.evidence_id.as_str())?;
845 ensure_external_consent_evidence_field("evidence_digest", evidence.evidence_digest.as_str())?;
846 ensure_external_consent_evidence_field("proposal_id", evidence.proposal_id.as_str())?;
847 ensure_external_consent_evidence_field("proposal_digest", evidence.proposal_digest.as_str())?;
848 ensure_external_consent_evidence_field("receipt_id", evidence.receipt_id.as_str())?;
849 ensure_external_consent_evidence_field("receipt_digest", evidence.receipt_digest.as_str())?;
850 ensure_external_consent_evidence_field("subject", evidence.subject.as_str())?;
851 ensure_external_consent_evidence_field("status_summary", evidence.status_summary.as_str())?;
852 if evidence.status_summary != external_upgrade_consent_summary(evidence.consent_state) {
853 return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch {
854 field: "status_summary",
855 });
856 }
857 if evidence.evidence_digest != external_upgrade_consent_evidence_digest(evidence) {
858 return Err(ExternalUpgradeConsentEvidenceError::DigestMismatch {
859 field: "evidence_digest",
860 });
861 }
862 Ok(())
863}
864
865pub fn validate_external_upgrade_consent_evidence_for_receipt(
868 evidence: &ExternalUpgradeConsentEvidenceV1,
869 proposal: &ExternalUpgradeProposalV1,
870 receipt: &ExternalUpgradeReceiptV1,
871) -> Result<(), ExternalUpgradeConsentEvidenceError> {
872 validate_external_upgrade_consent_evidence(evidence)?;
873 let expected = external_upgrade_consent_evidence_from_receipt(
874 evidence.evidence_id.clone(),
875 proposal,
876 receipt,
877 )?;
878 if evidence != &expected {
879 return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch { field: "receipt" });
880 }
881 Ok(())
882}
883
884pub fn external_upgrade_verification_report_from_receipt(
889 report_id: impl Into<String>,
890 proposal: &ExternalUpgradeProposalV1,
891 receipt: &ExternalUpgradeReceiptV1,
892) -> Result<ExternalUpgradeVerificationReportV1, ExternalUpgradeReceiptError> {
893 validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
894 let verification_result = receipt.verification_result;
895 let mut report = ExternalUpgradeVerificationReportV1 {
896 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
897 report_id: report_id.into(),
898 report_digest: String::new(),
899 proposal_id: proposal.proposal_id.clone(),
900 proposal_digest: proposal.proposal_digest.clone(),
901 receipt_id: receipt.receipt_id.clone(),
902 receipt_digest: receipt.receipt_digest.clone(),
903 subject: proposal.subject.clone(),
904 canister_id: proposal.canister_id.clone(),
905 role: proposal.role.clone(),
906 verification_result,
907 verification_notes: receipt.verification_notes.clone(),
908 live_inventory_required: verification_result
909 != ExternalUpgradeVerificationResultV1::Pending
910 && verification_result != ExternalUpgradeVerificationResultV1::Refused,
911 status_summary: external_upgrade_verification_summary(verification_result).to_string(),
912 };
913 report.report_digest = external_upgrade_verification_report_digest(&report);
914 Ok(report)
915}
916
917pub fn validate_external_upgrade_verification_report(
920 report: &ExternalUpgradeVerificationReportV1,
921) -> Result<(), ExternalUpgradeVerificationReportError> {
922 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
923 return Err(
924 ExternalUpgradeVerificationReportError::SchemaVersionMismatch {
925 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
926 actual: report.schema_version,
927 },
928 );
929 }
930 ensure_external_verification_report_field("report_id", report.report_id.as_str())?;
931 ensure_external_verification_report_field("report_digest", report.report_digest.as_str())?;
932 ensure_external_verification_report_field("proposal_id", report.proposal_id.as_str())?;
933 ensure_external_verification_report_field("proposal_digest", report.proposal_digest.as_str())?;
934 ensure_external_verification_report_field("receipt_id", report.receipt_id.as_str())?;
935 ensure_external_verification_report_field("receipt_digest", report.receipt_digest.as_str())?;
936 ensure_external_verification_report_field("subject", report.subject.as_str())?;
937 ensure_external_verification_report_field("status_summary", report.status_summary.as_str())?;
938 if report.status_summary != external_upgrade_verification_summary(report.verification_result) {
939 return Err(ExternalUpgradeVerificationReportError::SourceMismatch {
940 field: "status_summary",
941 });
942 }
943 if report.report_digest != external_upgrade_verification_report_digest(report) {
944 return Err(ExternalUpgradeVerificationReportError::DigestMismatch {
945 field: "report_digest",
946 });
947 }
948 Ok(())
949}
950
951pub fn validate_external_upgrade_verification_report_for_receipt(
954 report: &ExternalUpgradeVerificationReportV1,
955 proposal: &ExternalUpgradeProposalV1,
956 receipt: &ExternalUpgradeReceiptV1,
957) -> Result<(), ExternalUpgradeVerificationReportError> {
958 validate_external_upgrade_verification_report(report)?;
959 let expected = external_upgrade_verification_report_from_receipt(
960 report.report_id.clone(),
961 proposal,
962 receipt,
963 )?;
964 if report != &expected {
965 return Err(ExternalUpgradeVerificationReportError::SourceMismatch { field: "receipt" });
966 }
967 Ok(())
968}
969
970#[must_use]
975pub fn external_upgrade_proposal_report_from_lifecycle_plan(
976 report_id: impl Into<String>,
977 lifecycle_plan: &ExternalLifecyclePlanV1,
978 check: &DeploymentCheckV1,
979) -> ExternalUpgradeProposalReportV1 {
980 let report_id = report_id.into();
981 let mut proposals = Vec::new();
982 for authority in lifecycle_plan
983 .lifecycle_authority_rows
984 .iter()
985 .filter(|authority| authority.external_action_required && !authority.blocked)
986 {
987 proposals.push(external_upgrade_proposal(
988 &report_id,
989 lifecycle_plan,
990 check,
991 authority,
992 observed_canister_for_authority(&check.inventory, authority),
993 target_artifact_for_authority(&check.plan, authority),
994 ));
995 }
996
997 proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
998
999 let mut report = ExternalUpgradeProposalReportV1 {
1000 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1001 report_id,
1002 report_digest: String::new(),
1003 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1004 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1005 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1006 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1007 inventory_id: check.inventory.inventory_id.clone(),
1008 proposals,
1009 blocked_subjects: lifecycle_plan
1010 .blocked_role_upgrades
1011 .iter()
1012 .map(|upgrade| upgrade.subject.clone())
1013 .collect(),
1014 };
1015 report.report_digest = external_upgrade_proposal_report_digest(&report);
1016 report
1017}
1018
1019pub fn validate_external_upgrade_proposal_report(
1021 report: &ExternalUpgradeProposalReportV1,
1022) -> Result<(), ExternalUpgradeProposalReportError> {
1023 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1024 return Err(ExternalUpgradeProposalReportError::SchemaVersionMismatch {
1025 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1026 actual: report.schema_version,
1027 });
1028 }
1029 ensure_external_proposal_report_field("report_id", report.report_id.as_str())?;
1030 ensure_external_proposal_report_field("report_digest", report.report_digest.as_str())?;
1031 ensure_external_proposal_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1032 ensure_external_proposal_report_field(
1033 "lifecycle_plan_digest",
1034 report.lifecycle_plan_digest.as_str(),
1035 )?;
1036 ensure_external_proposal_report_field(
1037 "deployment_plan_id",
1038 report.deployment_plan_id.as_str(),
1039 )?;
1040 ensure_external_proposal_report_field(
1041 "deployment_plan_digest",
1042 report.deployment_plan_digest.as_str(),
1043 )?;
1044 ensure_external_proposal_report_field("inventory_id", report.inventory_id.as_str())?;
1045
1046 let mut subjects = BTreeSet::new();
1047 for proposal in &report.proposals {
1048 if !subjects.insert(proposal.subject.clone()) {
1049 return Err(ExternalUpgradeProposalReportError::DuplicateSubject {
1050 subject: proposal.subject.clone(),
1051 });
1052 }
1053 validate_external_upgrade_proposal(proposal)?;
1054 }
1055 if report.report_digest != external_upgrade_proposal_report_digest(report) {
1056 return Err(ExternalUpgradeProposalReportError::DigestMismatch {
1057 field: "report_digest",
1058 });
1059 }
1060 Ok(())
1061}
1062
1063pub fn validate_external_upgrade_proposal_report_for_lifecycle_plan(
1066 report: &ExternalUpgradeProposalReportV1,
1067 lifecycle_plan: &ExternalLifecyclePlanV1,
1068 check: &DeploymentCheckV1,
1069) -> Result<(), ExternalUpgradeProposalReportError> {
1070 validate_external_upgrade_proposal_report(report)?;
1071 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1072 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1073 field: "lifecycle_plan_id",
1074 });
1075 }
1076 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1077 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1078 field: "lifecycle_plan_digest",
1079 });
1080 }
1081 let expected = external_upgrade_proposal_report_from_lifecycle_plan(
1082 report.report_id.clone(),
1083 lifecycle_plan,
1084 check,
1085 );
1086 if report != &expected {
1087 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1088 field: "deployment_check",
1089 });
1090 }
1091 Ok(())
1092}
1093
1094#[must_use]
1097pub fn external_lifecycle_pending_report_from_plan(
1098 report_id: impl Into<String>,
1099 lifecycle_plan: &ExternalLifecyclePlanV1,
1100 proposal_report: &ExternalUpgradeProposalReportV1,
1101) -> ExternalLifecyclePendingReportV1 {
1102 let report_id = report_id.into();
1103 let pending_external_actions = proposal_report
1104 .proposals
1105 .iter()
1106 .map(external_lifecycle_pending_action)
1107 .collect::<Vec<_>>();
1108 let blocked_subjects = lifecycle_plan
1109 .blocked_role_upgrades
1110 .iter()
1111 .map(|upgrade| upgrade.subject.clone())
1112 .collect::<Vec<_>>();
1113 let mut report = ExternalLifecyclePendingReportV1 {
1114 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1115 report_id,
1116 report_digest: String::new(),
1117 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1118 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1119 proposal_report_id: proposal_report.report_id.clone(),
1120 proposal_report_digest: proposal_report.report_digest.clone(),
1121 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1122 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1123 inventory_id: lifecycle_plan.inventory_id.clone(),
1124 direct_upgrade_count: lifecycle_plan.directly_executable_role_upgrades.len(),
1125 pending_external_count: pending_external_actions.len(),
1126 blocked_count: blocked_subjects.len(),
1127 pending_external_actions,
1128 blocked_subjects,
1129 residual_exposure: lifecycle_plan.residual_exposure.clone(),
1130 status: lifecycle_plan.status,
1131 };
1132 report.report_digest = external_lifecycle_pending_report_digest(&report);
1133 report
1134}
1135
1136pub fn validate_external_lifecycle_pending_report(
1138 report: &ExternalLifecyclePendingReportV1,
1139) -> Result<(), ExternalLifecyclePendingReportError> {
1140 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1141 return Err(ExternalLifecyclePendingReportError::SchemaVersionMismatch {
1142 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1143 actual: report.schema_version,
1144 });
1145 }
1146 ensure_external_pending_report_field("report_id", report.report_id.as_str())?;
1147 ensure_external_pending_report_field("report_digest", report.report_digest.as_str())?;
1148 ensure_external_pending_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1149 ensure_external_pending_report_field(
1150 "lifecycle_plan_digest",
1151 report.lifecycle_plan_digest.as_str(),
1152 )?;
1153 ensure_external_pending_report_field("proposal_report_id", report.proposal_report_id.as_str())?;
1154 ensure_external_pending_report_field(
1155 "proposal_report_digest",
1156 report.proposal_report_digest.as_str(),
1157 )?;
1158 ensure_external_pending_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1159 ensure_external_pending_report_field(
1160 "deployment_plan_digest",
1161 report.deployment_plan_digest.as_str(),
1162 )?;
1163 ensure_external_pending_report_field("inventory_id", report.inventory_id.as_str())?;
1164 if report.pending_external_count != report.pending_external_actions.len()
1165 || report.blocked_count != report.blocked_subjects.len()
1166 {
1167 return Err(ExternalLifecyclePendingReportError::CountMismatch);
1168 }
1169 let mut subjects = BTreeSet::new();
1170 for action in &report.pending_external_actions {
1171 ensure_external_pending_report_field("pending_action.subject", action.subject.as_str())?;
1172 ensure_external_pending_report_field(
1173 "pending_action.proposal_id",
1174 action.proposal_id.as_str(),
1175 )?;
1176 ensure_external_pending_report_field(
1177 "pending_action.proposal_digest",
1178 action.proposal_digest.as_str(),
1179 )?;
1180 ensure_external_pending_report_field(
1181 "pending_action.required_external_action",
1182 action.required_external_action.as_str(),
1183 )?;
1184 if !subjects.insert(action.subject.clone()) {
1185 return Err(ExternalLifecyclePendingReportError::DuplicateSubject {
1186 subject: action.subject.clone(),
1187 });
1188 }
1189 }
1190 if report.report_digest != external_lifecycle_pending_report_digest(report) {
1191 return Err(ExternalLifecyclePendingReportError::DigestMismatch {
1192 field: "report_digest",
1193 });
1194 }
1195 Ok(())
1196}
1197
1198pub fn validate_external_lifecycle_pending_report_for_plan(
1201 report: &ExternalLifecyclePendingReportV1,
1202 lifecycle_plan: &ExternalLifecyclePlanV1,
1203 proposal_report: &ExternalUpgradeProposalReportV1,
1204) -> Result<(), ExternalLifecyclePendingReportError> {
1205 validate_external_lifecycle_pending_report(report)?;
1206 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1207 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1208 field: "lifecycle_plan_id",
1209 });
1210 }
1211 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1212 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1213 field: "lifecycle_plan_digest",
1214 });
1215 }
1216 if report.proposal_report_id != proposal_report.report_id {
1217 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1218 field: "proposal_report_id",
1219 });
1220 }
1221 if report.proposal_report_digest != proposal_report.report_digest {
1222 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1223 field: "proposal_report_digest",
1224 });
1225 }
1226 let expected = external_lifecycle_pending_report_from_plan(
1227 report.report_id.clone(),
1228 lifecycle_plan,
1229 proposal_report,
1230 );
1231 if report != &expected {
1232 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1233 field: "lifecycle_plan",
1234 });
1235 }
1236 Ok(())
1237}
1238
1239#[must_use]
1241pub fn external_lifecycle_check_from_reports(
1242 check_id: impl Into<String>,
1243 lifecycle_plan: &ExternalLifecyclePlanV1,
1244 proposal_report: &ExternalUpgradeProposalReportV1,
1245 pending_report: &ExternalLifecyclePendingReportV1,
1246) -> ExternalLifecycleCheckV1 {
1247 let check_id = check_id.into();
1248 let status = pending_report.status;
1249 let mut check = ExternalLifecycleCheckV1 {
1250 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1251 check_id,
1252 check_digest: String::new(),
1253 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1254 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1255 proposal_report_id: proposal_report.report_id.clone(),
1256 proposal_report_digest: proposal_report.report_digest.clone(),
1257 pending_report_id: pending_report.report_id.clone(),
1258 pending_report_digest: pending_report.report_digest.clone(),
1259 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1260 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1261 inventory_id: lifecycle_plan.inventory_id.clone(),
1262 status,
1263 direct_upgrade_count: pending_report.direct_upgrade_count,
1264 pending_external_count: pending_report.pending_external_count,
1265 blocked_count: pending_report.blocked_count,
1266 residual_exposure_count: pending_report.residual_exposure.len(),
1267 summary: external_lifecycle_check_summary(status, pending_report),
1268 next_actions: external_lifecycle_check_next_actions(status, pending_report),
1269 };
1270 check.check_digest = external_lifecycle_check_digest(&check);
1271 check
1272}
1273
1274pub fn validate_external_lifecycle_check(
1276 check: &ExternalLifecycleCheckV1,
1277) -> Result<(), ExternalLifecycleCheckError> {
1278 if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1279 return Err(ExternalLifecycleCheckError::SchemaVersionMismatch {
1280 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1281 actual: check.schema_version,
1282 });
1283 }
1284 ensure_external_lifecycle_check_field("check_id", check.check_id.as_str())?;
1285 ensure_external_lifecycle_check_field("check_digest", check.check_digest.as_str())?;
1286 ensure_external_lifecycle_check_field("lifecycle_plan_id", check.lifecycle_plan_id.as_str())?;
1287 ensure_external_lifecycle_check_field(
1288 "lifecycle_plan_digest",
1289 check.lifecycle_plan_digest.as_str(),
1290 )?;
1291 ensure_external_lifecycle_check_field("proposal_report_id", check.proposal_report_id.as_str())?;
1292 ensure_external_lifecycle_check_field(
1293 "proposal_report_digest",
1294 check.proposal_report_digest.as_str(),
1295 )?;
1296 ensure_external_lifecycle_check_field("pending_report_id", check.pending_report_id.as_str())?;
1297 ensure_external_lifecycle_check_field(
1298 "pending_report_digest",
1299 check.pending_report_digest.as_str(),
1300 )?;
1301 ensure_external_lifecycle_check_field("deployment_plan_id", check.deployment_plan_id.as_str())?;
1302 ensure_external_lifecycle_check_field(
1303 "deployment_plan_digest",
1304 check.deployment_plan_digest.as_str(),
1305 )?;
1306 ensure_external_lifecycle_check_field("inventory_id", check.inventory_id.as_str())?;
1307 ensure_external_lifecycle_check_field("summary", check.summary.as_str())?;
1308 if check.check_digest != external_lifecycle_check_digest(check) {
1309 return Err(ExternalLifecycleCheckError::DigestMismatch {
1310 field: "check_digest",
1311 });
1312 }
1313 Ok(())
1314}
1315
1316pub fn validate_external_lifecycle_check_for_reports(
1319 check: &ExternalLifecycleCheckV1,
1320 lifecycle_plan: &ExternalLifecyclePlanV1,
1321 proposal_report: &ExternalUpgradeProposalReportV1,
1322 pending_report: &ExternalLifecyclePendingReportV1,
1323) -> Result<(), ExternalLifecycleCheckError> {
1324 validate_external_lifecycle_check(check)?;
1325 if check.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1326 return Err(ExternalLifecycleCheckError::SourceMismatch {
1327 field: "lifecycle_plan_id",
1328 });
1329 }
1330 if check.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1331 return Err(ExternalLifecycleCheckError::SourceMismatch {
1332 field: "lifecycle_plan_digest",
1333 });
1334 }
1335 if check.proposal_report_id != proposal_report.report_id {
1336 return Err(ExternalLifecycleCheckError::SourceMismatch {
1337 field: "proposal_report_id",
1338 });
1339 }
1340 if check.proposal_report_digest != proposal_report.report_digest {
1341 return Err(ExternalLifecycleCheckError::SourceMismatch {
1342 field: "proposal_report_digest",
1343 });
1344 }
1345 if check.pending_report_id != pending_report.report_id {
1346 return Err(ExternalLifecycleCheckError::SourceMismatch {
1347 field: "pending_report_id",
1348 });
1349 }
1350 if check.pending_report_digest != pending_report.report_digest {
1351 return Err(ExternalLifecycleCheckError::SourceMismatch {
1352 field: "pending_report_digest",
1353 });
1354 }
1355 if check.direct_upgrade_count != pending_report.direct_upgrade_count
1356 || check.pending_external_count != pending_report.pending_external_count
1357 || check.blocked_count != pending_report.blocked_count
1358 || check.residual_exposure_count != pending_report.residual_exposure.len()
1359 {
1360 return Err(ExternalLifecycleCheckError::CountMismatch);
1361 }
1362 let expected = external_lifecycle_check_from_reports(
1363 check.check_id.clone(),
1364 lifecycle_plan,
1365 proposal_report,
1366 pending_report,
1367 );
1368 if check != &expected {
1369 return Err(ExternalLifecycleCheckError::SourceMismatch {
1370 field: "pending_report",
1371 });
1372 }
1373 Ok(())
1374}
1375
1376#[must_use]
1378pub fn external_lifecycle_handoff_from_reports(
1379 handoff_id: impl Into<String>,
1380 lifecycle_check: &ExternalLifecycleCheckV1,
1381 proposal_report: &ExternalUpgradeProposalReportV1,
1382 pending_report: &ExternalLifecyclePendingReportV1,
1383) -> ExternalLifecycleHandoffV1 {
1384 let proposal_by_id = proposal_report
1385 .proposals
1386 .iter()
1387 .map(|proposal| (proposal.proposal_id.as_str(), proposal))
1388 .collect::<BTreeMap<_, _>>();
1389 let handoff_actions = pending_report
1390 .pending_external_actions
1391 .iter()
1392 .filter_map(|action| proposal_by_id.get(action.proposal_id.as_str()))
1393 .map(|proposal| external_lifecycle_handoff_action(proposal))
1394 .collect::<Vec<_>>();
1395 let mut handoff = ExternalLifecycleHandoffV1 {
1396 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1397 handoff_id: handoff_id.into(),
1398 handoff_digest: String::new(),
1399 lifecycle_check_id: lifecycle_check.check_id.clone(),
1400 lifecycle_check_digest: lifecycle_check.check_digest.clone(),
1401 pending_report_id: pending_report.report_id.clone(),
1402 pending_report_digest: pending_report.report_digest.clone(),
1403 proposal_report_id: proposal_report.report_id.clone(),
1404 proposal_report_digest: proposal_report.report_digest.clone(),
1405 deployment_plan_id: pending_report.deployment_plan_id.clone(),
1406 deployment_plan_digest: pending_report.deployment_plan_digest.clone(),
1407 inventory_id: pending_report.inventory_id.clone(),
1408 status: pending_report.status,
1409 handoff_actions,
1410 blocked_subjects: pending_report.blocked_subjects.clone(),
1411 residual_exposure: pending_report.residual_exposure.clone(),
1412 operator_summary: external_lifecycle_handoff_summary(pending_report),
1413 };
1414 handoff.handoff_digest = external_lifecycle_handoff_digest(&handoff);
1415 handoff
1416}
1417
1418pub fn validate_external_lifecycle_handoff(
1420 handoff: &ExternalLifecycleHandoffV1,
1421) -> Result<(), ExternalLifecycleHandoffError> {
1422 if handoff.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1423 return Err(ExternalLifecycleHandoffError::SchemaVersionMismatch {
1424 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1425 actual: handoff.schema_version,
1426 });
1427 }
1428 ensure_external_lifecycle_handoff_field("handoff_id", handoff.handoff_id.as_str())?;
1429 ensure_external_lifecycle_handoff_field("handoff_digest", handoff.handoff_digest.as_str())?;
1430 ensure_external_lifecycle_handoff_field(
1431 "lifecycle_check_id",
1432 handoff.lifecycle_check_id.as_str(),
1433 )?;
1434 ensure_external_lifecycle_handoff_field(
1435 "lifecycle_check_digest",
1436 handoff.lifecycle_check_digest.as_str(),
1437 )?;
1438 ensure_external_lifecycle_handoff_field(
1439 "pending_report_id",
1440 handoff.pending_report_id.as_str(),
1441 )?;
1442 ensure_external_lifecycle_handoff_field(
1443 "pending_report_digest",
1444 handoff.pending_report_digest.as_str(),
1445 )?;
1446 ensure_external_lifecycle_handoff_field(
1447 "proposal_report_id",
1448 handoff.proposal_report_id.as_str(),
1449 )?;
1450 ensure_external_lifecycle_handoff_field(
1451 "proposal_report_digest",
1452 handoff.proposal_report_digest.as_str(),
1453 )?;
1454 ensure_external_lifecycle_handoff_field(
1455 "deployment_plan_id",
1456 handoff.deployment_plan_id.as_str(),
1457 )?;
1458 ensure_external_lifecycle_handoff_field(
1459 "deployment_plan_digest",
1460 handoff.deployment_plan_digest.as_str(),
1461 )?;
1462 ensure_external_lifecycle_handoff_field("inventory_id", handoff.inventory_id.as_str())?;
1463 ensure_external_lifecycle_handoff_field("operator_summary", handoff.operator_summary.as_str())?;
1464 let mut subjects = BTreeSet::new();
1465 for action in &handoff.handoff_actions {
1466 ensure_external_lifecycle_handoff_field("handoff_action.subject", action.subject.as_str())?;
1467 ensure_external_lifecycle_handoff_field(
1468 "handoff_action.proposal_id",
1469 action.proposal_id.as_str(),
1470 )?;
1471 ensure_external_lifecycle_handoff_field(
1472 "handoff_action.proposal_digest",
1473 action.proposal_digest.as_str(),
1474 )?;
1475 ensure_external_lifecycle_handoff_field(
1476 "handoff_action.required_external_action",
1477 action.required_external_action.as_str(),
1478 )?;
1479 if !subjects.insert(action.subject.clone()) {
1480 return Err(ExternalLifecycleHandoffError::DuplicateSubject {
1481 subject: action.subject.clone(),
1482 });
1483 }
1484 }
1485 if handoff.handoff_digest != external_lifecycle_handoff_digest(handoff) {
1486 return Err(ExternalLifecycleHandoffError::DigestMismatch {
1487 field: "handoff_digest",
1488 });
1489 }
1490 Ok(())
1491}
1492
1493pub fn validate_external_lifecycle_handoff_for_reports(
1496 handoff: &ExternalLifecycleHandoffV1,
1497 lifecycle_check: &ExternalLifecycleCheckV1,
1498 proposal_report: &ExternalUpgradeProposalReportV1,
1499 pending_report: &ExternalLifecyclePendingReportV1,
1500) -> Result<(), ExternalLifecycleHandoffError> {
1501 validate_external_lifecycle_handoff(handoff)?;
1502 if handoff.lifecycle_check_id != lifecycle_check.check_id {
1503 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1504 field: "lifecycle_check_id",
1505 });
1506 }
1507 if handoff.lifecycle_check_digest != lifecycle_check.check_digest {
1508 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1509 field: "lifecycle_check_digest",
1510 });
1511 }
1512 if handoff.pending_report_id != pending_report.report_id {
1513 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1514 field: "pending_report_id",
1515 });
1516 }
1517 if handoff.pending_report_digest != pending_report.report_digest {
1518 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1519 field: "pending_report_digest",
1520 });
1521 }
1522 if handoff.proposal_report_id != proposal_report.report_id {
1523 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1524 field: "proposal_report_id",
1525 });
1526 }
1527 if handoff.proposal_report_digest != proposal_report.report_digest {
1528 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1529 field: "proposal_report_digest",
1530 });
1531 }
1532 let expected = external_lifecycle_handoff_from_reports(
1533 handoff.handoff_id.clone(),
1534 lifecycle_check,
1535 proposal_report,
1536 pending_report,
1537 );
1538 if handoff != &expected {
1539 return Err(ExternalLifecycleHandoffError::SourceMismatch {
1540 field: "pending_report",
1541 });
1542 }
1543 Ok(())
1544}
1545
1546fn external_lifecycle_pending_action(
1547 proposal: &ExternalUpgradeProposalV1,
1548) -> ExternalLifecyclePendingActionV1 {
1549 ExternalLifecyclePendingActionV1 {
1550 subject: proposal.subject.clone(),
1551 proposal_id: proposal.proposal_id.clone(),
1552 proposal_digest: proposal.proposal_digest.clone(),
1553 canister_id: proposal.canister_id.clone(),
1554 role: proposal.role.clone(),
1555 control_class: proposal.control_class,
1556 lifecycle_mode: proposal.lifecycle_mode,
1557 required_external_action: proposal.required_external_action.clone(),
1558 consent_requirements: proposal.consent_requirements.clone(),
1559 verification_requirements: proposal.verification_requirements.clone(),
1560 }
1561}
1562
1563fn external_lifecycle_handoff_action(
1564 proposal: &ExternalUpgradeProposalV1,
1565) -> ExternalLifecycleHandoffActionV1 {
1566 let primary_requirement = proposal.consent_requirements.first();
1567 ExternalLifecycleHandoffActionV1 {
1568 subject: proposal.subject.clone(),
1569 proposal_id: proposal.proposal_id.clone(),
1570 proposal_digest: proposal.proposal_digest.clone(),
1571 canister_id: proposal.canister_id.clone(),
1572 role: proposal.role.clone(),
1573 control_class: proposal.control_class,
1574 lifecycle_mode: proposal.lifecycle_mode,
1575 required_external_action: proposal.required_external_action.clone(),
1576 consent_channel_kind: primary_requirement
1577 .map_or(ConsentChannelKindV1::OutOfBand, |requirement| {
1578 requirement.consent_channel_kind
1579 }),
1580 consent_subject_kind: primary_requirement.map_or(
1581 ConsentSubjectKindV1::UnknownExternalController,
1582 |requirement| requirement.consent_subject_kind,
1583 ),
1584 required_principals: primary_requirement.map_or_else(Vec::new, |requirement| {
1585 requirement.required_principals.clone()
1586 }),
1587 current_module_hash: proposal.current_module_hash.clone(),
1588 target_installed_module_hash: proposal.target_installed_module_hash.clone(),
1589 target_canonical_embedded_config_sha256: proposal
1590 .target_canonical_embedded_config_sha256
1591 .clone(),
1592 verification_requirements: proposal.verification_requirements.clone(),
1593 operator_instructions: external_lifecycle_handoff_instructions(proposal),
1594 }
1595}
1596
1597fn lifecycle_roles(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
1598 lifecycle_plan
1599 .lifecycle_authority_rows
1600 .iter()
1601 .filter_map(|authority| authority.role.clone())
1602 .collect::<BTreeSet<_>>()
1603 .into_iter()
1604 .collect()
1605}
1606
1607fn lifecycle_canisters(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
1608 lifecycle_plan
1609 .lifecycle_authority_rows
1610 .iter()
1611 .filter_map(|authority| authority.canister_id.clone())
1612 .collect::<BTreeSet<_>>()
1613 .into_iter()
1614 .collect()
1615}
1616
1617fn role_names(upgrades: &[ExternalLifecycleRoleUpgradeV1]) -> Vec<String> {
1618 upgrades
1619 .iter()
1620 .filter_map(|upgrade| upgrade.role.clone())
1621 .collect::<BTreeSet<_>>()
1622 .into_iter()
1623 .collect()
1624}
1625
1626fn critical_fix_next_steps(
1627 pending_external_count: usize,
1628 blocked_count: usize,
1629 protected_call_implications: &[String],
1630) -> Vec<String> {
1631 let mut steps = Vec::new();
1632 if pending_external_count > 0 {
1633 steps.push(
1634 "request external consent or completion for externally controlled roles".to_string(),
1635 );
1636 }
1637 if blocked_count > 0 {
1638 steps.push(
1639 "resolve blocked lifecycle rows before reporting the deployment fully patched"
1640 .to_string(),
1641 );
1642 }
1643 if !protected_call_implications.is_empty() {
1644 steps.push(
1645 "review protected-call readiness and role epoch implications before closure"
1646 .to_string(),
1647 );
1648 }
1649 if steps.is_empty() {
1650 steps.push("no external lifecycle work remains for this critical fix".to_string());
1651 }
1652 steps
1653}
1654
1655#[must_use]
1658pub fn critical_external_fix_report_from_pending(
1659 report_id: impl Into<String>,
1660 fix_id: impl Into<String>,
1661 severity: impl Into<String>,
1662 lifecycle_plan: &ExternalLifecyclePlanV1,
1663 pending_report: &ExternalLifecyclePendingReportV1,
1664) -> CriticalExternalFixReportV1 {
1665 let report_id = report_id.into();
1666 let fix_id = fix_id.into();
1667 let severity = severity.into();
1668 let affected_roles = lifecycle_roles(lifecycle_plan);
1669 let affected_canisters = lifecycle_canisters(lifecycle_plan);
1670 let directly_patchable_roles = role_names(&lifecycle_plan.directly_executable_role_upgrades);
1671 let externally_blocked_roles = pending_report
1672 .pending_external_actions
1673 .iter()
1674 .filter_map(|action| action.role.clone())
1675 .collect::<BTreeSet<_>>()
1676 .into_iter()
1677 .collect::<Vec<_>>();
1678 let dependency_blocked_roles = role_names(&lifecycle_plan.blocked_role_upgrades);
1679 let required_external_actions = pending_report
1680 .pending_external_actions
1681 .iter()
1682 .map(|action| format!("{}: {}", action.subject, action.required_external_action))
1683 .collect::<Vec<_>>();
1684 let operator_next_steps = critical_fix_next_steps(
1685 pending_report.pending_external_count,
1686 pending_report.blocked_count,
1687 lifecycle_plan.protected_call_implications.as_slice(),
1688 );
1689 let mut report = CriticalExternalFixReportV1 {
1690 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1691 report_id,
1692 report_digest: String::new(),
1693 fix_id,
1694 severity,
1695 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1696 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1697 pending_report_id: pending_report.report_id.clone(),
1698 pending_report_digest: pending_report.report_digest.clone(),
1699 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1700 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1701 inventory_id: lifecycle_plan.inventory_id.clone(),
1702 affected_roles,
1703 affected_canisters,
1704 directly_patchable_roles,
1705 externally_blocked_roles,
1706 dependency_blocked_roles,
1707 required_external_actions,
1708 protected_call_implications: lifecycle_plan.protected_call_implications.clone(),
1709 residual_exposure: pending_report.residual_exposure.clone(),
1710 operator_next_steps,
1711 };
1712 report.report_digest = critical_external_fix_report_digest(&report);
1713 report
1714}
1715
1716pub fn validate_critical_external_fix_report(
1718 report: &CriticalExternalFixReportV1,
1719) -> Result<(), CriticalExternalFixReportError> {
1720 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1721 return Err(CriticalExternalFixReportError::SchemaVersionMismatch {
1722 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1723 actual: report.schema_version,
1724 });
1725 }
1726 ensure_critical_fix_report_field("report_id", report.report_id.as_str())?;
1727 ensure_critical_fix_report_field("report_digest", report.report_digest.as_str())?;
1728 ensure_critical_fix_report_field("fix_id", report.fix_id.as_str())?;
1729 ensure_critical_fix_report_field("severity", report.severity.as_str())?;
1730 ensure_critical_fix_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1731 ensure_critical_fix_report_field(
1732 "lifecycle_plan_digest",
1733 report.lifecycle_plan_digest.as_str(),
1734 )?;
1735 ensure_critical_fix_report_field("pending_report_id", report.pending_report_id.as_str())?;
1736 ensure_critical_fix_report_field(
1737 "pending_report_digest",
1738 report.pending_report_digest.as_str(),
1739 )?;
1740 ensure_critical_fix_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1741 ensure_critical_fix_report_field(
1742 "deployment_plan_digest",
1743 report.deployment_plan_digest.as_str(),
1744 )?;
1745 ensure_critical_fix_report_field("inventory_id", report.inventory_id.as_str())?;
1746 if report.report_digest != critical_external_fix_report_digest(report) {
1747 return Err(CriticalExternalFixReportError::DigestMismatch {
1748 field: "report_digest",
1749 });
1750 }
1751 Ok(())
1752}
1753
1754pub fn validate_critical_external_fix_report_for_pending(
1757 report: &CriticalExternalFixReportV1,
1758 lifecycle_plan: &ExternalLifecyclePlanV1,
1759 pending_report: &ExternalLifecyclePendingReportV1,
1760) -> Result<(), CriticalExternalFixReportError> {
1761 validate_critical_external_fix_report(report)?;
1762 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1763 return Err(CriticalExternalFixReportError::SourceMismatch {
1764 field: "lifecycle_plan_id",
1765 });
1766 }
1767 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1768 return Err(CriticalExternalFixReportError::SourceMismatch {
1769 field: "lifecycle_plan_digest",
1770 });
1771 }
1772 if report.pending_report_id != pending_report.report_id {
1773 return Err(CriticalExternalFixReportError::SourceMismatch {
1774 field: "pending_report_id",
1775 });
1776 }
1777 if report.pending_report_digest != pending_report.report_digest {
1778 return Err(CriticalExternalFixReportError::SourceMismatch {
1779 field: "pending_report_digest",
1780 });
1781 }
1782 let expected = critical_external_fix_report_from_pending(
1783 report.report_id.clone(),
1784 report.fix_id.clone(),
1785 report.severity.clone(),
1786 lifecycle_plan,
1787 pending_report,
1788 );
1789 if report != &expected {
1790 return Err(CriticalExternalFixReportError::SourceMismatch {
1791 field: "lifecycle_plan",
1792 });
1793 }
1794 Ok(())
1795}
1796
1797fn external_upgrade_proposal(
1798 report_id: &str,
1799 lifecycle_plan: &ExternalLifecyclePlanV1,
1800 check: &DeploymentCheckV1,
1801 authority: &LifecycleAuthorityV1,
1802 observed: Option<&ObservedCanisterV1>,
1803 target_artifact: Option<&RoleArtifactV1>,
1804) -> ExternalUpgradeProposalV1 {
1805 let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
1806 let current_canonical_embedded_config_sha256 =
1807 observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
1808 let mut proposal = ExternalUpgradeProposalV1 {
1809 proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
1810 proposal_digest: String::new(),
1811 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1812 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1813 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1814 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1815 promotion_plan_id: None,
1816 promotion_plan_digest: None,
1817 promotion_provenance_id: None,
1818 promotion_provenance_digest: None,
1819 subject: authority.subject.clone(),
1820 canister_id: authority.canister_id.clone(),
1821 role: authority.role.clone(),
1822 control_class: authority.control_class,
1823 lifecycle_mode: authority.lifecycle_mode,
1824 observed_before_digest: observed_before_digest(
1825 authority,
1826 current_module_hash.as_ref(),
1827 current_canonical_embedded_config_sha256.as_ref(),
1828 ),
1829 current_module_hash,
1830 current_canonical_embedded_config_sha256,
1831 target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
1832 target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
1833 target_installed_module_hash: target_artifact
1834 .and_then(|artifact| artifact.installed_module_hash.clone()),
1835 target_role_artifact_identity: target_artifact.map(role_artifact_identity),
1836 target_canonical_embedded_config_sha256: target_artifact
1837 .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
1838 root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
1839 authority_profile_hash: check
1840 .plan
1841 .deployment_identity
1842 .authority_profile_hash
1843 .clone(),
1844 required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
1845 consent_requirements: authority.consent_requirements.clone(),
1846 allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
1847 verification_requirements: authority.verification_requirements.clone(),
1848 expires_at: None,
1849 supersedes_proposal_id: None,
1850 };
1851 proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
1852 proposal
1853}
1854
1855fn validate_external_upgrade_proposal(
1856 proposal: &ExternalUpgradeProposalV1,
1857) -> Result<(), ExternalUpgradeProposalReportError> {
1858 ensure_external_proposal_report_field("proposal_id", proposal.proposal_id.as_str())?;
1859 ensure_external_proposal_report_field("proposal_digest", proposal.proposal_digest.as_str())?;
1860 ensure_external_proposal_report_field(
1861 "proposal.deployment_plan_id",
1862 proposal.deployment_plan_id.as_str(),
1863 )?;
1864 ensure_external_proposal_report_field(
1865 "proposal.deployment_plan_digest",
1866 proposal.deployment_plan_digest.as_str(),
1867 )?;
1868 ensure_external_proposal_report_field(
1869 "proposal.lifecycle_plan_id",
1870 proposal.lifecycle_plan_id.as_str(),
1871 )?;
1872 ensure_external_proposal_report_field(
1873 "proposal.lifecycle_plan_digest",
1874 proposal.lifecycle_plan_digest.as_str(),
1875 )?;
1876 ensure_external_proposal_report_field(
1877 "proposal.observed_before_digest",
1878 proposal.observed_before_digest.as_str(),
1879 )?;
1880 ensure_external_proposal_report_field("proposal.subject", proposal.subject.as_str())?;
1881 if proposal.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority {
1882 return Err(
1883 ExternalUpgradeProposalReportError::DirectLifecycleProposal {
1884 subject: proposal.subject.clone(),
1885 },
1886 );
1887 }
1888 if proposal.proposal_digest != external_upgrade_proposal_digest(proposal) {
1889 return Err(ExternalUpgradeProposalReportError::DigestMismatch {
1890 field: "proposal_digest",
1891 });
1892 }
1893 Ok(())
1894}
1895
1896fn lifecycle_authority_for_expected_canister(
1897 plan: &DeploymentPlanV1,
1898 expected: &ExpectedCanisterV1,
1899 observed: Option<&ObservedCanisterV1>,
1900) -> LifecycleAuthorityV1 {
1901 let canister_id = expected
1902 .canister_id
1903 .clone()
1904 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
1905 let role = Some(expected.role.clone());
1906 let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
1907 let observed_controllers =
1908 observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
1909 lifecycle_authority(
1910 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
1911 canister_id,
1912 role,
1913 control_class,
1914 observed_controllers,
1915 &plan.authority_profile.expected_controllers,
1916 plan.expected_verifier_readiness.required,
1917 )
1918}
1919
1920fn lifecycle_authority_for_expected_pool(
1921 expected: &ExpectedPoolCanisterV1,
1922 observed: Option<&ObservedPoolCanisterV1>,
1923) -> LifecycleAuthorityV1 {
1924 let canister_id = expected
1925 .canister_id
1926 .clone()
1927 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
1928 let role = expected
1929 .role
1930 .clone()
1931 .or_else(|| observed.and_then(|observed| observed.role.clone()));
1932 let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
1933 observed.control_class
1934 });
1935 lifecycle_authority(
1936 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
1937 canister_id,
1938 role,
1939 control_class,
1940 Vec::new(),
1941 &[],
1942 false,
1943 )
1944}
1945
1946fn lifecycle_authority_for_unplanned_canister(
1947 observed: &ObservedCanisterV1,
1948) -> LifecycleAuthorityV1 {
1949 lifecycle_authority(
1950 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
1951 Some(observed.canister_id.clone()),
1952 observed.role.clone(),
1953 observed.control_class,
1954 observed.controllers.clone(),
1955 &[],
1956 false,
1957 )
1958}
1959
1960fn lifecycle_authority_for_unplanned_pool(
1961 observed: &ObservedPoolCanisterV1,
1962) -> LifecycleAuthorityV1 {
1963 lifecycle_authority(
1964 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
1965 Some(observed.canister_id.clone()),
1966 observed.role.clone(),
1967 observed.control_class,
1968 Vec::new(),
1969 &[],
1970 false,
1971 )
1972}
1973
1974fn lifecycle_authority(
1975 subject: String,
1976 canister_id: Option<String>,
1977 role: Option<String>,
1978 control_class: CanisterControlClassV1,
1979 observed_controllers: Vec<String>,
1980 expected_controllers: &[String],
1981 verifier_required: bool,
1982) -> LifecycleAuthorityV1 {
1983 let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
1984 let external_controllers =
1985 external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
1986 let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
1987 let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
1988 let verification_requirements = lifecycle_verification_requirements(verifier_required);
1989 let external_action_required = lifecycle_external_action_required(control_class);
1990 let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
1991 let lifecycle_mode = lifecycle_mode(control_class);
1992 let blockers = lifecycle_blockers(control_class);
1993 let warnings = lifecycle_warnings(control_class);
1994 let reason = lifecycle_reason(control_class);
1995 LifecycleAuthorityV1 {
1996 subject,
1997 canister_id,
1998 role,
1999 control_class,
2000 lifecycle_mode,
2001 observed_controllers,
2002 expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
2003 external_controllers,
2004 required_controllers,
2005 consent_requirements,
2006 allowed_upgrade_modes,
2007 verification_requirements,
2008 external_action_required,
2009 blocked,
2010 blockers,
2011 warnings,
2012 reason,
2013 }
2014}
2015
2016fn required_lifecycle_controllers(
2017 control_class: CanisterControlClassV1,
2018 expected_controllers: &[String],
2019) -> Vec<String> {
2020 match control_class {
2021 CanisterControlClassV1::DeploymentControlled
2022 | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
2023 CanisterControlClassV1::CanicManagedPool
2024 | CanisterControlClassV1::ExternallyImported
2025 | CanisterControlClassV1::UserControlled
2026 | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
2027 }
2028}
2029
2030fn external_lifecycle_controllers(
2031 control_class: CanisterControlClassV1,
2032 observed_controllers: &[String],
2033 required_controllers: &[String],
2034) -> Vec<String> {
2035 match control_class {
2036 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2037 Vec::new()
2038 }
2039 CanisterControlClassV1::JointlyControlled => {
2040 let required = required_controllers.iter().collect::<BTreeSet<_>>();
2041 sorted_unique(
2042 observed_controllers
2043 .iter()
2044 .filter(|controller| !required.contains(controller))
2045 .cloned()
2046 .collect(),
2047 )
2048 }
2049 CanisterControlClassV1::CanicManagedPool
2050 | CanisterControlClassV1::ExternallyImported
2051 | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
2052 }
2053}
2054
2055fn lifecycle_consent_requirements(
2056 control_class: CanisterControlClassV1,
2057 external_controllers: &[String],
2058) -> Vec<ConsentRequirementV1> {
2059 if !lifecycle_external_action_required(control_class) {
2060 return Vec::new();
2061 }
2062 vec![ConsentRequirementV1 {
2063 consent_subject_kind: consent_subject_kind(control_class),
2064 required_principals: sorted_unique(external_controllers.to_vec()),
2065 required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
2066 consent_channel_kind: consent_channel_kind(control_class),
2067 required_action: required_consent_action(control_class),
2068 }]
2069}
2070
2071const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
2072 match control_class {
2073 CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
2074 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
2075 ConsentSubjectKindV1::CustomerController
2076 }
2077 CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
2078 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2079 ConsentSubjectKindV1::UnknownExternalController
2080 }
2081 }
2082}
2083
2084const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
2085 match control_class {
2086 CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
2087 CanisterControlClassV1::ExternallyImported
2088 | CanisterControlClassV1::JointlyControlled
2089 | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
2090 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2091 ConsentChannelKindV1::OutOfBand
2092 }
2093 }
2094}
2095
2096const fn required_consent_action(
2097 control_class: CanisterControlClassV1,
2098) -> ExternalUpgradeAuthorizationModeV1 {
2099 match control_class {
2100 CanisterControlClassV1::JointlyControlled => {
2101 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
2102 }
2103 CanisterControlClassV1::CanicManagedPool => {
2104 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
2105 }
2106 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
2107 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
2108 }
2109 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2110 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
2111 }
2112 }
2113}
2114
2115const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
2116 match control_class {
2117 CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
2118 CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
2119 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
2120 LifecycleModeV1::ExternalCompletionOnly
2121 }
2122 CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
2123 CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
2124 }
2125}
2126
2127fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
2128 if control_class == CanisterControlClassV1::UnknownUnsafe {
2129 vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
2130 } else {
2131 Vec::new()
2132 }
2133}
2134
2135fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
2136 match control_class {
2137 CanisterControlClassV1::CanicManagedPool => {
2138 vec!["pool-aware lifecycle policy is required before mutation".to_string()]
2139 }
2140 CanisterControlClassV1::ExternallyImported => {
2141 vec!["external controller action or verification is required".to_string()]
2142 }
2143 CanisterControlClassV1::JointlyControlled => {
2144 vec!["joint controller consent or delegation is required".to_string()]
2145 }
2146 CanisterControlClassV1::UserControlled => {
2147 vec!["user or delegated lifecycle action is required".to_string()]
2148 }
2149 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2150 Vec::new()
2151 }
2152 }
2153}
2154
2155fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
2156 match control_class {
2157 CanisterControlClassV1::DeploymentControlled => vec![
2158 LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
2159 LifecycleUpgradeModeV1::VerifyExternalCompletion,
2160 ],
2161 CanisterControlClassV1::CanicManagedPool
2162 | CanisterControlClassV1::ExternallyImported
2163 | CanisterControlClassV1::JointlyControlled
2164 | CanisterControlClassV1::UserControlled => vec![
2165 LifecycleUpgradeModeV1::ExternalProposal,
2166 LifecycleUpgradeModeV1::ExternalExecution,
2167 LifecycleUpgradeModeV1::VerifyExternalCompletion,
2168 LifecycleUpgradeModeV1::ObserveOnly,
2169 ],
2170 CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
2171 }
2172}
2173
2174fn lifecycle_verification_requirements(
2175 verifier_required: bool,
2176) -> Vec<LifecycleVerificationRequirementV1> {
2177 let mut requirements = vec![
2178 LifecycleVerificationRequirementV1::LiveInventory,
2179 LifecycleVerificationRequirementV1::ControllerObservation,
2180 LifecycleVerificationRequirementV1::ModuleHash,
2181 LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
2182 ];
2183 if verifier_required {
2184 requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
2185 }
2186 requirements
2187}
2188
2189const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
2190 matches!(
2191 control_class,
2192 CanisterControlClassV1::CanicManagedPool
2193 | CanisterControlClassV1::ExternallyImported
2194 | CanisterControlClassV1::JointlyControlled
2195 | CanisterControlClassV1::UserControlled
2196 )
2197}
2198
2199fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
2200 match control_class {
2201 CanisterControlClassV1::DeploymentControlled => {
2202 "deployment authority can execute lifecycle directly".to_string()
2203 }
2204 CanisterControlClassV1::CanicManagedPool => {
2205 "Canic-managed pool lifecycle requires pool-aware external action".to_string()
2206 }
2207 CanisterControlClassV1::ExternallyImported => {
2208 "externally imported canister requires external controller action".to_string()
2209 }
2210 CanisterControlClassV1::JointlyControlled => {
2211 "jointly controlled canister requires non-deployment-controller consent".to_string()
2212 }
2213 CanisterControlClassV1::UserControlled => {
2214 "user-controlled canister requires user or delegated lifecycle action".to_string()
2215 }
2216 CanisterControlClassV1::UnknownUnsafe => {
2217 "unknown or unsafe controller state blocks lifecycle action".to_string()
2218 }
2219 }
2220}
2221
2222fn observed_canister_for_expected<'a>(
2223 inventory: &'a DeploymentInventoryV1,
2224 expected: &ExpectedCanisterV1,
2225) -> Option<&'a ObservedCanisterV1> {
2226 if let Some(canister_id) = &expected.canister_id
2227 && let Some(observed) = inventory
2228 .observed_canisters
2229 .iter()
2230 .find(|observed| &observed.canister_id == canister_id)
2231 {
2232 return Some(observed);
2233 }
2234 inventory
2235 .observed_canisters
2236 .iter()
2237 .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
2238}
2239
2240fn observed_pool_for_expected<'a>(
2241 inventory: &'a DeploymentInventoryV1,
2242 expected: &ExpectedPoolCanisterV1,
2243) -> Option<&'a ObservedPoolCanisterV1> {
2244 if let Some(canister_id) = &expected.canister_id
2245 && let Some(observed) = inventory
2246 .observed_pool
2247 .iter()
2248 .find(|observed| &observed.canister_id == canister_id)
2249 {
2250 return Some(observed);
2251 }
2252 inventory.observed_pool.iter().find(|observed| {
2253 observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
2254 })
2255}
2256
2257fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
2258 lifecycle_subject_for_parts(Some(canister_id), role)
2259}
2260
2261fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
2262 match (role, canister_id) {
2263 (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
2264 (Some(role), None) => format!("{role}:unassigned"),
2265 (None, Some(canister_id)) => canister_id.to_string(),
2266 (None, None) => "unknown".to_string(),
2267 }
2268}
2269
2270fn observed_canister_for_authority<'a>(
2271 inventory: &'a DeploymentInventoryV1,
2272 authority: &LifecycleAuthorityV1,
2273) -> Option<&'a ObservedCanisterV1> {
2274 if let Some(canister_id) = &authority.canister_id
2275 && let Some(observed) = inventory
2276 .observed_canisters
2277 .iter()
2278 .find(|observed| &observed.canister_id == canister_id)
2279 {
2280 return Some(observed);
2281 }
2282 inventory
2283 .observed_canisters
2284 .iter()
2285 .find(|observed| observed.role == authority.role)
2286}
2287
2288fn target_artifact_for_authority<'a>(
2289 plan: &'a DeploymentPlanV1,
2290 authority: &LifecycleAuthorityV1,
2291) -> Option<&'a RoleArtifactV1> {
2292 let role = authority.role.as_ref()?;
2293 plan.role_artifacts
2294 .iter()
2295 .find(|artifact| &artifact.role == role)
2296}
2297
2298fn external_lifecycle_role_upgrade(
2299 authority: &LifecycleAuthorityV1,
2300) -> ExternalLifecycleRoleUpgradeV1 {
2301 ExternalLifecycleRoleUpgradeV1 {
2302 subject: authority.subject.clone(),
2303 canister_id: authority.canister_id.clone(),
2304 role: authority.role.clone(),
2305 control_class: authority.control_class,
2306 lifecycle_mode: authority.lifecycle_mode,
2307 required_external_action: authority
2308 .external_action_required
2309 .then(|| required_external_action(authority.lifecycle_mode).to_string()),
2310 blockers: authority.blockers.clone(),
2311 warnings: authority.warnings.clone(),
2312 }
2313}
2314
2315fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
2316 if check.plan.expected_verifier_readiness.required {
2317 vec!["protected-call verifier readiness must be checked before completion".to_string()]
2318 } else {
2319 Vec::new()
2320 }
2321}
2322
2323const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
2324 match lifecycle_mode {
2325 LifecycleModeV1::DirectDeploymentAuthority => "none",
2326 LifecycleModeV1::ProposalRequired => "proposal_and_consent",
2327 LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
2328 LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
2329 LifecycleModeV1::VerifyOnly => "verify_external_completion",
2330 LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
2331 }
2332}
2333
2334fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
2335 stable_json_sha256_hex(&(
2336 artifact.role.as_str(),
2337 artifact.wasm_sha256.as_deref(),
2338 artifact.wasm_gz_sha256.as_deref(),
2339 artifact.installed_module_hash.as_deref(),
2340 artifact.candid_sha256.as_deref(),
2341 artifact.canonical_embedded_config_sha256.as_deref(),
2342 ))
2343}
2344
2345fn external_upgrade_authorization_modes(
2346 control_class: CanisterControlClassV1,
2347) -> Vec<ExternalUpgradeAuthorizationModeV1> {
2348 match control_class {
2349 CanisterControlClassV1::JointlyControlled => vec![
2350 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
2351 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
2352 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
2353 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
2354 ],
2355 CanisterControlClassV1::CanicManagedPool
2356 | CanisterControlClassV1::ExternallyImported
2357 | CanisterControlClassV1::UserControlled => vec![
2358 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
2359 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
2360 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
2361 ],
2362 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2363 Vec::new()
2364 }
2365 }
2366}
2367
2368fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
2369 let subject = subject.replace([':', '/'], "-");
2370 format!("{report_id}:{subject}")
2371}
2372
2373fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
2374 stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
2375 lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
2376 deployment_plan_id: &plan.deployment_plan_id,
2377 deployment_plan_digest: &plan.deployment_plan_digest,
2378 inventory_id: &plan.inventory_id,
2379 lifecycle_authority_rows: &plan.lifecycle_authority_rows,
2380 directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
2381 proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
2382 blocked_role_upgrades: &plan.blocked_role_upgrades,
2383 dependency_blockers: &plan.dependency_blockers,
2384 protected_call_implications: &plan.protected_call_implications,
2385 residual_exposure: &plan.residual_exposure,
2386 status: plan.status,
2387 })
2388}
2389
2390fn lifecycle_authority_report_digest(report: &LifecycleAuthorityReportV1) -> String {
2391 stable_json_sha256_hex(&LifecycleAuthorityReportDigestInput {
2392 report_id: &report.report_id,
2393 check_id: &report.check_id,
2394 plan_id: &report.plan_id,
2395 inventory_id: &report.inventory_id,
2396 authorities: &report.authorities,
2397 external_action_required_count: report.external_action_required_count,
2398 blocked_count: report.blocked_count,
2399 })
2400}
2401
2402const fn expected_lifecycle_plan_status(
2403 plan: &ExternalLifecyclePlanV1,
2404) -> ExternalLifecyclePlanStatusV1 {
2405 if !plan.blocked_role_upgrades.is_empty() {
2406 ExternalLifecyclePlanStatusV1::Blocked
2407 } else if !plan.proposed_external_role_upgrades.is_empty() {
2408 ExternalLifecyclePlanStatusV1::PendingExternalAction
2409 } else {
2410 ExternalLifecyclePlanStatusV1::Ready
2411 }
2412}
2413
2414fn ensure_unique_lifecycle_subjects(
2415 rows: &[LifecycleAuthorityV1],
2416) -> Result<(), ExternalLifecyclePlanError> {
2417 let mut subjects = BTreeSet::new();
2418 for row in rows {
2419 if !subjects.insert(row.subject.clone()) {
2420 return Err(ExternalLifecyclePlanError::DuplicateSubject {
2421 subject: row.subject.clone(),
2422 });
2423 }
2424 }
2425 Ok(())
2426}
2427
2428fn ensure_unique_authority_subjects(
2429 rows: &[LifecycleAuthorityV1],
2430) -> Result<(), LifecycleAuthorityReportError> {
2431 let mut subjects = BTreeSet::new();
2432 for row in rows {
2433 if !subjects.insert(row.subject.clone()) {
2434 return Err(LifecycleAuthorityReportError::DuplicateSubject {
2435 subject: row.subject.clone(),
2436 });
2437 }
2438 }
2439 Ok(())
2440}
2441
2442fn ensure_unique_role_upgrade_subjects(
2443 rows: &[ExternalLifecycleRoleUpgradeV1],
2444) -> Result<(), ExternalLifecyclePlanError> {
2445 let mut subjects = BTreeSet::new();
2446 for row in rows {
2447 if !subjects.insert(row.subject.clone()) {
2448 return Err(ExternalLifecyclePlanError::DuplicateSubject {
2449 subject: row.subject.clone(),
2450 });
2451 }
2452 }
2453 Ok(())
2454}
2455
2456fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
2457 stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
2458 deployment_plan_id: &proposal.deployment_plan_id,
2459 deployment_plan_digest: &proposal.deployment_plan_digest,
2460 lifecycle_plan_id: &proposal.lifecycle_plan_id,
2461 lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
2462 promotion_plan_id: &proposal.promotion_plan_id,
2463 promotion_plan_digest: &proposal.promotion_plan_digest,
2464 promotion_provenance_id: &proposal.promotion_provenance_id,
2465 promotion_provenance_digest: &proposal.promotion_provenance_digest,
2466 subject: &proposal.subject,
2467 canister_id: &proposal.canister_id,
2468 role: &proposal.role,
2469 control_class: proposal.control_class,
2470 lifecycle_mode: proposal.lifecycle_mode,
2471 observed_before_digest: &proposal.observed_before_digest,
2472 current_module_hash: &proposal.current_module_hash,
2473 current_canonical_embedded_config_sha256: &proposal
2474 .current_canonical_embedded_config_sha256,
2475 target_wasm_sha256: &proposal.target_wasm_sha256,
2476 target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
2477 target_installed_module_hash: &proposal.target_installed_module_hash,
2478 target_role_artifact_identity: &proposal.target_role_artifact_identity,
2479 target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
2480 root_trust_anchor: &proposal.root_trust_anchor,
2481 authority_profile_hash: &proposal.authority_profile_hash,
2482 required_external_action: &proposal.required_external_action,
2483 consent_requirements: &proposal.consent_requirements,
2484 allowed_authorization_modes: &proposal.allowed_authorization_modes,
2485 verification_requirements: &proposal.verification_requirements,
2486 expires_at: &proposal.expires_at,
2487 supersedes_proposal_id: &proposal.supersedes_proposal_id,
2488 })
2489}
2490
2491fn external_upgrade_proposal_report_digest(report: &ExternalUpgradeProposalReportV1) -> String {
2492 stable_json_sha256_hex(&ExternalUpgradeProposalReportDigestInput {
2493 report_id: &report.report_id,
2494 lifecycle_plan_id: &report.lifecycle_plan_id,
2495 lifecycle_plan_digest: &report.lifecycle_plan_digest,
2496 deployment_plan_id: &report.deployment_plan_id,
2497 deployment_plan_digest: &report.deployment_plan_digest,
2498 inventory_id: &report.inventory_id,
2499 proposals: &report.proposals,
2500 blocked_subjects: &report.blocked_subjects,
2501 })
2502}
2503
2504fn external_lifecycle_pending_report_digest(report: &ExternalLifecyclePendingReportV1) -> String {
2505 stable_json_sha256_hex(&ExternalLifecyclePendingReportDigestInput {
2506 report_id: &report.report_id,
2507 lifecycle_plan_id: &report.lifecycle_plan_id,
2508 lifecycle_plan_digest: &report.lifecycle_plan_digest,
2509 proposal_report_id: &report.proposal_report_id,
2510 proposal_report_digest: &report.proposal_report_digest,
2511 deployment_plan_id: &report.deployment_plan_id,
2512 deployment_plan_digest: &report.deployment_plan_digest,
2513 inventory_id: &report.inventory_id,
2514 direct_upgrade_count: report.direct_upgrade_count,
2515 pending_external_count: report.pending_external_count,
2516 blocked_count: report.blocked_count,
2517 pending_external_actions: &report.pending_external_actions,
2518 blocked_subjects: &report.blocked_subjects,
2519 residual_exposure: &report.residual_exposure,
2520 status: report.status,
2521 })
2522}
2523
2524fn external_lifecycle_check_digest(check: &ExternalLifecycleCheckV1) -> String {
2525 stable_json_sha256_hex(&ExternalLifecycleCheckDigestInput {
2526 check_id: &check.check_id,
2527 lifecycle_plan_id: &check.lifecycle_plan_id,
2528 lifecycle_plan_digest: &check.lifecycle_plan_digest,
2529 proposal_report_id: &check.proposal_report_id,
2530 proposal_report_digest: &check.proposal_report_digest,
2531 pending_report_id: &check.pending_report_id,
2532 pending_report_digest: &check.pending_report_digest,
2533 deployment_plan_id: &check.deployment_plan_id,
2534 deployment_plan_digest: &check.deployment_plan_digest,
2535 inventory_id: &check.inventory_id,
2536 status: check.status,
2537 direct_upgrade_count: check.direct_upgrade_count,
2538 pending_external_count: check.pending_external_count,
2539 blocked_count: check.blocked_count,
2540 residual_exposure_count: check.residual_exposure_count,
2541 summary: &check.summary,
2542 next_actions: &check.next_actions,
2543 })
2544}
2545
2546fn external_lifecycle_handoff_digest(handoff: &ExternalLifecycleHandoffV1) -> String {
2547 stable_json_sha256_hex(&ExternalLifecycleHandoffDigestInput {
2548 handoff_id: &handoff.handoff_id,
2549 lifecycle_check_id: &handoff.lifecycle_check_id,
2550 lifecycle_check_digest: &handoff.lifecycle_check_digest,
2551 pending_report_id: &handoff.pending_report_id,
2552 pending_report_digest: &handoff.pending_report_digest,
2553 proposal_report_id: &handoff.proposal_report_id,
2554 proposal_report_digest: &handoff.proposal_report_digest,
2555 deployment_plan_id: &handoff.deployment_plan_id,
2556 deployment_plan_digest: &handoff.deployment_plan_digest,
2557 inventory_id: &handoff.inventory_id,
2558 status: handoff.status,
2559 handoff_actions: &handoff.handoff_actions,
2560 blocked_subjects: &handoff.blocked_subjects,
2561 residual_exposure: &handoff.residual_exposure,
2562 operator_summary: &handoff.operator_summary,
2563 })
2564}
2565
2566fn critical_external_fix_report_digest(report: &CriticalExternalFixReportV1) -> String {
2567 stable_json_sha256_hex(&CriticalExternalFixReportDigestInput {
2568 report_id: &report.report_id,
2569 fix_id: &report.fix_id,
2570 severity: &report.severity,
2571 lifecycle_plan_id: &report.lifecycle_plan_id,
2572 lifecycle_plan_digest: &report.lifecycle_plan_digest,
2573 pending_report_id: &report.pending_report_id,
2574 pending_report_digest: &report.pending_report_digest,
2575 deployment_plan_id: &report.deployment_plan_id,
2576 deployment_plan_digest: &report.deployment_plan_digest,
2577 inventory_id: &report.inventory_id,
2578 affected_roles: &report.affected_roles,
2579 affected_canisters: &report.affected_canisters,
2580 directly_patchable_roles: &report.directly_patchable_roles,
2581 externally_blocked_roles: &report.externally_blocked_roles,
2582 dependency_blocked_roles: &report.dependency_blocked_roles,
2583 required_external_actions: &report.required_external_actions,
2584 protected_call_implications: &report.protected_call_implications,
2585 residual_exposure: &report.residual_exposure,
2586 operator_next_steps: &report.operator_next_steps,
2587 })
2588}
2589
2590fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
2591 stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
2592 proposal_id: &receipt.proposal_id,
2593 proposal_digest: &receipt.proposal_digest,
2594 subject: &receipt.subject,
2595 canister_id: &receipt.canister_id,
2596 role: &receipt.role,
2597 consent_state: receipt.consent_state,
2598 reported_by: &receipt.reported_by,
2599 observed_before_module_hash: &receipt.observed_before_module_hash,
2600 observed_after_module_hash: &receipt.observed_after_module_hash,
2601 observed_after_canonical_embedded_config_sha256: &receipt
2602 .observed_after_canonical_embedded_config_sha256,
2603 verification_result: receipt.verification_result,
2604 verification_notes: &receipt.verification_notes,
2605 })
2606}
2607
2608fn external_upgrade_consent_evidence_digest(evidence: &ExternalUpgradeConsentEvidenceV1) -> String {
2609 stable_json_sha256_hex(&ExternalUpgradeConsentEvidenceDigestInput {
2610 evidence_id: &evidence.evidence_id,
2611 proposal_id: &evidence.proposal_id,
2612 proposal_digest: &evidence.proposal_digest,
2613 receipt_id: &evidence.receipt_id,
2614 receipt_digest: &evidence.receipt_digest,
2615 subject: &evidence.subject,
2616 canister_id: &evidence.canister_id,
2617 role: &evidence.role,
2618 consent_state: evidence.consent_state,
2619 reported_by: &evidence.reported_by,
2620 consent_requirements: &evidence.consent_requirements,
2621 allowed_authorization_modes: &evidence.allowed_authorization_modes,
2622 status_summary: &evidence.status_summary,
2623 })
2624}
2625
2626fn external_upgrade_verification_report_digest(
2627 report: &ExternalUpgradeVerificationReportV1,
2628) -> String {
2629 stable_json_sha256_hex(&ExternalUpgradeVerificationReportDigestInput {
2630 report_id: &report.report_id,
2631 proposal_id: &report.proposal_id,
2632 proposal_digest: &report.proposal_digest,
2633 receipt_id: &report.receipt_id,
2634 receipt_digest: &report.receipt_digest,
2635 subject: &report.subject,
2636 canister_id: &report.canister_id,
2637 role: &report.role,
2638 verification_result: report.verification_result,
2639 verification_notes: &report.verification_notes,
2640 live_inventory_required: report.live_inventory_required,
2641 status_summary: &report.status_summary,
2642 })
2643}
2644
2645fn observed_before_digest(
2646 authority: &LifecycleAuthorityV1,
2647 current_module_hash: Option<&String>,
2648 current_config_hash: Option<&String>,
2649) -> String {
2650 stable_json_sha256_hex(&ObservedBeforeDigestInput {
2651 subject: &authority.subject,
2652 canister_id: &authority.canister_id,
2653 role: &authority.role,
2654 observed_controllers: &authority.observed_controllers,
2655 current_module_hash,
2656 current_canonical_embedded_config_sha256: current_config_hash,
2657 })
2658}
2659
2660fn external_upgrade_verification_result(
2661 consent_state: ExternalUpgradeConsentStateV1,
2662 proposal: &ExternalUpgradeProposalV1,
2663 observed_after_module_hash: Option<&str>,
2664 observed_after_config: Option<&str>,
2665) -> ExternalUpgradeVerificationResultV1 {
2666 match consent_state {
2667 ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
2668 ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
2669 ExternalUpgradeConsentStateV1::Delegated
2670 | ExternalUpgradeConsentStateV1::ExecutedExternally => {
2671 if external_upgrade_observation_matches(
2672 proposal.target_installed_module_hash.as_deref(),
2673 observed_after_module_hash,
2674 ) && external_upgrade_observation_matches(
2675 proposal.target_canonical_embedded_config_sha256.as_deref(),
2676 observed_after_config,
2677 ) {
2678 ExternalUpgradeVerificationResultV1::Verified
2679 } else {
2680 ExternalUpgradeVerificationResultV1::Mismatch
2681 }
2682 }
2683 }
2684}
2685
2686fn external_upgrade_verification_notes(
2687 verification_result: ExternalUpgradeVerificationResultV1,
2688 proposal: &ExternalUpgradeProposalV1,
2689 observed_after_module_hash: Option<&str>,
2690 observed_after_config: Option<&str>,
2691) -> Vec<String> {
2692 let mut notes = Vec::new();
2693 if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
2694 if !external_upgrade_observation_matches(
2695 proposal.target_installed_module_hash.as_deref(),
2696 observed_after_module_hash,
2697 ) {
2698 notes.push("observed module hash does not match proposal target".to_string());
2699 }
2700 if !external_upgrade_observation_matches(
2701 proposal.target_canonical_embedded_config_sha256.as_deref(),
2702 observed_after_config,
2703 ) {
2704 notes.push("observed embedded config does not match proposal target".to_string());
2705 }
2706 }
2707 notes
2708}
2709
2710fn external_lifecycle_check_summary(
2711 status: ExternalLifecyclePlanStatusV1,
2712 pending_report: &ExternalLifecyclePendingReportV1,
2713) -> String {
2714 match status {
2715 ExternalLifecyclePlanStatusV1::Ready => {
2716 format!(
2717 "external lifecycle is ready: {} directly executable role(s), no pending external action",
2718 pending_report.direct_upgrade_count
2719 )
2720 }
2721 ExternalLifecyclePlanStatusV1::PendingExternalAction => {
2722 format!(
2723 "external lifecycle has {} pending external action(s) and {} directly executable role(s)",
2724 pending_report.pending_external_count, pending_report.direct_upgrade_count
2725 )
2726 }
2727 ExternalLifecyclePlanStatusV1::Blocked => {
2728 format!(
2729 "external lifecycle is blocked by {} role/canister subject(s)",
2730 pending_report.blocked_count
2731 )
2732 }
2733 }
2734}
2735
2736fn external_lifecycle_check_next_actions(
2737 status: ExternalLifecyclePlanStatusV1,
2738 pending_report: &ExternalLifecyclePendingReportV1,
2739) -> Vec<String> {
2740 match status {
2741 ExternalLifecyclePlanStatusV1::Ready => {
2742 vec!["continue through the normal guarded deployment path".to_string()]
2743 }
2744 ExternalLifecyclePlanStatusV1::PendingExternalAction => pending_report
2745 .pending_external_actions
2746 .iter()
2747 .map(|action| {
2748 format!(
2749 "request {} for {}",
2750 action.required_external_action, action.subject
2751 )
2752 })
2753 .collect(),
2754 ExternalLifecyclePlanStatusV1::Blocked => {
2755 vec!["resolve blocked external lifecycle subjects before execution".to_string()]
2756 }
2757 }
2758}
2759
2760fn external_lifecycle_handoff_summary(report: &ExternalLifecyclePendingReportV1) -> String {
2761 match report.status {
2762 ExternalLifecyclePlanStatusV1::Ready => {
2763 "no external lifecycle handoff is required".to_string()
2764 }
2765 ExternalLifecyclePlanStatusV1::PendingExternalAction => format!(
2766 "{} external lifecycle handoff action(s) require operator coordination",
2767 report.pending_external_count
2768 ),
2769 ExternalLifecyclePlanStatusV1::Blocked => format!(
2770 "external lifecycle handoff is blocked by {} subject(s)",
2771 report.blocked_count
2772 ),
2773 }
2774}
2775
2776fn external_lifecycle_handoff_instructions(proposal: &ExternalUpgradeProposalV1) -> Vec<String> {
2777 let mut instructions = vec![
2778 format!(
2779 "present proposal {} for subject {}",
2780 proposal.proposal_id, proposal.subject
2781 ),
2782 "verify live inventory after any reported external action".to_string(),
2783 ];
2784 if let Some(expires_at) = proposal.expires_at.as_deref() {
2785 instructions.push(format!("do not use this proposal after {expires_at}"));
2786 }
2787 match proposal.lifecycle_mode {
2788 LifecycleModeV1::ProposalRequired => {
2789 instructions.push("collect explicit consent before direct install".to_string());
2790 }
2791 LifecycleModeV1::DelegatedInstallRequired => {
2792 instructions.push("use delegated install authority only if policy allows".to_string());
2793 }
2794 LifecycleModeV1::ExternalCompletionOnly | LifecycleModeV1::VerifyOnly => {
2795 instructions
2796 .push("wait for external completion evidence before verification".to_string());
2797 }
2798 LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => {
2799 instructions.push("do not execute; report blocked lifecycle state".to_string());
2800 }
2801 LifecycleModeV1::DirectDeploymentAuthority => {
2802 instructions.push("no external handoff should be required".to_string());
2803 }
2804 }
2805 instructions
2806}
2807
2808const fn external_upgrade_verification_summary(
2809 result: ExternalUpgradeVerificationResultV1,
2810) -> &'static str {
2811 match result {
2812 ExternalUpgradeVerificationResultV1::Pending => {
2813 "external action has not been reported as complete"
2814 }
2815 ExternalUpgradeVerificationResultV1::Refused => "external consent was refused",
2816 ExternalUpgradeVerificationResultV1::Verified => {
2817 "reported external completion matches proposal target facts"
2818 }
2819 ExternalUpgradeVerificationResultV1::Mismatch => {
2820 "reported external completion does not match proposal target facts"
2821 }
2822 }
2823}
2824
2825const fn external_upgrade_consent_summary(state: ExternalUpgradeConsentStateV1) -> &'static str {
2826 match state {
2827 ExternalUpgradeConsentStateV1::Pending => {
2828 "external consent or action has not been reported"
2829 }
2830 ExternalUpgradeConsentStateV1::Refused => "external consent was refused",
2831 ExternalUpgradeConsentStateV1::Delegated => "delegated install authority was reported",
2832 ExternalUpgradeConsentStateV1::ExecutedExternally => {
2833 "external controller execution was reported"
2834 }
2835 }
2836}
2837
2838fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
2839 expected.is_none_or(|expected| observed == Some(expected))
2840}
2841
2842fn ensure_external_receipt_field(
2843 field: &'static str,
2844 value: &str,
2845) -> Result<(), ExternalUpgradeReceiptError> {
2846 if value.trim().is_empty() {
2847 return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
2848 }
2849 Ok(())
2850}
2851
2852fn ensure_external_receipt_matches_proposal(
2853 field: &'static str,
2854 actual: &str,
2855 expected: &str,
2856) -> Result<(), ExternalUpgradeReceiptError> {
2857 if actual != expected {
2858 return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
2859 }
2860 Ok(())
2861}
2862
2863fn ensure_external_receipt_option_matches_proposal(
2864 field: &'static str,
2865 actual: Option<&str>,
2866 expected: Option<&str>,
2867) -> Result<(), ExternalUpgradeReceiptError> {
2868 if actual != expected {
2869 return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
2870 }
2871 Ok(())
2872}
2873
2874fn ensure_external_consent_evidence_field(
2875 field: &'static str,
2876 value: &str,
2877) -> Result<(), ExternalUpgradeConsentEvidenceError> {
2878 if value.trim().is_empty() {
2879 return Err(ExternalUpgradeConsentEvidenceError::MissingRequiredField { field });
2880 }
2881 Ok(())
2882}
2883
2884fn ensure_external_verification_report_field(
2885 field: &'static str,
2886 value: &str,
2887) -> Result<(), ExternalUpgradeVerificationReportError> {
2888 if value.trim().is_empty() {
2889 return Err(ExternalUpgradeVerificationReportError::MissingRequiredField { field });
2890 }
2891 Ok(())
2892}
2893
2894fn ensure_external_lifecycle_plan_field(
2895 field: &'static str,
2896 value: &str,
2897) -> Result<(), ExternalLifecyclePlanError> {
2898 if value.trim().is_empty() {
2899 return Err(ExternalLifecyclePlanError::MissingRequiredField { field });
2900 }
2901 Ok(())
2902}
2903
2904fn ensure_external_proposal_report_field(
2905 field: &'static str,
2906 value: &str,
2907) -> Result<(), ExternalUpgradeProposalReportError> {
2908 if value.trim().is_empty() {
2909 return Err(ExternalUpgradeProposalReportError::MissingRequiredField { field });
2910 }
2911 Ok(())
2912}
2913
2914fn ensure_external_pending_report_field(
2915 field: &'static str,
2916 value: &str,
2917) -> Result<(), ExternalLifecyclePendingReportError> {
2918 if value.trim().is_empty() {
2919 return Err(ExternalLifecyclePendingReportError::MissingRequiredField { field });
2920 }
2921 Ok(())
2922}
2923
2924fn ensure_external_lifecycle_check_field(
2925 field: &'static str,
2926 value: &str,
2927) -> Result<(), ExternalLifecycleCheckError> {
2928 if value.trim().is_empty() {
2929 return Err(ExternalLifecycleCheckError::MissingRequiredField { field });
2930 }
2931 Ok(())
2932}
2933
2934fn ensure_external_lifecycle_handoff_field(
2935 field: &'static str,
2936 value: &str,
2937) -> Result<(), ExternalLifecycleHandoffError> {
2938 if value.trim().is_empty() {
2939 return Err(ExternalLifecycleHandoffError::MissingRequiredField { field });
2940 }
2941 Ok(())
2942}
2943
2944fn ensure_critical_fix_report_field(
2945 field: &'static str,
2946 value: &str,
2947) -> Result<(), CriticalExternalFixReportError> {
2948 if value.trim().is_empty() {
2949 return Err(CriticalExternalFixReportError::MissingRequiredField { field });
2950 }
2951 Ok(())
2952}
2953
2954fn ensure_lifecycle_authority_report_field(
2955 field: &'static str,
2956 value: &str,
2957) -> Result<(), LifecycleAuthorityReportError> {
2958 if value.trim().is_empty() {
2959 return Err(LifecycleAuthorityReportError::MissingRequiredField { field });
2960 }
2961 Ok(())
2962}
2963
2964fn sorted_unique(values: Vec<String>) -> Vec<String> {
2965 values
2966 .into_iter()
2967 .collect::<BTreeSet<_>>()
2968 .into_iter()
2969 .collect()
2970}