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