Skip to main content

canic_backup/topology/
mod.rs

1//! Module: topology
2//!
3//! Responsibility: model and hash backup topology snapshots.
4//! Does not own: registry discovery, manifest projection, or snapshot IO.
5//! Boundary: provides deterministic topology inputs for backup invariants.
6
7#[cfg(test)]
8mod tests;
9
10use crate::hash::sha256_hex;
11
12use candid::Principal;
13use serde::{Deserialize, Serialize};
14
15///
16/// TopologyRecord
17///
18/// Backup topology row used to build deterministic topology hashes.
19/// Owned by backup topology support and projected from discovered targets.
20///
21
22#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
23pub struct TopologyRecord {
24    pub pid: Principal,
25    pub parent_pid: Option<Principal>,
26    pub role: String,
27    pub module_hash: Option<String>,
28}
29
30///
31/// TopologyHash
32///
33/// Deterministic hash metadata for a discovered or pre-snapshot topology.
34/// Owned by backup topology support and stored in backup manifests.
35///
36
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38pub struct TopologyHash {
39    pub algorithm: String,
40    pub input: String,
41    pub hash: String,
42}
43
44///
45/// TopologyHasher
46///
47/// Stateless topology hashing entry point for discovery and snapshot guards.
48/// Owned by backup topology support and used by discovery and snapshot flows.
49///
50
51pub struct TopologyHasher;
52
53impl TopologyHasher {
54    /// Compute the canonical SHA-256 topology hash for discovery invariants.
55    #[must_use]
56    pub fn hash(records: &[TopologyRecord]) -> TopologyHash {
57        let input = Self::canonical_input(records);
58        let hash = sha256_hex(input.as_bytes());
59
60        TopologyHash {
61            algorithm: "sha256".to_string(),
62            input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
63            hash,
64        }
65    }
66
67    /// Build the stable canonical topology input used by `hash`.
68    #[must_use]
69    pub fn canonical_input(records: &[TopologyRecord]) -> String {
70        let mut rows = records.iter().map(canonical_row).collect::<Vec<_>>();
71        rows.sort();
72        rows.join("\n")
73    }
74}
75
76// Encode one topology record with explicit null markers for optional fields.
77fn canonical_row(record: &TopologyRecord) -> String {
78    format!(
79        "pid={}|parent_pid={}|role={}|module_hash={}",
80        record.pid,
81        optional_principal(record.parent_pid),
82        record.role,
83        optional_str(record.module_hash.as_deref())
84    )
85}
86
87// Encode optional principals with a stable null marker.
88fn optional_principal(value: Option<Principal>) -> String {
89    value.map_or_else(|| "null".to_string(), |pid| pid.to_string())
90}
91
92// Encode optional string fields with a stable null marker.
93fn optional_str(value: Option<&str>) -> &str {
94    value.unwrap_or("null")
95}