canic_backup/topology/
mod.rs1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
10pub struct TopologyRecord {
11 pub pid: Principal,
12 pub parent_pid: Option<Principal>,
13 pub role: String,
14 pub module_hash: Option<String>,
15}
16
17#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct TopologyHash {
23 pub algorithm: String,
24 pub input: String,
25 pub hash: String,
26}
27
28pub struct TopologyHasher;
33
34impl TopologyHasher {
35 #[must_use]
37 pub fn hash(records: &[TopologyRecord]) -> TopologyHash {
38 let input = Self::canonical_input(records);
39 let hash = sha256_hex(input.as_bytes());
40
41 TopologyHash {
42 algorithm: "sha256".to_string(),
43 input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
44 hash,
45 }
46 }
47
48 #[must_use]
50 pub fn canonical_input(records: &[TopologyRecord]) -> String {
51 let mut rows = records.iter().map(canonical_row).collect::<Vec<_>>();
52 rows.sort();
53 rows.join("\n")
54 }
55}
56
57fn canonical_row(record: &TopologyRecord) -> String {
59 format!(
60 "pid={}|parent_pid={}|role={}|module_hash={}",
61 record.pid,
62 optional_principal(record.parent_pid),
63 record.role,
64 optional_str(record.module_hash.as_deref())
65 )
66}
67
68fn optional_principal(value: Option<Principal>) -> String {
70 value.map_or_else(|| "null".to_string(), |pid| pid.to_string())
71}
72
73fn optional_str(value: Option<&str>) -> &str {
75 value.unwrap_or("null")
76}
77
78fn sha256_hex(bytes: &[u8]) -> String {
80 let digest = Sha256::digest(bytes);
81 let mut out = String::with_capacity(digest.len() * 2);
82 for byte in digest {
83 out.push(hex_char(byte >> 4));
84 out.push(hex_char(byte & 0x0f));
85 }
86 out
87}
88
89const fn hex_char(nibble: u8) -> char {
91 match nibble {
92 0..=9 => (b'0' + nibble) as char,
93 10..=15 => (b'a' + (nibble - 10)) as char,
94 _ => unreachable!(),
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 const ROOT: Principal = Principal::from_slice(&[]);
103
104 fn p(id: u8) -> Principal {
106 Principal::from_slice(&[id; 29])
107 }
108
109 #[test]
111 fn topology_hash_is_order_independent() {
112 let records = vec![record(p(2), Some(ROOT), "app"), record(ROOT, None, "root")];
113 let reversed = vec![record(ROOT, None, "root"), record(p(2), Some(ROOT), "app")];
114
115 let first = TopologyHasher::hash(&records);
116 let second = TopologyHasher::hash(&reversed);
117
118 assert_eq!(first.hash, second.hash);
119 assert_eq!(first.hash.len(), 64);
120 }
121
122 #[test]
124 fn topology_hash_changes_when_parent_changes() {
125 let original = vec![record(p(2), Some(ROOT), "app")];
126 let changed = vec![record(p(2), Some(p(3)), "app")];
127
128 let first = TopologyHasher::hash(&original);
129 let second = TopologyHasher::hash(&changed);
130
131 assert_ne!(first.hash, second.hash);
132 }
133
134 #[test]
136 fn canonical_input_uses_explicit_null_markers() {
137 let input = TopologyHasher::canonical_input(&[record(ROOT, None, "root")]);
138
139 assert!(input.contains("parent_pid=null"));
140 assert!(input.contains("module_hash=null"));
141 }
142
143 fn record(pid: Principal, parent_pid: Option<Principal>, role: &str) -> TopologyRecord {
145 TopologyRecord {
146 pid,
147 parent_pid,
148 role: role.to_string(),
149 module_hash: None,
150 }
151 }
152}