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 NETWORK_MISMATCH_CODE: &str = "network_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 network: String,
154 pub workspace_root: std::path::PathBuf,
155 pub icp_root: std::path::PathBuf,
156 pub config_path: Option<std::path::PathBuf>,
157 pub observed_at: String,
158 pub runtime_variant: String,
159 pub build_profile: String,
160}
161
162pub fn check_local_deployment(
164 request: &LocalDeploymentCheckRequest,
165) -> Result<DeploymentCheckV1, DeploymentTruthError> {
166 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
167 deployment_name: request.deployment_name.clone(),
168 network: request.network.clone(),
169 workspace_root: request.workspace_root.clone(),
170 icp_root: request.icp_root.clone(),
171 config_path: request.config_path.clone(),
172 runtime_variant: request.runtime_variant.clone(),
173 build_profile: request.build_profile.clone(),
174 });
175 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
176 deployment_name: request.deployment_name.clone(),
177 network: request.network.clone(),
178 workspace_root: request.workspace_root.clone(),
179 icp_root: request.icp_root.clone(),
180 config_path: request.config_path.clone(),
181 observed_at: request.observed_at.clone(),
182 })?;
183 let mut diff = compare_plan_to_inventory(&plan, &inventory);
184 apply_root_auth_signer_subnet_check(&mut diff, &inventory, &request.network, &request.icp_root);
185 let report = safety_report_from_diff(
186 format!(
187 "local:{}:{}:report",
188 request.network, request.deployment_name
189 ),
190 Some(format!(
191 "local:{}:{}:diff",
192 request.network, request.deployment_name
193 )),
194 &diff,
195 );
196
197 Ok(DeploymentCheckV1 {
198 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
199 check_id: format!(
200 "local:{}:{}:check",
201 request.network, request.deployment_name
202 ),
203 plan,
204 inventory,
205 diff,
206 report,
207 })
208}
209
210fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
211 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
212 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
213}
214
215#[must_use]
217pub fn compare_plan_to_inventory(
218 plan: &DeploymentPlanV1,
219 inventory: &DeploymentInventoryV1,
220) -> DeploymentDiffV1 {
221 let mut artifact_diff = Vec::new();
222 let mut controller_diff = Vec::new();
223 let mut pool_diff = Vec::new();
224 let mut embedded_config_diff = Vec::new();
225 let mut module_hash_diff = Vec::new();
226 let mut verifier_readiness_diff = Vec::new();
227 let mut hard_failures = Vec::new();
228 let mut warnings = Vec::new();
229
230 compare_identity(plan, inventory, &mut hard_failures);
231 compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
232 compare_artifacts(
233 plan,
234 inventory,
235 &mut artifact_diff,
236 &mut hard_failures,
237 &mut warnings,
238 );
239 compare_observed_canister_id_conflicts(
240 inventory,
241 &mut controller_diff,
242 &mut hard_failures,
243 &mut warnings,
244 );
245 compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
246 compare_canisters(
247 plan,
248 inventory,
249 &mut controller_diff,
250 &mut hard_failures,
251 &mut warnings,
252 );
253 compare_pools(
254 plan,
255 inventory,
256 &mut pool_diff,
257 &mut hard_failures,
258 &mut warnings,
259 );
260 compare_module_hashes(
261 plan,
262 inventory,
263 &mut module_hash_diff,
264 &mut hard_failures,
265 &mut warnings,
266 );
267 compare_raw_config(
268 plan,
269 inventory,
270 &mut embedded_config_diff,
271 &mut hard_failures,
272 );
273 compare_embedded_config(
274 plan,
275 inventory,
276 &mut embedded_config_diff,
277 &mut hard_failures,
278 &mut warnings,
279 );
280 compare_verifier_readiness(
281 plan,
282 inventory,
283 &mut verifier_readiness_diff,
284 &mut hard_failures,
285 &mut warnings,
286 );
287 record_plan_assumptions(plan, &mut hard_failures, &mut warnings);
288 for gap in &inventory.unresolved_observations {
289 warnings.push(SafetyFindingV1 {
290 code: OBSERVATION_GAP_CODE.to_string(),
291 message: gap.description.clone(),
292 severity: SafetySeverityV1::Warning,
293 subject: Some(gap.key.clone()),
294 });
295 }
296
297 let status = safety_status(&hard_failures, &warnings);
298 DeploymentDiffV1 {
299 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
300 plan_identity: plan.deployment_identity.clone(),
301 observed_identity: inventory.observed_identity.clone(),
302 artifact_diff,
303 controller_diff,
304 pool_diff,
305 embedded_config_diff,
306 module_hash_diff,
307 verifier_readiness_diff,
308 resume_safety: ResumeSafetyV1 {
309 status,
310 reasons: resume_safety_reasons(&hard_failures, &warnings),
311 },
312 hard_failures,
313 warnings,
314 resumable_phases: Vec::new(),
315 }
316}
317
318fn record_plan_assumptions(
319 plan: &DeploymentPlanV1,
320 hard_failures: &mut Vec<SafetyFindingV1>,
321 warnings: &mut Vec<SafetyFindingV1>,
322) {
323 for assumption in &plan.unresolved_assumptions {
324 if assumption.key == "local_state.unverified_root_canister_id" {
325 hard_failures.push(SafetyFindingV1 {
326 code: UNVERIFIED_DEPLOYMENT_ROOT_CODE.to_string(),
327 message: assumption.description.clone(),
328 severity: SafetySeverityV1::HardFailure,
329 subject: Some(assumption.key.clone()),
330 });
331 } else {
332 warnings.push(SafetyFindingV1 {
333 code: PLAN_ASSUMPTION_CODE.to_string(),
334 message: assumption.description.clone(),
335 severity: SafetySeverityV1::Warning,
336 subject: Some(assumption.key.clone()),
337 });
338 }
339 }
340}
341
342fn compare_identity(
343 plan: &DeploymentPlanV1,
344 inventory: &DeploymentInventoryV1,
345 hard_failures: &mut Vec<SafetyFindingV1>,
346) {
347 let Some(observed) = &inventory.observed_identity else {
348 hard_failures.push(finding(
349 IDENTITY_UNOBSERVED_CODE,
350 "deployment identity was not observed",
351 SafetySeverityV1::HardFailure,
352 None,
353 ));
354 return;
355 };
356
357 if observed.network != plan.deployment_identity.network {
358 hard_failures.push(finding(
359 NETWORK_MISMATCH_CODE,
360 format!(
361 "plan network {} differs from observed network {}",
362 plan.deployment_identity.network, observed.network
363 ),
364 SafetySeverityV1::HardFailure,
365 Some("deployment_identity.network".to_string()),
366 ));
367 }
368 if let (Some(expected), Some(actual)) = (
369 plan.deployment_identity.root_principal.as_ref(),
370 observed.root_principal.as_ref(),
371 ) && expected != actual
372 {
373 hard_failures.push(finding(
374 ROOT_TRUST_ANCHOR_MISMATCH_CODE,
375 format!("plan root {expected} differs from observed root {actual}"),
376 SafetySeverityV1::HardFailure,
377 Some("deployment_identity.root_principal".to_string()),
378 ));
379 }
380 match (
381 plan.deployment_identity.deployment_manifest_digest.as_ref(),
382 observed.deployment_manifest_digest.as_ref(),
383 ) {
384 (Some(expected), Some(actual)) if expected != actual => {
385 hard_failures.push(finding(
386 DEPLOYMENT_MANIFEST_MISMATCH_CODE,
387 "deployment manifest digest differs from the observed local config",
388 SafetySeverityV1::HardFailure,
389 Some("deployment_identity.deployment_manifest_digest".to_string()),
390 ));
391 }
392 (Some(_), None) => {
393 hard_failures.push(finding(
394 DEPLOYMENT_MANIFEST_UNOBSERVED_CODE,
395 "deployment manifest digest was not observed",
396 SafetySeverityV1::HardFailure,
397 Some("deployment_identity.deployment_manifest_digest".to_string()),
398 ));
399 }
400 _ => {}
401 }
402}
403
404fn finding(
405 code: impl Into<String>,
406 message: impl Into<String>,
407 severity: SafetySeverityV1,
408 subject: Option<String>,
409) -> SafetyFindingV1 {
410 SafetyFindingV1 {
411 code: code.into(),
412 message: message.into(),
413 severity,
414 subject,
415 }
416}
417
418fn diff_item(
419 category: impl Into<String>,
420 subject: impl Into<String>,
421 expected: Option<String>,
422 observed: Option<String>,
423 severity: SafetySeverityV1,
424) -> DiffItemV1 {
425 DiffItemV1 {
426 category: category.into(),
427 subject: subject.into(),
428 expected,
429 observed,
430 severity,
431 }
432}
433
434fn duplicate_evidence_groups<T>(
435 items: &[T],
436 subject: impl Fn(&T) -> String,
437 evidence: impl Fn(&T) -> String,
438 evidence_separator: &str,
439) -> Vec<DuplicateEvidenceGroup> {
440 let mut groups = Vec::new();
441 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
442 if entries.len() <= 1 {
443 continue;
444 }
445 let evidence_values = entries
446 .iter()
447 .map(|entry| evidence(entry))
448 .collect::<BTreeSet<_>>();
449 groups.push(DuplicateEvidenceGroup {
450 subject,
451 count: entries.len(),
452 evidence_label: evidence_values
453 .iter()
454 .cloned()
455 .collect::<Vec<_>>()
456 .join(evidence_separator),
457 is_conflict: evidence_values.len() > 1,
458 });
459 }
460 groups
461}
462
463fn duplicate_evidence_groups_by<T, K>(
464 items: &[T],
465 subject: impl Fn(&T) -> String,
466 evidence_key: impl Fn(&T) -> K,
467 evidence_label: impl Fn(&T) -> String,
468 evidence_separator: &str,
469) -> Vec<DuplicateEvidenceGroup>
470where
471 K: Ord,
472{
473 let mut groups = Vec::new();
474 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
475 if entries.len() <= 1 {
476 continue;
477 }
478 let evidence_values = entries
479 .iter()
480 .map(|entry| (evidence_key(entry), evidence_label(entry)))
481 .collect::<BTreeMap<_, _>>();
482 groups.push(DuplicateEvidenceGroup {
483 subject,
484 count: entries.len(),
485 evidence_label: evidence_values
486 .values()
487 .cloned()
488 .collect::<Vec<_>>()
489 .join(evidence_separator),
490 is_conflict: evidence_values.len() > 1,
491 });
492 }
493 groups
494}
495
496fn conflicting_assignment_groups<T>(
497 items: &[T],
498 subject: impl Fn(&T) -> Option<String>,
499 value: impl Fn(&T) -> String,
500 value_separator: &str,
501) -> Vec<DuplicateEvidenceGroup> {
502 let mut groups = Vec::new();
503 for (subject, entries) in group_by_subject(items, subject) {
504 if entries.len() <= 1 {
505 continue;
506 }
507 let values = entries
508 .iter()
509 .map(|entry| value(entry))
510 .collect::<BTreeSet<_>>();
511 if values.len() <= 1 {
512 continue;
513 }
514 groups.push(DuplicateEvidenceGroup {
515 subject,
516 count: entries.len(),
517 evidence_label: values
518 .iter()
519 .cloned()
520 .collect::<Vec<_>>()
521 .join(value_separator),
522 is_conflict: true,
523 });
524 }
525 groups
526}
527
528fn group_by_subject<T>(
529 items: &[T],
530 subject: impl Fn(&T) -> Option<String>,
531) -> BTreeMap<String, Vec<&T>> {
532 let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
533 for item in items {
534 if let Some(subject) = subject(item) {
535 by_subject.entry(subject).or_default().push(item);
536 }
537 }
538 by_subject
539}
540
541#[cfg(test)]
542mod classification_tests {
543 use super::*;
544
545 #[test]
546 fn finding_classification_uses_exact_owner_codes() {
547 assert!(is_evidence_conflict_finding_code(
548 ARTIFACT_ROLE_CONFLICT_CODE
549 ));
550 assert!(is_evidence_conflict_finding_code(
551 RECEIPT_PHASE_CONFLICT_CODE
552 ));
553 assert!(!is_evidence_conflict_finding_code("artifact_conflict"));
554 assert!(!is_evidence_conflict_finding_code("conflict"));
555
556 assert!(is_artifact_role_failure_code(ARTIFACT_MISSING_CODE));
557 assert!(!is_artifact_role_failure_code(
558 PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
559 ));
560 }
561}