1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4mod artifacts;
5mod canisters;
6mod config_digests;
7mod controllers;
8mod module_hashes;
9mod pools;
10mod receipt_resume;
11mod root_subnet;
12mod safety;
13mod verifier_readiness;
14
15use artifacts::compare_artifacts;
16#[cfg(test)]
17pub(super) use artifacts::{
18 ARTIFACT_DUPLICATE_DIFF_CATEGORY, ARTIFACT_FILE_DIGEST_MISMATCH_CODE,
19 ARTIFACT_FILE_SHA256_DIFF_CATEGORY, ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
20 DUPLICATE_ARTIFACT_OBSERVED_CODE, DUPLICATE_PLANNED_ARTIFACT_ROLE_CODE,
21 PLANNED_ARTIFACT_DUPLICATE_DIFF_CATEGORY, PLANNED_ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
22};
23pub(in crate::deployment_truth) use artifacts::{
24 ARTIFACT_MISSING_CODE, is_artifact_role_failure_code,
25};
26pub(super) use artifacts::{ARTIFACT_ROLE_CONFLICT_CODE, PLANNED_ARTIFACT_ROLE_CONFLICT_CODE};
27#[cfg(test)]
28pub(super) use canisters::{
29 CANISTER_DUPLICATE_DIFF_CATEGORY, CANISTER_EXTRA_DIFF_CATEGORY,
30 CANISTER_ID_ROLE_CONFLICT_DIFF_CATEGORY, CANISTER_ROLE_AMBIGUOUS_CODE,
31 CANISTER_ROLE_AMBIGUOUS_DIFF_CATEGORY, CANISTER_ROLE_MISMATCH_CODE, CANISTER_UNOBSERVED_CODE,
32 DUPLICATE_CANISTER_OBSERVED_CODE, DUPLICATE_PLANNED_CANISTER_ROLE_CODE,
33 EXTRA_CANISTER_OBSERVED_CODE, PLANNED_CANISTER_DUPLICATE_DIFF_CATEGORY,
34 PLANNED_CANISTER_ID_CONFLICT_DIFF_CATEGORY, PLANNED_CANISTER_ROLE_CONFLICT_DIFF_CATEGORY,
35 ROLE_MISMATCH_DIFF_CATEGORY, SUBNET_REGISTRY_ROLE_MISSING_CODE, UNSAFE_CONTROL_CLASS_CODE,
36};
37pub(super) use canisters::{
38 CANISTER_ID_ROLE_CONFLICT_CODE, PLANNED_CANISTER_ID_CONFLICT_CODE,
39 PLANNED_CANISTER_ROLE_CONFLICT_CODE,
40};
41use canisters::{compare_canisters, compare_observed_canister_id_conflicts};
42#[cfg(test)]
43pub(super) use config_digests::{RAW_CONFIG_DIGEST_MISMATCH_CODE, RAW_CONFIG_SHA256_DIFF_CATEGORY};
44use config_digests::{compare_embedded_config, compare_raw_config};
45use controllers::compare_authority_profile;
46#[cfg(test)]
47pub(super) use controllers::{
48 CONTROLLER_AUTHORITY_OVERLAP_CODE, CONTROLLER_EXTRA_DIFF_CATEGORY,
49 CONTROLLER_MISSING_DIFF_CATEGORY, CONTROLLERS_UNOBSERVED_CODE,
50 EXPECTED_CONTROLLER_MISSING_CODE, EXTRA_CONTROLLER_OBSERVED_CODE,
51};
52use module_hashes::compare_module_hashes;
53#[cfg(test)]
54pub(super) use module_hashes::{
55 INSTALLED_MODULE_HASH_AMBIGUOUS_CODE, INSTALLED_MODULE_HASH_AMBIGUOUS_DIFF_CATEGORY,
56 INSTALLED_MODULE_HASH_DIFF_CATEGORY, INSTALLED_MODULE_HASH_MISMATCH_CODE,
57};
58pub(super) use pools::{
59 CANISTER_POOL_ROLE_CONFLICT_CODE, PLANNED_POOL_CONFLICT_CODE, PLANNED_POOL_ID_CONFLICT_CODE,
60 POOL_CANISTER_ID_CONFLICT_CODE,
61};
62#[cfg(test)]
63pub(super) use pools::{
64 CANISTER_POOL_ROLE_CONFLICT_DIFF_CATEGORY, DUPLICATE_PLANNED_POOL_CODE,
65 DUPLICATE_POOL_CANISTER_OBSERVED_CODE, EXTRA_POOL_CANISTER_OBSERVED_CODE,
66 PLANNED_POOL_CONFLICT_DIFF_CATEGORY, PLANNED_POOL_DUPLICATE_DIFF_CATEGORY,
67 PLANNED_POOL_ID_CONFLICT_DIFF_CATEGORY, POOL_CANISTER_DIFF_CATEGORY,
68 POOL_CANISTER_DUPLICATE_DIFF_CATEGORY, POOL_CANISTER_ID_CONFLICT_DIFF_CATEGORY,
69 POOL_CANISTER_ID_DIFF_CATEGORY, POOL_CANISTER_ID_MISMATCH_CODE, POOL_CANISTER_MISSING_CODE,
70 POOL_CONTROL_CLASS_DIFF_CATEGORY, POOL_EXTRA_DIFF_CATEGORY, UNSAFE_POOL_CONTROL_CLASS_CODE,
71};
72use pools::{compare_observed_canister_pool_role_conflicts, compare_pools};
73pub use receipt_resume::compare_plan_inventory_and_receipt;
74#[cfg(test)]
75pub(super) use receipt_resume::{
76 DUPLICATE_RECEIPT_PHASE_CODE, DUPLICATE_RECEIPT_ROLE_PHASE_CODE,
77 RECEIPT_EXECUTION_STATUS_MISMATCH_CODE, RECEIPT_PLAN_MISMATCH_CODE,
78 RECEIPT_POSTCONDITION_UNVERIFIED_CODE,
79};
80pub(super) use receipt_resume::{RECEIPT_PHASE_CONFLICT_CODE, RECEIPT_ROLE_PHASE_CONFLICT_CODE};
81#[cfg(test)]
82pub(super) use root_subnet::ROOT_AUTH_CLOUD_ENGINE_SUBNET_CODE;
83pub(super) use root_subnet::apply_root_auth_signer_subnet_check;
84#[cfg(test)]
85pub(super) use root_subnet::{
86 RootSubnetEvidence, RootSubnetEvidenceSource, apply_root_auth_signer_subnet_check_with_source,
87};
88pub use safety::safety_report_from_diff;
89pub(in crate::deployment_truth::report) use safety::{resume_safety_reasons, safety_status};
90use verifier_readiness::compare_verifier_readiness;
91#[cfg(test)]
92pub(super) use verifier_readiness::{
93 DUPLICATE_PLANNED_VERIFIER_ROLE_EPOCH_CODE, DUPLICATE_VERIFIER_ROLE_EPOCH_OBSERVED_CODE,
94 PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY,
95 PLANNED_VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_NOT_OBSERVED_LABEL,
96 VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_DIFF_CATEGORY,
97 VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_STALE_CODE,
98 VERIFIER_ROLE_EPOCH_UNOBSERVED_CODE,
99};
100pub(super) use verifier_readiness::{
101 PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE, VERIFIER_ROLE_EPOCH_CONFLICT_CODE,
102};
103
104pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_MISMATCH_CODE: &str =
105 "deployment_manifest_mismatch";
106pub(in crate::deployment_truth) const OBSERVATION_GAP_CODE: &str = "observation_gap";
107pub(in crate::deployment_truth) const UNVERIFIED_DEPLOYMENT_ROOT_CODE: &str =
108 "unverified_deployment_root";
109pub(in crate::deployment_truth) const PLAN_ASSUMPTION_CODE: &str = "plan_assumption";
110pub(in crate::deployment_truth) const IDENTITY_UNOBSERVED_CODE: &str = "identity_unobserved";
111pub(in crate::deployment_truth) const ENVIRONMENT_MISMATCH_CODE: &str = "environment_mismatch";
112pub(in crate::deployment_truth) const ROOT_TRUST_ANCHOR_MISMATCH_CODE: &str =
113 "root_trust_anchor_mismatch";
114pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_UNOBSERVED_CODE: &str =
115 "deployment_manifest_unobserved";
116
117#[must_use]
118pub fn is_evidence_conflict_finding_code(code: &str) -> bool {
119 matches!(
120 code,
121 PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
122 | ARTIFACT_ROLE_CONFLICT_CODE
123 | CANISTER_ID_ROLE_CONFLICT_CODE
124 | PLANNED_CANISTER_ROLE_CONFLICT_CODE
125 | PLANNED_CANISTER_ID_CONFLICT_CODE
126 | CANISTER_POOL_ROLE_CONFLICT_CODE
127 | PLANNED_POOL_CONFLICT_CODE
128 | PLANNED_POOL_ID_CONFLICT_CODE
129 | POOL_CANISTER_ID_CONFLICT_CODE
130 | RECEIPT_PHASE_CONFLICT_CODE
131 | RECEIPT_ROLE_PHASE_CONFLICT_CODE
132 | PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE
133 | VERIFIER_ROLE_EPOCH_CONFLICT_CODE
134 )
135}
136
137struct DuplicateEvidenceGroup {
141 subject: String,
142 count: usize,
143 evidence_label: String,
144 is_conflict: bool,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct LocalDeploymentCheckRequest {
152 pub deployment_name: String,
153 pub environment: String,
154 pub artifact_environment: String,
155 pub workspace_root: std::path::PathBuf,
156 pub icp_root: std::path::PathBuf,
157 pub config_path: Option<std::path::PathBuf>,
158 pub observed_at: String,
159 pub runtime_variant: String,
160 pub build_profile: String,
161}
162
163pub fn check_local_deployment(
165 request: &LocalDeploymentCheckRequest,
166) -> Result<DeploymentCheckV1, DeploymentTruthError> {
167 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
168 deployment_name: request.deployment_name.clone(),
169 environment: request.environment.clone(),
170 artifact_environment: request.artifact_environment.clone(),
171 workspace_root: request.workspace_root.clone(),
172 icp_root: request.icp_root.clone(),
173 config_path: request.config_path.clone(),
174 runtime_variant: request.runtime_variant.clone(),
175 build_profile: request.build_profile.clone(),
176 });
177 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
178 deployment_name: request.deployment_name.clone(),
179 environment: request.environment.clone(),
180 artifact_environment: request.artifact_environment.clone(),
181 workspace_root: request.workspace_root.clone(),
182 icp_root: request.icp_root.clone(),
183 config_path: request.config_path.clone(),
184 observed_at: request.observed_at.clone(),
185 })?;
186 let mut diff = compare_plan_to_inventory(&plan, &inventory);
187 apply_root_auth_signer_subnet_check(
188 &mut diff,
189 &inventory,
190 &request.environment,
191 &request.icp_root,
192 );
193 let report = safety_report_from_diff(
194 format!(
195 "local:{}:{}:report",
196 request.environment, request.deployment_name
197 ),
198 Some(format!(
199 "local:{}:{}:diff",
200 request.environment, request.deployment_name
201 )),
202 &diff,
203 );
204
205 Ok(DeploymentCheckV1 {
206 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
207 check_id: format!(
208 "local:{}:{}:check",
209 request.environment, request.deployment_name
210 ),
211 plan,
212 inventory,
213 diff,
214 report,
215 })
216}
217
218fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
219 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
220 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
221}
222
223#[must_use]
225pub fn compare_plan_to_inventory(
226 plan: &DeploymentPlanV1,
227 inventory: &DeploymentInventoryV1,
228) -> DeploymentDiffV1 {
229 let mut artifact_diff = Vec::new();
230 let mut controller_diff = Vec::new();
231 let mut pool_diff = Vec::new();
232 let mut embedded_config_diff = Vec::new();
233 let mut module_hash_diff = Vec::new();
234 let mut verifier_readiness_diff = Vec::new();
235 let mut hard_failures = Vec::new();
236 let mut warnings = Vec::new();
237
238 compare_identity(plan, inventory, &mut hard_failures);
239 compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
240 compare_artifacts(
241 plan,
242 inventory,
243 &mut artifact_diff,
244 &mut hard_failures,
245 &mut warnings,
246 );
247 compare_observed_canister_id_conflicts(
248 inventory,
249 &mut controller_diff,
250 &mut hard_failures,
251 &mut warnings,
252 );
253 compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
254 compare_canisters(
255 plan,
256 inventory,
257 &mut controller_diff,
258 &mut hard_failures,
259 &mut warnings,
260 );
261 compare_pools(
262 plan,
263 inventory,
264 &mut pool_diff,
265 &mut hard_failures,
266 &mut warnings,
267 );
268 compare_module_hashes(
269 plan,
270 inventory,
271 &mut module_hash_diff,
272 &mut hard_failures,
273 &mut warnings,
274 );
275 compare_raw_config(
276 plan,
277 inventory,
278 &mut embedded_config_diff,
279 &mut hard_failures,
280 );
281 compare_embedded_config(
282 plan,
283 inventory,
284 &mut embedded_config_diff,
285 &mut hard_failures,
286 &mut warnings,
287 );
288 compare_verifier_readiness(
289 plan,
290 inventory,
291 &mut verifier_readiness_diff,
292 &mut hard_failures,
293 &mut warnings,
294 );
295 record_plan_assumptions(plan, &mut hard_failures, &mut warnings);
296 for gap in &inventory.unresolved_observations {
297 warnings.push(SafetyFindingV1 {
298 code: OBSERVATION_GAP_CODE.to_string(),
299 message: gap.description.clone(),
300 severity: SafetySeverityV1::Warning,
301 subject: Some(gap.key.clone()),
302 });
303 }
304
305 let status = safety_status(&hard_failures, &warnings);
306 DeploymentDiffV1 {
307 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
308 plan_identity: plan.deployment_identity.clone(),
309 observed_identity: inventory.observed_identity.clone(),
310 artifact_diff,
311 controller_diff,
312 pool_diff,
313 embedded_config_diff,
314 module_hash_diff,
315 verifier_readiness_diff,
316 resume_safety: ResumeSafetyV1 {
317 status,
318 reasons: resume_safety_reasons(&hard_failures, &warnings),
319 },
320 hard_failures,
321 warnings,
322 resumable_phases: Vec::new(),
323 }
324}
325
326fn record_plan_assumptions(
327 plan: &DeploymentPlanV1,
328 hard_failures: &mut Vec<SafetyFindingV1>,
329 warnings: &mut Vec<SafetyFindingV1>,
330) {
331 for assumption in &plan.unresolved_assumptions {
332 if assumption.key == "local_state.unverified_root_canister_id" {
333 hard_failures.push(SafetyFindingV1 {
334 code: UNVERIFIED_DEPLOYMENT_ROOT_CODE.to_string(),
335 message: assumption.description.clone(),
336 severity: SafetySeverityV1::HardFailure,
337 subject: Some(assumption.key.clone()),
338 });
339 } else {
340 warnings.push(SafetyFindingV1 {
341 code: PLAN_ASSUMPTION_CODE.to_string(),
342 message: assumption.description.clone(),
343 severity: SafetySeverityV1::Warning,
344 subject: Some(assumption.key.clone()),
345 });
346 }
347 }
348}
349
350fn compare_identity(
351 plan: &DeploymentPlanV1,
352 inventory: &DeploymentInventoryV1,
353 hard_failures: &mut Vec<SafetyFindingV1>,
354) {
355 let Some(observed) = &inventory.observed_identity else {
356 hard_failures.push(finding(
357 IDENTITY_UNOBSERVED_CODE,
358 "deployment identity was not observed",
359 SafetySeverityV1::HardFailure,
360 None,
361 ));
362 return;
363 };
364
365 if observed.environment != plan.deployment_identity.environment {
366 hard_failures.push(finding(
367 ENVIRONMENT_MISMATCH_CODE,
368 format!(
369 "plan environment {} differs from observed environment {}",
370 plan.deployment_identity.environment, observed.environment
371 ),
372 SafetySeverityV1::HardFailure,
373 Some("deployment_identity.environment".to_string()),
374 ));
375 }
376 if let (Some(expected), Some(actual)) = (
377 plan.deployment_identity.root_principal.as_ref(),
378 observed.root_principal.as_ref(),
379 ) && expected != actual
380 {
381 hard_failures.push(finding(
382 ROOT_TRUST_ANCHOR_MISMATCH_CODE,
383 format!("plan root {expected} differs from observed root {actual}"),
384 SafetySeverityV1::HardFailure,
385 Some("deployment_identity.root_principal".to_string()),
386 ));
387 }
388 match (
389 plan.deployment_identity.deployment_manifest_digest.as_ref(),
390 observed.deployment_manifest_digest.as_ref(),
391 ) {
392 (Some(expected), Some(actual)) if expected != actual => {
393 hard_failures.push(finding(
394 DEPLOYMENT_MANIFEST_MISMATCH_CODE,
395 "deployment manifest digest differs from the observed local config",
396 SafetySeverityV1::HardFailure,
397 Some("deployment_identity.deployment_manifest_digest".to_string()),
398 ));
399 }
400 (Some(_), None) => {
401 hard_failures.push(finding(
402 DEPLOYMENT_MANIFEST_UNOBSERVED_CODE,
403 "deployment manifest digest was not observed",
404 SafetySeverityV1::HardFailure,
405 Some("deployment_identity.deployment_manifest_digest".to_string()),
406 ));
407 }
408 _ => {}
409 }
410}
411
412fn finding(
413 code: impl Into<String>,
414 message: impl Into<String>,
415 severity: SafetySeverityV1,
416 subject: Option<String>,
417) -> SafetyFindingV1 {
418 SafetyFindingV1 {
419 code: code.into(),
420 message: message.into(),
421 severity,
422 subject,
423 }
424}
425
426fn diff_item(
427 category: impl Into<String>,
428 subject: impl Into<String>,
429 expected: Option<String>,
430 observed: Option<String>,
431 severity: SafetySeverityV1,
432) -> DiffItemV1 {
433 DiffItemV1 {
434 category: category.into(),
435 subject: subject.into(),
436 expected,
437 observed,
438 severity,
439 }
440}
441
442fn duplicate_evidence_groups<T>(
443 items: &[T],
444 subject: impl Fn(&T) -> String,
445 evidence: impl Fn(&T) -> String,
446 evidence_separator: &str,
447) -> Vec<DuplicateEvidenceGroup> {
448 let mut groups = Vec::new();
449 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
450 if entries.len() <= 1 {
451 continue;
452 }
453 let evidence_values = entries
454 .iter()
455 .map(|entry| evidence(entry))
456 .collect::<BTreeSet<_>>();
457 groups.push(DuplicateEvidenceGroup {
458 subject,
459 count: entries.len(),
460 evidence_label: evidence_values
461 .iter()
462 .cloned()
463 .collect::<Vec<_>>()
464 .join(evidence_separator),
465 is_conflict: evidence_values.len() > 1,
466 });
467 }
468 groups
469}
470
471fn duplicate_evidence_groups_by<T, K>(
472 items: &[T],
473 subject: impl Fn(&T) -> String,
474 evidence_key: impl Fn(&T) -> K,
475 evidence_label: impl Fn(&T) -> String,
476 evidence_separator: &str,
477) -> Vec<DuplicateEvidenceGroup>
478where
479 K: Ord,
480{
481 let mut groups = Vec::new();
482 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
483 if entries.len() <= 1 {
484 continue;
485 }
486 let evidence_values = entries
487 .iter()
488 .map(|entry| (evidence_key(entry), evidence_label(entry)))
489 .collect::<BTreeMap<_, _>>();
490 groups.push(DuplicateEvidenceGroup {
491 subject,
492 count: entries.len(),
493 evidence_label: evidence_values
494 .values()
495 .cloned()
496 .collect::<Vec<_>>()
497 .join(evidence_separator),
498 is_conflict: evidence_values.len() > 1,
499 });
500 }
501 groups
502}
503
504fn conflicting_assignment_groups<T>(
505 items: &[T],
506 subject: impl Fn(&T) -> Option<String>,
507 value: impl Fn(&T) -> String,
508 value_separator: &str,
509) -> Vec<DuplicateEvidenceGroup> {
510 let mut groups = Vec::new();
511 for (subject, entries) in group_by_subject(items, subject) {
512 if entries.len() <= 1 {
513 continue;
514 }
515 let values = entries
516 .iter()
517 .map(|entry| value(entry))
518 .collect::<BTreeSet<_>>();
519 if values.len() <= 1 {
520 continue;
521 }
522 groups.push(DuplicateEvidenceGroup {
523 subject,
524 count: entries.len(),
525 evidence_label: values
526 .iter()
527 .cloned()
528 .collect::<Vec<_>>()
529 .join(value_separator),
530 is_conflict: true,
531 });
532 }
533 groups
534}
535
536fn group_by_subject<T>(
537 items: &[T],
538 subject: impl Fn(&T) -> Option<String>,
539) -> BTreeMap<String, Vec<&T>> {
540 let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
541 for item in items {
542 if let Some(subject) = subject(item) {
543 by_subject.entry(subject).or_default().push(item);
544 }
545 }
546 by_subject
547}
548
549#[cfg(test)]
550mod classification_tests {
551 use super::*;
552
553 #[test]
554 fn finding_classification_uses_exact_owner_codes() {
555 assert!(is_evidence_conflict_finding_code(
556 ARTIFACT_ROLE_CONFLICT_CODE
557 ));
558 assert!(is_evidence_conflict_finding_code(
559 RECEIPT_PHASE_CONFLICT_CODE
560 ));
561 assert!(!is_evidence_conflict_finding_code("artifact_conflict"));
562 assert!(!is_evidence_conflict_finding_code("conflict"));
563
564 assert!(is_artifact_role_failure_code(ARTIFACT_MISSING_CODE));
565 assert!(!is_artifact_role_failure_code(
566 PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
567 ));
568 }
569}