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;
16use canisters::{compare_canisters, compare_observed_canister_id_conflicts};
17use config_digests::{compare_embedded_config, compare_raw_config};
18use controllers::compare_authority_profile;
19use module_hashes::compare_module_hashes;
20use pools::{compare_observed_canister_pool_role_conflicts, compare_pools};
21pub use receipt_resume::compare_plan_inventory_and_receipt;
22pub(super) use root_subnet::apply_root_canister_signature_subnet_check;
23#[cfg(test)]
24pub(super) use root_subnet::{
25 RootSubnetEvidence, RootSubnetEvidenceSource,
26 apply_root_canister_signature_subnet_check_with_source,
27};
28pub use safety::safety_report_from_diff;
29pub(in crate::deployment_truth::report) use safety::{resume_safety_reasons, safety_status};
30use verifier_readiness::compare_verifier_readiness;
31
32struct DuplicateEvidenceGroup {
36 subject: String,
37 count: usize,
38 evidence_label: String,
39 is_conflict: bool,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct LocalDeploymentCheckRequest {
47 pub deployment_name: String,
48 pub network: String,
49 pub workspace_root: std::path::PathBuf,
50 pub icp_root: std::path::PathBuf,
51 pub config_path: Option<std::path::PathBuf>,
52 pub observed_at: String,
53 pub runtime_variant: String,
54 pub build_profile: String,
55}
56
57pub fn check_local_deployment(
59 request: &LocalDeploymentCheckRequest,
60) -> Result<DeploymentCheckV1, DeploymentTruthError> {
61 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
62 deployment_name: request.deployment_name.clone(),
63 network: request.network.clone(),
64 workspace_root: request.workspace_root.clone(),
65 icp_root: request.icp_root.clone(),
66 config_path: request.config_path.clone(),
67 runtime_variant: request.runtime_variant.clone(),
68 build_profile: request.build_profile.clone(),
69 });
70 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
71 deployment_name: request.deployment_name.clone(),
72 network: request.network.clone(),
73 workspace_root: request.workspace_root.clone(),
74 icp_root: request.icp_root.clone(),
75 config_path: request.config_path.clone(),
76 observed_at: request.observed_at.clone(),
77 })?;
78 let mut diff = compare_plan_to_inventory(&plan, &inventory);
79 apply_root_canister_signature_subnet_check(
80 &mut diff,
81 &inventory,
82 &request.network,
83 &request.icp_root,
84 );
85 let report = safety_report_from_diff(
86 format!(
87 "local:{}:{}:report",
88 request.network, request.deployment_name
89 ),
90 Some(format!(
91 "local:{}:{}:diff",
92 request.network, request.deployment_name
93 )),
94 &diff,
95 );
96
97 Ok(DeploymentCheckV1 {
98 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
99 check_id: format!(
100 "local:{}:{}:check",
101 request.network, request.deployment_name
102 ),
103 plan,
104 inventory,
105 diff,
106 report,
107 })
108}
109
110fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
111 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
112 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
113}
114
115#[must_use]
117pub fn compare_plan_to_inventory(
118 plan: &DeploymentPlanV1,
119 inventory: &DeploymentInventoryV1,
120) -> DeploymentDiffV1 {
121 let mut artifact_diff = Vec::new();
122 let mut controller_diff = Vec::new();
123 let mut pool_diff = Vec::new();
124 let mut embedded_config_diff = Vec::new();
125 let mut module_hash_diff = Vec::new();
126 let mut verifier_readiness_diff = Vec::new();
127 let mut hard_failures = Vec::new();
128 let mut warnings = Vec::new();
129
130 compare_identity(plan, inventory, &mut hard_failures);
131 compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
132 compare_artifacts(
133 plan,
134 inventory,
135 &mut artifact_diff,
136 &mut hard_failures,
137 &mut warnings,
138 );
139 compare_observed_canister_id_conflicts(
140 inventory,
141 &mut controller_diff,
142 &mut hard_failures,
143 &mut warnings,
144 );
145 compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
146 compare_canisters(
147 plan,
148 inventory,
149 &mut controller_diff,
150 &mut hard_failures,
151 &mut warnings,
152 );
153 compare_pools(
154 plan,
155 inventory,
156 &mut pool_diff,
157 &mut hard_failures,
158 &mut warnings,
159 );
160 compare_module_hashes(
161 plan,
162 inventory,
163 &mut module_hash_diff,
164 &mut hard_failures,
165 &mut warnings,
166 );
167 compare_raw_config(
168 plan,
169 inventory,
170 &mut embedded_config_diff,
171 &mut hard_failures,
172 );
173 compare_embedded_config(
174 plan,
175 inventory,
176 &mut embedded_config_diff,
177 &mut hard_failures,
178 &mut warnings,
179 );
180 compare_verifier_readiness(
181 plan,
182 inventory,
183 &mut verifier_readiness_diff,
184 &mut hard_failures,
185 &mut warnings,
186 );
187 record_plan_assumptions(plan, &mut hard_failures, &mut warnings);
188 for gap in &inventory.unresolved_observations {
189 warnings.push(SafetyFindingV1 {
190 code: "observation_gap".to_string(),
191 message: gap.description.clone(),
192 severity: SafetySeverityV1::Warning,
193 subject: Some(gap.key.clone()),
194 });
195 }
196
197 let status = safety_status(&hard_failures, &warnings);
198 DeploymentDiffV1 {
199 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
200 plan_identity: plan.deployment_identity.clone(),
201 observed_identity: inventory.observed_identity.clone(),
202 artifact_diff,
203 controller_diff,
204 pool_diff,
205 embedded_config_diff,
206 module_hash_diff,
207 verifier_readiness_diff,
208 resume_safety: ResumeSafetyV1 {
209 status,
210 reasons: resume_safety_reasons(&hard_failures, &warnings),
211 },
212 hard_failures,
213 warnings,
214 resumable_phases: Vec::new(),
215 }
216}
217
218fn record_plan_assumptions(
219 plan: &DeploymentPlanV1,
220 hard_failures: &mut Vec<SafetyFindingV1>,
221 warnings: &mut Vec<SafetyFindingV1>,
222) {
223 for assumption in &plan.unresolved_assumptions {
224 if assumption.key == "local_state.unverified_root_canister_id" {
225 hard_failures.push(SafetyFindingV1 {
226 code: "unverified_deployment_root".to_string(),
227 message: assumption.description.clone(),
228 severity: SafetySeverityV1::HardFailure,
229 subject: Some(assumption.key.clone()),
230 });
231 } else {
232 warnings.push(SafetyFindingV1 {
233 code: "plan_assumption".to_string(),
234 message: assumption.description.clone(),
235 severity: SafetySeverityV1::Warning,
236 subject: Some(assumption.key.clone()),
237 });
238 }
239 }
240}
241
242fn compare_identity(
243 plan: &DeploymentPlanV1,
244 inventory: &DeploymentInventoryV1,
245 hard_failures: &mut Vec<SafetyFindingV1>,
246) {
247 let Some(observed) = &inventory.observed_identity else {
248 hard_failures.push(finding(
249 "identity_unobserved",
250 "deployment identity was not observed",
251 SafetySeverityV1::HardFailure,
252 None,
253 ));
254 return;
255 };
256
257 if observed.network != plan.deployment_identity.network {
258 hard_failures.push(finding(
259 "network_mismatch",
260 format!(
261 "plan network {} differs from observed network {}",
262 plan.deployment_identity.network, observed.network
263 ),
264 SafetySeverityV1::HardFailure,
265 Some("deployment_identity.network".to_string()),
266 ));
267 }
268 if let (Some(expected), Some(actual)) = (
269 plan.deployment_identity.root_principal.as_ref(),
270 observed.root_principal.as_ref(),
271 ) && expected != actual
272 {
273 hard_failures.push(finding(
274 "root_trust_anchor_mismatch",
275 format!("plan root {expected} differs from observed root {actual}"),
276 SafetySeverityV1::HardFailure,
277 Some("deployment_identity.root_principal".to_string()),
278 ));
279 }
280 match (
281 plan.deployment_identity.deployment_manifest_digest.as_ref(),
282 observed.deployment_manifest_digest.as_ref(),
283 ) {
284 (Some(expected), Some(actual)) if expected != actual => {
285 hard_failures.push(finding(
286 "deployment_manifest_mismatch",
287 "deployment manifest digest differs from the observed local config",
288 SafetySeverityV1::HardFailure,
289 Some("deployment_identity.deployment_manifest_digest".to_string()),
290 ));
291 }
292 (Some(_), None) => {
293 hard_failures.push(finding(
294 "deployment_manifest_unobserved",
295 "deployment manifest digest was not observed",
296 SafetySeverityV1::HardFailure,
297 Some("deployment_identity.deployment_manifest_digest".to_string()),
298 ));
299 }
300 _ => {}
301 }
302}
303
304fn finding(
305 code: impl Into<String>,
306 message: impl Into<String>,
307 severity: SafetySeverityV1,
308 subject: Option<String>,
309) -> SafetyFindingV1 {
310 SafetyFindingV1 {
311 code: code.into(),
312 message: message.into(),
313 severity,
314 subject,
315 }
316}
317
318fn diff_item(
319 category: impl Into<String>,
320 subject: impl Into<String>,
321 expected: Option<String>,
322 observed: Option<String>,
323 severity: SafetySeverityV1,
324) -> DiffItemV1 {
325 DiffItemV1 {
326 category: category.into(),
327 subject: subject.into(),
328 expected,
329 observed,
330 severity,
331 }
332}
333
334fn duplicate_evidence_groups<T>(
335 items: &[T],
336 subject: impl Fn(&T) -> String,
337 evidence: impl Fn(&T) -> String,
338 evidence_separator: &str,
339) -> Vec<DuplicateEvidenceGroup> {
340 let mut groups = Vec::new();
341 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
342 if entries.len() <= 1 {
343 continue;
344 }
345 let evidence_values = entries
346 .iter()
347 .map(|entry| evidence(entry))
348 .collect::<BTreeSet<_>>();
349 groups.push(DuplicateEvidenceGroup {
350 subject,
351 count: entries.len(),
352 evidence_label: evidence_values
353 .iter()
354 .cloned()
355 .collect::<Vec<_>>()
356 .join(evidence_separator),
357 is_conflict: evidence_values.len() > 1,
358 });
359 }
360 groups
361}
362
363fn conflicting_assignment_groups<T>(
364 items: &[T],
365 subject: impl Fn(&T) -> Option<String>,
366 value: impl Fn(&T) -> String,
367 value_separator: &str,
368) -> Vec<DuplicateEvidenceGroup> {
369 let mut groups = Vec::new();
370 for (subject, entries) in group_by_subject(items, subject) {
371 if entries.len() <= 1 {
372 continue;
373 }
374 let values = entries
375 .iter()
376 .map(|entry| value(entry))
377 .collect::<BTreeSet<_>>();
378 if values.len() <= 1 {
379 continue;
380 }
381 groups.push(DuplicateEvidenceGroup {
382 subject,
383 count: entries.len(),
384 evidence_label: values
385 .iter()
386 .cloned()
387 .collect::<Vec<_>>()
388 .join(value_separator),
389 is_conflict: true,
390 });
391 }
392 groups
393}
394
395fn group_by_subject<T>(
396 items: &[T],
397 subject: impl Fn(&T) -> Option<String>,
398) -> BTreeMap<String, Vec<&T>> {
399 let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
400 for item in items {
401 if let Some(subject) = subject(item) {
402 by_subject.entry(subject).or_default().push(item);
403 }
404 }
405 by_subject
406}