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