1use super::*;
2use serde::Serialize;
3use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Serialize)]
6struct DeploymentComparisonReportDigestInput<'a> {
7 report_id: &'a str,
8 compared_at: &'a str,
9 left: &'a DeploymentComparisonTargetV1,
10 right: &'a DeploymentComparisonTargetV1,
11 status: SafetyStatusV1,
12 identity_diff: &'a [DeploymentComparisonDiffV1],
13 artifact_diff: &'a [DeploymentComparisonDiffV1],
14 module_hash_diff: &'a [DeploymentComparisonDiffV1],
15 embedded_config_diff: &'a [DeploymentComparisonDiffV1],
16 authority_diff: &'a [DeploymentComparisonDiffV1],
17 pool_diff: &'a [DeploymentComparisonDiffV1],
18 verifier_readiness_diff: &'a [DeploymentComparisonDiffV1],
19 external_lifecycle_diff: &'a [DeploymentComparisonDiffV1],
20 hard_failures: &'a [SafetyFindingV1],
21 warnings: &'a [SafetyFindingV1],
22 next_actions: &'a [String],
23}
24
25#[derive(Debug, Eq, thiserror::Error, PartialEq)]
29pub enum DeploymentComparisonReportError {
30 #[error(
31 "deployment comparison report schema version {actual} does not match expected {expected}"
32 )]
33 SchemaVersionMismatch { expected: u32, actual: u32 },
34 #[error("deployment comparison report field `{field}` is required")]
35 MissingRequiredField { field: &'static str },
36 #[error("deployment comparison report field `{field}` digest is stale")]
37 DigestMismatch { field: &'static str },
38 #[error("deployment comparison report status does not match report findings")]
39 StatusMismatch,
40}
41
42#[must_use]
46pub fn deployment_comparison_report_from_checks(
47 report_id: impl Into<String>,
48 compared_at: impl Into<String>,
49 left_label: impl Into<String>,
50 right_label: impl Into<String>,
51 left: &DeploymentCheckV1,
52 right: &DeploymentCheckV1,
53) -> DeploymentComparisonReportV1 {
54 let mut identity_diff = Vec::new();
55 let mut artifact_diff = Vec::new();
56 let mut module_hash_diff = Vec::new();
57 let mut embedded_config_diff = Vec::new();
58 let mut authority_diff = Vec::new();
59 let mut pool_diff = Vec::new();
60 let mut verifier_readiness_diff = Vec::new();
61 let mut external_lifecycle_diff = Vec::new();
62
63 compare_identity(left, right, &mut identity_diff);
64 compare_artifact_evidence(left, right, &mut artifact_diff);
65 compare_observed_module_hashes(left, right, &mut module_hash_diff);
66 compare_embedded_config_evidence(left, right, &mut embedded_config_diff);
67 compare_authority_evidence(left, right, &mut authority_diff);
68 compare_pool_evidence(left, right, &mut pool_diff);
69 compare_verifier_readiness_evidence(left, right, &mut verifier_readiness_diff);
70 compare_external_lifecycle_evidence(left, right, &mut external_lifecycle_diff);
71
72 let hard_failures = Vec::new();
73 let diff_groups = [
74 identity_diff.as_slice(),
75 artifact_diff.as_slice(),
76 module_hash_diff.as_slice(),
77 embedded_config_diff.as_slice(),
78 authority_diff.as_slice(),
79 pool_diff.as_slice(),
80 verifier_readiness_diff.as_slice(),
81 external_lifecycle_diff.as_slice(),
82 ];
83 let warnings = comparison_warnings(&diff_groups);
84 let status = comparison_status(&hard_failures, &warnings);
85 let next_actions = comparison_next_actions(status);
86
87 let mut report = DeploymentComparisonReportV1 {
88 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
89 report_id: report_id.into(),
90 report_digest: String::new(),
91 compared_at: compared_at.into(),
92 left: comparison_target(left_label.into(), left),
93 right: comparison_target(right_label.into(), right),
94 status,
95 identity_diff,
96 artifact_diff,
97 module_hash_diff,
98 embedded_config_diff,
99 authority_diff,
100 pool_diff,
101 verifier_readiness_diff,
102 external_lifecycle_diff,
103 hard_failures,
104 warnings,
105 next_actions,
106 };
107 report.report_digest = deployment_comparison_report_digest(&report);
108 report
109}
110
111pub fn validate_deployment_comparison_report(
113 report: &DeploymentComparisonReportV1,
114) -> Result<(), DeploymentComparisonReportError> {
115 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
116 return Err(DeploymentComparisonReportError::SchemaVersionMismatch {
117 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
118 actual: report.schema_version,
119 });
120 }
121 ensure_comparison_field("report_id", report.report_id.as_str())?;
122 ensure_comparison_field("report_digest", report.report_digest.as_str())?;
123 ensure_comparison_field("compared_at", report.compared_at.as_str())?;
124 validate_comparison_target("left", &report.left)?;
125 validate_comparison_target("right", &report.right)?;
126 if report.status != comparison_status(&report.hard_failures, &report.warnings) {
127 return Err(DeploymentComparisonReportError::StatusMismatch);
128 }
129 if report.report_digest != deployment_comparison_report_digest(report) {
130 return Err(DeploymentComparisonReportError::DigestMismatch {
131 field: "report_digest",
132 });
133 }
134 Ok(())
135}
136
137fn comparison_target(label: String, check: &DeploymentCheckV1) -> DeploymentComparisonTargetV1 {
138 DeploymentComparisonTargetV1 {
139 label,
140 check_id: check.check_id.clone(),
141 check_digest: stable_json_sha256_hex(check),
142 plan_id: check.plan.plan_id.clone(),
143 plan_digest: stable_json_sha256_hex(&check.plan),
144 inventory_id: check.inventory.inventory_id.clone(),
145 inventory_digest: stable_json_sha256_hex(&check.inventory),
146 deployment_identity: check.plan.deployment_identity.clone(),
147 }
148}
149
150fn compare_identity(
151 left: &DeploymentCheckV1,
152 right: &DeploymentCheckV1,
153 diffs: &mut Vec<DeploymentComparisonDiffV1>,
154) {
155 compare_identity_names(left, right, diffs);
156 compare_identity_digests(left, right, diffs);
157 compare_identity_plan_shape(left, right, diffs);
158 compare_identity_trust_domain(left, right, diffs);
159}
160
161fn compare_identity_names(
162 left: &DeploymentCheckV1,
163 right: &DeploymentCheckV1,
164 diffs: &mut Vec<DeploymentComparisonDiffV1>,
165) {
166 compare_value(
167 DeploymentComparisonCategoryV1::Identity,
168 "deployment_name",
169 Some(left.plan.deployment_identity.deployment_name.as_str()),
170 Some(right.plan.deployment_identity.deployment_name.as_str()),
171 "deployment names differ",
172 diffs,
173 );
174 compare_value(
175 DeploymentComparisonCategoryV1::Identity,
176 "network",
177 Some(left.plan.deployment_identity.network.as_str()),
178 Some(right.plan.deployment_identity.network.as_str()),
179 "deployment networks differ",
180 diffs,
181 );
182 compare_optional(
183 DeploymentComparisonCategoryV1::Identity,
184 "root_principal",
185 left.plan.deployment_identity.root_principal.as_deref(),
186 right.plan.deployment_identity.root_principal.as_deref(),
187 "root principals differ",
188 diffs,
189 );
190}
191
192fn compare_identity_digests(
193 left: &DeploymentCheckV1,
194 right: &DeploymentCheckV1,
195 diffs: &mut Vec<DeploymentComparisonDiffV1>,
196) {
197 compare_optional(
198 DeploymentComparisonCategoryV1::Identity,
199 "authority_profile_hash",
200 left.plan
201 .deployment_identity
202 .authority_profile_hash
203 .as_deref(),
204 right
205 .plan
206 .deployment_identity
207 .authority_profile_hash
208 .as_deref(),
209 "authority profile hashes differ",
210 diffs,
211 );
212 compare_optional(
213 DeploymentComparisonCategoryV1::Identity,
214 "artifact_set_digest",
215 left.plan.deployment_identity.artifact_set_digest.as_deref(),
216 right
217 .plan
218 .deployment_identity
219 .artifact_set_digest
220 .as_deref(),
221 "artifact set digests differ",
222 diffs,
223 );
224 compare_optional(
225 DeploymentComparisonCategoryV1::Identity,
226 "role_topology_hash",
227 left.plan.deployment_identity.role_topology_hash.as_deref(),
228 right.plan.deployment_identity.role_topology_hash.as_deref(),
229 "role topology hashes differ",
230 diffs,
231 );
232 compare_optional(
233 DeploymentComparisonCategoryV1::Identity,
234 "pool_identity_set_digest",
235 left.plan
236 .deployment_identity
237 .pool_identity_set_digest
238 .as_deref(),
239 right
240 .plan
241 .deployment_identity
242 .pool_identity_set_digest
243 .as_deref(),
244 "pool identity set digests differ",
245 diffs,
246 );
247 compare_optional(
248 DeploymentComparisonCategoryV1::Identity,
249 "canonical_runtime_config_digest",
250 left.plan
251 .deployment_identity
252 .canonical_runtime_config_digest
253 .as_deref(),
254 right
255 .plan
256 .deployment_identity
257 .canonical_runtime_config_digest
258 .as_deref(),
259 "canonical runtime config digests differ",
260 diffs,
261 );
262 compare_optional(
263 DeploymentComparisonCategoryV1::Identity,
264 "role_embedded_config_set_digest",
265 left.plan
266 .deployment_identity
267 .role_embedded_config_set_digest
268 .as_deref(),
269 right
270 .plan
271 .deployment_identity
272 .role_embedded_config_set_digest
273 .as_deref(),
274 "role embedded config set digests differ",
275 diffs,
276 );
277}
278
279fn compare_identity_plan_shape(
280 left: &DeploymentCheckV1,
281 right: &DeploymentCheckV1,
282 diffs: &mut Vec<DeploymentComparisonDiffV1>,
283) {
284 compare_value(
285 DeploymentComparisonCategoryV1::Identity,
286 "fleet_template",
287 Some(left.plan.fleet_template.as_str()),
288 Some(right.plan.fleet_template.as_str()),
289 "fleet templates differ",
290 diffs,
291 );
292 compare_value(
293 DeploymentComparisonCategoryV1::Identity,
294 "runtime_variant",
295 Some(left.plan.runtime_variant.as_str()),
296 Some(right.plan.runtime_variant.as_str()),
297 "runtime variants differ",
298 diffs,
299 );
300}
301
302fn compare_identity_trust_domain(
303 left: &DeploymentCheckV1,
304 right: &DeploymentCheckV1,
305 diffs: &mut Vec<DeploymentComparisonDiffV1>,
306) {
307 compare_optional(
308 DeploymentComparisonCategoryV1::TrustDomain,
309 "root_trust_anchor",
310 left.plan.trust_domain.root_trust_anchor.as_deref(),
311 right.plan.trust_domain.root_trust_anchor.as_deref(),
312 "root trust anchors differ",
313 diffs,
314 );
315 compare_optional(
316 DeploymentComparisonCategoryV1::TrustDomain,
317 "migration_from",
318 left.plan.trust_domain.migration_from.as_deref(),
319 right.plan.trust_domain.migration_from.as_deref(),
320 "migration sources differ",
321 diffs,
322 );
323}
324
325fn compare_artifact_evidence(
326 left: &DeploymentCheckV1,
327 right: &DeploymentCheckV1,
328 diffs: &mut Vec<DeploymentComparisonDiffV1>,
329) {
330 compare_maps(
331 DeploymentComparisonCategoryV1::Artifact,
332 &role_artifact_fingerprints(&left.plan.role_artifacts),
333 &role_artifact_fingerprints(&right.plan.role_artifacts),
334 "role artifact identity differs",
335 diffs,
336 );
337}
338
339fn compare_observed_module_hashes(
340 left: &DeploymentCheckV1,
341 right: &DeploymentCheckV1,
342 diffs: &mut Vec<DeploymentComparisonDiffV1>,
343) {
344 compare_maps(
345 DeploymentComparisonCategoryV1::ModuleHash,
346 &observed_canister_map(&left.inventory, |canister| {
347 canister
348 .module_hash
349 .clone()
350 .unwrap_or_else(|| "missing".into())
351 }),
352 &observed_canister_map(&right.inventory, |canister| {
353 canister
354 .module_hash
355 .clone()
356 .unwrap_or_else(|| "missing".into())
357 }),
358 "observed module hash differs",
359 diffs,
360 );
361}
362
363fn compare_embedded_config_evidence(
364 left: &DeploymentCheckV1,
365 right: &DeploymentCheckV1,
366 diffs: &mut Vec<DeploymentComparisonDiffV1>,
367) {
368 compare_maps(
369 DeploymentComparisonCategoryV1::EmbeddedConfig,
370 &observed_canister_map(&left.inventory, |canister| {
371 canister
372 .canonical_embedded_config_digest
373 .clone()
374 .unwrap_or_else(|| "missing".into())
375 }),
376 &observed_canister_map(&right.inventory, |canister| {
377 canister
378 .canonical_embedded_config_digest
379 .clone()
380 .unwrap_or_else(|| "missing".into())
381 }),
382 "observed embedded config digest differs",
383 diffs,
384 );
385}
386
387fn compare_authority_evidence(
388 left: &DeploymentCheckV1,
389 right: &DeploymentCheckV1,
390 diffs: &mut Vec<DeploymentComparisonDiffV1>,
391) {
392 compare_maps(
393 DeploymentComparisonCategoryV1::Authority,
394 &observed_canister_map(&left.inventory, canister_authority_fingerprint),
395 &observed_canister_map(&right.inventory, canister_authority_fingerprint),
396 "observed authority evidence differs",
397 diffs,
398 );
399}
400
401fn compare_pool_evidence(
402 left: &DeploymentCheckV1,
403 right: &DeploymentCheckV1,
404 diffs: &mut Vec<DeploymentComparisonDiffV1>,
405) {
406 compare_maps(
407 DeploymentComparisonCategoryV1::Pool,
408 &pool_fingerprints(&left.inventory.observed_pool),
409 &pool_fingerprints(&right.inventory.observed_pool),
410 "observed pool evidence differs",
411 diffs,
412 );
413}
414
415fn compare_verifier_readiness_evidence(
416 left: &DeploymentCheckV1,
417 right: &DeploymentCheckV1,
418 diffs: &mut Vec<DeploymentComparisonDiffV1>,
419) {
420 compare_value(
421 DeploymentComparisonCategoryV1::VerifierReadiness,
422 "verifier_readiness",
423 Some(stable_json_sha256_hex(&left.inventory.observed_verifier_readiness).as_str()),
424 Some(stable_json_sha256_hex(&right.inventory.observed_verifier_readiness).as_str()),
425 "verifier readiness observations differ",
426 diffs,
427 );
428}
429
430fn compare_external_lifecycle_evidence(
431 left: &DeploymentCheckV1,
432 right: &DeploymentCheckV1,
433 diffs: &mut Vec<DeploymentComparisonDiffV1>,
434) {
435 compare_maps(
436 DeploymentComparisonCategoryV1::ExternalLifecycle,
437 &control_class_counts(&left.inventory),
438 &control_class_counts(&right.inventory),
439 "external lifecycle control-class evidence differs",
440 diffs,
441 );
442}
443
444fn role_artifact_fingerprints(artifacts: &[RoleArtifactV1]) -> BTreeMap<String, String> {
445 artifacts
446 .iter()
447 .map(|artifact| {
448 (
449 artifact.role.clone(),
450 stable_json_sha256_hex(&(
451 artifact.source,
452 artifact.wasm_sha256.as_deref(),
453 artifact.wasm_gz_sha256.as_deref(),
454 artifact.installed_module_hash.as_deref(),
455 artifact.candid_sha256.as_deref(),
456 artifact.canonical_embedded_config_sha256.as_deref(),
457 artifact.package_version.as_deref(),
458 )),
459 )
460 })
461 .collect()
462}
463
464fn observed_canister_map(
465 inventory: &DeploymentInventoryV1,
466 value: impl Fn(&ObservedCanisterV1) -> String,
467) -> BTreeMap<String, String> {
468 inventory
469 .observed_canisters
470 .iter()
471 .map(|canister| (canister_subject(canister), value(canister)))
472 .collect()
473}
474
475fn canister_authority_fingerprint(canister: &ObservedCanisterV1) -> String {
476 stable_json_sha256_hex(&(
477 canister.control_class,
478 &canister.controllers,
479 canister.root_trust_anchor.as_deref(),
480 ))
481}
482
483fn pool_fingerprints(pool: &[ObservedPoolCanisterV1]) -> BTreeMap<String, String> {
484 pool.iter()
485 .map(|canister| {
486 (
487 format!("{}:{}", canister.pool, canister.canister_id),
488 stable_json_sha256_hex(&(canister.role.as_deref(), canister.control_class)),
489 )
490 })
491 .collect()
492}
493
494fn control_class_counts(inventory: &DeploymentInventoryV1) -> BTreeMap<String, String> {
495 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
496 for canister in &inventory.observed_canisters {
497 *counts
498 .entry(format!("{:?}", canister.control_class))
499 .or_default() += 1;
500 }
501 counts
502 .into_iter()
503 .map(|(class, count)| (class, count.to_string()))
504 .collect()
505}
506
507fn canister_subject(canister: &ObservedCanisterV1) -> String {
508 canister
509 .role
510 .as_ref()
511 .map_or_else(|| canister.canister_id.clone(), Clone::clone)
512}
513
514fn compare_maps(
515 category: DeploymentComparisonCategoryV1,
516 left: &BTreeMap<String, String>,
517 right: &BTreeMap<String, String>,
518 message: &'static str,
519 diffs: &mut Vec<DeploymentComparisonDiffV1>,
520) {
521 let subjects: BTreeSet<_> = left.keys().chain(right.keys()).cloned().collect();
522 for subject in subjects {
523 compare_optional(
524 category,
525 &subject,
526 left.get(&subject).map(String::as_str),
527 right.get(&subject).map(String::as_str),
528 message,
529 diffs,
530 );
531 }
532}
533
534fn compare_value(
535 category: DeploymentComparisonCategoryV1,
536 subject: &str,
537 left: Option<&str>,
538 right: Option<&str>,
539 message: &'static str,
540 diffs: &mut Vec<DeploymentComparisonDiffV1>,
541) {
542 if left == right {
543 return;
544 }
545 diffs.push(DeploymentComparisonDiffV1 {
546 category,
547 subject: subject.to_string(),
548 left: left.map(str::to_string),
549 right: right.map(str::to_string),
550 severity: SafetySeverityV1::Warning,
551 message: message.to_string(),
552 });
553}
554
555fn compare_optional(
556 category: DeploymentComparisonCategoryV1,
557 subject: &str,
558 left: Option<&str>,
559 right: Option<&str>,
560 message: &'static str,
561 diffs: &mut Vec<DeploymentComparisonDiffV1>,
562) {
563 compare_value(category, subject, left, right, message, diffs);
564}
565
566fn comparison_warnings(diff_groups: &[&[DeploymentComparisonDiffV1]]) -> Vec<SafetyFindingV1> {
567 let diff_count = diff_groups.iter().map(|group| group.len()).sum::<usize>();
568 if diff_count == 0 {
569 return Vec::new();
570 }
571 vec![SafetyFindingV1 {
572 code: "deployment_comparison_drift".to_string(),
573 message: format!("deployment comparison found {diff_count} drift item(s)"),
574 severity: SafetySeverityV1::Warning,
575 subject: None,
576 }]
577}
578
579const fn comparison_status(
580 hard_failures: &[SafetyFindingV1],
581 warnings: &[SafetyFindingV1],
582) -> SafetyStatusV1 {
583 if !hard_failures.is_empty() {
584 SafetyStatusV1::Blocked
585 } else if !warnings.is_empty() {
586 SafetyStatusV1::Warning
587 } else {
588 SafetyStatusV1::Safe
589 }
590}
591
592fn comparison_next_actions(status: SafetyStatusV1) -> Vec<String> {
593 match status {
594 SafetyStatusV1::Safe => vec!["no cross-deployment drift detected".to_string()],
595 SafetyStatusV1::Warning => {
596 vec!["review comparison drift before promotion, rebuild, or teardown".to_string()]
597 }
598 SafetyStatusV1::Blocked => {
599 vec!["resolve hard comparison failures before using this evidence".to_string()]
600 }
601 SafetyStatusV1::NotEvaluated => vec!["run deployment comparison".to_string()],
602 }
603}
604
605fn deployment_comparison_report_digest(report: &DeploymentComparisonReportV1) -> String {
606 stable_json_sha256_hex(&DeploymentComparisonReportDigestInput {
607 report_id: &report.report_id,
608 compared_at: &report.compared_at,
609 left: &report.left,
610 right: &report.right,
611 status: report.status,
612 identity_diff: &report.identity_diff,
613 artifact_diff: &report.artifact_diff,
614 module_hash_diff: &report.module_hash_diff,
615 embedded_config_diff: &report.embedded_config_diff,
616 authority_diff: &report.authority_diff,
617 pool_diff: &report.pool_diff,
618 verifier_readiness_diff: &report.verifier_readiness_diff,
619 external_lifecycle_diff: &report.external_lifecycle_diff,
620 hard_failures: &report.hard_failures,
621 warnings: &report.warnings,
622 next_actions: &report.next_actions,
623 })
624}
625
626fn validate_comparison_target(
627 prefix: &'static str,
628 target: &DeploymentComparisonTargetV1,
629) -> Result<(), DeploymentComparisonReportError> {
630 ensure_comparison_field(field_name(prefix, "label"), target.label.as_str())?;
631 ensure_comparison_field(field_name(prefix, "check_id"), target.check_id.as_str())?;
632 ensure_comparison_field(
633 field_name(prefix, "check_digest"),
634 target.check_digest.as_str(),
635 )?;
636 ensure_comparison_field(field_name(prefix, "plan_id"), target.plan_id.as_str())?;
637 ensure_comparison_field(
638 field_name(prefix, "plan_digest"),
639 target.plan_digest.as_str(),
640 )?;
641 ensure_comparison_field(
642 field_name(prefix, "inventory_id"),
643 target.inventory_id.as_str(),
644 )?;
645 ensure_comparison_field(
646 field_name(prefix, "inventory_digest"),
647 target.inventory_digest.as_str(),
648 )?;
649 Ok(())
650}
651
652fn field_name(prefix: &'static str, field: &'static str) -> &'static str {
653 match (prefix, field) {
654 ("left", "label") => "left.label",
655 ("left", "check_id") => "left.check_id",
656 ("left", "check_digest") => "left.check_digest",
657 ("left", "plan_id") => "left.plan_id",
658 ("left", "plan_digest") => "left.plan_digest",
659 ("left", "inventory_id") => "left.inventory_id",
660 ("left", "inventory_digest") => "left.inventory_digest",
661 ("right", "label") => "right.label",
662 ("right", "check_id") => "right.check_id",
663 ("right", "check_digest") => "right.check_digest",
664 ("right", "plan_id") => "right.plan_id",
665 ("right", "plan_digest") => "right.plan_digest",
666 ("right", "inventory_id") => "right.inventory_id",
667 ("right", "inventory_digest") => "right.inventory_digest",
668 _ => field,
669 }
670}
671
672fn ensure_comparison_field(
673 field: &'static str,
674 value: &str,
675) -> Result<(), DeploymentComparisonReportError> {
676 if value.trim().is_empty() {
677 return Err(DeploymentComparisonReportError::MissingRequiredField { field });
678 }
679 Ok(())
680}