1use canic_control_plane::state_contract::canic_control_plane_state_manifest;
11use canic_core::state_contract::{
12 MigrationPolicy, RemovedStateManifest, ReservedMemoryManifest, STATE_MANIFEST_SCHEMA_VERSION,
13 StateDomainManifest, StateManifest, StateMigrationManifest, StateRoleManifest, StateStorage,
14 canic_state_manifest,
15};
16use serde::Serialize;
17use std::collections::BTreeMap;
18
19pub const STATE_AUDIT_COMMAND: &str = "canic state audit";
20pub const STATE_MANIFEST_COMMAND: &str = "canic state manifest";
21pub const STATE_AUDIT_SCHEMA_VERSION: u16 = 1;
22
23const SCOPE_PROJECT: &str = "project";
24const SCOPE_ROLE: &str = "role";
25const SOURCE_STATE_MANIFEST: &str = "state_manifest";
26const CATEGORY_MANIFEST: &str = "manifest";
27const CATEGORY_SCHEMA_VERSION: &str = "schema_version";
28const CATEGORY_MEMORY_ID: &str = "memory_id";
29const CATEGORY_MIGRATION: &str = "migration";
30const CATEGORY_REMOVED_STATE: &str = "removed_state";
31const CATEGORY_SNAPSHOT: &str = "snapshot";
32const CATEGORY_NAMING: &str = "naming";
33const CATEGORY_LIFECYCLE: &str = "lifecycle";
34const CATEGORY_INVARIANT: &str = "invariant";
35const CATEGORY_TEST_COVERAGE: &str = "test_coverage";
36
37#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
44pub struct StateAuditReport {
45 pub schema_version: u16,
46 pub command: &'static str,
47 pub scope: &'static str,
48 pub role: Option<String>,
49 pub status: StateAuditStatus,
50 pub manifest: StateManifest,
51 pub checks: Vec<StateAuditCheck>,
52 pub next_actions: Vec<String>,
53}
54
55#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62pub struct StateAuditCheck {
63 pub category: &'static str,
64 pub code: &'static str,
65 pub status: StateAuditStatus,
66 pub severity: StateAuditSeverity,
67 pub subject: String,
68 pub detail: String,
69 pub next: Option<String>,
70 pub source: &'static str,
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum StateAuditStatus {
82 Pass,
83 Warn,
84 Fail,
85 NotEvaluated,
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
95#[serde(rename_all = "snake_case")]
96pub enum StateAuditSeverity {
97 Info,
98 Warning,
99 Blocked,
100 Unsupported,
101}
102
103#[must_use]
104pub fn declared_state_manifest(role: Option<&str>) -> StateManifest {
105 let mut manifest = merge_manifests(vec![
106 canic_state_manifest(),
107 canic_control_plane_state_manifest(),
108 ]);
109 if let Some(role) = role {
110 manifest
111 .roles
112 .retain(|entry| entry.canister_role.as_str() == role);
113 }
114 manifest
115}
116
117fn merge_manifests(manifests: Vec<StateManifest>) -> StateManifest {
118 let mut by_role = BTreeMap::<String, StateRoleManifest>::new();
119
120 for manifest in manifests {
121 for role in manifest.roles {
122 let entry = by_role
123 .entry(role.canister_role.clone())
124 .or_insert_with(|| StateRoleManifest {
125 canister_role: role.canister_role.clone(),
126 state: Vec::new(),
127 removed_state: Vec::new(),
128 reserved_memory: Vec::new(),
129 });
130 entry.state.extend(role.state);
131 entry.removed_state.extend(role.removed_state);
132 entry.reserved_memory.extend(role.reserved_memory);
133 }
134 }
135
136 let mut roles = by_role.into_values().collect::<Vec<_>>();
137 sort_roles(&mut roles);
138 StateManifest {
139 schema_version: STATE_MANIFEST_SCHEMA_VERSION,
140 roles,
141 }
142}
143
144fn sort_roles(roles: &mut [StateRoleManifest]) {
145 roles.sort_by(|left, right| left.canister_role.cmp(&right.canister_role));
146 for role in roles {
147 role.state
148 .sort_by(|left, right| left.domain.cmp(&right.domain));
149 role.removed_state
150 .sort_by(|left, right| left.domain.cmp(&right.domain));
151 role.reserved_memory
152 .sort_by_key(|reservation| reservation.memory_id);
153 for domain in &mut role.state {
154 domain
155 .migrations
156 .sort_by_key(|migration| (migration.from, migration.to));
157 }
158 }
159}
160
161#[must_use]
162pub fn build_state_audit_report(role: Option<&str>) -> StateAuditReport {
163 let manifest = declared_state_manifest(role);
164 let mut checks = audit_checks(&manifest, role);
165 sort_checks(&mut checks);
166
167 let status = aggregate_status(&checks);
168 let mut next_actions = next_actions(status, role);
169 next_actions.sort();
170 next_actions.dedup();
171
172 StateAuditReport {
173 schema_version: STATE_AUDIT_SCHEMA_VERSION,
174 command: STATE_AUDIT_COMMAND,
175 scope: if role.is_some() {
176 SCOPE_ROLE
177 } else {
178 SCOPE_PROJECT
179 },
180 role: role.map(ToString::to_string),
181 status,
182 manifest,
183 checks,
184 next_actions,
185 }
186}
187
188fn audit_checks(manifest: &StateManifest, role_filter: Option<&str>) -> Vec<StateAuditCheck> {
189 let mut checks = manifest_schema_checks(manifest);
190
191 if manifest.roles.is_empty() {
192 if let Some(check) = role_filter.map(role_not_found_check) {
193 checks.push(check);
194 }
195 return checks;
196 }
197
198 checks.extend(role_identity_checks(&manifest.roles));
199
200 for role in &manifest.roles {
201 checks.push(pass(
202 CATEGORY_MANIFEST,
203 "state_domain_registered",
204 &role.canister_role,
205 format!(
206 "{} state domain(s) registered for {}",
207 role.state.len(),
208 role.canister_role
209 ),
210 ));
211 checks.extend(domain_identity_checks(&role.canister_role, &role.state));
212 checks.extend(memory_id_checks(
213 &role.canister_role,
214 &role.state,
215 &role.removed_state,
216 ));
217 checks.extend(role_state_checks(&role.canister_role, &role.state));
218 checks.extend(removed_state_checks(
219 &role.canister_role,
220 &role.removed_state,
221 ));
222 checks.extend(reserved_memory_checks(
223 &role.canister_role,
224 &role.state,
225 &role.removed_state,
226 &role.reserved_memory,
227 ));
228 }
229 checks
230}
231
232fn role_identity_checks(roles: &[StateRoleManifest]) -> Vec<StateAuditCheck> {
233 let mut by_role = BTreeMap::<&str, usize>::new();
234 for role in roles {
235 *by_role.entry(&role.canister_role).or_default() += 1;
236 }
237
238 by_role
239 .into_iter()
240 .filter(|&(_, count)| count > 1)
241 .map(|(role, count)| {
242 fail(
243 CATEGORY_MANIFEST,
244 "state_role_duplicate",
245 role,
246 format!("state role {role} is declared {count} times"),
247 "declare each canister role once in the state manifest",
248 )
249 })
250 .collect()
251}
252
253fn role_not_found_check(role: &str) -> StateAuditCheck {
254 fail(
255 CATEGORY_MANIFEST,
256 "state_role_missing",
257 role,
258 format!("no state manifest role named {role} is declared"),
259 "choose a declared role or omit --role to audit all declared roles",
260 )
261}
262
263fn manifest_schema_checks(manifest: &StateManifest) -> Vec<StateAuditCheck> {
264 if manifest.schema_version == STATE_MANIFEST_SCHEMA_VERSION {
265 vec![pass(
266 CATEGORY_SCHEMA_VERSION,
267 "state_manifest_schema_version_supported",
268 "state_manifest",
269 format!(
270 "manifest schema version {} is supported",
271 manifest.schema_version
272 ),
273 )]
274 } else {
275 vec![fail(
276 CATEGORY_SCHEMA_VERSION,
277 "state_manifest_schema_version_unsupported",
278 "state_manifest",
279 format!(
280 "manifest schema version {} is not supported by schema version {}",
281 manifest.schema_version, STATE_MANIFEST_SCHEMA_VERSION
282 ),
283 "regenerate the manifest from current Rust state declarations",
284 )]
285 }
286}
287
288fn domain_identity_checks(role: &str, domains: &[StateDomainManifest]) -> Vec<StateAuditCheck> {
289 let mut by_domain = BTreeMap::<&str, usize>::new();
290 for domain in domains {
291 *by_domain.entry(&domain.domain).or_default() += 1;
292 }
293
294 by_domain
295 .into_iter()
296 .filter(|&(_, count)| count > 1)
297 .map(|(domain, count)| {
298 fail(
299 CATEGORY_MANIFEST,
300 "state_domain_duplicate",
301 &format!("{role}/{domain}"),
302 format!("state domain {domain} is declared {count} times for role {role}"),
303 "declare each state domain once per canister role",
304 )
305 })
306 .collect()
307}
308
309fn memory_id_checks(
310 role: &str,
311 domains: &[StateDomainManifest],
312 removed: &[RemovedStateManifest],
313) -> Vec<StateAuditCheck> {
314 let mut by_id = BTreeMap::<u8, Vec<&str>>::new();
315 for domain in domains
316 .iter()
317 .filter(|domain| domain.storage == StateStorage::StableMemory)
318 {
319 if let Some(memory_id) = domain.memory_id {
320 by_id.entry(memory_id).or_default().push(&domain.domain);
321 }
322 }
323
324 let mut checks = Vec::new();
325 let duplicates = by_id
326 .iter()
327 .filter(|(_, domains)| domains.len() > 1)
328 .collect::<Vec<_>>();
329 if duplicates.is_empty() {
330 checks.push(pass(
331 CATEGORY_MEMORY_ID,
332 "memory_id_unique",
333 role,
334 format!("all stable-memory domains for {role} use unique memory IDs"),
335 ));
336 } else {
337 checks.extend(duplicates.into_iter().map(|(memory_id, domains)| {
338 fail(
339 CATEGORY_MEMORY_ID,
340 "memory_id_duplicate",
341 &format!("{role}/memory_id/{memory_id}"),
342 format!("memory id {memory_id} is used by {}", domains.join(", ")),
343 "assign a unique memory id or add an explicit migration design",
344 )
345 }));
346 }
347
348 checks.extend(removed_memory_id_checks(role, &by_id, removed));
349 checks
350}
351
352fn removed_memory_id_checks(
353 role: &str,
354 active_by_id: &BTreeMap<u8, Vec<&str>>,
355 removed: &[RemovedStateManifest],
356) -> Vec<StateAuditCheck> {
357 removed
358 .iter()
359 .filter_map(|entry| {
360 let memory_id = entry.memory_id?;
361 let subject = format!("{role}/memory_id/{memory_id}");
362 if let Some(active_domains) = active_by_id.get(&memory_id) {
363 Some(fail(
364 CATEGORY_REMOVED_STATE,
365 "removed_state_memory_id_reclaimed",
366 &subject,
367 format!(
368 "removed state {} reserved memory id {memory_id}, but active domain(s) {} use it",
369 entry.domain,
370 active_domains.join(", ")
371 ),
372 "keep retired memory ids reserved or add an explicit migration design",
373 ))
374 } else {
375 Some(pass(
376 CATEGORY_REMOVED_STATE,
377 "removed_state_memory_id_reserved",
378 &subject,
379 format!(
380 "removed state {} keeps retired memory id {memory_id} reserved",
381 entry.domain
382 ),
383 ))
384 }
385 })
386 .collect()
387}
388
389fn role_state_checks(role: &str, domains: &[StateDomainManifest]) -> Vec<StateAuditCheck> {
390 let mut checks = Vec::new();
391 for domain in domains {
392 checks.extend(schema_checks(role, domain));
393 checks.extend(storage_checks(role, domain));
394 checks.extend(naming_checks(role, domain));
395 checks.extend(export_import_contract_checks(role, domain));
396 checks.extend(migration_checks(role, domain));
397 checks.extend(lifecycle_checks(role, domain));
398 }
399 checks
400}
401
402fn schema_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
403 let subject = domain_subject(role, domain);
404 if domain.version == 0 {
405 vec![fail(
406 CATEGORY_SCHEMA_VERSION,
407 "state_domain_missing_version",
408 &subject,
409 "state domain does not declare a positive schema version".to_string(),
410 "declare the current schema version for this state domain",
411 )]
412 } else {
413 vec![pass(
414 CATEGORY_SCHEMA_VERSION,
415 "state_domain_registered",
416 &subject,
417 format!("declares schema version {}", domain.version),
418 )]
419 }
420}
421
422fn storage_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
423 let subject = domain_subject(role, domain);
424 match (domain.storage, domain.memory_id) {
425 (StateStorage::StableMemory, Some(memory_id)) => vec![pass(
426 CATEGORY_MEMORY_ID,
427 "memory_id_unique",
428 &subject,
429 format!("stable-memory domain declares memory id {memory_id}"),
430 )],
431 (StateStorage::StableMemory, None) => vec![fail(
432 CATEGORY_MEMORY_ID,
433 "state_domain_missing_memory_id",
434 &subject,
435 "stable-memory domain does not declare a memory id".to_string(),
436 "declare the stable-memory id owned by this domain",
437 )],
438 (StateStorage::HeapOnly, None) => vec![pass(
439 CATEGORY_MEMORY_ID,
440 "state_domain_declares_no_stable_memory",
441 &subject,
442 "heap-only domain explicitly declares no stable memory".to_string(),
443 )],
444 (StateStorage::HeapOnly, Some(memory_id)) => vec![warn(
445 CATEGORY_MEMORY_ID,
446 "state_domain_declares_no_stable_memory",
447 &subject,
448 format!("heap-only domain also declares memory id {memory_id}"),
449 "remove the memory id or change storage to stable_memory",
450 )],
451 (StateStorage::NotApplicable, None) => vec![pass(
452 CATEGORY_MEMORY_ID,
453 "state_domain_storage_not_applicable",
454 &subject,
455 "domain explicitly declares storage is not applicable".to_string(),
456 )],
457 (StateStorage::NotApplicable, Some(memory_id)) => vec![warn(
458 CATEGORY_MEMORY_ID,
459 "state_domain_storage_not_applicable",
460 &subject,
461 format!("storage-not-applicable domain also declares memory id {memory_id}"),
462 "remove the memory id or choose a concrete storage substrate",
463 )],
464 }
465}
466
467fn naming_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
468 let subject = domain_subject(role, domain);
469 let record_check = if domain.record.ends_with("Record") {
470 pass(
471 CATEGORY_NAMING,
472 "record_name_valid",
473 &subject,
474 format!(
475 "record type {} follows the Record suffix convention",
476 domain.record
477 ),
478 )
479 } else {
480 warn(
481 CATEGORY_NAMING,
482 "record_name_invalid",
483 &subject,
484 format!("record type {} does not end with Record", domain.record),
485 "rename persisted records to use the Record suffix when safe",
486 )
487 };
488 let snapshot_check = if domain.snapshot.ends_with("Data") {
489 pass(
490 CATEGORY_SNAPSHOT,
491 "snapshot_name_valid",
492 &subject,
493 format!(
494 "snapshot type {} follows the Data suffix convention",
495 domain.snapshot
496 ),
497 )
498 } else {
499 warn(
500 CATEGORY_SNAPSHOT,
501 "snapshot_name_invalid",
502 &subject,
503 format!("snapshot type {} does not end with Data", domain.snapshot),
504 "introduce canonical *Data snapshot types before relying on this domain for migration audits",
505 )
506 };
507
508 vec![record_check, snapshot_check]
509}
510
511fn export_import_contract_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
512 let subject = domain_subject(role, domain);
513 if domain.snapshot.ends_with("Data") {
514 vec![pass(
515 CATEGORY_SNAPSHOT,
516 "reserved_export_import_ok",
517 &subject,
518 format!(
519 "snapshot type {} is a canonical Data shape for export/import boundaries",
520 domain.snapshot
521 ),
522 )]
523 } else {
524 vec![warn(
525 CATEGORY_SNAPSHOT,
526 "reserved_export_import_violation",
527 &subject,
528 format!(
529 "snapshot type {} is not a canonical Data shape for export/import boundaries",
530 domain.snapshot
531 ),
532 "reserve export/import for canonical *Data snapshot shapes",
533 )]
534 }
535}
536
537fn migration_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
538 let subject = domain_subject(role, domain);
539 if domain.min_supported_version == 0 || domain.min_supported_version > domain.version {
540 return vec![fail(
541 CATEGORY_MIGRATION,
542 "state_domain_invalid_support_window",
543 &subject,
544 format!(
545 "min_supported_version {} is not valid for current version {}",
546 domain.min_supported_version, domain.version
547 ),
548 "set min_supported_version to a positive version less than or equal to the current version",
549 )];
550 }
551
552 if domain.min_supported_version == domain.version {
553 return vec![pass(
554 CATEGORY_MIGRATION,
555 "migration_available",
556 &subject,
557 "no older supported schema version requires migration".to_string(),
558 )];
559 }
560
561 match domain.migration_policy {
562 MigrationPolicy::Migrate => {
563 let mut checks = migration_declaration_checks(role, domain);
564 checks.extend(migration_path_checks(role, domain));
565 checks
566 }
567 MigrationPolicy::ManualMigrationRequired => vec![warn(
568 CATEGORY_MIGRATION,
569 "manual_migration_required_declared",
570 &subject,
571 format!(
572 "manual migration is declared for supported versions {} through {}",
573 domain.min_supported_version,
574 domain.version.saturating_sub(1)
575 ),
576 "treat manual migration as a release gate when the old version is in production support",
577 )],
578 MigrationPolicy::DiscardDeclared => vec![pass(
579 CATEGORY_MIGRATION,
580 "migration_unsupported_declared",
581 &subject,
582 "old supported state is declared as discarded by policy".to_string(),
583 )],
584 MigrationPolicy::NotApplicable | MigrationPolicy::NewDomain => vec![fail(
585 CATEGORY_MIGRATION,
586 "migration_missing",
587 &subject,
588 "supported old versions exist but no migration policy can handle them".to_string(),
589 "declare migration, manual migration, discard, or hard-cut min_supported_version",
590 )],
591 }
592}
593
594fn migration_declaration_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
595 let mut checks = Vec::new();
596 let mut by_edge = BTreeMap::<(u32, u32), usize>::new();
597
598 for migration in &domain.migrations {
599 *by_edge.entry((migration.from, migration.to)).or_default() += 1;
600 let expected_next = migration.from.checked_add(1);
601 if migration.from == 0
602 || migration.to == 0
603 || migration.from >= migration.to
604 || expected_next != Some(migration.to)
605 || migration.from < domain.min_supported_version
606 || migration.to > domain.version
607 {
608 let subject = format!(
609 "{}/{domain} v{} -> v{}",
610 role,
611 migration.from,
612 migration.to,
613 domain = domain.domain
614 );
615 checks.push(fail(
616 CATEGORY_MIGRATION,
617 "migration_declaration_invalid",
618 &subject,
619 format!(
620 "declared migration edge v{} -> v{} is outside the supported window {}..={}",
621 migration.from, migration.to, domain.min_supported_version, domain.version
622 ),
623 "declare only one-step migrations inside the supported version window",
624 ));
625 }
626 }
627
628 checks.extend(by_edge.into_iter().filter(|&(_, count)| count > 1).map(
629 |((from, to), count)| {
630 let subject = format!("{}/{domain} v{from} -> v{to}", role, domain = domain.domain);
631 fail(
632 CATEGORY_MIGRATION,
633 "migration_declaration_duplicate",
634 &subject,
635 format!("migration edge v{from} -> v{to} is declared {count} times"),
636 "declare each migration edge once",
637 )
638 },
639 ));
640 checks
641}
642
643fn migration_path_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
644 let mut checks = Vec::new();
645 for from in domain.min_supported_version..domain.version {
646 let to = from + 1;
647 let subject = format!("{}/{domain} v{from} -> v{to}", role, domain = domain.domain);
648 match migration_for(domain, from, to) {
649 Some(migration) => checks.push(migration_available_check(&subject, migration)),
650 None => checks.push(fail(
651 CATEGORY_MIGRATION,
652 "migration_missing",
653 &subject,
654 format!("no declared migration covers v{from} -> v{to}"),
655 "declare migration coverage or hard-cut min_supported_version",
656 )),
657 }
658 }
659 checks
660}
661
662fn migration_available_check(subject: &str, migration: &StateMigrationManifest) -> StateAuditCheck {
663 if migration.test.is_some() {
664 pass(
665 CATEGORY_TEST_COVERAGE,
666 "upgrade_test_declared",
667 subject,
668 format!(
669 "migration {} declares upgrade test coverage",
670 migration_label(migration)
671 ),
672 )
673 } else {
674 warn(
675 CATEGORY_TEST_COVERAGE,
676 "upgrade_test_missing",
677 subject,
678 format!(
679 "migration {} has no declared upgrade test",
680 migration_label(migration)
681 ),
682 "declare upgrade test coverage or hard-cut min_supported_version",
683 )
684 }
685}
686
687fn migration_for(
688 domain: &StateDomainManifest,
689 from: u32,
690 to: u32,
691) -> Option<&StateMigrationManifest> {
692 domain
693 .migrations
694 .iter()
695 .find(|migration| migration.from == from && migration.to == to)
696}
697
698fn lifecycle_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
699 let subject = domain_subject(role, domain);
700 let restore_check = if domain.restore_order.is_some() {
701 pass(
702 CATEGORY_LIFECYCLE,
703 "restore_order_declared",
704 &subject,
705 "restore order is declared".to_string(),
706 )
707 } else {
708 warn(
709 CATEGORY_LIFECYCLE,
710 "restore_order_missing",
711 &subject,
712 "restore order is not declared".to_string(),
713 "declare lifecycle restore order before broad upgrade gating",
714 )
715 };
716 let invariant_check = if domain.post_upgrade_invariant.is_some() {
717 pass(
718 CATEGORY_INVARIANT,
719 "post_upgrade_invariant_declared",
720 &subject,
721 "post-upgrade invariant declaration is present".to_string(),
722 )
723 } else {
724 warn(
725 CATEGORY_INVARIANT,
726 "post_upgrade_invariant_missing",
727 &subject,
728 "post-upgrade invariant declaration is missing".to_string(),
729 "declare invariant checks or document why this domain is not applicable",
730 )
731 };
732
733 vec![restore_check, invariant_check]
734}
735
736fn removed_state_checks(role: &str, removed: &[RemovedStateManifest]) -> Vec<StateAuditCheck> {
737 removed
738 .iter()
739 .flat_map(|entry| {
740 let subject = format!("{role}/{}", entry.domain);
741 let mut checks = Vec::new();
742 checks.push(if entry.disposition.trim().is_empty() {
743 fail(
744 CATEGORY_REMOVED_STATE,
745 "removed_state_disposition_missing",
746 &subject,
747 "removed state does not declare a disposition".to_string(),
748 "declare whether the state is migrated, discarded, or manually handled",
749 )
750 } else {
751 pass(
752 CATEGORY_REMOVED_STATE,
753 "removed_state_disposition_declared",
754 &subject,
755 format!("removed state disposition declared: {}", entry.disposition),
756 )
757 });
758 checks.push(if entry.reason.trim().is_empty() {
759 warn(
760 CATEGORY_REMOVED_STATE,
761 "removed_state_reason_missing",
762 &subject,
763 "removed state disposition does not declare a reason".to_string(),
764 "document why the removed state can be migrated, discarded, or manually handled",
765 )
766 } else {
767 pass(
768 CATEGORY_REMOVED_STATE,
769 "removed_state_reason_declared",
770 &subject,
771 format!("removed state reason declared: {}", entry.reason),
772 )
773 });
774 checks.push(
775 if entry.test.as_ref().is_some_and(|test| !test.trim().is_empty()) {
776 pass(
777 CATEGORY_TEST_COVERAGE,
778 "removed_state_test_declared",
779 &subject,
780 "removed state declares upgrade test coverage".to_string(),
781 )
782 } else {
783 warn(
784 CATEGORY_TEST_COVERAGE,
785 "removed_state_test_missing",
786 &subject,
787 "removed state has no declared upgrade test coverage".to_string(),
788 "declare upgrade test coverage for the removed-state disposition",
789 )
790 },
791 );
792 checks
793 })
794 .collect()
795}
796
797fn reserved_memory_checks(
798 role: &str,
799 domains: &[StateDomainManifest],
800 removed: &[RemovedStateManifest],
801 reserved: &[ReservedMemoryManifest],
802) -> Vec<StateAuditCheck> {
803 let active_by_id = active_memory_ids(domains);
804 let removed_by_id = removed_memory_ids(removed);
805 let mut reserved_by_id = BTreeMap::<u8, Vec<&str>>::new();
806 for entry in reserved {
807 reserved_by_id
808 .entry(entry.memory_id)
809 .or_default()
810 .push(&entry.label);
811 }
812
813 let mut checks = Vec::new();
814 for entry in reserved {
815 let subject = format!("{role}/memory_id/{}", entry.memory_id);
816 if let Some(active_domains) = active_by_id.get(&entry.memory_id) {
817 checks.push(fail(
818 CATEGORY_MEMORY_ID,
819 "reserved_memory_id_collision",
820 &subject,
821 format!(
822 "reserved memory id {} for {} is used by active domain(s) {}",
823 entry.memory_id,
824 entry.label,
825 active_domains.join(", ")
826 ),
827 "declare one owner for the memory id or add an explicit migration design",
828 ));
829 } else if let Some(removed_domains) = removed_by_id.get(&entry.memory_id) {
830 checks.push(fail(
831 CATEGORY_MEMORY_ID,
832 "reserved_memory_id_collision",
833 &subject,
834 format!(
835 "reserved memory id {} for {} is already declared by removed state {}",
836 entry.memory_id,
837 entry.label,
838 removed_domains.join(", ")
839 ),
840 "keep the memory id in exactly one manifest section",
841 ));
842 } else if reserved_by_id
843 .get(&entry.memory_id)
844 .is_some_and(|labels| labels.len() > 1)
845 {
846 checks.push(fail(
847 CATEGORY_MEMORY_ID,
848 "reserved_memory_id_duplicate",
849 &subject,
850 format!(
851 "reserved memory id {} is listed for {}",
852 entry.memory_id,
853 reserved_by_id
854 .get(&entry.memory_id)
855 .map(|labels| labels.join(", "))
856 .unwrap_or_default()
857 ),
858 "reserve each memory id once",
859 ));
860 } else {
861 checks.push(warn(
862 CATEGORY_MEMORY_ID,
863 "reserved_memory_id_declared",
864 &subject,
865 format!(
866 "memory id {} is reserved for {} but is not yet modeled as an active or removed state domain",
867 entry.memory_id, entry.label
868 ),
869 "model this reservation as a precise state domain or removed-state disposition when the state shape is known",
870 ));
871 }
872 }
873 checks
874}
875
876fn active_memory_ids(domains: &[StateDomainManifest]) -> BTreeMap<u8, Vec<&str>> {
877 let mut by_id = BTreeMap::<u8, Vec<&str>>::new();
878 for domain in domains
879 .iter()
880 .filter(|domain| domain.storage == StateStorage::StableMemory)
881 {
882 if let Some(memory_id) = domain.memory_id {
883 by_id.entry(memory_id).or_default().push(&domain.domain);
884 }
885 }
886 by_id
887}
888
889fn removed_memory_ids(removed: &[RemovedStateManifest]) -> BTreeMap<u8, Vec<&str>> {
890 let mut by_id = BTreeMap::<u8, Vec<&str>>::new();
891 for entry in removed {
892 if let Some(memory_id) = entry.memory_id {
893 by_id.entry(memory_id).or_default().push(&entry.domain);
894 }
895 }
896 by_id
897}
898
899fn domain_subject(role: &str, domain: &StateDomainManifest) -> String {
900 format!("{role}/{}", domain.domain)
901}
902
903fn migration_label(migration: &StateMigrationManifest) -> String {
904 migration
905 .name
906 .clone()
907 .unwrap_or_else(|| format!("{}->{}", migration.from, migration.to))
908}
909
910fn pass(
911 category: &'static str,
912 code: &'static str,
913 subject: &str,
914 detail: String,
915) -> StateAuditCheck {
916 StateAuditCheck {
917 category,
918 code,
919 status: StateAuditStatus::Pass,
920 severity: StateAuditSeverity::Info,
921 subject: subject.to_string(),
922 detail,
923 next: None,
924 source: SOURCE_STATE_MANIFEST,
925 }
926}
927
928fn warn(
929 category: &'static str,
930 code: &'static str,
931 subject: &str,
932 detail: String,
933 next: &str,
934) -> StateAuditCheck {
935 StateAuditCheck {
936 category,
937 code,
938 status: StateAuditStatus::Warn,
939 severity: StateAuditSeverity::Warning,
940 subject: subject.to_string(),
941 detail,
942 next: Some(next.to_string()),
943 source: SOURCE_STATE_MANIFEST,
944 }
945}
946
947fn fail(
948 category: &'static str,
949 code: &'static str,
950 subject: &str,
951 detail: String,
952 next: &str,
953) -> StateAuditCheck {
954 StateAuditCheck {
955 category,
956 code,
957 status: StateAuditStatus::Fail,
958 severity: StateAuditSeverity::Blocked,
959 subject: subject.to_string(),
960 detail,
961 next: Some(next.to_string()),
962 source: SOURCE_STATE_MANIFEST,
963 }
964}
965
966fn aggregate_status(checks: &[StateAuditCheck]) -> StateAuditStatus {
967 if checks.is_empty() {
968 return StateAuditStatus::NotEvaluated;
969 }
970 if checks
971 .iter()
972 .any(|check| check.status == StateAuditStatus::Fail)
973 {
974 return StateAuditStatus::Fail;
975 }
976 if checks
977 .iter()
978 .any(|check| check.status == StateAuditStatus::Warn)
979 {
980 return StateAuditStatus::Warn;
981 }
982 StateAuditStatus::Pass
983}
984
985fn next_actions(status: StateAuditStatus, role: Option<&str>) -> Vec<String> {
986 let scope = role.unwrap_or("project");
987 match status {
988 StateAuditStatus::Pass => vec![format!(
989 "state metadata declarations for {scope} have no blocking findings"
990 )],
991 StateAuditStatus::Warn => vec![format!(
992 "review warning checks before using {scope} state metadata as an upgrade gate"
993 )],
994 StateAuditStatus::Fail => vec![format!(
995 "fix failing state metadata checks before upgrade or release gating for {scope}"
996 )],
997 StateAuditStatus::NotEvaluated => vec![format!(
998 "declare state metadata before auditing {scope} upgrade safety"
999 )],
1000 }
1001}
1002
1003fn sort_checks(checks: &mut [StateAuditCheck]) {
1004 checks.sort_by(|left, right| {
1005 (
1006 status_rank(left.status),
1007 left.category,
1008 left.code,
1009 left.subject.as_str(),
1010 )
1011 .cmp(&(
1012 status_rank(right.status),
1013 right.category,
1014 right.code,
1015 right.subject.as_str(),
1016 ))
1017 });
1018}
1019
1020const fn status_rank(status: StateAuditStatus) -> u8 {
1021 match status {
1022 StateAuditStatus::Fail => 0,
1023 StateAuditStatus::Warn => 1,
1024 StateAuditStatus::NotEvaluated => 2,
1025 StateAuditStatus::Pass => 3,
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use canic_core::state_contract::{MigrationPolicy, StateRoleManifest};
1033
1034 #[test]
1035 fn builtin_report_is_warning_only_for_reserved_memory() {
1036 let report = build_state_audit_report(Some("root"));
1037
1038 assert_eq!(report.status, StateAuditStatus::Warn);
1039 assert!(report.checks.iter().any(|check| {
1040 check.code == "state_manifest_schema_version_supported"
1041 && check.status == StateAuditStatus::Pass
1042 }));
1043 assert!(
1044 report
1045 .checks
1046 .iter()
1047 .any(|check| check.code == "reserved_memory_id_declared")
1048 );
1049 assert!(
1050 report
1051 .checks
1052 .iter()
1053 .all(|check| check.code != "snapshot_name_invalid")
1054 );
1055 assert!(
1056 report
1057 .checks
1058 .iter()
1059 .any(|check| check.code == "reserved_export_import_ok")
1060 );
1061 assert!(
1062 report
1063 .checks
1064 .iter()
1065 .all(|check| check.status != StateAuditStatus::Fail)
1066 );
1067 }
1068
1069 #[test]
1070 fn builtin_manifest_merges_control_plane_state_by_role() {
1071 let manifest = declared_state_manifest(Some("root"));
1072 let role = manifest.roles.first().expect("root role");
1073
1074 assert!(role.state.iter().any(|domain| {
1075 domain.domain == "template_manifests"
1076 && domain.owner == "canic-control-plane"
1077 && domain.memory_id == Some(80)
1078 }));
1079 assert!(
1080 role.state
1081 .iter()
1082 .any(|domain| { domain.domain == "auth_state" && domain.owner == "canic-core" })
1083 );
1084 }
1085
1086 #[test]
1087 fn wasm_store_role_audits_cleanly() {
1088 let report = build_state_audit_report(Some("wasm_store"));
1089
1090 assert_eq!(report.status, StateAuditStatus::Pass);
1091 assert!(report.manifest.roles.iter().any(|role| {
1092 role.canister_role == "wasm_store"
1093 && role
1094 .state
1095 .iter()
1096 .any(|domain| domain.domain == "wasm_store_gc_state")
1097 }));
1098 }
1099
1100 #[test]
1101 fn unsupported_manifest_schema_version_fails() {
1102 let mut manifest = declared_state_manifest(Some("root"));
1103 manifest.schema_version = STATE_MANIFEST_SCHEMA_VERSION + 1;
1104
1105 let checks = audit_checks(&manifest, Some("root"));
1106
1107 assert!(checks.iter().any(|check| {
1108 check.code == "state_manifest_schema_version_unsupported"
1109 && check.status == StateAuditStatus::Fail
1110 }));
1111 }
1112
1113 #[test]
1114 fn duplicate_state_role_fails() {
1115 let mut manifest = declared_state_manifest(Some("root"));
1116 let duplicate = manifest.roles[0].clone();
1117 manifest.roles.push(duplicate);
1118
1119 let checks = audit_checks(&manifest, None);
1120
1121 assert!(checks.iter().any(|check| {
1122 check.code == "state_role_duplicate" && check.status == StateAuditStatus::Fail
1123 }));
1124 }
1125
1126 #[test]
1127 fn duplicate_memory_id_fails_within_role() {
1128 let mut manifest = declared_state_manifest(Some("root"));
1129 let role = manifest.roles.first_mut().expect("root role");
1130 role.state[1].memory_id = role.state[0].memory_id;
1131
1132 let checks = audit_checks(&manifest, Some("root"));
1133 assert!(
1134 checks
1135 .iter()
1136 .any(|check| check.code == "memory_id_duplicate"
1137 && check.status == StateAuditStatus::Fail)
1138 );
1139 }
1140
1141 #[test]
1142 fn duplicate_state_domain_fails_within_role() {
1143 let mut manifest = declared_state_manifest(Some("root"));
1144 let role = manifest.roles.first_mut().expect("root role");
1145 let mut duplicate = role.state[0].clone();
1146 duplicate.memory_id = Some(250);
1147 role.state.push(duplicate);
1148
1149 let checks = audit_checks(&manifest, Some("root"));
1150
1151 assert!(
1152 checks
1153 .iter()
1154 .any(|check| check.code == "state_domain_duplicate"
1155 && check.status == StateAuditStatus::Fail)
1156 );
1157 }
1158
1159 #[test]
1160 fn active_domain_reclaiming_removed_memory_id_fails() {
1161 let mut manifest = declared_state_manifest(Some("root"));
1162 let role = manifest.roles.first_mut().expect("root role");
1163 let retired_id = role
1164 .removed_state
1165 .first()
1166 .and_then(|entry| entry.memory_id)
1167 .expect("retired memory id");
1168 role.state[0].memory_id = Some(retired_id);
1169
1170 let checks = audit_checks(&manifest, Some("root"));
1171
1172 assert!(
1173 checks
1174 .iter()
1175 .any(|check| check.code == "removed_state_memory_id_reclaimed"
1176 && check.status == StateAuditStatus::Fail)
1177 );
1178 }
1179
1180 #[test]
1181 fn reserved_memory_ids_warn_until_modeled() {
1182 let report = build_state_audit_report(Some("root"));
1183
1184 assert!(report.checks.iter().any(|check| {
1185 check.code == "reserved_memory_id_declared" && check.status == StateAuditStatus::Warn
1186 }));
1187 }
1188
1189 #[test]
1190 fn active_domain_reclaiming_reserved_memory_id_fails() {
1191 let mut manifest = declared_state_manifest(Some("root"));
1192 let role = manifest.roles.first_mut().expect("root role");
1193 let reserved_id = role
1194 .reserved_memory
1195 .first()
1196 .map(|entry| entry.memory_id)
1197 .expect("reserved memory id");
1198 role.state[0].memory_id = Some(reserved_id);
1199
1200 let checks = audit_checks(&manifest, Some("root"));
1201
1202 assert!(
1203 checks
1204 .iter()
1205 .any(|check| check.code == "reserved_memory_id_collision"
1206 && check.status == StateAuditStatus::Fail)
1207 );
1208 }
1209
1210 #[test]
1211 fn storage_not_applicable_is_explicit_metadata() {
1212 let manifest = StateManifest {
1213 schema_version: 1,
1214 roles: vec![StateRoleManifest {
1215 canister_role: "root".to_string(),
1216 state: vec![StateDomainManifest {
1217 domain: "external_authority".to_string(),
1218 version: 1,
1219 storage: StateStorage::NotApplicable,
1220 memory_id: None,
1221 owner: "canic-core".to_string(),
1222 record: "ExternalAuthorityRecord".to_string(),
1223 snapshot: "ExternalAuthorityData".to_string(),
1224 min_supported_version: 1,
1225 migration_policy: MigrationPolicy::NotApplicable,
1226 restore_order: Some(10),
1227 post_upgrade_invariant: Some("external_authority_invariants".to_string()),
1228 migrations: Vec::new(),
1229 }],
1230 removed_state: Vec::new(),
1231 reserved_memory: Vec::new(),
1232 }],
1233 };
1234
1235 let checks = audit_checks(&manifest, None);
1236
1237 assert!(checks.iter().any(|check| {
1238 check.code == "state_domain_storage_not_applicable"
1239 && check.status == StateAuditStatus::Pass
1240 }));
1241 assert!(
1242 checks
1243 .iter()
1244 .all(|check| check.code != "state_domain_missing_memory_id")
1245 );
1246 }
1247
1248 #[test]
1249 fn invalid_support_window_fails() {
1250 let manifest = StateManifest {
1251 schema_version: 1,
1252 roles: vec![StateRoleManifest {
1253 canister_role: "root".to_string(),
1254 state: vec![StateDomainManifest {
1255 domain: "auth_sessions".to_string(),
1256 version: 2,
1257 storage: StateStorage::StableMemory,
1258 memory_id: Some(19),
1259 owner: "canic-core".to_string(),
1260 record: "AuthSessionRecord".to_string(),
1261 snapshot: "AuthSessionsData".to_string(),
1262 min_supported_version: 3,
1263 migration_policy: MigrationPolicy::NewDomain,
1264 restore_order: Some(10),
1265 post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1266 migrations: Vec::new(),
1267 }],
1268 removed_state: Vec::new(),
1269 reserved_memory: Vec::new(),
1270 }],
1271 };
1272
1273 let checks = audit_checks(&manifest, None);
1274
1275 assert!(checks.iter().any(|check| {
1276 check.code == "state_domain_invalid_support_window"
1277 && check.status == StateAuditStatus::Fail
1278 }));
1279 assert!(
1280 checks
1281 .iter()
1282 .all(|check| check.code != "migration_available")
1283 );
1284 }
1285
1286 #[test]
1287 fn duplicate_migration_declaration_fails() {
1288 let manifest = StateManifest {
1289 schema_version: 1,
1290 roles: vec![StateRoleManifest {
1291 canister_role: "root".to_string(),
1292 state: vec![StateDomainManifest {
1293 domain: "auth_sessions".to_string(),
1294 version: 3,
1295 storage: StateStorage::StableMemory,
1296 memory_id: Some(19),
1297 owner: "canic-core".to_string(),
1298 record: "AuthSessionRecord".to_string(),
1299 snapshot: "AuthSessionsData".to_string(),
1300 min_supported_version: 2,
1301 migration_policy: MigrationPolicy::Migrate,
1302 restore_order: Some(10),
1303 post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1304 migrations: vec![
1305 StateMigrationManifest {
1306 from: 2,
1307 to: 3,
1308 kind: "function".to_string(),
1309 name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
1310 test: Some(
1311 "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
1312 ),
1313 },
1314 StateMigrationManifest {
1315 from: 2,
1316 to: 3,
1317 kind: "function".to_string(),
1318 name: Some("migrate_auth_sessions_v2_to_v3_again".to_string()),
1319 test: Some(
1320 "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
1321 ),
1322 },
1323 ],
1324 }],
1325 removed_state: Vec::new(),
1326 reserved_memory: Vec::new(),
1327 }],
1328 };
1329
1330 let checks = audit_checks(&manifest, None);
1331
1332 assert!(checks.iter().any(|check| {
1333 check.code == "migration_declaration_duplicate"
1334 && check.status == StateAuditStatus::Fail
1335 }));
1336 }
1337
1338 #[test]
1339 fn invalid_migration_declaration_fails() {
1340 let manifest = StateManifest {
1341 schema_version: 1,
1342 roles: vec![StateRoleManifest {
1343 canister_role: "root".to_string(),
1344 state: vec![StateDomainManifest {
1345 domain: "auth_sessions".to_string(),
1346 version: 4,
1347 storage: StateStorage::StableMemory,
1348 memory_id: Some(19),
1349 owner: "canic-core".to_string(),
1350 record: "AuthSessionRecord".to_string(),
1351 snapshot: "AuthSessionsData".to_string(),
1352 min_supported_version: 2,
1353 migration_policy: MigrationPolicy::Migrate,
1354 restore_order: Some(10),
1355 post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1356 migrations: vec![StateMigrationManifest {
1357 from: 2,
1358 to: 4,
1359 kind: "function".to_string(),
1360 name: Some("migrate_auth_sessions_v2_to_v4".to_string()),
1361 test: Some("auth_sessions_v2_to_v4_upgrade_preserves_sessions".to_string()),
1362 }],
1363 }],
1364 removed_state: Vec::new(),
1365 reserved_memory: Vec::new(),
1366 }],
1367 };
1368
1369 let checks = audit_checks(&manifest, None);
1370
1371 assert!(checks.iter().any(|check| {
1372 check.code == "migration_declaration_invalid" && check.status == StateAuditStatus::Fail
1373 }));
1374 }
1375
1376 #[test]
1377 fn missing_migration_test_warns_separately_from_missing_migration() {
1378 let manifest = StateManifest {
1379 schema_version: 1,
1380 roles: vec![StateRoleManifest {
1381 canister_role: "root".to_string(),
1382 state: vec![StateDomainManifest {
1383 domain: "auth_sessions".to_string(),
1384 version: 3,
1385 storage: StateStorage::StableMemory,
1386 memory_id: Some(19),
1387 owner: "canic-core".to_string(),
1388 record: "AuthSessionRecord".to_string(),
1389 snapshot: "AuthSessionsData".to_string(),
1390 min_supported_version: 2,
1391 migration_policy: MigrationPolicy::Migrate,
1392 restore_order: Some(10),
1393 post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1394 migrations: vec![StateMigrationManifest {
1395 from: 2,
1396 to: 3,
1397 kind: "function".to_string(),
1398 name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
1399 test: None,
1400 }],
1401 }],
1402 removed_state: Vec::new(),
1403 reserved_memory: Vec::new(),
1404 }],
1405 };
1406 let checks = audit_checks(&manifest, None);
1407
1408 assert!(
1409 checks
1410 .iter()
1411 .any(|check| check.code == "upgrade_test_missing"
1412 && check.status == StateAuditStatus::Warn)
1413 );
1414 assert!(checks.iter().all(|check| check.code != "migration_missing"));
1415 }
1416
1417 #[test]
1418 fn removed_state_missing_reason_and_test_warn() {
1419 let manifest = StateManifest {
1420 schema_version: 1,
1421 roles: vec![StateRoleManifest {
1422 canister_role: "root".to_string(),
1423 state: Vec::new(),
1424 removed_state: vec![RemovedStateManifest {
1425 domain: "legacy_cache".to_string(),
1426 last_version: 1,
1427 removed_in_version: 2,
1428 memory_id: Some(99),
1429 disposition: "discarded".to_string(),
1430 reason: String::new(),
1431 test: None,
1432 }],
1433 reserved_memory: Vec::new(),
1434 }],
1435 };
1436
1437 let checks = audit_checks(&manifest, None);
1438
1439 assert!(checks.iter().any(|check| {
1440 check.code == "removed_state_reason_missing" && check.status == StateAuditStatus::Warn
1441 }));
1442 assert!(checks.iter().any(|check| {
1443 check.code == "removed_state_test_missing" && check.status == StateAuditStatus::Warn
1444 }));
1445 }
1446
1447 #[test]
1448 fn unknown_filtered_role_fails() {
1449 let report = build_state_audit_report(Some("missing"));
1450
1451 assert_eq!(report.status, StateAuditStatus::Fail);
1452 assert_eq!(report.checks[0].code, "state_role_missing");
1453 }
1454}