1use super::*;
2use serde::Serialize;
3use thiserror::Error as ThisError;
4
5#[derive(Serialize)]
6struct DeploymentRootVerificationReportDigestInput<'a> {
7 report_id: &'a str,
8 requested_at: &'a str,
9 evidence_status: DeploymentRootVerificationEvidenceStatusV1,
10 state_transition: DeploymentRootVerificationStateTransitionV1,
11 deployment_name: &'a str,
12 network: &'a str,
13 expected_fleet_template: &'a str,
14 expected_root_principal: &'a str,
15 observed_deployment_name: &'a Option<String>,
16 observed_network: &'a Option<String>,
17 observed_fleet_template: &'a Option<String>,
18 observed_root_principal: &'a Option<String>,
19 observed_root_canister_id: &'a Option<String>,
20 observed_root_observation_source: &'a Option<DeploymentRootObservationSourceV1>,
21 source: DeploymentRootVerificationSourceV1,
22 source_check_id: &'a str,
23 source_check_digest: &'a str,
24 source_deployment_plan_id: &'a str,
25 source_deployment_plan_digest: &'a str,
26 source_inventory_id: &'a str,
27 source_inventory_digest: &'a str,
28 current_root_verification: DeploymentRootVerificationStateV1,
29 identity_checks: &'a [DeploymentRootVerificationCheckV1],
30 evidence_checks: &'a [DeploymentRootVerificationCheckV1],
31 blockers: &'a [SafetyFindingV1],
32 warnings: &'a [SafetyFindingV1],
33 recommended_next_actions: &'a [String],
34}
35
36#[derive(Serialize)]
37struct DeploymentRootVerificationReceiptDigestInput<'a> {
38 receipt_id: &'a str,
39 deployment_name: &'a str,
40 network: &'a str,
41 fleet_template: &'a str,
42 root_principal: &'a str,
43 previous_root_verification: DeploymentRootVerificationStateV1,
44 new_root_verification: DeploymentRootVerificationStateV1,
45 state_transition: DeploymentRootVerificationStateTransitionV1,
46 source_report_id: &'a str,
47 source_report_digest: &'a str,
48 source_report_requested_at: &'a str,
49 source_report_source: DeploymentRootVerificationSourceV1,
50 source_report_evidence_status: DeploymentRootVerificationEvidenceStatusV1,
51 source_report_current_root_verification: DeploymentRootVerificationStateV1,
52 source_report_state_transition: DeploymentRootVerificationStateTransitionV1,
53 source_root_observation_source: DeploymentRootObservationSourceV1,
54 source_observed_root_canister_id: &'a str,
55 source_check_id: &'a str,
56 source_check_digest: &'a str,
57 source_deployment_plan_id: &'a str,
58 source_deployment_plan_digest: &'a str,
59 source_inventory_id: &'a str,
60 source_inventory_digest: &'a str,
61 verified_at_unix_secs: u64,
62 local_state_path: &'a str,
63 local_state_digest_before: &'a str,
64 local_state_digest_after: &'a str,
65 warnings: &'a [SafetyFindingV1],
66}
67
68#[derive(Debug, Eq, PartialEq, ThisError)]
72pub enum DeploymentRootVerificationReportError {
73 #[error(
74 "deployment root verification report schema version {actual} does not match expected {expected}"
75 )]
76 SchemaVersionMismatch { expected: u32, actual: u32 },
77
78 #[error("deployment root verification report field `{field}` is required")]
79 MissingRequiredField { field: &'static str },
80
81 #[error("deployment root verification report field `{field}` must be lowercase SHA-256 hex")]
82 InvalidSha256Digest { field: &'static str },
83
84 #[error("deployment root verification report field `{field}` digest is stale")]
85 DigestMismatch { field: &'static str },
86
87 #[error("deployment root verification report check `{check}` is inconsistent")]
88 CheckMismatch { check: String },
89
90 #[error("deployment root verification report status is inconsistent")]
91 StatusMismatch,
92}
93
94#[derive(Debug, Eq, PartialEq, ThisError)]
98pub enum DeploymentRootVerificationReceiptError {
99 #[error(
100 "deployment root verification receipt schema version {actual} does not match expected {expected}"
101 )]
102 SchemaVersionMismatch { expected: u32, actual: u32 },
103
104 #[error("deployment root verification receipt field `{field}` is required")]
105 MissingRequiredField { field: &'static str },
106
107 #[error("deployment root verification receipt field `{field}` must be lowercase SHA-256 hex")]
108 InvalidSha256Digest { field: &'static str },
109
110 #[error(
111 "deployment root verification receipt field `{field}` must be a supported timestamp label"
112 )]
113 InvalidTimestampLabel { field: &'static str },
114
115 #[error("deployment root verification receipt field `{field}` digest is stale")]
116 DigestMismatch { field: &'static str },
117
118 #[error("deployment root verification receipt state transition is inconsistent")]
119 StateTransitionMismatch,
120
121 #[error("deployment root verification receipt local state digests are inconsistent")]
122 LocalStateDigestMismatch,
123
124 #[error("deployment root verification receipt source evidence is inconsistent")]
125 SourceEvidenceMismatch,
126}
127
128#[must_use]
134pub fn deployment_root_verification_report_from_check(
135 request: DeploymentRootVerificationRequestV1,
136) -> DeploymentRootVerificationReportV1 {
137 let check = &request.deployment_check;
138 let observed_root = check.inventory.observed_root.as_ref();
139 let identity_checks = root_verification_identity_checks(&request, check, observed_root);
140 let evidence_checks = root_verification_evidence_checks(&request, check, observed_root);
141 let blockers = root_verification_blockers(&identity_checks, &evidence_checks, check);
142
143 let evidence_status = if blockers.is_empty() {
144 DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied
145 } else {
146 DeploymentRootVerificationEvidenceStatusV1::VerificationFailed
147 };
148 let state_transition =
149 root_verification_transition(evidence_status, request.current_root_verification);
150 let recommended_next_actions = root_verification_next_actions(evidence_status);
151 let mut report = DeploymentRootVerificationReportV1 {
152 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
153 report_id: request.report_id,
154 report_digest: String::new(),
155 requested_at: request.requested_at,
156 evidence_status,
157 state_transition,
158 deployment_name: request.deployment_name,
159 network: request.network,
160 expected_fleet_template: request.expected_fleet_template,
161 expected_root_principal: request.expected_root_principal,
162 observed_deployment_name: observed_root.map(|root| root.deployment_name.clone()),
163 observed_network: observed_root.map(|root| root.network.clone()),
164 observed_fleet_template: observed_root.map(|root| root.fleet_template.clone()),
165 observed_root_principal: observed_root.map(|root| root.root_principal.clone()),
166 observed_root_canister_id: observed_root.map(|root| root.observed_canister_id.clone()),
167 observed_root_observation_source: observed_root.map(|root| root.observation_source),
168 source: request.source,
169 source_check_id: check.check_id.clone(),
170 source_check_digest: stable_json_sha256_hex(check),
171 source_deployment_plan_id: check.plan.plan_id.clone(),
172 source_deployment_plan_digest: stable_json_sha256_hex(&check.plan),
173 source_inventory_id: check.inventory.inventory_id.clone(),
174 source_inventory_digest: stable_json_sha256_hex(&check.inventory),
175 current_root_verification: request.current_root_verification,
176 identity_checks,
177 evidence_checks,
178 blockers,
179 warnings: check.report.warnings.clone(),
180 recommended_next_actions,
181 };
182 report.report_digest = deployment_root_verification_report_digest(&report);
183 report
184}
185
186pub fn validate_deployment_root_verification_report(
191 report: &DeploymentRootVerificationReportV1,
192) -> Result<(), DeploymentRootVerificationReportError> {
193 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
194 return Err(
195 DeploymentRootVerificationReportError::SchemaVersionMismatch {
196 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
197 actual: report.schema_version,
198 },
199 );
200 }
201 ensure_root_verification_field("report_id", report.report_id.as_str())?;
202 ensure_root_verification_sha256("report_digest", report.report_digest.as_str())?;
203 ensure_root_verification_field("requested_at", report.requested_at.as_str())?;
204 ensure_root_verification_field("deployment_name", report.deployment_name.as_str())?;
205 ensure_root_verification_field("network", report.network.as_str())?;
206 ensure_root_verification_field(
207 "expected_fleet_template",
208 report.expected_fleet_template.as_str(),
209 )?;
210 ensure_root_verification_field(
211 "expected_root_principal",
212 report.expected_root_principal.as_str(),
213 )?;
214 ensure_root_verification_field("source_check_id", report.source_check_id.as_str())?;
215 ensure_root_verification_sha256("source_check_digest", report.source_check_digest.as_str())?;
216 ensure_root_verification_field(
217 "source_deployment_plan_id",
218 report.source_deployment_plan_id.as_str(),
219 )?;
220 ensure_root_verification_sha256(
221 "source_deployment_plan_digest",
222 report.source_deployment_plan_digest.as_str(),
223 )?;
224 ensure_root_verification_field("source_inventory_id", report.source_inventory_id.as_str())?;
225 ensure_root_verification_sha256(
226 "source_inventory_digest",
227 report.source_inventory_digest.as_str(),
228 )?;
229 if report.evidence_status != report_evidence_status(report)
230 || report.state_transition != report_state_transition(report)
231 {
232 return Err(DeploymentRootVerificationReportError::StatusMismatch);
233 }
234 ensure_root_verification_report_checks_consistent(report)?;
235 if report.report_digest != deployment_root_verification_report_digest(report) {
236 return Err(DeploymentRootVerificationReportError::DigestMismatch {
237 field: "report_digest",
238 });
239 }
240 Ok(())
241}
242
243#[must_use]
246pub fn deployment_root_verification_receipt_digest(
247 receipt: &DeploymentRootVerificationReceiptV1,
248) -> String {
249 stable_json_sha256_hex(&DeploymentRootVerificationReceiptDigestInput {
250 receipt_id: &receipt.receipt_id,
251 deployment_name: &receipt.deployment_name,
252 network: &receipt.network,
253 fleet_template: &receipt.fleet_template,
254 root_principal: &receipt.root_principal,
255 previous_root_verification: receipt.previous_root_verification,
256 new_root_verification: receipt.new_root_verification,
257 state_transition: receipt.state_transition,
258 source_report_id: &receipt.source_report_id,
259 source_report_digest: &receipt.source_report_digest,
260 source_report_requested_at: &receipt.source_report_requested_at,
261 source_report_source: receipt.source_report_source,
262 source_report_evidence_status: receipt.source_report_evidence_status,
263 source_report_current_root_verification: receipt.source_report_current_root_verification,
264 source_report_state_transition: receipt.source_report_state_transition,
265 source_root_observation_source: receipt.source_root_observation_source,
266 source_observed_root_canister_id: &receipt.source_observed_root_canister_id,
267 source_check_id: &receipt.source_check_id,
268 source_check_digest: &receipt.source_check_digest,
269 source_deployment_plan_id: &receipt.source_deployment_plan_id,
270 source_deployment_plan_digest: &receipt.source_deployment_plan_digest,
271 source_inventory_id: &receipt.source_inventory_id,
272 source_inventory_digest: &receipt.source_inventory_digest,
273 verified_at_unix_secs: receipt.verified_at_unix_secs,
274 local_state_path: &receipt.local_state_path,
275 local_state_digest_before: &receipt.local_state_digest_before,
276 local_state_digest_after: &receipt.local_state_digest_after,
277 warnings: &receipt.warnings,
278 })
279}
280
281pub fn validate_deployment_root_verification_receipt(
284 receipt: &DeploymentRootVerificationReceiptV1,
285) -> Result<(), DeploymentRootVerificationReceiptError> {
286 if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
287 return Err(
288 DeploymentRootVerificationReceiptError::SchemaVersionMismatch {
289 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
290 actual: receipt.schema_version,
291 },
292 );
293 }
294 ensure_root_verification_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
295 ensure_root_verification_receipt_sha256("receipt_digest", receipt.receipt_digest.as_str())?;
296 ensure_root_verification_receipt_field("deployment_name", receipt.deployment_name.as_str())?;
297 ensure_root_verification_receipt_field("network", receipt.network.as_str())?;
298 ensure_root_verification_receipt_field("fleet_template", receipt.fleet_template.as_str())?;
299 ensure_root_verification_receipt_field("root_principal", receipt.root_principal.as_str())?;
300 ensure_root_verification_receipt_field("source_report_id", receipt.source_report_id.as_str())?;
301 ensure_root_verification_receipt_sha256(
302 "source_report_digest",
303 receipt.source_report_digest.as_str(),
304 )?;
305 ensure_root_verification_receipt_field(
306 "source_report_requested_at",
307 receipt.source_report_requested_at.as_str(),
308 )?;
309 ensure_root_verification_receipt_timestamp(
310 "source_report_requested_at",
311 receipt.source_report_requested_at.as_str(),
312 )?;
313 ensure_root_verification_receipt_field(
314 "source_observed_root_canister_id",
315 receipt.source_observed_root_canister_id.as_str(),
316 )?;
317 if receipt.source_report_evidence_status
318 != DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied
319 || receipt.source_report_source != DeploymentRootVerificationSourceV1::DeploymentTruthCheck
320 || receipt.source_report_current_root_verification != receipt.previous_root_verification
321 || receipt.source_root_observation_source
322 != DeploymentRootObservationSourceV1::IcpCanisterStatus
323 || receipt.source_observed_root_canister_id != receipt.root_principal
324 || receipt.source_report_state_transition != source_report_transition_for_receipt(receipt)
325 || !source_report_timestamp_matches_receipt(receipt)
326 {
327 return Err(DeploymentRootVerificationReceiptError::SourceEvidenceMismatch);
328 }
329 ensure_root_verification_receipt_field("source_check_id", receipt.source_check_id.as_str())?;
330 ensure_root_verification_receipt_sha256(
331 "source_check_digest",
332 receipt.source_check_digest.as_str(),
333 )?;
334 ensure_root_verification_receipt_field(
335 "source_deployment_plan_id",
336 receipt.source_deployment_plan_id.as_str(),
337 )?;
338 ensure_root_verification_receipt_sha256(
339 "source_deployment_plan_digest",
340 receipt.source_deployment_plan_digest.as_str(),
341 )?;
342 ensure_root_verification_receipt_field(
343 "source_inventory_id",
344 receipt.source_inventory_id.as_str(),
345 )?;
346 ensure_root_verification_receipt_sha256(
347 "source_inventory_digest",
348 receipt.source_inventory_digest.as_str(),
349 )?;
350 ensure_root_verification_receipt_field("local_state_path", receipt.local_state_path.as_str())?;
351 ensure_root_verification_receipt_sha256(
352 "local_state_digest_before",
353 receipt.local_state_digest_before.as_str(),
354 )?;
355 ensure_root_verification_receipt_sha256(
356 "local_state_digest_after",
357 receipt.local_state_digest_after.as_str(),
358 )?;
359
360 if receipt.new_root_verification != DeploymentRootVerificationStateV1::Verified
361 || receipt.state_transition != receipt_state_transition(receipt)
362 {
363 return Err(DeploymentRootVerificationReceiptError::StateTransitionMismatch);
364 }
365 if !receipt_local_state_digest_transition_is_valid(receipt) {
366 return Err(DeploymentRootVerificationReceiptError::LocalStateDigestMismatch);
367 }
368 if receipt.receipt_digest != deployment_root_verification_receipt_digest(receipt) {
369 return Err(DeploymentRootVerificationReceiptError::DigestMismatch {
370 field: "receipt_digest",
371 });
372 }
373 Ok(())
374}
375
376fn root_verification_identity_checks(
377 request: &DeploymentRootVerificationRequestV1,
378 check: &DeploymentCheckV1,
379 observed_root: Option<&DeploymentRootObservationV1>,
380) -> Vec<DeploymentRootVerificationCheckV1> {
381 let mut checks = Vec::new();
382 push_check(
383 &mut checks,
384 "deployment_name",
385 Some(request.deployment_name.as_str()),
386 observed_root.map(|root| root.deployment_name.as_str()),
387 );
388 push_check(
389 &mut checks,
390 "network",
391 Some(request.network.as_str()),
392 observed_root.map(|root| root.network.as_str()),
393 );
394 push_check(
395 &mut checks,
396 "fleet_template",
397 Some(request.expected_fleet_template.as_str()),
398 observed_root.map(|root| root.fleet_template.as_str()),
399 );
400 push_check(
401 &mut checks,
402 "root_principal",
403 Some(request.expected_root_principal.as_str()),
404 observed_root.map(|root| root.root_principal.as_str()),
405 );
406 push_check(
407 &mut checks,
408 "plan_deployment_name",
409 Some(request.deployment_name.as_str()),
410 Some(check.plan.deployment_identity.deployment_name.as_str()),
411 );
412 push_check(
413 &mut checks,
414 "plan_network",
415 Some(request.network.as_str()),
416 Some(check.plan.deployment_identity.network.as_str()),
417 );
418 push_check(
419 &mut checks,
420 "plan_fleet_template",
421 Some(request.expected_fleet_template.as_str()),
422 Some(check.plan.fleet_template.as_str()),
423 );
424 checks
425}
426
427fn root_verification_evidence_checks(
428 request: &DeploymentRootVerificationRequestV1,
429 check: &DeploymentCheckV1,
430 observed_root: Option<&DeploymentRootObservationV1>,
431) -> Vec<DeploymentRootVerificationCheckV1> {
432 let mut checks = Vec::new();
433 push_check(
434 &mut checks,
435 "explicit_observed_root",
436 Some("present"),
437 observed_root.map(|_| "present"),
438 );
439 push_check(
440 &mut checks,
441 "root_observation_source",
442 Some("IcpCanisterStatus"),
443 observed_root.map(root_observation_source_label),
444 );
445 push_check(
446 &mut checks,
447 "observed_root_canister_id",
448 Some(request.expected_root_principal.as_str()),
449 observed_root.map(|root| root.observed_canister_id.as_str()),
450 );
451 push_check(
452 &mut checks,
453 "source_check_id",
454 Some("present"),
455 present_value(check.check_id.as_str()),
456 );
457 push_check(
458 &mut checks,
459 "source_deployment_plan_id",
460 Some("present"),
461 present_value(check.plan.plan_id.as_str()),
462 );
463 push_check(
464 &mut checks,
465 "source_inventory_id",
466 Some("present"),
467 present_value(check.inventory.inventory_id.as_str()),
468 );
469 checks
470}
471
472fn root_verification_blockers(
473 identity_checks: &[DeploymentRootVerificationCheckV1],
474 evidence_checks: &[DeploymentRootVerificationCheckV1],
475 check: &DeploymentCheckV1,
476) -> Vec<SafetyFindingV1> {
477 let mut blockers = failed_checks("identity", identity_checks);
478 blockers.extend(failed_checks("evidence", evidence_checks));
479 blockers.extend(source_check_consistency_blockers(check));
480 blockers.extend(source_check_blockers(check));
481 blockers
482}
483
484fn push_check(
485 checks: &mut Vec<DeploymentRootVerificationCheckV1>,
486 name: impl Into<String>,
487 expected: Option<&str>,
488 observed: Option<&str>,
489) {
490 checks.push(DeploymentRootVerificationCheckV1 {
491 name: name.into(),
492 expected: expected.map(str::to_string),
493 observed: observed.map(str::to_string),
494 satisfied: expected == observed,
495 });
496}
497
498const fn present_value(value: &str) -> Option<&'static str> {
499 if value.is_empty() {
500 None
501 } else {
502 Some("present")
503 }
504}
505
506const fn root_observation_source_label(root: &DeploymentRootObservationV1) -> &str {
507 root_observation_source_label_from_source(&root.observation_source)
508}
509
510const fn root_observation_source_label_from_source(
511 source: &DeploymentRootObservationSourceV1,
512) -> &str {
513 match *source {
514 DeploymentRootObservationSourceV1::IcpCanisterStatus => "IcpCanisterStatus",
515 DeploymentRootObservationSourceV1::LocalDeploymentState => "LocalDeploymentState",
516 }
517}
518
519fn failed_checks(
520 category: &'static str,
521 checks: &[DeploymentRootVerificationCheckV1],
522) -> Vec<SafetyFindingV1> {
523 checks
524 .iter()
525 .filter(|check| !check.satisfied)
526 .map(|check| SafetyFindingV1 {
527 code: "root_verification_check_failed".to_string(),
528 message: format!("{category} check {} did not match", check.name),
529 severity: SafetySeverityV1::HardFailure,
530 subject: Some(check.name.clone()),
531 })
532 .collect()
533}
534
535fn source_check_blockers(check: &DeploymentCheckV1) -> Vec<SafetyFindingV1> {
536 let hard_failures = &check.report.hard_failures;
537 if hard_failures.is_empty() {
538 return Vec::new();
539 }
540 if hard_failures.len() == 1 && is_expected_unverified_root_finding(&hard_failures[0]) {
541 return Vec::new();
542 }
543 hard_failures.clone()
544}
545
546fn source_check_consistency_blockers(check: &DeploymentCheckV1) -> Vec<SafetyFindingV1> {
547 let mut blockers = Vec::new();
548 if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
549 blockers.push(SafetyFindingV1 {
550 code: "root_verification_source_check_schema_mismatch".to_string(),
551 message: "source deployment check schema version is unsupported".to_string(),
552 severity: SafetySeverityV1::HardFailure,
553 subject: Some(check.check_id.clone()),
554 });
555 return blockers;
556 }
557
558 let expected_diff = compare_plan_to_inventory(&check.plan, &check.inventory);
559 if check.diff != expected_diff {
560 blockers.push(SafetyFindingV1 {
561 code: "root_verification_source_check_diff_stale".to_string(),
562 message: "source deployment check diff does not match its plan and inventory"
563 .to_string(),
564 severity: SafetySeverityV1::HardFailure,
565 subject: Some(check.check_id.clone()),
566 });
567 return blockers;
568 }
569
570 let expected_report = safety_report_from_diff(
571 &check.report.report_id,
572 check.report.diff_id.clone(),
573 &check.diff,
574 );
575 if check.report != expected_report {
576 blockers.push(SafetyFindingV1 {
577 code: "root_verification_source_check_report_stale".to_string(),
578 message: "source deployment check report does not match its diff".to_string(),
579 severity: SafetySeverityV1::HardFailure,
580 subject: Some(check.check_id.clone()),
581 });
582 }
583 blockers
584}
585
586fn is_expected_unverified_root_finding(finding: &SafetyFindingV1) -> bool {
587 finding.code == "unverified_deployment_root"
588 && finding.subject.as_deref() == Some("local_state.unverified_root_canister_id")
589}
590
591const fn root_verification_transition(
592 status: DeploymentRootVerificationEvidenceStatusV1,
593 current: DeploymentRootVerificationStateV1,
594) -> DeploymentRootVerificationStateTransitionV1 {
595 match (status, current) {
596 (
597 DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied,
598 DeploymentRootVerificationStateV1::NotVerified,
599 ) => DeploymentRootVerificationStateTransitionV1::WouldPromoteNotVerifiedToVerified,
600 (
601 DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied,
602 DeploymentRootVerificationStateV1::Verified,
603 ) => DeploymentRootVerificationStateTransitionV1::NoStateChange,
604 _ => DeploymentRootVerificationStateTransitionV1::Blocked,
605 }
606}
607
608fn root_verification_next_actions(
609 status: DeploymentRootVerificationEvidenceStatusV1,
610) -> Vec<String> {
611 match status {
612 DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied => vec![
613 "run the explicit root verification command to write verified local state".to_string(),
614 ],
615 DeploymentRootVerificationEvidenceStatusV1::VerificationFailed => vec![
616 "collect a deployment-truth check with matching root evidence before verifying"
617 .to_string(),
618 ],
619 DeploymentRootVerificationEvidenceStatusV1::NotApplicable => Vec::new(),
620 }
621}
622
623fn report_evidence_status(
624 report: &DeploymentRootVerificationReportV1,
625) -> DeploymentRootVerificationEvidenceStatusV1 {
626 if report.blockers.is_empty()
627 && report.identity_checks.iter().all(|check| check.satisfied)
628 && report.evidence_checks.iter().all(|check| check.satisfied)
629 {
630 DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied
631 } else {
632 DeploymentRootVerificationEvidenceStatusV1::VerificationFailed
633 }
634}
635
636const fn report_state_transition(
637 report: &DeploymentRootVerificationReportV1,
638) -> DeploymentRootVerificationStateTransitionV1 {
639 root_verification_transition(report.evidence_status, report.current_root_verification)
640}
641
642fn ensure_root_verification_report_checks_consistent(
643 report: &DeploymentRootVerificationReportV1,
644) -> Result<(), DeploymentRootVerificationReportError> {
645 ensure_report_check_names(
646 &report.identity_checks,
647 &[
648 "deployment_name",
649 "network",
650 "fleet_template",
651 "root_principal",
652 "plan_deployment_name",
653 "plan_network",
654 "plan_fleet_template",
655 ],
656 )?;
657 ensure_report_check_names(
658 &report.evidence_checks,
659 &[
660 "explicit_observed_root",
661 "root_observation_source",
662 "observed_root_canister_id",
663 "source_check_id",
664 "source_deployment_plan_id",
665 "source_inventory_id",
666 ],
667 )?;
668 for check in report.identity_checks.iter().chain(&report.evidence_checks) {
669 if check.satisfied != (check.expected == check.observed) {
670 return Err(DeploymentRootVerificationReportError::CheckMismatch {
671 check: check.name.clone(),
672 });
673 }
674 }
675
676 ensure_report_check_value(
677 &report.identity_checks,
678 "deployment_name",
679 Some(report.deployment_name.as_str()),
680 report.observed_deployment_name.as_deref(),
681 )?;
682 ensure_report_check_value(
683 &report.identity_checks,
684 "network",
685 Some(report.network.as_str()),
686 report.observed_network.as_deref(),
687 )?;
688 ensure_report_check_value(
689 &report.identity_checks,
690 "fleet_template",
691 Some(report.expected_fleet_template.as_str()),
692 report.observed_fleet_template.as_deref(),
693 )?;
694 ensure_report_check_value(
695 &report.identity_checks,
696 "root_principal",
697 Some(report.expected_root_principal.as_str()),
698 report.observed_root_principal.as_deref(),
699 )?;
700 let observed_root_present = report.observed_deployment_name.is_some()
701 && report.observed_network.is_some()
702 && report.observed_fleet_template.is_some()
703 && report.observed_root_principal.is_some()
704 && report.observed_root_canister_id.is_some()
705 && report.observed_root_observation_source.is_some();
706 ensure_report_check_value(
707 &report.evidence_checks,
708 "explicit_observed_root",
709 Some("present"),
710 observed_root_present.then_some("present"),
711 )?;
712 ensure_report_check_value(
713 &report.evidence_checks,
714 "root_observation_source",
715 Some("IcpCanisterStatus"),
716 report
717 .observed_root_observation_source
718 .as_ref()
719 .map(root_observation_source_label_from_source),
720 )?;
721 ensure_report_check_value(
722 &report.evidence_checks,
723 "observed_root_canister_id",
724 Some(report.expected_root_principal.as_str()),
725 report.observed_root_canister_id.as_deref(),
726 )?;
727 ensure_report_check_value(
728 &report.evidence_checks,
729 "source_check_id",
730 Some("present"),
731 present_value(report.source_check_id.as_str()),
732 )?;
733 ensure_report_check_value(
734 &report.evidence_checks,
735 "source_deployment_plan_id",
736 Some("present"),
737 present_value(report.source_deployment_plan_id.as_str()),
738 )?;
739 ensure_report_check_value(
740 &report.evidence_checks,
741 "source_inventory_id",
742 Some("present"),
743 present_value(report.source_inventory_id.as_str()),
744 )?;
745 Ok(())
746}
747
748fn ensure_report_check_names(
749 checks: &[DeploymentRootVerificationCheckV1],
750 expected: &[&'static str],
751) -> Result<(), DeploymentRootVerificationReportError> {
752 for check in checks {
753 if !expected.contains(&check.name.as_str()) {
754 return Err(DeploymentRootVerificationReportError::CheckMismatch {
755 check: check.name.clone(),
756 });
757 }
758 }
759 for expected_name in expected {
760 if checks
761 .iter()
762 .filter(|check| check.name == *expected_name)
763 .count()
764 != 1
765 {
766 return Err(DeploymentRootVerificationReportError::CheckMismatch {
767 check: (*expected_name).to_string(),
768 });
769 }
770 }
771 Ok(())
772}
773
774fn ensure_report_check_value(
775 checks: &[DeploymentRootVerificationCheckV1],
776 name: &'static str,
777 expected: Option<&str>,
778 observed: Option<&str>,
779) -> Result<(), DeploymentRootVerificationReportError> {
780 let Some(check) = checks.iter().find(|check| check.name == name) else {
781 return Err(DeploymentRootVerificationReportError::CheckMismatch {
782 check: name.to_string(),
783 });
784 };
785 if check.expected.as_deref() == expected
786 && check.observed.as_deref() == observed
787 && check.satisfied == (expected == observed)
788 {
789 Ok(())
790 } else {
791 Err(DeploymentRootVerificationReportError::CheckMismatch {
792 check: name.to_string(),
793 })
794 }
795}
796
797const fn receipt_state_transition(
798 receipt: &DeploymentRootVerificationReceiptV1,
799) -> DeploymentRootVerificationStateTransitionV1 {
800 match receipt.previous_root_verification {
801 DeploymentRootVerificationStateV1::NotVerified => {
802 DeploymentRootVerificationStateTransitionV1::PromotedNotVerifiedToVerified
803 }
804 DeploymentRootVerificationStateV1::Verified => {
805 DeploymentRootVerificationStateTransitionV1::NoStateChange
806 }
807 }
808}
809
810const fn source_report_transition_for_receipt(
811 receipt: &DeploymentRootVerificationReceiptV1,
812) -> DeploymentRootVerificationStateTransitionV1 {
813 match receipt.previous_root_verification {
814 DeploymentRootVerificationStateV1::NotVerified => {
815 DeploymentRootVerificationStateTransitionV1::WouldPromoteNotVerifiedToVerified
816 }
817 DeploymentRootVerificationStateV1::Verified => {
818 DeploymentRootVerificationStateTransitionV1::NoStateChange
819 }
820 }
821}
822
823fn receipt_local_state_digest_transition_is_valid(
824 receipt: &DeploymentRootVerificationReceiptV1,
825) -> bool {
826 match receipt.state_transition {
827 DeploymentRootVerificationStateTransitionV1::PromotedNotVerifiedToVerified => {
828 receipt.local_state_digest_before != receipt.local_state_digest_after
829 }
830 DeploymentRootVerificationStateTransitionV1::NoStateChange => {
831 receipt.local_state_digest_before == receipt.local_state_digest_after
832 }
833 DeploymentRootVerificationStateTransitionV1::NotAttempted
834 | DeploymentRootVerificationStateTransitionV1::Blocked
835 | DeploymentRootVerificationStateTransitionV1::WouldPromoteNotVerifiedToVerified => false,
836 }
837}
838
839fn deployment_root_verification_report_digest(
840 report: &DeploymentRootVerificationReportV1,
841) -> String {
842 stable_json_sha256_hex(&DeploymentRootVerificationReportDigestInput {
843 report_id: &report.report_id,
844 requested_at: &report.requested_at,
845 evidence_status: report.evidence_status,
846 state_transition: report.state_transition,
847 deployment_name: &report.deployment_name,
848 network: &report.network,
849 expected_fleet_template: &report.expected_fleet_template,
850 expected_root_principal: &report.expected_root_principal,
851 observed_deployment_name: &report.observed_deployment_name,
852 observed_network: &report.observed_network,
853 observed_fleet_template: &report.observed_fleet_template,
854 observed_root_principal: &report.observed_root_principal,
855 observed_root_canister_id: &report.observed_root_canister_id,
856 observed_root_observation_source: &report.observed_root_observation_source,
857 source: report.source,
858 source_check_id: &report.source_check_id,
859 source_check_digest: &report.source_check_digest,
860 source_deployment_plan_id: &report.source_deployment_plan_id,
861 source_deployment_plan_digest: &report.source_deployment_plan_digest,
862 source_inventory_id: &report.source_inventory_id,
863 source_inventory_digest: &report.source_inventory_digest,
864 current_root_verification: report.current_root_verification,
865 identity_checks: &report.identity_checks,
866 evidence_checks: &report.evidence_checks,
867 blockers: &report.blockers,
868 warnings: &report.warnings,
869 recommended_next_actions: &report.recommended_next_actions,
870 })
871}
872
873const fn ensure_root_verification_field(
874 field: &'static str,
875 value: &str,
876) -> Result<(), DeploymentRootVerificationReportError> {
877 if value.is_empty() {
878 Err(DeploymentRootVerificationReportError::MissingRequiredField { field })
879 } else {
880 Ok(())
881 }
882}
883
884fn ensure_root_verification_sha256(
885 field: &'static str,
886 value: &str,
887) -> Result<(), DeploymentRootVerificationReportError> {
888 if value.is_empty() {
889 return Err(DeploymentRootVerificationReportError::MissingRequiredField { field });
890 }
891 if is_lower_hex_sha256(value) {
892 Ok(())
893 } else {
894 Err(DeploymentRootVerificationReportError::InvalidSha256Digest { field })
895 }
896}
897
898const fn ensure_root_verification_receipt_field(
899 field: &'static str,
900 value: &str,
901) -> Result<(), DeploymentRootVerificationReceiptError> {
902 if value.is_empty() {
903 Err(DeploymentRootVerificationReceiptError::MissingRequiredField { field })
904 } else {
905 Ok(())
906 }
907}
908
909fn ensure_root_verification_receipt_sha256(
910 field: &'static str,
911 value: &str,
912) -> Result<(), DeploymentRootVerificationReceiptError> {
913 if value.is_empty() {
914 return Err(DeploymentRootVerificationReceiptError::MissingRequiredField { field });
915 }
916 if is_lower_hex_sha256(value) {
917 Ok(())
918 } else {
919 Err(DeploymentRootVerificationReceiptError::InvalidSha256Digest { field })
920 }
921}
922
923fn ensure_root_verification_receipt_timestamp(
924 field: &'static str,
925 value: &str,
926) -> Result<(), DeploymentRootVerificationReceiptError> {
927 if value.is_empty() {
928 return Err(DeploymentRootVerificationReceiptError::MissingRequiredField { field });
929 }
930 if is_supported_root_verification_timestamp_label(value) {
931 Ok(())
932 } else {
933 Err(DeploymentRootVerificationReceiptError::InvalidTimestampLabel { field })
934 }
935}
936
937fn is_supported_root_verification_timestamp_label(value: &str) -> bool {
938 if let Some(unix_value) = value.strip_prefix("unix:") {
939 return !unix_value.is_empty() && unix_value.bytes().all(|byte| byte.is_ascii_digit());
940 }
941 value.len() >= "1970-01-01T00:00:00Z".len() && value.contains('T') && value.ends_with('Z')
942}
943
944fn source_report_timestamp_matches_receipt(receipt: &DeploymentRootVerificationReceiptV1) -> bool {
945 let Some(unix_value) = receipt.source_report_requested_at.strip_prefix("unix:") else {
946 return true;
947 };
948 unix_value.parse::<u64>() == Ok(receipt.verified_at_unix_secs)
949}
950
951fn is_lower_hex_sha256(value: &str) -> bool {
952 value.len() == 64
953 && value
954 .bytes()
955 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
956}