1#[cfg(test)]
2use candid::Encode;
3use candid::{CandidType, Decode, Principal};
4use serde::Deserialize;
5use serde_json::Value;
6use thiserror::Error as ThisError;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct RegistryEntry {
14 pub pid: String,
15 pub role: Option<String>,
16 pub kind: Option<String>,
17 pub parent_pid: Option<String>,
18 pub module_hash: Option<String>,
19}
20
21#[derive(Debug, ThisError)]
26pub enum RegistryParseError {
27 #[error("registry JSON must be an array, {{\"Ok\": [...]}}, or ICP response_bytes envelope")]
28 InvalidRegistryJsonShape,
29
30 #[error("registry response_bytes was not valid hex")]
31 InvalidResponseBytes,
32
33 #[error("registry response rejected: {0}")]
34 Rejected(String),
35
36 #[error("could not decode registry response_bytes: {0}")]
37 Candid(String),
38
39 #[error(transparent)]
40 Json(#[from] serde_json::Error),
41}
42
43pub fn parse_registry_entries(
45 registry_json: &str,
46) -> Result<Vec<RegistryEntry>, RegistryParseError> {
47 let data = serde_json::from_str::<Value>(registry_json)?;
48 if let Some(entries) = parse_registry_entries_json(&data) {
49 return Ok(entries);
50 }
51 if let Some(entries) = parse_registry_entries_response_bytes(&data)? {
52 return Ok(entries);
53 }
54
55 Err(RegistryParseError::InvalidRegistryJsonShape)
56}
57
58fn parse_registry_entries_json(data: &Value) -> Option<Vec<RegistryEntry>> {
59 let entries = data
60 .get("Ok")
61 .and_then(Value::as_array)
62 .or_else(|| data.as_array())?;
63
64 Some(entries.iter().filter_map(parse_registry_entry).collect())
65}
66
67fn parse_registry_entries_response_bytes(
68 data: &Value,
69) -> Result<Option<Vec<RegistryEntry>>, RegistryParseError> {
70 let Some(bytes) = data.get("response_bytes").and_then(Value::as_str) else {
71 return Ok(None);
72 };
73 let bytes = hex_to_bytes(bytes).ok_or(RegistryParseError::InvalidResponseBytes)?;
74 let response = Decode!(
75 &bytes,
76 Result<SubnetRegistryResponseWire, CanicErrorWire>
77 )
78 .map_err(|err| RegistryParseError::Candid(err.to_string()))?;
79 let response = response.map_err(|err| RegistryParseError::Rejected(err.message))?;
80 Ok(Some(response.to_registry_entries()))
81}
82
83fn parse_registry_entry(value: &Value) -> Option<RegistryEntry> {
85 let pid = value.get("pid").and_then(Value::as_str)?.to_string();
86 let role = value
87 .get("role")
88 .and_then(Value::as_str)
89 .map(str::to_string);
90 let parent_pid = value
91 .get("record")
92 .and_then(|record| record.get("parent_pid"))
93 .and_then(parse_optional_principal);
94 let kind = value
95 .get("kind")
96 .or_else(|| value.get("record").and_then(|record| record.get("kind")))
97 .and_then(Value::as_str)
98 .map(str::to_string);
99 let module_hash = value
100 .get("record")
101 .and_then(|record| record.get("module_hash"))
102 .and_then(parse_module_hash);
103
104 Some(RegistryEntry {
105 pid,
106 role,
107 kind,
108 parent_pid,
109 module_hash,
110 })
111}
112
113fn parse_module_hash(value: &Value) -> Option<String> {
115 if value.is_null() {
116 return None;
117 }
118 if let Some(text) = value.as_str() {
119 return Some(text.to_string());
120 }
121 let bytes = value
122 .as_array()?
123 .iter()
124 .map(|item| {
125 let value = item.as_u64()?;
126 u8::try_from(value).ok()
127 })
128 .collect::<Option<Vec<_>>>()?;
129 Some(hex_bytes(&bytes))
130}
131
132fn parse_optional_principal(value: &Value) -> Option<String> {
134 if value.is_null() {
135 return None;
136 }
137 if let Some(text) = value.as_str() {
138 return Some(text.to_string());
139 }
140 value
141 .as_array()
142 .and_then(|items| items.first())
143 .and_then(Value::as_str)
144 .map(str::to_string)
145}
146
147fn hex_bytes(bytes: &[u8]) -> String {
148 const HEX: &[u8; 16] = b"0123456789abcdef";
149 let mut out = String::with_capacity(bytes.len() * 2);
150 for byte in bytes {
151 out.push(char::from(HEX[usize::from(byte >> 4)]));
152 out.push(char::from(HEX[usize::from(byte & 0x0f)]));
153 }
154 out
155}
156
157fn hex_to_bytes(text: &str) -> Option<Vec<u8>> {
158 if !text.len().is_multiple_of(2) {
159 return None;
160 }
161 text.as_bytes()
162 .chunks_exact(2)
163 .map(|pair| {
164 let high = hex_value(pair[0])?;
165 let low = hex_value(pair[1])?;
166 Some((high << 4) | low)
167 })
168 .collect()
169}
170
171const fn hex_value(byte: u8) -> Option<u8> {
172 match byte {
173 b'0'..=b'9' => Some(byte - b'0'),
174 b'a'..=b'f' => Some(byte - b'a' + 10),
175 b'A'..=b'F' => Some(byte - b'A' + 10),
176 _ => None,
177 }
178}
179
180#[derive(CandidType, Deserialize)]
185struct SubnetRegistryResponseWire(Vec<SubnetRegistryEntryWire>);
186
187impl SubnetRegistryResponseWire {
188 fn to_registry_entries(&self) -> Vec<RegistryEntry> {
189 self.0
190 .iter()
191 .map(SubnetRegistryEntryWire::to_registry_entry)
192 .collect()
193 }
194}
195
196#[derive(CandidType, Deserialize)]
201struct SubnetRegistryEntryWire {
202 pid: Principal,
203 role: String,
204 record: CanisterInfoWire,
205}
206
207impl SubnetRegistryEntryWire {
208 fn to_registry_entry(&self) -> RegistryEntry {
209 let pid = self.pid.to_text();
210 let record_pid = self.record.pid.to_text();
211 debug_assert_eq!(record_pid, pid);
212 let role = if self.role.is_empty() {
213 self.record.role.clone()
214 } else {
215 self.role.clone()
216 };
217 RegistryEntry {
218 pid,
219 role: Some(role),
220 kind: None,
221 parent_pid: self.record.parent_pid.as_ref().map(Principal::to_text),
222 module_hash: self.record.module_hash.as_deref().map(hex_bytes),
223 }
224 }
225}
226
227#[derive(CandidType, Deserialize)]
232struct CanisterInfoWire {
233 pid: Principal,
234 role: String,
235 parent_pid: Option<Principal>,
236 module_hash: Option<Vec<u8>>,
237}
238
239#[derive(CandidType, Deserialize)]
244struct CanicErrorWire {
245 message: String,
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 const ROOT_TEXT: &str = "aaaaa-aa";
253 const APP_TEXT: &str = "renrk-eyaaa-aaaaa-aaada-cai";
254 const WORKER_TEXT: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
255 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
256
257 #[test]
259 fn registry_entries_parse_wrapped_cli_json() {
260 let entries = parse_registry_entries(®istry_json()).expect("parse registry");
261
262 assert_eq!(
263 entries,
264 vec![
265 RegistryEntry {
266 pid: ROOT_TEXT.to_string(),
267 role: Some("root".to_string()),
268 kind: Some("root".to_string()),
269 parent_pid: None,
270 module_hash: None,
271 },
272 RegistryEntry {
273 pid: APP_TEXT.to_string(),
274 role: Some("app".to_string()),
275 kind: Some("singleton".to_string()),
276 parent_pid: Some(ROOT_TEXT.to_string()),
277 module_hash: Some("01ab".to_string()),
278 },
279 RegistryEntry {
280 pid: WORKER_TEXT.to_string(),
281 role: Some("worker".to_string()),
282 kind: Some("replica".to_string()),
283 parent_pid: Some(APP_TEXT.to_string()),
284 module_hash: Some(HASH.to_string()),
285 },
286 ]
287 );
288 }
289
290 fn registry_json() -> String {
291 serde_json::json!({
292 "Ok": [
293 {
294 "pid": ROOT_TEXT,
295 "role": "root",
296 "record": {
297 "pid": ROOT_TEXT,
298 "role": "root",
299 "kind": "root",
300 "parent_pid": null,
301 "module_hash": null
302 }
303 },
304 {
305 "pid": APP_TEXT,
306 "role": "app",
307 "kind": "singleton",
308 "record": {
309 "pid": APP_TEXT,
310 "role": "app",
311 "parent_pid": [ROOT_TEXT],
312 "module_hash": [1, 171]
313 }
314 },
315 {
316 "pid": WORKER_TEXT,
317 "role": "worker",
318 "kind": "replica",
319 "record": {
320 "pid": WORKER_TEXT,
321 "role": "worker",
322 "parent_pid": [APP_TEXT],
323 "module_hash": HASH
324 }
325 }
326 ]
327 })
328 .to_string()
329 }
330
331 #[test]
332 fn registry_entries_parse_icp_response_bytes_json() {
333 #[derive(CandidType)]
334 struct FullSubnetRegistryEntryWire {
335 pid: Principal,
336 role: String,
337 record: FullCanisterInfoWire,
338 }
339
340 #[derive(CandidType)]
341 struct FullSubnetRegistryResponseWire(Vec<FullSubnetRegistryEntryWire>);
342
343 #[derive(CandidType)]
344 struct FullCanisterInfoWire {
345 pid: Principal,
346 role: String,
347 parent_pid: Option<Principal>,
348 module_hash: Option<Vec<u8>>,
349 created_at: u64,
350 }
351
352 let response = Ok::<_, CanicErrorWire>(FullSubnetRegistryResponseWire(vec![
353 FullSubnetRegistryEntryWire {
354 pid: Principal::from_text(ROOT_TEXT).expect("root principal"),
355 role: "root".to_string(),
356 record: FullCanisterInfoWire {
357 pid: Principal::from_text(ROOT_TEXT).expect("root principal"),
358 role: "root".to_string(),
359 parent_pid: None,
360 module_hash: None,
361 created_at: 1,
362 },
363 },
364 FullSubnetRegistryEntryWire {
365 pid: Principal::from_text(APP_TEXT).expect("app principal"),
366 role: "app".to_string(),
367 record: FullCanisterInfoWire {
368 pid: Principal::from_text(APP_TEXT).expect("app principal"),
369 role: "app".to_string(),
370 parent_pid: Some(Principal::from_text(ROOT_TEXT).expect("root principal")),
371 module_hash: Some(vec![1, 171]),
372 created_at: 2,
373 },
374 },
375 ]));
376 let bytes = candid::Encode!(&response).expect("encode registry response");
377 let payload = serde_json::json!({
378 "response_bytes": hex_bytes(&bytes),
379 "response_candid": "(variant { Ok = vec { ... } })"
380 })
381 .to_string();
382 let entries = parse_registry_entries(&payload).expect("parse response bytes registry");
383
384 assert_eq!(
385 entries,
386 vec![
387 RegistryEntry {
388 pid: ROOT_TEXT.to_string(),
389 role: Some("root".to_string()),
390 kind: None,
391 parent_pid: None,
392 module_hash: None,
393 },
394 RegistryEntry {
395 pid: APP_TEXT.to_string(),
396 role: Some("app".to_string()),
397 kind: None,
398 parent_pid: Some(ROOT_TEXT.to_string()),
399 module_hash: Some("01ab".to_string()),
400 },
401 ]
402 );
403 }
404
405 #[test]
406 fn registry_entries_reject_invalid_response_bytes_hex() {
407 let payload = serde_json::json!({
408 "response_bytes": "not-hex"
409 })
410 .to_string();
411 let err = parse_registry_entries(&payload).expect_err("reject invalid response bytes");
412
413 assert!(matches!(err, RegistryParseError::InvalidResponseBytes));
414 }
415
416 #[test]
417 fn registry_entries_surface_response_bytes_rejection() {
418 let response = Err::<SubnetRegistryResponseWire, _>(CanicErrorWire {
419 message: "not ready".into(),
420 });
421 let bytes = candid::Encode!(&response).expect("encode registry rejection");
422 let payload = serde_json::json!({
423 "response_bytes": hex_bytes(&bytes)
424 })
425 .to_string();
426 let err = parse_registry_entries(&payload).expect_err("surface registry rejection");
427
428 assert!(matches!(err, RegistryParseError::Rejected(message) if message == "not ready"));
429 }
430}