Skip to main content

canic_host/
registry.rs

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///
9/// RegistryEntry
10///
11
12#[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///
22/// RegistryParseError
23///
24
25#[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
43/// Parse the wrapped subnet registry JSON shape.
44pub 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
83// Parse one registry entry from registry JSON.
84fn 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
113// Parse optional wasm module hash JSON emitted as bytes or text.
114fn 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
132// Parse optional principal JSON emitted as null, string, or optional vector form.
133fn 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///
181/// SubnetRegistryResponseWire
182///
183
184#[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///
197/// SubnetRegistryEntryWire
198///
199
200#[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///
228/// CanisterInfoWire
229///
230
231#[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///
240/// CanicErrorWire
241///
242
243#[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    // Ensure registry parsing accepts the wrapped registry JSON shape.
258    #[test]
259    fn registry_entries_parse_wrapped_cli_json() {
260        let entries = parse_registry_entries(&registry_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}