Skip to main content

ic_wasm/check_endpoints/
mod.rs

1mod candid;
2
3pub use crate::check_endpoints::candid::CandidParser;
4use crate::{info::ExportedMethodInfo, utils::get_exported_methods};
5use anyhow::anyhow;
6use parse_display::{Display, FromStr};
7use std::io::BufReader;
8use std::{collections::BTreeSet, io::BufRead, path::Path, str::FromStr};
9use walrus::Module;
10
11#[derive(Clone, Eq, Debug, Ord, PartialEq, PartialOrd, Display, FromStr)]
12pub enum CanisterEndpoint {
13    #[display("canister_update:{0}")]
14    Update(String),
15    #[display("canister_query:{0}")]
16    Query(String),
17    #[display("canister_composite_query:{0}")]
18    CompositeQuery(String),
19    #[display("{0}")]
20    Entrypoint(String),
21}
22
23impl TryFrom<&ExportedMethodInfo> for CanisterEndpoint {
24    type Error = anyhow::Error;
25
26    fn try_from(method: &ExportedMethodInfo) -> Result<Self, Self::Error> {
27        type EndpointConstructor = fn(&str) -> CanisterEndpoint;
28        const MAPPINGS: &[(&str, EndpointConstructor)] = &[
29            ("canister_update", |s| {
30                CanisterEndpoint::Update(s.to_string())
31            }),
32            ("canister_query", |s| CanisterEndpoint::Query(s.to_string())),
33            ("canister_composite_query", |s| {
34                CanisterEndpoint::CompositeQuery(s.to_string())
35            }),
36        ];
37
38        for (candid_prefix, constructor) in MAPPINGS {
39            if let Some(rest) = method.name.strip_prefix(candid_prefix) {
40                return Ok(constructor(rest.trim()));
41            }
42        }
43
44        let trimmed = method.name.trim();
45        if !trimmed.is_empty() {
46            Ok(CanisterEndpoint::Entrypoint(trimmed.to_string()))
47        } else {
48            Err(anyhow!("Exported method in canister WASM has empty name"))
49        }
50    }
51}
52
53pub fn check_endpoints(
54    module: &Module,
55    candid_path: Option<&Path>,
56    hidden_path: Option<&Path>,
57) -> anyhow::Result<()> {
58    let wasm_endpoints = get_exported_methods(module)
59        .iter()
60        .map(CanisterEndpoint::try_from)
61        .collect::<Result<BTreeSet<CanisterEndpoint>, _>>()?;
62
63    let candid_endpoints = CandidParser::try_from_wasm(module)?
64        .or_else(|| candid_path.map(CandidParser::from_candid_file))
65        .ok_or(anyhow!(
66            "Candid interface not specified in WASM file and Candid file not provided"
67        ))?
68        .parse()?;
69
70    let missing_candid_endpoints = candid_endpoints
71        .difference(&wasm_endpoints)
72        .collect::<BTreeSet<_>>();
73    missing_candid_endpoints.iter().for_each(|endpoint| {
74        eprintln!(
75            "ERROR: The following Candid endpoint is missing from the WASM exports section: {endpoint}"
76        );
77    });
78
79    let hidden_endpoints = read_hidden_endpoints(hidden_path)?;
80    let missing_hidden_endpoints = hidden_endpoints
81        .difference(&wasm_endpoints)
82        .collect::<BTreeSet<_>>();
83    missing_hidden_endpoints.iter().for_each(|endpoint| {
84        eprintln!(
85            "ERROR: The following hidden endpoint is missing from the WASM exports section: {endpoint}"
86        );
87    });
88
89    let unexpected_endpoints = wasm_endpoints
90        .iter()
91        .filter(|endpoint| {
92            !candid_endpoints.contains(endpoint) && !hidden_endpoints.contains(endpoint)
93        })
94        .collect::<BTreeSet<_>>();
95    unexpected_endpoints.iter().for_each(|endpoint| {
96        eprintln!(
97            "ERROR: The following endpoint is unexpected in the WASM exports section: {endpoint}"
98        );
99    });
100
101    if !missing_candid_endpoints.is_empty()
102        || !missing_hidden_endpoints.is_empty()
103        || !unexpected_endpoints.is_empty()
104    {
105        Err(anyhow!("Canister WASM and Candid interface do not match!"))
106    } else {
107        println!("Canister WASM and Candid interface match!");
108        Ok(())
109    }
110}
111
112fn read_hidden_endpoints(maybe_path: Option<&Path>) -> anyhow::Result<BTreeSet<CanisterEndpoint>> {
113    if let Some(path) = maybe_path {
114        let mut endpoints = BTreeSet::new();
115        for line in read_lines(path)? {
116            if let Some(endpoint) = parse_line(line)? {
117                endpoints.insert(endpoint);
118            }
119        }
120        Ok(endpoints)
121    } else {
122        Ok(BTreeSet::new())
123    }
124}
125
126fn read_lines(path: &Path) -> anyhow::Result<Vec<String>> {
127    let file = std::fs::File::open(path)
128        .map_err(|e| anyhow!("Could not open hidden endpoints file: {e:?}"))?;
129
130    let reader = BufReader::new(file);
131    let mut lines = Vec::new();
132
133    for line in reader.lines() {
134        let line = line?;
135        let trimmed = line.trim();
136        if !trimmed.is_empty() {
137            lines.push(trimmed.to_string());
138        }
139    }
140
141    Ok(lines)
142}
143
144fn parse_line(line: String) -> anyhow::Result<Option<CanisterEndpoint>> {
145    fn parse_uncommented_line(line: String) -> anyhow::Result<Option<CanisterEndpoint>> {
146        CanisterEndpoint::from_str(line.as_str())
147            .map(Some)
148            .map_err(Into::into)
149    }
150    // Comment: ignore line
151    if line.starts_with("#") {
152        return Ok(None);
153    }
154    // Quoted line: use JSON string syntax to allow for character escaping
155    if line.starts_with('"') {
156        if !line.ends_with('"') || line.len() < 2 {
157            return Err(anyhow!(
158                "Could not parse hidden endpoint, missing terminating quote: {line}"
159            ));
160        }
161        return serde_json::from_str::<String>(&line)
162            .map_err(|e| anyhow!("Could not parse hidden endpoint: {e:?}"))
163            .and_then(parse_uncommented_line);
164    }
165    // Regular line
166    parse_uncommented_line(line)
167}