1use super::*;
2use serde::Serialize;
3use std::collections::BTreeSet;
4
5#[derive(Serialize)]
6struct LifecycleAuthorityReportDigestInput<'a> {
7 report_id: &'a str,
8 check_id: &'a str,
9 plan_id: &'a str,
10 inventory_id: &'a str,
11 authorities: &'a [LifecycleAuthorityV1],
12 external_action_required_count: usize,
13 blocked_count: usize,
14}
15
16#[derive(Serialize)]
17struct ExternalLifecyclePlanDigestInput<'a> {
18 lifecycle_authority_report_id: &'a str,
19 deployment_plan_id: &'a str,
20 deployment_plan_digest: &'a str,
21 inventory_id: &'a str,
22 lifecycle_authority_rows: &'a [LifecycleAuthorityV1],
23 directly_executable_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
24 proposed_external_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
25 blocked_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
26 dependency_blockers: &'a [String],
27 protected_call_implications: &'a [String],
28 residual_exposure: &'a [String],
29 status: ExternalLifecyclePlanStatusV1,
30}
31
32#[derive(Serialize)]
33struct ExternalUpgradeProposalReportDigestInput<'a> {
34 report_id: &'a str,
35 lifecycle_plan_id: &'a str,
36 lifecycle_plan_digest: &'a str,
37 deployment_plan_id: &'a str,
38 deployment_plan_digest: &'a str,
39 inventory_id: &'a str,
40 proposals: &'a [ExternalUpgradeProposalV1],
41 blocked_subjects: &'a [String],
42}
43
44#[derive(Serialize)]
45struct 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 CriticalExternalFixReportDigestInput<'a> {
65 report_id: &'a str,
66 fix_id: &'a str,
67 severity: &'a str,
68 lifecycle_plan_id: &'a str,
69 lifecycle_plan_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 affected_roles: &'a [String],
76 affected_canisters: &'a [String],
77 directly_patchable_roles: &'a [String],
78 externally_blocked_roles: &'a [String],
79 dependency_blocked_roles: &'a [String],
80 required_external_actions: &'a [String],
81 protected_call_implications: &'a [String],
82 residual_exposure: &'a [String],
83 operator_next_steps: &'a [String],
84}
85
86#[derive(Serialize)]
87struct ExternalUpgradeProposalDigestInput<'a> {
88 deployment_plan_id: &'a str,
89 deployment_plan_digest: &'a str,
90 lifecycle_plan_id: &'a str,
91 lifecycle_plan_digest: &'a str,
92 promotion_plan_id: &'a Option<String>,
93 promotion_plan_digest: &'a Option<String>,
94 promotion_provenance_id: &'a Option<String>,
95 promotion_provenance_digest: &'a Option<String>,
96 subject: &'a str,
97 canister_id: &'a Option<String>,
98 role: &'a Option<String>,
99 control_class: CanisterControlClassV1,
100 lifecycle_mode: LifecycleModeV1,
101 observed_before_digest: &'a str,
102 current_module_hash: &'a Option<String>,
103 current_canonical_embedded_config_sha256: &'a Option<String>,
104 target_wasm_sha256: &'a Option<String>,
105 target_wasm_gz_sha256: &'a Option<String>,
106 target_installed_module_hash: &'a Option<String>,
107 target_role_artifact_identity: &'a Option<String>,
108 target_canonical_embedded_config_sha256: &'a Option<String>,
109 root_trust_anchor: &'a Option<String>,
110 authority_profile_hash: &'a Option<String>,
111 required_external_action: &'a str,
112 consent_requirements: &'a [ConsentRequirementV1],
113 allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
114 verification_requirements: &'a [LifecycleVerificationRequirementV1],
115 expires_at: &'a Option<String>,
116 supersedes_proposal_id: &'a Option<String>,
117}
118
119#[derive(Serialize)]
120struct ExternalUpgradeReceiptDigestInput<'a> {
121 proposal_id: &'a str,
122 proposal_digest: &'a str,
123 subject: &'a str,
124 canister_id: &'a Option<String>,
125 role: &'a Option<String>,
126 consent_state: ExternalUpgradeConsentStateV1,
127 reported_by: &'a Option<String>,
128 observed_before_module_hash: &'a Option<String>,
129 observed_after_module_hash: &'a Option<String>,
130 observed_after_canonical_embedded_config_sha256: &'a Option<String>,
131 verification_result: ExternalUpgradeVerificationResultV1,
132 verification_notes: &'a [String],
133}
134
135#[derive(Serialize)]
136struct ExternalUpgradeConsentEvidenceDigestInput<'a> {
137 evidence_id: &'a str,
138 proposal_id: &'a str,
139 proposal_digest: &'a str,
140 receipt_id: &'a str,
141 receipt_digest: &'a str,
142 subject: &'a str,
143 canister_id: &'a Option<String>,
144 role: &'a Option<String>,
145 consent_state: ExternalUpgradeConsentStateV1,
146 reported_by: &'a Option<String>,
147 consent_requirements: &'a [ConsentRequirementV1],
148 allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
149 status_summary: &'a str,
150}
151
152#[derive(Serialize)]
153struct ExternalUpgradeVerificationReportDigestInput<'a> {
154 report_id: &'a str,
155 proposal_id: &'a str,
156 proposal_digest: &'a str,
157 receipt_id: &'a str,
158 receipt_digest: &'a str,
159 subject: &'a str,
160 canister_id: &'a Option<String>,
161 role: &'a Option<String>,
162 verification_result: ExternalUpgradeVerificationResultV1,
163 verification_notes: &'a [String],
164 live_inventory_required: bool,
165 status_summary: &'a str,
166}
167
168#[derive(Serialize)]
169struct ObservedBeforeDigestInput<'a> {
170 subject: &'a str,
171 canister_id: &'a Option<String>,
172 role: &'a Option<String>,
173 observed_controllers: &'a [String],
174 current_module_hash: Option<&'a String>,
175 current_canonical_embedded_config_sha256: Option<&'a String>,
176}
177
178#[derive(Debug, Eq, thiserror::Error, PartialEq)]
182pub enum ExternalUpgradeReceiptError {
183 #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
184 SchemaVersionMismatch { expected: u32, actual: u32 },
185 #[error("external upgrade receipt field `{field}` is required")]
186 MissingRequiredField { field: &'static str },
187 #[error("external upgrade receipt field `{field}` digest is stale")]
188 DigestMismatch { field: &'static str },
189 #[error("external upgrade receipt field `{field}` does not match proposal source")]
190 SourceMismatch { field: &'static str },
191 #[error("external upgrade receipt verification result does not match observations")]
192 VerificationMismatch,
193 #[error("external upgrade receipt refused consent cannot be verified")]
194 RefusedConsentVerified,
195}
196
197#[derive(Debug, Eq, thiserror::Error, PartialEq)]
201pub enum ExternalUpgradeConsentEvidenceError {
202 #[error(
203 "external upgrade consent evidence schema version {actual} does not match expected {expected}"
204 )]
205 SchemaVersionMismatch { expected: u32, actual: u32 },
206 #[error("external upgrade consent evidence field `{field}` is required")]
207 MissingRequiredField { field: &'static str },
208 #[error("external upgrade consent evidence field `{field}` digest is stale")]
209 DigestMismatch { field: &'static str },
210 #[error("external upgrade consent evidence field `{field}` no longer matches source receipt")]
211 SourceMismatch { field: &'static str },
212 #[error(transparent)]
213 Receipt(#[from] ExternalUpgradeReceiptError),
214}
215
216#[derive(Debug, Eq, thiserror::Error, PartialEq)]
220pub enum ExternalUpgradeVerificationReportError {
221 #[error(
222 "external upgrade verification report schema version {actual} does not match expected {expected}"
223 )]
224 SchemaVersionMismatch { expected: u32, actual: u32 },
225 #[error("external upgrade verification report field `{field}` is required")]
226 MissingRequiredField { field: &'static str },
227 #[error("external upgrade verification report field `{field}` digest is stale")]
228 DigestMismatch { field: &'static str },
229 #[error("external upgrade verification report field `{field}` does not match source evidence")]
230 SourceMismatch { field: &'static str },
231 #[error(transparent)]
232 Receipt(#[from] ExternalUpgradeReceiptError),
233}
234
235#[derive(Debug, Eq, thiserror::Error, PartialEq)]
239pub enum LifecycleAuthorityReportError {
240 #[error(
241 "lifecycle authority report schema version {actual} does not match expected {expected}"
242 )]
243 SchemaVersionMismatch { expected: u32, actual: u32 },
244 #[error("lifecycle authority report field `{field}` is required")]
245 MissingRequiredField { field: &'static str },
246 #[error("lifecycle authority report field `{field}` digest is stale")]
247 DigestMismatch { field: &'static str },
248 #[error("lifecycle authority report contains duplicate subject `{subject}`")]
249 DuplicateSubject { subject: String },
250 #[error("lifecycle authority report counters do not match authority rows")]
251 CountMismatch,
252}
253
254#[derive(Debug, Eq, thiserror::Error, PartialEq)]
258pub enum ExternalLifecyclePlanError {
259 #[error("external lifecycle plan schema version {actual} does not match expected {expected}")]
260 SchemaVersionMismatch { expected: u32, actual: u32 },
261 #[error("external lifecycle plan field `{field}` is required")]
262 MissingRequiredField { field: &'static str },
263 #[error("external lifecycle plan field `{field}` digest is stale")]
264 DigestMismatch { field: &'static str },
265 #[error("external lifecycle plan field `{field}` does not match deployment truth source")]
266 SourceMismatch { field: &'static str },
267 #[error("external lifecycle plan status does not match role partitioning")]
268 StatusMismatch,
269 #[error("external lifecycle plan contains duplicate subject `{subject}`")]
270 DuplicateSubject { subject: String },
271}
272
273#[derive(Debug, Eq, thiserror::Error, PartialEq)]
277pub enum ExternalUpgradeProposalReportError {
278 #[error(
279 "external upgrade proposal report schema version {actual} does not match expected {expected}"
280 )]
281 SchemaVersionMismatch { expected: u32, actual: u32 },
282 #[error("external upgrade proposal report field `{field}` is required")]
283 MissingRequiredField { field: &'static str },
284 #[error("external upgrade proposal report field `{field}` digest is stale")]
285 DigestMismatch { field: &'static str },
286 #[error("external upgrade proposal report field `{field}` does not match lifecycle source")]
287 SourceMismatch { field: &'static str },
288 #[error(
289 "external upgrade proposal report contains proposal for directly controlled row `{subject}`"
290 )]
291 DirectLifecycleProposal { subject: String },
292 #[error("external upgrade proposal report contains duplicate subject `{subject}`")]
293 DuplicateSubject { subject: String },
294}
295
296#[derive(Debug, Eq, thiserror::Error, PartialEq)]
300pub enum ExternalLifecyclePendingReportError {
301 #[error(
302 "external lifecycle pending report schema version {actual} does not match expected {expected}"
303 )]
304 SchemaVersionMismatch { expected: u32, actual: u32 },
305 #[error("external lifecycle pending report field `{field}` is required")]
306 MissingRequiredField { field: &'static str },
307 #[error("external lifecycle pending report field `{field}` digest is stale")]
308 DigestMismatch { field: &'static str },
309 #[error("external lifecycle pending report field `{field}` does not match lifecycle source")]
310 SourceMismatch { field: &'static str },
311 #[error("external lifecycle pending report counters do not match action rows")]
312 CountMismatch,
313 #[error("external lifecycle pending report contains duplicate subject `{subject}`")]
314 DuplicateSubject { subject: String },
315}
316
317#[derive(Debug, Eq, thiserror::Error, PartialEq)]
321pub enum CriticalExternalFixReportError {
322 #[error(
323 "critical external fix report schema version {actual} does not match expected {expected}"
324 )]
325 SchemaVersionMismatch { expected: u32, actual: u32 },
326 #[error("critical external fix report field `{field}` is required")]
327 MissingRequiredField { field: &'static str },
328 #[error("critical external fix report field `{field}` digest is stale")]
329 DigestMismatch { field: &'static str },
330 #[error("critical external fix report field `{field}` does not match lifecycle source")]
331 SourceMismatch { field: &'static str },
332}
333
334#[must_use]
338pub fn lifecycle_authority_report_from_check(
339 report_id: impl Into<String>,
340 check: &DeploymentCheckV1,
341) -> LifecycleAuthorityReportV1 {
342 let mut authorities = Vec::new();
343 let mut seen_subjects = BTreeSet::new();
344
345 for expected in &check.plan.expected_canisters {
346 let observed = observed_canister_for_expected(&check.inventory, expected);
347 let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
348 seen_subjects.insert(authority.subject.clone());
349 authorities.push(authority);
350 }
351
352 for expected in &check.plan.expected_pool {
353 let observed = observed_pool_for_expected(&check.inventory, expected);
354 let authority = lifecycle_authority_for_expected_pool(expected, observed);
355 seen_subjects.insert(authority.subject.clone());
356 authorities.push(authority);
357 }
358
359 for observed in &check.inventory.observed_canisters {
360 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
361 if seen_subjects.contains(&subject) {
362 continue;
363 }
364 authorities.push(lifecycle_authority_for_unplanned_canister(observed));
365 }
366
367 for observed in &check.inventory.observed_pool {
368 let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
369 if seen_subjects.contains(&subject) {
370 continue;
371 }
372 authorities.push(lifecycle_authority_for_unplanned_pool(observed));
373 }
374
375 authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
376 let external_action_required_count = authorities
377 .iter()
378 .filter(|authority| authority.external_action_required)
379 .count();
380 let blocked_count = authorities
381 .iter()
382 .filter(|authority| authority.blocked)
383 .count();
384
385 let mut report = LifecycleAuthorityReportV1 {
386 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
387 report_id: report_id.into(),
388 report_digest: String::new(),
389 check_id: check.check_id.clone(),
390 plan_id: check.plan.plan_id.clone(),
391 inventory_id: check.inventory.inventory_id.clone(),
392 authorities,
393 external_action_required_count,
394 blocked_count,
395 };
396 report.report_digest = lifecycle_authority_report_digest(&report);
397 report
398}
399
400pub fn validate_lifecycle_authority_report(
402 report: &LifecycleAuthorityReportV1,
403) -> Result<(), LifecycleAuthorityReportError> {
404 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
405 return Err(LifecycleAuthorityReportError::SchemaVersionMismatch {
406 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
407 actual: report.schema_version,
408 });
409 }
410 ensure_lifecycle_authority_report_field("report_id", report.report_id.as_str())?;
411 ensure_lifecycle_authority_report_field("report_digest", report.report_digest.as_str())?;
412 ensure_lifecycle_authority_report_field("check_id", report.check_id.as_str())?;
413 ensure_lifecycle_authority_report_field("plan_id", report.plan_id.as_str())?;
414 ensure_lifecycle_authority_report_field("inventory_id", report.inventory_id.as_str())?;
415 ensure_unique_authority_subjects(&report.authorities)?;
416 if report.external_action_required_count
417 != report
418 .authorities
419 .iter()
420 .filter(|authority| authority.external_action_required)
421 .count()
422 || report.blocked_count
423 != report
424 .authorities
425 .iter()
426 .filter(|authority| authority.blocked)
427 .count()
428 {
429 return Err(LifecycleAuthorityReportError::CountMismatch);
430 }
431 if report.report_digest != lifecycle_authority_report_digest(report) {
432 return Err(LifecycleAuthorityReportError::DigestMismatch {
433 field: "report_digest",
434 });
435 }
436 Ok(())
437}
438
439#[must_use]
445pub fn external_lifecycle_plan_from_check(
446 lifecycle_plan_id: impl Into<String>,
447 lifecycle_authority_report_id: impl Into<String>,
448 check: &DeploymentCheckV1,
449) -> ExternalLifecyclePlanV1 {
450 let lifecycle_authority_report =
451 lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
452 let lifecycle_authority_rows = lifecycle_authority_report.authorities;
453 let directly_executable_role_upgrades = lifecycle_authority_rows
454 .iter()
455 .filter(|authority| {
456 authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
457 && !authority.blocked
458 })
459 .map(external_lifecycle_role_upgrade)
460 .collect::<Vec<_>>();
461 let proposed_external_role_upgrades = lifecycle_authority_rows
462 .iter()
463 .filter(|authority| authority.external_action_required && !authority.blocked)
464 .map(external_lifecycle_role_upgrade)
465 .collect::<Vec<_>>();
466 let blocked_role_upgrades = lifecycle_authority_rows
467 .iter()
468 .filter(|authority| authority.blocked)
469 .map(external_lifecycle_role_upgrade)
470 .collect::<Vec<_>>();
471 let residual_exposure = proposed_external_role_upgrades
472 .iter()
473 .map(|upgrade| {
474 format!(
475 "{} remains pending external lifecycle action",
476 upgrade.subject
477 )
478 })
479 .collect::<Vec<_>>();
480 let status = if !blocked_role_upgrades.is_empty() {
481 ExternalLifecyclePlanStatusV1::Blocked
482 } else if !proposed_external_role_upgrades.is_empty() {
483 ExternalLifecyclePlanStatusV1::PendingExternalAction
484 } else {
485 ExternalLifecyclePlanStatusV1::Ready
486 };
487 let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
488 let mut plan = ExternalLifecyclePlanV1 {
489 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
490 lifecycle_plan_id: lifecycle_plan_id.into(),
491 lifecycle_plan_digest: String::new(),
492 lifecycle_authority_report_id: lifecycle_authority_report.report_id,
493 deployment_plan_id: check.plan.plan_id.clone(),
494 deployment_plan_digest,
495 inventory_id: check.inventory.inventory_id.clone(),
496 lifecycle_authority_rows,
497 directly_executable_role_upgrades,
498 proposed_external_role_upgrades,
499 blocked_role_upgrades,
500 dependency_blockers: Vec::new(),
501 protected_call_implications: protected_call_implications_for_check(check),
502 residual_exposure,
503 status,
504 };
505 plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
506 plan
507}
508
509pub fn validate_external_lifecycle_plan(
511 plan: &ExternalLifecyclePlanV1,
512) -> Result<(), ExternalLifecyclePlanError> {
513 if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
514 return Err(ExternalLifecyclePlanError::SchemaVersionMismatch {
515 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
516 actual: plan.schema_version,
517 });
518 }
519 ensure_external_lifecycle_plan_field("lifecycle_plan_id", plan.lifecycle_plan_id.as_str())?;
520 ensure_external_lifecycle_plan_field(
521 "lifecycle_authority_report_id",
522 plan.lifecycle_authority_report_id.as_str(),
523 )?;
524 ensure_external_lifecycle_plan_field("deployment_plan_id", plan.deployment_plan_id.as_str())?;
525 ensure_external_lifecycle_plan_field("inventory_id", plan.inventory_id.as_str())?;
526 if plan.lifecycle_plan_digest != external_lifecycle_plan_digest(plan) {
527 return Err(ExternalLifecyclePlanError::DigestMismatch {
528 field: "lifecycle_plan_digest",
529 });
530 }
531 if plan.status != expected_lifecycle_plan_status(plan) {
532 return Err(ExternalLifecyclePlanError::StatusMismatch);
533 }
534 ensure_unique_lifecycle_subjects(&plan.lifecycle_authority_rows)?;
535 ensure_unique_role_upgrade_subjects(&plan.directly_executable_role_upgrades)?;
536 ensure_unique_role_upgrade_subjects(&plan.proposed_external_role_upgrades)?;
537 ensure_unique_role_upgrade_subjects(&plan.blocked_role_upgrades)?;
538 Ok(())
539}
540
541pub fn validate_external_lifecycle_plan_for_check(
544 plan: &ExternalLifecyclePlanV1,
545 check: &DeploymentCheckV1,
546) -> Result<(), ExternalLifecyclePlanError> {
547 validate_external_lifecycle_plan(plan)?;
548 let expected = external_lifecycle_plan_from_check(
549 plan.lifecycle_plan_id.clone(),
550 plan.lifecycle_authority_report_id.clone(),
551 check,
552 );
553 if plan != &expected {
554 return Err(ExternalLifecyclePlanError::SourceMismatch {
555 field: "deployment_check",
556 });
557 }
558 Ok(())
559}
560
561#[must_use]
566pub fn external_upgrade_receipt_from_observation(
567 receipt_id: impl Into<String>,
568 proposal: &ExternalUpgradeProposalV1,
569 consent_state: ExternalUpgradeConsentStateV1,
570 reported_by: Option<String>,
571 observed_after: Option<&ObservedCanisterV1>,
572) -> ExternalUpgradeReceiptV1 {
573 let observed_after_module_hash =
574 observed_after.and_then(|observed| observed.module_hash.clone());
575 let observed_after_canonical_embedded_config_sha256 =
576 observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
577 let verification_result = external_upgrade_verification_result(
578 consent_state,
579 proposal,
580 observed_after_module_hash.as_deref(),
581 observed_after_canonical_embedded_config_sha256.as_deref(),
582 );
583 let verification_notes = external_upgrade_verification_notes(
584 verification_result,
585 proposal,
586 observed_after_module_hash.as_deref(),
587 observed_after_canonical_embedded_config_sha256.as_deref(),
588 );
589
590 let mut receipt = ExternalUpgradeReceiptV1 {
591 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
592 receipt_id: receipt_id.into(),
593 proposal_id: proposal.proposal_id.clone(),
594 proposal_digest: proposal.proposal_digest.clone(),
595 subject: proposal.subject.clone(),
596 canister_id: proposal.canister_id.clone(),
597 role: proposal.role.clone(),
598 consent_state,
599 reported_by,
600 observed_before_module_hash: proposal.current_module_hash.clone(),
601 observed_after_module_hash,
602 observed_after_canonical_embedded_config_sha256,
603 verification_result,
604 verification_notes,
605 receipt_digest: String::new(),
606 };
607 receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
608 receipt
609}
610
611pub fn validate_external_upgrade_receipt(
616 receipt: &ExternalUpgradeReceiptV1,
617) -> Result<(), ExternalUpgradeReceiptError> {
618 if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
619 return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
620 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
621 actual: receipt.schema_version,
622 });
623 }
624 ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
625 ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
626 ensure_external_receipt_field("proposal_digest", receipt.proposal_digest.as_str())?;
627 ensure_external_receipt_field("subject", receipt.subject.as_str())?;
628 ensure_external_receipt_field("receipt_digest", receipt.receipt_digest.as_str())?;
629
630 if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
631 && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
632 {
633 return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
634 }
635 let has_observation = receipt.observed_after_module_hash.is_some()
636 || receipt
637 .observed_after_canonical_embedded_config_sha256
638 .is_some();
639 if matches!(
640 receipt.verification_result,
641 ExternalUpgradeVerificationResultV1::Verified
642 | ExternalUpgradeVerificationResultV1::Mismatch
643 ) && !has_observation
644 {
645 return Err(ExternalUpgradeReceiptError::VerificationMismatch);
646 }
647 if receipt.receipt_digest != external_upgrade_receipt_digest(receipt) {
648 return Err(ExternalUpgradeReceiptError::DigestMismatch {
649 field: "receipt_digest",
650 });
651 }
652 Ok(())
653}
654
655pub fn validate_external_upgrade_receipt_for_proposal(
662 receipt: &ExternalUpgradeReceiptV1,
663 proposal: &ExternalUpgradeProposalV1,
664) -> Result<(), ExternalUpgradeReceiptError> {
665 validate_external_upgrade_receipt(receipt)?;
666 ensure_external_receipt_matches_proposal(
667 "proposal_id",
668 receipt.proposal_id.as_str(),
669 proposal.proposal_id.as_str(),
670 )?;
671 ensure_external_receipt_matches_proposal(
672 "proposal_digest",
673 receipt.proposal_digest.as_str(),
674 proposal.proposal_digest.as_str(),
675 )?;
676 ensure_external_receipt_matches_proposal(
677 "subject",
678 receipt.subject.as_str(),
679 proposal.subject.as_str(),
680 )?;
681 ensure_external_receipt_option_matches_proposal(
682 "canister_id",
683 receipt.canister_id.as_deref(),
684 proposal.canister_id.as_deref(),
685 )?;
686 ensure_external_receipt_option_matches_proposal(
687 "role",
688 receipt.role.as_deref(),
689 proposal.role.as_deref(),
690 )?;
691 ensure_external_receipt_option_matches_proposal(
692 "observed_before_module_hash",
693 receipt.observed_before_module_hash.as_deref(),
694 proposal.current_module_hash.as_deref(),
695 )?;
696
697 let expected_result = external_upgrade_verification_result(
698 receipt.consent_state,
699 proposal,
700 receipt.observed_after_module_hash.as_deref(),
701 receipt
702 .observed_after_canonical_embedded_config_sha256
703 .as_deref(),
704 );
705 if receipt.verification_result != expected_result {
706 return Err(ExternalUpgradeReceiptError::VerificationMismatch);
707 }
708 let expected_notes = external_upgrade_verification_notes(
709 expected_result,
710 proposal,
711 receipt.observed_after_module_hash.as_deref(),
712 receipt
713 .observed_after_canonical_embedded_config_sha256
714 .as_deref(),
715 );
716 if receipt.verification_notes != expected_notes {
717 return Err(ExternalUpgradeReceiptError::SourceMismatch {
718 field: "verification_notes",
719 });
720 }
721
722 Ok(())
723}
724
725pub fn external_upgrade_consent_evidence_from_receipt(
731 evidence_id: impl Into<String>,
732 proposal: &ExternalUpgradeProposalV1,
733 receipt: &ExternalUpgradeReceiptV1,
734) -> Result<ExternalUpgradeConsentEvidenceV1, ExternalUpgradeReceiptError> {
735 validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
736 let consent_state = receipt.consent_state;
737 let mut evidence = ExternalUpgradeConsentEvidenceV1 {
738 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
739 evidence_id: evidence_id.into(),
740 evidence_digest: String::new(),
741 proposal_id: proposal.proposal_id.clone(),
742 proposal_digest: proposal.proposal_digest.clone(),
743 receipt_id: receipt.receipt_id.clone(),
744 receipt_digest: receipt.receipt_digest.clone(),
745 subject: proposal.subject.clone(),
746 canister_id: proposal.canister_id.clone(),
747 role: proposal.role.clone(),
748 consent_state,
749 reported_by: receipt.reported_by.clone(),
750 consent_requirements: proposal.consent_requirements.clone(),
751 allowed_authorization_modes: proposal.allowed_authorization_modes.clone(),
752 status_summary: external_upgrade_consent_summary(consent_state).to_string(),
753 };
754 evidence.evidence_digest = external_upgrade_consent_evidence_digest(&evidence);
755 Ok(evidence)
756}
757
758pub fn validate_external_upgrade_consent_evidence(
760 evidence: &ExternalUpgradeConsentEvidenceV1,
761) -> Result<(), ExternalUpgradeConsentEvidenceError> {
762 if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
763 return Err(ExternalUpgradeConsentEvidenceError::SchemaVersionMismatch {
764 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
765 actual: evidence.schema_version,
766 });
767 }
768 ensure_external_consent_evidence_field("evidence_id", evidence.evidence_id.as_str())?;
769 ensure_external_consent_evidence_field("evidence_digest", evidence.evidence_digest.as_str())?;
770 ensure_external_consent_evidence_field("proposal_id", evidence.proposal_id.as_str())?;
771 ensure_external_consent_evidence_field("proposal_digest", evidence.proposal_digest.as_str())?;
772 ensure_external_consent_evidence_field("receipt_id", evidence.receipt_id.as_str())?;
773 ensure_external_consent_evidence_field("receipt_digest", evidence.receipt_digest.as_str())?;
774 ensure_external_consent_evidence_field("subject", evidence.subject.as_str())?;
775 ensure_external_consent_evidence_field("status_summary", evidence.status_summary.as_str())?;
776 if evidence.status_summary != external_upgrade_consent_summary(evidence.consent_state) {
777 return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch {
778 field: "status_summary",
779 });
780 }
781 if evidence.evidence_digest != external_upgrade_consent_evidence_digest(evidence) {
782 return Err(ExternalUpgradeConsentEvidenceError::DigestMismatch {
783 field: "evidence_digest",
784 });
785 }
786 Ok(())
787}
788
789pub fn validate_external_upgrade_consent_evidence_for_receipt(
792 evidence: &ExternalUpgradeConsentEvidenceV1,
793 proposal: &ExternalUpgradeProposalV1,
794 receipt: &ExternalUpgradeReceiptV1,
795) -> Result<(), ExternalUpgradeConsentEvidenceError> {
796 validate_external_upgrade_consent_evidence(evidence)?;
797 let expected = external_upgrade_consent_evidence_from_receipt(
798 evidence.evidence_id.clone(),
799 proposal,
800 receipt,
801 )?;
802 if evidence != &expected {
803 return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch { field: "receipt" });
804 }
805 Ok(())
806}
807
808pub fn external_upgrade_verification_report_from_receipt(
813 report_id: impl Into<String>,
814 proposal: &ExternalUpgradeProposalV1,
815 receipt: &ExternalUpgradeReceiptV1,
816) -> Result<ExternalUpgradeVerificationReportV1, ExternalUpgradeReceiptError> {
817 validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
818 let verification_result = receipt.verification_result;
819 let mut report = ExternalUpgradeVerificationReportV1 {
820 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
821 report_id: report_id.into(),
822 report_digest: String::new(),
823 proposal_id: proposal.proposal_id.clone(),
824 proposal_digest: proposal.proposal_digest.clone(),
825 receipt_id: receipt.receipt_id.clone(),
826 receipt_digest: receipt.receipt_digest.clone(),
827 subject: proposal.subject.clone(),
828 canister_id: proposal.canister_id.clone(),
829 role: proposal.role.clone(),
830 verification_result,
831 verification_notes: receipt.verification_notes.clone(),
832 live_inventory_required: verification_result
833 != ExternalUpgradeVerificationResultV1::Pending
834 && verification_result != ExternalUpgradeVerificationResultV1::Refused,
835 status_summary: external_upgrade_verification_summary(verification_result).to_string(),
836 };
837 report.report_digest = external_upgrade_verification_report_digest(&report);
838 Ok(report)
839}
840
841pub fn validate_external_upgrade_verification_report(
844 report: &ExternalUpgradeVerificationReportV1,
845) -> Result<(), ExternalUpgradeVerificationReportError> {
846 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
847 return Err(
848 ExternalUpgradeVerificationReportError::SchemaVersionMismatch {
849 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
850 actual: report.schema_version,
851 },
852 );
853 }
854 ensure_external_verification_report_field("report_id", report.report_id.as_str())?;
855 ensure_external_verification_report_field("report_digest", report.report_digest.as_str())?;
856 ensure_external_verification_report_field("proposal_id", report.proposal_id.as_str())?;
857 ensure_external_verification_report_field("proposal_digest", report.proposal_digest.as_str())?;
858 ensure_external_verification_report_field("receipt_id", report.receipt_id.as_str())?;
859 ensure_external_verification_report_field("receipt_digest", report.receipt_digest.as_str())?;
860 ensure_external_verification_report_field("subject", report.subject.as_str())?;
861 ensure_external_verification_report_field("status_summary", report.status_summary.as_str())?;
862 if report.status_summary != external_upgrade_verification_summary(report.verification_result) {
863 return Err(ExternalUpgradeVerificationReportError::SourceMismatch {
864 field: "status_summary",
865 });
866 }
867 if report.report_digest != external_upgrade_verification_report_digest(report) {
868 return Err(ExternalUpgradeVerificationReportError::DigestMismatch {
869 field: "report_digest",
870 });
871 }
872 Ok(())
873}
874
875pub fn validate_external_upgrade_verification_report_for_receipt(
878 report: &ExternalUpgradeVerificationReportV1,
879 proposal: &ExternalUpgradeProposalV1,
880 receipt: &ExternalUpgradeReceiptV1,
881) -> Result<(), ExternalUpgradeVerificationReportError> {
882 validate_external_upgrade_verification_report(report)?;
883 let expected = external_upgrade_verification_report_from_receipt(
884 report.report_id.clone(),
885 proposal,
886 receipt,
887 )?;
888 if report != &expected {
889 return Err(ExternalUpgradeVerificationReportError::SourceMismatch { field: "receipt" });
890 }
891 Ok(())
892}
893
894#[must_use]
899pub fn external_upgrade_proposal_report_from_lifecycle_plan(
900 report_id: impl Into<String>,
901 lifecycle_plan: &ExternalLifecyclePlanV1,
902 check: &DeploymentCheckV1,
903) -> ExternalUpgradeProposalReportV1 {
904 let report_id = report_id.into();
905 let mut proposals = Vec::new();
906 for authority in lifecycle_plan
907 .lifecycle_authority_rows
908 .iter()
909 .filter(|authority| authority.external_action_required && !authority.blocked)
910 {
911 proposals.push(external_upgrade_proposal(
912 &report_id,
913 lifecycle_plan,
914 check,
915 authority,
916 observed_canister_for_authority(&check.inventory, authority),
917 target_artifact_for_authority(&check.plan, authority),
918 ));
919 }
920
921 proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
922
923 let mut report = ExternalUpgradeProposalReportV1 {
924 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
925 report_id,
926 report_digest: String::new(),
927 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
928 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
929 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
930 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
931 inventory_id: check.inventory.inventory_id.clone(),
932 proposals,
933 blocked_subjects: lifecycle_plan
934 .blocked_role_upgrades
935 .iter()
936 .map(|upgrade| upgrade.subject.clone())
937 .collect(),
938 };
939 report.report_digest = external_upgrade_proposal_report_digest(&report);
940 report
941}
942
943pub fn validate_external_upgrade_proposal_report(
945 report: &ExternalUpgradeProposalReportV1,
946) -> Result<(), ExternalUpgradeProposalReportError> {
947 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
948 return Err(ExternalUpgradeProposalReportError::SchemaVersionMismatch {
949 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
950 actual: report.schema_version,
951 });
952 }
953 ensure_external_proposal_report_field("report_id", report.report_id.as_str())?;
954 ensure_external_proposal_report_field("report_digest", report.report_digest.as_str())?;
955 ensure_external_proposal_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
956 ensure_external_proposal_report_field(
957 "lifecycle_plan_digest",
958 report.lifecycle_plan_digest.as_str(),
959 )?;
960 ensure_external_proposal_report_field(
961 "deployment_plan_id",
962 report.deployment_plan_id.as_str(),
963 )?;
964 ensure_external_proposal_report_field(
965 "deployment_plan_digest",
966 report.deployment_plan_digest.as_str(),
967 )?;
968 ensure_external_proposal_report_field("inventory_id", report.inventory_id.as_str())?;
969
970 let mut subjects = BTreeSet::new();
971 for proposal in &report.proposals {
972 if !subjects.insert(proposal.subject.clone()) {
973 return Err(ExternalUpgradeProposalReportError::DuplicateSubject {
974 subject: proposal.subject.clone(),
975 });
976 }
977 validate_external_upgrade_proposal(proposal)?;
978 }
979 if report.report_digest != external_upgrade_proposal_report_digest(report) {
980 return Err(ExternalUpgradeProposalReportError::DigestMismatch {
981 field: "report_digest",
982 });
983 }
984 Ok(())
985}
986
987pub fn validate_external_upgrade_proposal_report_for_lifecycle_plan(
990 report: &ExternalUpgradeProposalReportV1,
991 lifecycle_plan: &ExternalLifecyclePlanV1,
992 check: &DeploymentCheckV1,
993) -> Result<(), ExternalUpgradeProposalReportError> {
994 validate_external_upgrade_proposal_report(report)?;
995 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
996 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
997 field: "lifecycle_plan_id",
998 });
999 }
1000 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1001 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1002 field: "lifecycle_plan_digest",
1003 });
1004 }
1005 let expected = external_upgrade_proposal_report_from_lifecycle_plan(
1006 report.report_id.clone(),
1007 lifecycle_plan,
1008 check,
1009 );
1010 if report != &expected {
1011 return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1012 field: "deployment_check",
1013 });
1014 }
1015 Ok(())
1016}
1017
1018#[must_use]
1021pub fn external_lifecycle_pending_report_from_plan(
1022 report_id: impl Into<String>,
1023 lifecycle_plan: &ExternalLifecyclePlanV1,
1024 proposal_report: &ExternalUpgradeProposalReportV1,
1025) -> ExternalLifecyclePendingReportV1 {
1026 let report_id = report_id.into();
1027 let pending_external_actions = proposal_report
1028 .proposals
1029 .iter()
1030 .map(external_lifecycle_pending_action)
1031 .collect::<Vec<_>>();
1032 let blocked_subjects = lifecycle_plan
1033 .blocked_role_upgrades
1034 .iter()
1035 .map(|upgrade| upgrade.subject.clone())
1036 .collect::<Vec<_>>();
1037 let mut report = ExternalLifecyclePendingReportV1 {
1038 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1039 report_id,
1040 report_digest: String::new(),
1041 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1042 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1043 proposal_report_id: proposal_report.report_id.clone(),
1044 proposal_report_digest: proposal_report.report_digest.clone(),
1045 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1046 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1047 inventory_id: lifecycle_plan.inventory_id.clone(),
1048 direct_upgrade_count: lifecycle_plan.directly_executable_role_upgrades.len(),
1049 pending_external_count: pending_external_actions.len(),
1050 blocked_count: blocked_subjects.len(),
1051 pending_external_actions,
1052 blocked_subjects,
1053 residual_exposure: lifecycle_plan.residual_exposure.clone(),
1054 status: lifecycle_plan.status,
1055 };
1056 report.report_digest = external_lifecycle_pending_report_digest(&report);
1057 report
1058}
1059
1060pub fn validate_external_lifecycle_pending_report(
1062 report: &ExternalLifecyclePendingReportV1,
1063) -> Result<(), ExternalLifecyclePendingReportError> {
1064 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1065 return Err(ExternalLifecyclePendingReportError::SchemaVersionMismatch {
1066 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1067 actual: report.schema_version,
1068 });
1069 }
1070 ensure_external_pending_report_field("report_id", report.report_id.as_str())?;
1071 ensure_external_pending_report_field("report_digest", report.report_digest.as_str())?;
1072 ensure_external_pending_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1073 ensure_external_pending_report_field(
1074 "lifecycle_plan_digest",
1075 report.lifecycle_plan_digest.as_str(),
1076 )?;
1077 ensure_external_pending_report_field("proposal_report_id", report.proposal_report_id.as_str())?;
1078 ensure_external_pending_report_field(
1079 "proposal_report_digest",
1080 report.proposal_report_digest.as_str(),
1081 )?;
1082 ensure_external_pending_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1083 ensure_external_pending_report_field(
1084 "deployment_plan_digest",
1085 report.deployment_plan_digest.as_str(),
1086 )?;
1087 ensure_external_pending_report_field("inventory_id", report.inventory_id.as_str())?;
1088 if report.pending_external_count != report.pending_external_actions.len()
1089 || report.blocked_count != report.blocked_subjects.len()
1090 {
1091 return Err(ExternalLifecyclePendingReportError::CountMismatch);
1092 }
1093 let mut subjects = BTreeSet::new();
1094 for action in &report.pending_external_actions {
1095 ensure_external_pending_report_field("pending_action.subject", action.subject.as_str())?;
1096 ensure_external_pending_report_field(
1097 "pending_action.proposal_id",
1098 action.proposal_id.as_str(),
1099 )?;
1100 ensure_external_pending_report_field(
1101 "pending_action.proposal_digest",
1102 action.proposal_digest.as_str(),
1103 )?;
1104 ensure_external_pending_report_field(
1105 "pending_action.required_external_action",
1106 action.required_external_action.as_str(),
1107 )?;
1108 if !subjects.insert(action.subject.clone()) {
1109 return Err(ExternalLifecyclePendingReportError::DuplicateSubject {
1110 subject: action.subject.clone(),
1111 });
1112 }
1113 }
1114 if report.report_digest != external_lifecycle_pending_report_digest(report) {
1115 return Err(ExternalLifecyclePendingReportError::DigestMismatch {
1116 field: "report_digest",
1117 });
1118 }
1119 Ok(())
1120}
1121
1122pub fn validate_external_lifecycle_pending_report_for_plan(
1125 report: &ExternalLifecyclePendingReportV1,
1126 lifecycle_plan: &ExternalLifecyclePlanV1,
1127 proposal_report: &ExternalUpgradeProposalReportV1,
1128) -> Result<(), ExternalLifecyclePendingReportError> {
1129 validate_external_lifecycle_pending_report(report)?;
1130 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1131 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1132 field: "lifecycle_plan_id",
1133 });
1134 }
1135 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1136 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1137 field: "lifecycle_plan_digest",
1138 });
1139 }
1140 if report.proposal_report_id != proposal_report.report_id {
1141 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1142 field: "proposal_report_id",
1143 });
1144 }
1145 if report.proposal_report_digest != proposal_report.report_digest {
1146 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1147 field: "proposal_report_digest",
1148 });
1149 }
1150 let expected = external_lifecycle_pending_report_from_plan(
1151 report.report_id.clone(),
1152 lifecycle_plan,
1153 proposal_report,
1154 );
1155 if report != &expected {
1156 return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1157 field: "lifecycle_plan",
1158 });
1159 }
1160 Ok(())
1161}
1162
1163fn external_lifecycle_pending_action(
1164 proposal: &ExternalUpgradeProposalV1,
1165) -> ExternalLifecyclePendingActionV1 {
1166 ExternalLifecyclePendingActionV1 {
1167 subject: proposal.subject.clone(),
1168 proposal_id: proposal.proposal_id.clone(),
1169 proposal_digest: proposal.proposal_digest.clone(),
1170 canister_id: proposal.canister_id.clone(),
1171 role: proposal.role.clone(),
1172 control_class: proposal.control_class,
1173 lifecycle_mode: proposal.lifecycle_mode,
1174 required_external_action: proposal.required_external_action.clone(),
1175 consent_requirements: proposal.consent_requirements.clone(),
1176 verification_requirements: proposal.verification_requirements.clone(),
1177 }
1178}
1179
1180fn lifecycle_roles(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
1181 lifecycle_plan
1182 .lifecycle_authority_rows
1183 .iter()
1184 .filter_map(|authority| authority.role.clone())
1185 .collect::<BTreeSet<_>>()
1186 .into_iter()
1187 .collect()
1188}
1189
1190fn lifecycle_canisters(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
1191 lifecycle_plan
1192 .lifecycle_authority_rows
1193 .iter()
1194 .filter_map(|authority| authority.canister_id.clone())
1195 .collect::<BTreeSet<_>>()
1196 .into_iter()
1197 .collect()
1198}
1199
1200fn role_names(upgrades: &[ExternalLifecycleRoleUpgradeV1]) -> Vec<String> {
1201 upgrades
1202 .iter()
1203 .filter_map(|upgrade| upgrade.role.clone())
1204 .collect::<BTreeSet<_>>()
1205 .into_iter()
1206 .collect()
1207}
1208
1209fn critical_fix_next_steps(
1210 pending_external_count: usize,
1211 blocked_count: usize,
1212 protected_call_implications: &[String],
1213) -> Vec<String> {
1214 let mut steps = Vec::new();
1215 if pending_external_count > 0 {
1216 steps.push(
1217 "request external consent or completion for externally controlled roles".to_string(),
1218 );
1219 }
1220 if blocked_count > 0 {
1221 steps.push(
1222 "resolve blocked lifecycle rows before reporting the deployment fully patched"
1223 .to_string(),
1224 );
1225 }
1226 if !protected_call_implications.is_empty() {
1227 steps.push(
1228 "review protected-call readiness and role epoch implications before closure"
1229 .to_string(),
1230 );
1231 }
1232 if steps.is_empty() {
1233 steps.push("no external lifecycle work remains for this critical fix".to_string());
1234 }
1235 steps
1236}
1237
1238#[must_use]
1241pub fn critical_external_fix_report_from_pending(
1242 report_id: impl Into<String>,
1243 fix_id: impl Into<String>,
1244 severity: impl Into<String>,
1245 lifecycle_plan: &ExternalLifecyclePlanV1,
1246 pending_report: &ExternalLifecyclePendingReportV1,
1247) -> CriticalExternalFixReportV1 {
1248 let report_id = report_id.into();
1249 let fix_id = fix_id.into();
1250 let severity = severity.into();
1251 let affected_roles = lifecycle_roles(lifecycle_plan);
1252 let affected_canisters = lifecycle_canisters(lifecycle_plan);
1253 let directly_patchable_roles = role_names(&lifecycle_plan.directly_executable_role_upgrades);
1254 let externally_blocked_roles = pending_report
1255 .pending_external_actions
1256 .iter()
1257 .filter_map(|action| action.role.clone())
1258 .collect::<BTreeSet<_>>()
1259 .into_iter()
1260 .collect::<Vec<_>>();
1261 let dependency_blocked_roles = role_names(&lifecycle_plan.blocked_role_upgrades);
1262 let required_external_actions = pending_report
1263 .pending_external_actions
1264 .iter()
1265 .map(|action| format!("{}: {}", action.subject, action.required_external_action))
1266 .collect::<Vec<_>>();
1267 let operator_next_steps = critical_fix_next_steps(
1268 pending_report.pending_external_count,
1269 pending_report.blocked_count,
1270 lifecycle_plan.protected_call_implications.as_slice(),
1271 );
1272 let mut report = CriticalExternalFixReportV1 {
1273 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1274 report_id,
1275 report_digest: String::new(),
1276 fix_id,
1277 severity,
1278 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1279 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1280 pending_report_id: pending_report.report_id.clone(),
1281 pending_report_digest: pending_report.report_digest.clone(),
1282 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1283 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1284 inventory_id: lifecycle_plan.inventory_id.clone(),
1285 affected_roles,
1286 affected_canisters,
1287 directly_patchable_roles,
1288 externally_blocked_roles,
1289 dependency_blocked_roles,
1290 required_external_actions,
1291 protected_call_implications: lifecycle_plan.protected_call_implications.clone(),
1292 residual_exposure: pending_report.residual_exposure.clone(),
1293 operator_next_steps,
1294 };
1295 report.report_digest = critical_external_fix_report_digest(&report);
1296 report
1297}
1298
1299pub fn validate_critical_external_fix_report(
1301 report: &CriticalExternalFixReportV1,
1302) -> Result<(), CriticalExternalFixReportError> {
1303 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1304 return Err(CriticalExternalFixReportError::SchemaVersionMismatch {
1305 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1306 actual: report.schema_version,
1307 });
1308 }
1309 ensure_critical_fix_report_field("report_id", report.report_id.as_str())?;
1310 ensure_critical_fix_report_field("report_digest", report.report_digest.as_str())?;
1311 ensure_critical_fix_report_field("fix_id", report.fix_id.as_str())?;
1312 ensure_critical_fix_report_field("severity", report.severity.as_str())?;
1313 ensure_critical_fix_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1314 ensure_critical_fix_report_field(
1315 "lifecycle_plan_digest",
1316 report.lifecycle_plan_digest.as_str(),
1317 )?;
1318 ensure_critical_fix_report_field("pending_report_id", report.pending_report_id.as_str())?;
1319 ensure_critical_fix_report_field(
1320 "pending_report_digest",
1321 report.pending_report_digest.as_str(),
1322 )?;
1323 ensure_critical_fix_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1324 ensure_critical_fix_report_field(
1325 "deployment_plan_digest",
1326 report.deployment_plan_digest.as_str(),
1327 )?;
1328 ensure_critical_fix_report_field("inventory_id", report.inventory_id.as_str())?;
1329 if report.report_digest != critical_external_fix_report_digest(report) {
1330 return Err(CriticalExternalFixReportError::DigestMismatch {
1331 field: "report_digest",
1332 });
1333 }
1334 Ok(())
1335}
1336
1337pub fn validate_critical_external_fix_report_for_pending(
1340 report: &CriticalExternalFixReportV1,
1341 lifecycle_plan: &ExternalLifecyclePlanV1,
1342 pending_report: &ExternalLifecyclePendingReportV1,
1343) -> Result<(), CriticalExternalFixReportError> {
1344 validate_critical_external_fix_report(report)?;
1345 if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1346 return Err(CriticalExternalFixReportError::SourceMismatch {
1347 field: "lifecycle_plan_id",
1348 });
1349 }
1350 if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1351 return Err(CriticalExternalFixReportError::SourceMismatch {
1352 field: "lifecycle_plan_digest",
1353 });
1354 }
1355 if report.pending_report_id != pending_report.report_id {
1356 return Err(CriticalExternalFixReportError::SourceMismatch {
1357 field: "pending_report_id",
1358 });
1359 }
1360 if report.pending_report_digest != pending_report.report_digest {
1361 return Err(CriticalExternalFixReportError::SourceMismatch {
1362 field: "pending_report_digest",
1363 });
1364 }
1365 let expected = critical_external_fix_report_from_pending(
1366 report.report_id.clone(),
1367 report.fix_id.clone(),
1368 report.severity.clone(),
1369 lifecycle_plan,
1370 pending_report,
1371 );
1372 if report != &expected {
1373 return Err(CriticalExternalFixReportError::SourceMismatch {
1374 field: "lifecycle_plan",
1375 });
1376 }
1377 Ok(())
1378}
1379
1380fn external_upgrade_proposal(
1381 report_id: &str,
1382 lifecycle_plan: &ExternalLifecyclePlanV1,
1383 check: &DeploymentCheckV1,
1384 authority: &LifecycleAuthorityV1,
1385 observed: Option<&ObservedCanisterV1>,
1386 target_artifact: Option<&RoleArtifactV1>,
1387) -> ExternalUpgradeProposalV1 {
1388 let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
1389 let current_canonical_embedded_config_sha256 =
1390 observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
1391 let mut proposal = ExternalUpgradeProposalV1 {
1392 proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
1393 proposal_digest: String::new(),
1394 deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1395 deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1396 lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1397 lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1398 promotion_plan_id: None,
1399 promotion_plan_digest: None,
1400 promotion_provenance_id: None,
1401 promotion_provenance_digest: None,
1402 subject: authority.subject.clone(),
1403 canister_id: authority.canister_id.clone(),
1404 role: authority.role.clone(),
1405 control_class: authority.control_class,
1406 lifecycle_mode: authority.lifecycle_mode,
1407 observed_before_digest: observed_before_digest(
1408 authority,
1409 current_module_hash.as_ref(),
1410 current_canonical_embedded_config_sha256.as_ref(),
1411 ),
1412 current_module_hash,
1413 current_canonical_embedded_config_sha256,
1414 target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
1415 target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
1416 target_installed_module_hash: target_artifact
1417 .and_then(|artifact| artifact.installed_module_hash.clone()),
1418 target_role_artifact_identity: target_artifact.map(role_artifact_identity),
1419 target_canonical_embedded_config_sha256: target_artifact
1420 .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
1421 root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
1422 authority_profile_hash: check
1423 .plan
1424 .deployment_identity
1425 .authority_profile_hash
1426 .clone(),
1427 required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
1428 consent_requirements: authority.consent_requirements.clone(),
1429 allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
1430 verification_requirements: authority.verification_requirements.clone(),
1431 expires_at: None,
1432 supersedes_proposal_id: None,
1433 };
1434 proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
1435 proposal
1436}
1437
1438fn validate_external_upgrade_proposal(
1439 proposal: &ExternalUpgradeProposalV1,
1440) -> Result<(), ExternalUpgradeProposalReportError> {
1441 ensure_external_proposal_report_field("proposal_id", proposal.proposal_id.as_str())?;
1442 ensure_external_proposal_report_field("proposal_digest", proposal.proposal_digest.as_str())?;
1443 ensure_external_proposal_report_field(
1444 "proposal.deployment_plan_id",
1445 proposal.deployment_plan_id.as_str(),
1446 )?;
1447 ensure_external_proposal_report_field(
1448 "proposal.deployment_plan_digest",
1449 proposal.deployment_plan_digest.as_str(),
1450 )?;
1451 ensure_external_proposal_report_field(
1452 "proposal.lifecycle_plan_id",
1453 proposal.lifecycle_plan_id.as_str(),
1454 )?;
1455 ensure_external_proposal_report_field(
1456 "proposal.lifecycle_plan_digest",
1457 proposal.lifecycle_plan_digest.as_str(),
1458 )?;
1459 ensure_external_proposal_report_field(
1460 "proposal.observed_before_digest",
1461 proposal.observed_before_digest.as_str(),
1462 )?;
1463 ensure_external_proposal_report_field("proposal.subject", proposal.subject.as_str())?;
1464 if proposal.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority {
1465 return Err(
1466 ExternalUpgradeProposalReportError::DirectLifecycleProposal {
1467 subject: proposal.subject.clone(),
1468 },
1469 );
1470 }
1471 if proposal.proposal_digest != external_upgrade_proposal_digest(proposal) {
1472 return Err(ExternalUpgradeProposalReportError::DigestMismatch {
1473 field: "proposal_digest",
1474 });
1475 }
1476 Ok(())
1477}
1478
1479fn lifecycle_authority_for_expected_canister(
1480 plan: &DeploymentPlanV1,
1481 expected: &ExpectedCanisterV1,
1482 observed: Option<&ObservedCanisterV1>,
1483) -> LifecycleAuthorityV1 {
1484 let canister_id = expected
1485 .canister_id
1486 .clone()
1487 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
1488 let role = Some(expected.role.clone());
1489 let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
1490 let observed_controllers =
1491 observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
1492 lifecycle_authority(
1493 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
1494 canister_id,
1495 role,
1496 control_class,
1497 observed_controllers,
1498 &plan.authority_profile.expected_controllers,
1499 plan.expected_verifier_readiness.required,
1500 )
1501}
1502
1503fn lifecycle_authority_for_expected_pool(
1504 expected: &ExpectedPoolCanisterV1,
1505 observed: Option<&ObservedPoolCanisterV1>,
1506) -> LifecycleAuthorityV1 {
1507 let canister_id = expected
1508 .canister_id
1509 .clone()
1510 .or_else(|| observed.map(|observed| observed.canister_id.clone()));
1511 let role = expected
1512 .role
1513 .clone()
1514 .or_else(|| observed.and_then(|observed| observed.role.clone()));
1515 let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
1516 observed.control_class
1517 });
1518 lifecycle_authority(
1519 lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
1520 canister_id,
1521 role,
1522 control_class,
1523 Vec::new(),
1524 &[],
1525 false,
1526 )
1527}
1528
1529fn lifecycle_authority_for_unplanned_canister(
1530 observed: &ObservedCanisterV1,
1531) -> LifecycleAuthorityV1 {
1532 lifecycle_authority(
1533 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
1534 Some(observed.canister_id.clone()),
1535 observed.role.clone(),
1536 observed.control_class,
1537 observed.controllers.clone(),
1538 &[],
1539 false,
1540 )
1541}
1542
1543fn lifecycle_authority_for_unplanned_pool(
1544 observed: &ObservedPoolCanisterV1,
1545) -> LifecycleAuthorityV1 {
1546 lifecycle_authority(
1547 lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
1548 Some(observed.canister_id.clone()),
1549 observed.role.clone(),
1550 observed.control_class,
1551 Vec::new(),
1552 &[],
1553 false,
1554 )
1555}
1556
1557fn lifecycle_authority(
1558 subject: String,
1559 canister_id: Option<String>,
1560 role: Option<String>,
1561 control_class: CanisterControlClassV1,
1562 observed_controllers: Vec<String>,
1563 expected_controllers: &[String],
1564 verifier_required: bool,
1565) -> LifecycleAuthorityV1 {
1566 let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
1567 let external_controllers =
1568 external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
1569 let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
1570 let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
1571 let verification_requirements = lifecycle_verification_requirements(verifier_required);
1572 let external_action_required = lifecycle_external_action_required(control_class);
1573 let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
1574 let lifecycle_mode = lifecycle_mode(control_class);
1575 let blockers = lifecycle_blockers(control_class);
1576 let warnings = lifecycle_warnings(control_class);
1577 let reason = lifecycle_reason(control_class);
1578 LifecycleAuthorityV1 {
1579 subject,
1580 canister_id,
1581 role,
1582 control_class,
1583 lifecycle_mode,
1584 observed_controllers,
1585 expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
1586 external_controllers,
1587 required_controllers,
1588 consent_requirements,
1589 allowed_upgrade_modes,
1590 verification_requirements,
1591 external_action_required,
1592 blocked,
1593 blockers,
1594 warnings,
1595 reason,
1596 }
1597}
1598
1599fn required_lifecycle_controllers(
1600 control_class: CanisterControlClassV1,
1601 expected_controllers: &[String],
1602) -> Vec<String> {
1603 match control_class {
1604 CanisterControlClassV1::DeploymentControlled
1605 | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
1606 CanisterControlClassV1::CanicManagedPool
1607 | CanisterControlClassV1::ExternallyImported
1608 | CanisterControlClassV1::UserControlled
1609 | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
1610 }
1611}
1612
1613fn external_lifecycle_controllers(
1614 control_class: CanisterControlClassV1,
1615 observed_controllers: &[String],
1616 required_controllers: &[String],
1617) -> Vec<String> {
1618 match control_class {
1619 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1620 Vec::new()
1621 }
1622 CanisterControlClassV1::JointlyControlled => {
1623 let required = required_controllers.iter().collect::<BTreeSet<_>>();
1624 sorted_unique(
1625 observed_controllers
1626 .iter()
1627 .filter(|controller| !required.contains(controller))
1628 .cloned()
1629 .collect(),
1630 )
1631 }
1632 CanisterControlClassV1::CanicManagedPool
1633 | CanisterControlClassV1::ExternallyImported
1634 | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
1635 }
1636}
1637
1638fn lifecycle_consent_requirements(
1639 control_class: CanisterControlClassV1,
1640 external_controllers: &[String],
1641) -> Vec<ConsentRequirementV1> {
1642 if !lifecycle_external_action_required(control_class) {
1643 return Vec::new();
1644 }
1645 vec![ConsentRequirementV1 {
1646 consent_subject_kind: consent_subject_kind(control_class),
1647 required_principals: sorted_unique(external_controllers.to_vec()),
1648 required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
1649 consent_channel_kind: consent_channel_kind(control_class),
1650 required_action: required_consent_action(control_class),
1651 }]
1652}
1653
1654const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
1655 match control_class {
1656 CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
1657 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
1658 ConsentSubjectKindV1::CustomerController
1659 }
1660 CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
1661 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1662 ConsentSubjectKindV1::UnknownExternalController
1663 }
1664 }
1665}
1666
1667const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
1668 match control_class {
1669 CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
1670 CanisterControlClassV1::ExternallyImported
1671 | CanisterControlClassV1::JointlyControlled
1672 | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
1673 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1674 ConsentChannelKindV1::OutOfBand
1675 }
1676 }
1677}
1678
1679const fn required_consent_action(
1680 control_class: CanisterControlClassV1,
1681) -> ExternalUpgradeAuthorizationModeV1 {
1682 match control_class {
1683 CanisterControlClassV1::JointlyControlled => {
1684 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
1685 }
1686 CanisterControlClassV1::CanicManagedPool => {
1687 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
1688 }
1689 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
1690 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
1691 }
1692 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1693 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
1694 }
1695 }
1696}
1697
1698const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
1699 match control_class {
1700 CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
1701 CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
1702 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
1703 LifecycleModeV1::ExternalCompletionOnly
1704 }
1705 CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
1706 CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
1707 }
1708}
1709
1710fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
1711 if control_class == CanisterControlClassV1::UnknownUnsafe {
1712 vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
1713 } else {
1714 Vec::new()
1715 }
1716}
1717
1718fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
1719 match control_class {
1720 CanisterControlClassV1::CanicManagedPool => {
1721 vec!["pool-aware lifecycle policy is required before mutation".to_string()]
1722 }
1723 CanisterControlClassV1::ExternallyImported => {
1724 vec!["external controller action or verification is required".to_string()]
1725 }
1726 CanisterControlClassV1::JointlyControlled => {
1727 vec!["joint controller consent or delegation is required".to_string()]
1728 }
1729 CanisterControlClassV1::UserControlled => {
1730 vec!["user or delegated lifecycle action is required".to_string()]
1731 }
1732 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1733 Vec::new()
1734 }
1735 }
1736}
1737
1738fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
1739 match control_class {
1740 CanisterControlClassV1::DeploymentControlled => vec![
1741 LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
1742 LifecycleUpgradeModeV1::VerifyExternalCompletion,
1743 ],
1744 CanisterControlClassV1::CanicManagedPool
1745 | CanisterControlClassV1::ExternallyImported
1746 | CanisterControlClassV1::JointlyControlled
1747 | CanisterControlClassV1::UserControlled => vec![
1748 LifecycleUpgradeModeV1::ExternalProposal,
1749 LifecycleUpgradeModeV1::ExternalExecution,
1750 LifecycleUpgradeModeV1::VerifyExternalCompletion,
1751 LifecycleUpgradeModeV1::ObserveOnly,
1752 ],
1753 CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
1754 }
1755}
1756
1757fn lifecycle_verification_requirements(
1758 verifier_required: bool,
1759) -> Vec<LifecycleVerificationRequirementV1> {
1760 let mut requirements = vec![
1761 LifecycleVerificationRequirementV1::LiveInventory,
1762 LifecycleVerificationRequirementV1::ControllerObservation,
1763 LifecycleVerificationRequirementV1::ModuleHash,
1764 LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
1765 ];
1766 if verifier_required {
1767 requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
1768 }
1769 requirements
1770}
1771
1772const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
1773 matches!(
1774 control_class,
1775 CanisterControlClassV1::CanicManagedPool
1776 | CanisterControlClassV1::ExternallyImported
1777 | CanisterControlClassV1::JointlyControlled
1778 | CanisterControlClassV1::UserControlled
1779 )
1780}
1781
1782fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
1783 match control_class {
1784 CanisterControlClassV1::DeploymentControlled => {
1785 "deployment authority can execute lifecycle directly".to_string()
1786 }
1787 CanisterControlClassV1::CanicManagedPool => {
1788 "Canic-managed pool lifecycle requires pool-aware external action".to_string()
1789 }
1790 CanisterControlClassV1::ExternallyImported => {
1791 "externally imported canister requires external controller action".to_string()
1792 }
1793 CanisterControlClassV1::JointlyControlled => {
1794 "jointly controlled canister requires non-deployment-controller consent".to_string()
1795 }
1796 CanisterControlClassV1::UserControlled => {
1797 "user-controlled canister requires user or delegated lifecycle action".to_string()
1798 }
1799 CanisterControlClassV1::UnknownUnsafe => {
1800 "unknown or unsafe controller state blocks lifecycle action".to_string()
1801 }
1802 }
1803}
1804
1805fn observed_canister_for_expected<'a>(
1806 inventory: &'a DeploymentInventoryV1,
1807 expected: &ExpectedCanisterV1,
1808) -> Option<&'a ObservedCanisterV1> {
1809 if let Some(canister_id) = &expected.canister_id
1810 && let Some(observed) = inventory
1811 .observed_canisters
1812 .iter()
1813 .find(|observed| &observed.canister_id == canister_id)
1814 {
1815 return Some(observed);
1816 }
1817 inventory
1818 .observed_canisters
1819 .iter()
1820 .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
1821}
1822
1823fn observed_pool_for_expected<'a>(
1824 inventory: &'a DeploymentInventoryV1,
1825 expected: &ExpectedPoolCanisterV1,
1826) -> Option<&'a ObservedPoolCanisterV1> {
1827 if let Some(canister_id) = &expected.canister_id
1828 && let Some(observed) = inventory
1829 .observed_pool
1830 .iter()
1831 .find(|observed| &observed.canister_id == canister_id)
1832 {
1833 return Some(observed);
1834 }
1835 inventory.observed_pool.iter().find(|observed| {
1836 observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
1837 })
1838}
1839
1840fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
1841 lifecycle_subject_for_parts(Some(canister_id), role)
1842}
1843
1844fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
1845 match (role, canister_id) {
1846 (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
1847 (Some(role), None) => format!("{role}:unassigned"),
1848 (None, Some(canister_id)) => canister_id.to_string(),
1849 (None, None) => "unknown".to_string(),
1850 }
1851}
1852
1853fn observed_canister_for_authority<'a>(
1854 inventory: &'a DeploymentInventoryV1,
1855 authority: &LifecycleAuthorityV1,
1856) -> Option<&'a ObservedCanisterV1> {
1857 if let Some(canister_id) = &authority.canister_id
1858 && let Some(observed) = inventory
1859 .observed_canisters
1860 .iter()
1861 .find(|observed| &observed.canister_id == canister_id)
1862 {
1863 return Some(observed);
1864 }
1865 inventory
1866 .observed_canisters
1867 .iter()
1868 .find(|observed| observed.role == authority.role)
1869}
1870
1871fn target_artifact_for_authority<'a>(
1872 plan: &'a DeploymentPlanV1,
1873 authority: &LifecycleAuthorityV1,
1874) -> Option<&'a RoleArtifactV1> {
1875 let role = authority.role.as_ref()?;
1876 plan.role_artifacts
1877 .iter()
1878 .find(|artifact| &artifact.role == role)
1879}
1880
1881fn external_lifecycle_role_upgrade(
1882 authority: &LifecycleAuthorityV1,
1883) -> ExternalLifecycleRoleUpgradeV1 {
1884 ExternalLifecycleRoleUpgradeV1 {
1885 subject: authority.subject.clone(),
1886 canister_id: authority.canister_id.clone(),
1887 role: authority.role.clone(),
1888 control_class: authority.control_class,
1889 lifecycle_mode: authority.lifecycle_mode,
1890 required_external_action: authority
1891 .external_action_required
1892 .then(|| required_external_action(authority.lifecycle_mode).to_string()),
1893 blockers: authority.blockers.clone(),
1894 warnings: authority.warnings.clone(),
1895 }
1896}
1897
1898fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
1899 if check.plan.expected_verifier_readiness.required {
1900 vec!["protected-call verifier readiness must be checked before completion".to_string()]
1901 } else {
1902 Vec::new()
1903 }
1904}
1905
1906const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
1907 match lifecycle_mode {
1908 LifecycleModeV1::DirectDeploymentAuthority => "none",
1909 LifecycleModeV1::ProposalRequired => "proposal_and_consent",
1910 LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
1911 LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
1912 LifecycleModeV1::VerifyOnly => "verify_external_completion",
1913 LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
1914 }
1915}
1916
1917fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
1918 stable_json_sha256_hex(&(
1919 artifact.role.as_str(),
1920 artifact.wasm_sha256.as_deref(),
1921 artifact.wasm_gz_sha256.as_deref(),
1922 artifact.installed_module_hash.as_deref(),
1923 artifact.candid_sha256.as_deref(),
1924 artifact.canonical_embedded_config_sha256.as_deref(),
1925 ))
1926}
1927
1928fn external_upgrade_authorization_modes(
1929 control_class: CanisterControlClassV1,
1930) -> Vec<ExternalUpgradeAuthorizationModeV1> {
1931 match control_class {
1932 CanisterControlClassV1::JointlyControlled => vec![
1933 ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
1934 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1935 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1936 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1937 ],
1938 CanisterControlClassV1::CanicManagedPool
1939 | CanisterControlClassV1::ExternallyImported
1940 | CanisterControlClassV1::UserControlled => vec![
1941 ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1942 ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1943 ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1944 ],
1945 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1946 Vec::new()
1947 }
1948 }
1949}
1950
1951fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
1952 let subject = subject.replace([':', '/'], "-");
1953 format!("{report_id}:{subject}")
1954}
1955
1956fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
1957 stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
1958 lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
1959 deployment_plan_id: &plan.deployment_plan_id,
1960 deployment_plan_digest: &plan.deployment_plan_digest,
1961 inventory_id: &plan.inventory_id,
1962 lifecycle_authority_rows: &plan.lifecycle_authority_rows,
1963 directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
1964 proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
1965 blocked_role_upgrades: &plan.blocked_role_upgrades,
1966 dependency_blockers: &plan.dependency_blockers,
1967 protected_call_implications: &plan.protected_call_implications,
1968 residual_exposure: &plan.residual_exposure,
1969 status: plan.status,
1970 })
1971}
1972
1973fn lifecycle_authority_report_digest(report: &LifecycleAuthorityReportV1) -> String {
1974 stable_json_sha256_hex(&LifecycleAuthorityReportDigestInput {
1975 report_id: &report.report_id,
1976 check_id: &report.check_id,
1977 plan_id: &report.plan_id,
1978 inventory_id: &report.inventory_id,
1979 authorities: &report.authorities,
1980 external_action_required_count: report.external_action_required_count,
1981 blocked_count: report.blocked_count,
1982 })
1983}
1984
1985const fn expected_lifecycle_plan_status(
1986 plan: &ExternalLifecyclePlanV1,
1987) -> ExternalLifecyclePlanStatusV1 {
1988 if !plan.blocked_role_upgrades.is_empty() {
1989 ExternalLifecyclePlanStatusV1::Blocked
1990 } else if !plan.proposed_external_role_upgrades.is_empty() {
1991 ExternalLifecyclePlanStatusV1::PendingExternalAction
1992 } else {
1993 ExternalLifecyclePlanStatusV1::Ready
1994 }
1995}
1996
1997fn ensure_unique_lifecycle_subjects(
1998 rows: &[LifecycleAuthorityV1],
1999) -> Result<(), ExternalLifecyclePlanError> {
2000 let mut subjects = BTreeSet::new();
2001 for row in rows {
2002 if !subjects.insert(row.subject.clone()) {
2003 return Err(ExternalLifecyclePlanError::DuplicateSubject {
2004 subject: row.subject.clone(),
2005 });
2006 }
2007 }
2008 Ok(())
2009}
2010
2011fn ensure_unique_authority_subjects(
2012 rows: &[LifecycleAuthorityV1],
2013) -> Result<(), LifecycleAuthorityReportError> {
2014 let mut subjects = BTreeSet::new();
2015 for row in rows {
2016 if !subjects.insert(row.subject.clone()) {
2017 return Err(LifecycleAuthorityReportError::DuplicateSubject {
2018 subject: row.subject.clone(),
2019 });
2020 }
2021 }
2022 Ok(())
2023}
2024
2025fn ensure_unique_role_upgrade_subjects(
2026 rows: &[ExternalLifecycleRoleUpgradeV1],
2027) -> Result<(), ExternalLifecyclePlanError> {
2028 let mut subjects = BTreeSet::new();
2029 for row in rows {
2030 if !subjects.insert(row.subject.clone()) {
2031 return Err(ExternalLifecyclePlanError::DuplicateSubject {
2032 subject: row.subject.clone(),
2033 });
2034 }
2035 }
2036 Ok(())
2037}
2038
2039fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
2040 stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
2041 deployment_plan_id: &proposal.deployment_plan_id,
2042 deployment_plan_digest: &proposal.deployment_plan_digest,
2043 lifecycle_plan_id: &proposal.lifecycle_plan_id,
2044 lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
2045 promotion_plan_id: &proposal.promotion_plan_id,
2046 promotion_plan_digest: &proposal.promotion_plan_digest,
2047 promotion_provenance_id: &proposal.promotion_provenance_id,
2048 promotion_provenance_digest: &proposal.promotion_provenance_digest,
2049 subject: &proposal.subject,
2050 canister_id: &proposal.canister_id,
2051 role: &proposal.role,
2052 control_class: proposal.control_class,
2053 lifecycle_mode: proposal.lifecycle_mode,
2054 observed_before_digest: &proposal.observed_before_digest,
2055 current_module_hash: &proposal.current_module_hash,
2056 current_canonical_embedded_config_sha256: &proposal
2057 .current_canonical_embedded_config_sha256,
2058 target_wasm_sha256: &proposal.target_wasm_sha256,
2059 target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
2060 target_installed_module_hash: &proposal.target_installed_module_hash,
2061 target_role_artifact_identity: &proposal.target_role_artifact_identity,
2062 target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
2063 root_trust_anchor: &proposal.root_trust_anchor,
2064 authority_profile_hash: &proposal.authority_profile_hash,
2065 required_external_action: &proposal.required_external_action,
2066 consent_requirements: &proposal.consent_requirements,
2067 allowed_authorization_modes: &proposal.allowed_authorization_modes,
2068 verification_requirements: &proposal.verification_requirements,
2069 expires_at: &proposal.expires_at,
2070 supersedes_proposal_id: &proposal.supersedes_proposal_id,
2071 })
2072}
2073
2074fn external_upgrade_proposal_report_digest(report: &ExternalUpgradeProposalReportV1) -> String {
2075 stable_json_sha256_hex(&ExternalUpgradeProposalReportDigestInput {
2076 report_id: &report.report_id,
2077 lifecycle_plan_id: &report.lifecycle_plan_id,
2078 lifecycle_plan_digest: &report.lifecycle_plan_digest,
2079 deployment_plan_id: &report.deployment_plan_id,
2080 deployment_plan_digest: &report.deployment_plan_digest,
2081 inventory_id: &report.inventory_id,
2082 proposals: &report.proposals,
2083 blocked_subjects: &report.blocked_subjects,
2084 })
2085}
2086
2087fn external_lifecycle_pending_report_digest(report: &ExternalLifecyclePendingReportV1) -> String {
2088 stable_json_sha256_hex(&ExternalLifecyclePendingReportDigestInput {
2089 report_id: &report.report_id,
2090 lifecycle_plan_id: &report.lifecycle_plan_id,
2091 lifecycle_plan_digest: &report.lifecycle_plan_digest,
2092 proposal_report_id: &report.proposal_report_id,
2093 proposal_report_digest: &report.proposal_report_digest,
2094 deployment_plan_id: &report.deployment_plan_id,
2095 deployment_plan_digest: &report.deployment_plan_digest,
2096 inventory_id: &report.inventory_id,
2097 direct_upgrade_count: report.direct_upgrade_count,
2098 pending_external_count: report.pending_external_count,
2099 blocked_count: report.blocked_count,
2100 pending_external_actions: &report.pending_external_actions,
2101 blocked_subjects: &report.blocked_subjects,
2102 residual_exposure: &report.residual_exposure,
2103 status: report.status,
2104 })
2105}
2106
2107fn critical_external_fix_report_digest(report: &CriticalExternalFixReportV1) -> String {
2108 stable_json_sha256_hex(&CriticalExternalFixReportDigestInput {
2109 report_id: &report.report_id,
2110 fix_id: &report.fix_id,
2111 severity: &report.severity,
2112 lifecycle_plan_id: &report.lifecycle_plan_id,
2113 lifecycle_plan_digest: &report.lifecycle_plan_digest,
2114 pending_report_id: &report.pending_report_id,
2115 pending_report_digest: &report.pending_report_digest,
2116 deployment_plan_id: &report.deployment_plan_id,
2117 deployment_plan_digest: &report.deployment_plan_digest,
2118 inventory_id: &report.inventory_id,
2119 affected_roles: &report.affected_roles,
2120 affected_canisters: &report.affected_canisters,
2121 directly_patchable_roles: &report.directly_patchable_roles,
2122 externally_blocked_roles: &report.externally_blocked_roles,
2123 dependency_blocked_roles: &report.dependency_blocked_roles,
2124 required_external_actions: &report.required_external_actions,
2125 protected_call_implications: &report.protected_call_implications,
2126 residual_exposure: &report.residual_exposure,
2127 operator_next_steps: &report.operator_next_steps,
2128 })
2129}
2130
2131fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
2132 stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
2133 proposal_id: &receipt.proposal_id,
2134 proposal_digest: &receipt.proposal_digest,
2135 subject: &receipt.subject,
2136 canister_id: &receipt.canister_id,
2137 role: &receipt.role,
2138 consent_state: receipt.consent_state,
2139 reported_by: &receipt.reported_by,
2140 observed_before_module_hash: &receipt.observed_before_module_hash,
2141 observed_after_module_hash: &receipt.observed_after_module_hash,
2142 observed_after_canonical_embedded_config_sha256: &receipt
2143 .observed_after_canonical_embedded_config_sha256,
2144 verification_result: receipt.verification_result,
2145 verification_notes: &receipt.verification_notes,
2146 })
2147}
2148
2149fn external_upgrade_consent_evidence_digest(evidence: &ExternalUpgradeConsentEvidenceV1) -> String {
2150 stable_json_sha256_hex(&ExternalUpgradeConsentEvidenceDigestInput {
2151 evidence_id: &evidence.evidence_id,
2152 proposal_id: &evidence.proposal_id,
2153 proposal_digest: &evidence.proposal_digest,
2154 receipt_id: &evidence.receipt_id,
2155 receipt_digest: &evidence.receipt_digest,
2156 subject: &evidence.subject,
2157 canister_id: &evidence.canister_id,
2158 role: &evidence.role,
2159 consent_state: evidence.consent_state,
2160 reported_by: &evidence.reported_by,
2161 consent_requirements: &evidence.consent_requirements,
2162 allowed_authorization_modes: &evidence.allowed_authorization_modes,
2163 status_summary: &evidence.status_summary,
2164 })
2165}
2166
2167fn external_upgrade_verification_report_digest(
2168 report: &ExternalUpgradeVerificationReportV1,
2169) -> String {
2170 stable_json_sha256_hex(&ExternalUpgradeVerificationReportDigestInput {
2171 report_id: &report.report_id,
2172 proposal_id: &report.proposal_id,
2173 proposal_digest: &report.proposal_digest,
2174 receipt_id: &report.receipt_id,
2175 receipt_digest: &report.receipt_digest,
2176 subject: &report.subject,
2177 canister_id: &report.canister_id,
2178 role: &report.role,
2179 verification_result: report.verification_result,
2180 verification_notes: &report.verification_notes,
2181 live_inventory_required: report.live_inventory_required,
2182 status_summary: &report.status_summary,
2183 })
2184}
2185
2186fn observed_before_digest(
2187 authority: &LifecycleAuthorityV1,
2188 current_module_hash: Option<&String>,
2189 current_config_hash: Option<&String>,
2190) -> String {
2191 stable_json_sha256_hex(&ObservedBeforeDigestInput {
2192 subject: &authority.subject,
2193 canister_id: &authority.canister_id,
2194 role: &authority.role,
2195 observed_controllers: &authority.observed_controllers,
2196 current_module_hash,
2197 current_canonical_embedded_config_sha256: current_config_hash,
2198 })
2199}
2200
2201fn external_upgrade_verification_result(
2202 consent_state: ExternalUpgradeConsentStateV1,
2203 proposal: &ExternalUpgradeProposalV1,
2204 observed_after_module_hash: Option<&str>,
2205 observed_after_config: Option<&str>,
2206) -> ExternalUpgradeVerificationResultV1 {
2207 match consent_state {
2208 ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
2209 ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
2210 ExternalUpgradeConsentStateV1::Delegated
2211 | ExternalUpgradeConsentStateV1::ExecutedExternally => {
2212 if external_upgrade_observation_matches(
2213 proposal.target_installed_module_hash.as_deref(),
2214 observed_after_module_hash,
2215 ) && external_upgrade_observation_matches(
2216 proposal.target_canonical_embedded_config_sha256.as_deref(),
2217 observed_after_config,
2218 ) {
2219 ExternalUpgradeVerificationResultV1::Verified
2220 } else {
2221 ExternalUpgradeVerificationResultV1::Mismatch
2222 }
2223 }
2224 }
2225}
2226
2227fn external_upgrade_verification_notes(
2228 verification_result: ExternalUpgradeVerificationResultV1,
2229 proposal: &ExternalUpgradeProposalV1,
2230 observed_after_module_hash: Option<&str>,
2231 observed_after_config: Option<&str>,
2232) -> Vec<String> {
2233 let mut notes = Vec::new();
2234 if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
2235 if !external_upgrade_observation_matches(
2236 proposal.target_installed_module_hash.as_deref(),
2237 observed_after_module_hash,
2238 ) {
2239 notes.push("observed module hash does not match proposal target".to_string());
2240 }
2241 if !external_upgrade_observation_matches(
2242 proposal.target_canonical_embedded_config_sha256.as_deref(),
2243 observed_after_config,
2244 ) {
2245 notes.push("observed embedded config does not match proposal target".to_string());
2246 }
2247 }
2248 notes
2249}
2250
2251const fn external_upgrade_verification_summary(
2252 result: ExternalUpgradeVerificationResultV1,
2253) -> &'static str {
2254 match result {
2255 ExternalUpgradeVerificationResultV1::Pending => {
2256 "external action has not been reported as complete"
2257 }
2258 ExternalUpgradeVerificationResultV1::Refused => "external consent was refused",
2259 ExternalUpgradeVerificationResultV1::Verified => {
2260 "reported external completion matches proposal target facts"
2261 }
2262 ExternalUpgradeVerificationResultV1::Mismatch => {
2263 "reported external completion does not match proposal target facts"
2264 }
2265 }
2266}
2267
2268const fn external_upgrade_consent_summary(state: ExternalUpgradeConsentStateV1) -> &'static str {
2269 match state {
2270 ExternalUpgradeConsentStateV1::Pending => {
2271 "external consent or action has not been reported"
2272 }
2273 ExternalUpgradeConsentStateV1::Refused => "external consent was refused",
2274 ExternalUpgradeConsentStateV1::Delegated => "delegated install authority was reported",
2275 ExternalUpgradeConsentStateV1::ExecutedExternally => {
2276 "external controller execution was reported"
2277 }
2278 }
2279}
2280
2281fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
2282 expected.is_none_or(|expected| observed == Some(expected))
2283}
2284
2285fn ensure_external_receipt_field(
2286 field: &'static str,
2287 value: &str,
2288) -> Result<(), ExternalUpgradeReceiptError> {
2289 if value.trim().is_empty() {
2290 return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
2291 }
2292 Ok(())
2293}
2294
2295fn ensure_external_receipt_matches_proposal(
2296 field: &'static str,
2297 actual: &str,
2298 expected: &str,
2299) -> Result<(), ExternalUpgradeReceiptError> {
2300 if actual != expected {
2301 return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
2302 }
2303 Ok(())
2304}
2305
2306fn ensure_external_receipt_option_matches_proposal(
2307 field: &'static str,
2308 actual: Option<&str>,
2309 expected: Option<&str>,
2310) -> Result<(), ExternalUpgradeReceiptError> {
2311 if actual != expected {
2312 return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
2313 }
2314 Ok(())
2315}
2316
2317fn ensure_external_consent_evidence_field(
2318 field: &'static str,
2319 value: &str,
2320) -> Result<(), ExternalUpgradeConsentEvidenceError> {
2321 if value.trim().is_empty() {
2322 return Err(ExternalUpgradeConsentEvidenceError::MissingRequiredField { field });
2323 }
2324 Ok(())
2325}
2326
2327fn ensure_external_verification_report_field(
2328 field: &'static str,
2329 value: &str,
2330) -> Result<(), ExternalUpgradeVerificationReportError> {
2331 if value.trim().is_empty() {
2332 return Err(ExternalUpgradeVerificationReportError::MissingRequiredField { field });
2333 }
2334 Ok(())
2335}
2336
2337fn ensure_external_lifecycle_plan_field(
2338 field: &'static str,
2339 value: &str,
2340) -> Result<(), ExternalLifecyclePlanError> {
2341 if value.trim().is_empty() {
2342 return Err(ExternalLifecyclePlanError::MissingRequiredField { field });
2343 }
2344 Ok(())
2345}
2346
2347fn ensure_external_proposal_report_field(
2348 field: &'static str,
2349 value: &str,
2350) -> Result<(), ExternalUpgradeProposalReportError> {
2351 if value.trim().is_empty() {
2352 return Err(ExternalUpgradeProposalReportError::MissingRequiredField { field });
2353 }
2354 Ok(())
2355}
2356
2357fn ensure_external_pending_report_field(
2358 field: &'static str,
2359 value: &str,
2360) -> Result<(), ExternalLifecyclePendingReportError> {
2361 if value.trim().is_empty() {
2362 return Err(ExternalLifecyclePendingReportError::MissingRequiredField { field });
2363 }
2364 Ok(())
2365}
2366
2367fn ensure_critical_fix_report_field(
2368 field: &'static str,
2369 value: &str,
2370) -> Result<(), CriticalExternalFixReportError> {
2371 if value.trim().is_empty() {
2372 return Err(CriticalExternalFixReportError::MissingRequiredField { field });
2373 }
2374 Ok(())
2375}
2376
2377fn ensure_lifecycle_authority_report_field(
2378 field: &'static str,
2379 value: &str,
2380) -> Result<(), LifecycleAuthorityReportError> {
2381 if value.trim().is_empty() {
2382 return Err(LifecycleAuthorityReportError::MissingRequiredField { field });
2383 }
2384 Ok(())
2385}
2386
2387fn sorted_unique(values: Vec<String>) -> Vec<String> {
2388 values
2389 .into_iter()
2390 .collect::<BTreeSet<_>>()
2391 .into_iter()
2392 .collect()
2393}