ic_wasm/check_endpoints/
mod.rs1mod 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 if line.starts_with("#") {
152 return Ok(None);
153 }
154 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 parse_uncommented_line(line)
167}