use candid::{
types::value::{IDLArgs, IDLField, IDLValue},
types::Label,
Principal,
};
use ic_agent::Agent;
use rmcp::schemars;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetCandidArgs {
pub canister_id: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct GetCandidOutput {
pub canister_id: String,
pub candid: String,
pub oql: bool,
pub api_doc_available: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct OqlSchemaArgs {
pub canister_id: String,
#[serde(default, alias = "domain")]
pub derivation_origin: Option<String>,
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct OqlSchemaOutput {
pub canister_id: String,
pub schema: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub acted_as_principal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derived_for_origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requested: Option<String>,
pub is_anonymous: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
pub example_queries: Vec<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct OqlGuideOutput {
pub content: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ApiDocArgs {
pub canister_id: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ApiDocOutput {
pub canister_id: String,
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
pub expected: bool,
pub retry: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CanisterUpdateCallArgs {
pub canister_id: String,
pub method: String,
#[serde(default = "default_args")]
pub args: String,
#[serde(default, alias = "domain")]
pub derivation_origin: Option<String>,
#[serde(default)]
pub account: Option<String>,
#[serde(default)]
pub candid: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct CanisterUpdateCallOutput {
pub canister_id: String,
pub method: String,
pub reply: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub acted_as_principal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derived_for_origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requested: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derivation_origin_source: Option<String>,
pub is_anonymous: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CanisterQueryArgs {
pub canister_id: String,
#[serde(default)]
pub method: Option<String>,
#[serde(default = "default_args")]
pub args: String,
#[serde(default)]
pub oql: Option<String>,
#[serde(default, alias = "domain")]
pub derivation_origin: Option<String>,
#[serde(default)]
pub account: Option<String>,
#[serde(default)]
pub candid: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct CanisterQueryOutput {
pub canister_id: String,
pub mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply: Option<String>,
#[serde(default)]
pub columns: Vec<String>,
#[serde(default)]
pub rows: Vec<Vec<String>>,
#[serde(default)]
pub has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub acted_as_principal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derived_for_origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requested: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derivation_origin_source: Option<String>,
pub is_anonymous: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_entities: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub did_you_mean: Option<String>,
}
pub fn default_args() -> String {
"()".to_string()
}
pub async fn resolve_did(agent: &Agent, canister: Principal, provided: Option<&str>) -> Option<String> {
if let Some(did) = candid_service(agent, canister).await {
return Some(did);
}
provided.map(str::to_string)
}
pub async fn candid_service(agent: &Agent, canister: Principal) -> Option<String> {
let raw = agent
.read_state_canister_metadata(canister, "candid:service")
.await
.ok()?;
String::from_utf8(raw).ok()
}
pub(crate) const MAX_CANDID_TEXT_BYTES: usize = 1024 * 1024;
pub(crate) const MAX_CANDID_DEPTH: usize = 128;
pub(crate) const MAX_CANDID_TYPE_DECLS: usize = 1024;
const CANDID_PARSE_STACK_BYTES: usize = 64 * 1024 * 1024;
const CANDID_PARSE_THREADS_PER_CORE: usize = 1;
fn candid_parse_permits() -> &'static (std::sync::Mutex<usize>, std::sync::Condvar) {
static PERMITS: std::sync::OnceLock<(std::sync::Mutex<usize>, std::sync::Condvar)> =
std::sync::OnceLock::new();
PERMITS.get_or_init(|| {
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
let limit = (cores * CANDID_PARSE_THREADS_PER_CORE).max(4);
(std::sync::Mutex::new(limit), std::sync::Condvar::new())
})
}
struct CandidParsePermit;
impl CandidParsePermit {
fn acquire() -> Self {
let (free, wakeup) = candid_parse_permits();
let mut free = free.lock().expect("candid parse permits poisoned");
while *free == 0 {
free = wakeup.wait(free).expect("candid parse permits poisoned");
}
*free -= 1;
Self
}
}
impl Drop for CandidParsePermit {
fn drop(&mut self) {
let (free, wakeup) = candid_parse_permits();
*free.lock().expect("candid parse permits poisoned") += 1;
wakeup.notify_one();
}
}
pub(crate) fn on_deep_stack<T: Send>(f: impl FnOnce() -> T + Send) -> Option<T> {
let _permit = CandidParsePermit::acquire();
let mut out = None;
let mut panicked = None;
std::thread::scope(|scope| {
let slot = &mut out;
let spawned = std::thread::Builder::new()
.name("candid-parse".into())
.stack_size(CANDID_PARSE_STACK_BYTES)
.spawn_scoped(scope, move || *slot = Some(f()));
if let Ok(handle) = spawned {
if let Err(payload) = handle.join() {
panicked = Some(payload);
}
}
});
if let Some(payload) = panicked {
std::panic::resume_unwind(payload);
}
out
}
pub(crate) fn guard_candid_text(what: &str, text: &str) -> Result<(), String> {
if text.len() > MAX_CANDID_TEXT_BYTES {
return Err(format!(
"{what} is too large to parse ({} bytes; limit {MAX_CANDID_TEXT_BYTES})",
text.len()
));
}
let mut stack: Vec<u8> = Vec::new();
let mut type_decls = 0usize;
let bytes = text.as_bytes();
let is_word = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
fn resolve_prefixes(stack: &mut Vec<u8>) {
while stack.last() == Some(&b'P') {
stack.pop();
}
}
let peek_significant = |j: usize| -> Option<u8> { bytes.get(skip_trivia(bytes, j)).copied() };
let mut i = 0;
while i < bytes.len() {
i = skip_trivia(bytes, i);
let Some(&c) = bytes.get(i) else { break };
match c {
b'"' => {
i += 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => {
i += 1;
break;
}
_ => i += 1,
}
}
resolve_prefixes(&mut stack);
}
b'(' | b'{' | b'[' => {
stack.push(b'B');
if stack.len() > MAX_CANDID_DEPTH {
return Err(depth_err(what));
}
i += 1;
}
b')' | b'}' | b']' => {
if stack.last() == Some(&b'B') {
stack.pop();
}
resolve_prefixes(&mut stack);
i += 1;
}
b',' | b';' => {
resolve_prefixes(&mut stack);
i += 1;
}
_ if is_word(c) => {
let start = i;
while i < bytes.len() && is_word(bytes[i]) {
i += 1;
}
let word = &bytes[start..i];
if word == b"type" {
type_decls += 1;
if type_decls > MAX_CANDID_TYPE_DECLS {
return Err(format!(
"{what} declares too many types (limit \
{MAX_CANDID_TYPE_DECLS}) — refusing to parse"
));
}
}
if word == b"opt" || word == b"vec" {
stack.push(b'P');
if stack.len() > MAX_CANDID_DEPTH {
return Err(depth_err(what));
}
} else if !matches!(peek_significant(i), Some(b'{') | Some(b'(') | Some(b'[')) {
resolve_prefixes(&mut stack);
}
}
_ => i += 1,
}
}
Ok(())
}
fn depth_err(what: &str) -> String {
format!("{what} is nested too deeply (limit {MAX_CANDID_DEPTH}) — refusing to parse")
}
fn skip_trivia(bytes: &[u8], mut j: usize) -> usize {
loop {
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
match (bytes.get(j), bytes.get(j + 1)) {
(Some(b'/'), Some(b'/')) => {
j += 2;
while j < bytes.len() && bytes[j] != b'\n' {
j += 1;
}
}
(Some(b'/'), Some(b'*')) => {
j += 2;
let mut depth = 1usize;
while depth > 0 && j < bytes.len() {
match (bytes[j], bytes.get(j + 1)) {
(b'/', Some(b'*')) => {
depth += 1;
j += 2;
}
(b'*', Some(b'/')) => {
depth -= 1;
j += 2;
}
_ => j += 1,
}
}
}
_ => return j,
}
}
}
pub fn encode_args(did: Option<&str>, method: &str, args_text: &str) -> Result<Vec<u8>, String> {
guard_candid_text("the `args` value", args_text)?;
on_deep_stack(|| {
let parsed = candid_parser::parse_idl_args(args_text)
.map_err(|e| format!("could not parse args `{args_text}`: {e}"))?;
if let Some(did) = did.filter(|d| guard_candid_text("the `candid` interface", d).is_ok()) {
if let Ok((env, Some(actor))) = candid_parser::utils::CandidSource::Text(did).load() {
if let Ok(func) = env.get_method(&actor, method) {
return parsed.to_bytes_with_types(&env, &func.args).map_err(|e| {
format!("args don't match `{method}`'s Candid signature: {e}")
});
}
}
}
parsed
.to_bytes()
.map_err(|e| format!("could not encode args `{args_text}`: {e}"))
})
.unwrap_or_else(|| Err("could not spawn a thread to parse the `args` value".into()))
}
const REPLY_DECODING_QUOTA: usize = 3_000_000;
fn reply_decoder_config() -> candid::DecoderConfig {
let mut cfg = candid::DecoderConfig::new();
cfg.set_decoding_quota(REPLY_DECODING_QUOTA)
.set_skipping_quota(REPLY_DECODING_QUOTA);
cfg
}
pub fn decode_reply(did: Option<&str>, method: &str, bytes: &[u8]) -> String {
if let Some(text) = did.and_then(|d| decode_bytes_with_did(d, method, bytes)) {
return text;
}
on_deep_stack(|| match IDLArgs::from_bytes_with_config(bytes, &reply_decoder_config()) {
Ok(decoded) => decoded.to_string(),
Err(e) => format!("(call succeeded but reply is not decodable as Candid: {e})"),
})
.unwrap_or_else(|| "(could not spawn a thread to decode the reply)".to_string())
}
pub fn decode_bytes_with_did(did: &str, method: &str, bytes: &[u8]) -> Option<String> {
guard_candid_text("the `candid` interface", did).ok()?;
on_deep_stack(|| {
let (env, actor) = candid_parser::utils::CandidSource::Text(did).load().ok()?;
let actor = actor?;
let func = env.get_method(&actor, method).ok()?;
let decoded = IDLArgs::from_bytes_with_types_with_config(bytes, &env, &func.rets, &reply_decoder_config()).ok()?;
Some(decoded.to_string())
})
.flatten()
}
pub fn has_oql(did: &str) -> bool {
if guard_candid_text("the `candid` interface", did).is_err() {
return false;
}
on_deep_stack(|| {
let Ok((env, Some(actor))) = candid_parser::utils::CandidSource::Text(did).load() else {
return false;
};
env.get_method(&actor, "schema").is_ok() && env.get_method(&actor, "execute").is_ok()
})
.unwrap_or(false)
}
pub fn is_query_method(did: &str, method: &str) -> Option<bool> {
if guard_candid_text("the `candid` interface", did).is_err() {
return None;
}
on_deep_stack(|| {
let (env, actor) = candid_parser::utils::CandidSource::Text(did).load().ok()?;
let actor = actor?;
let func = env.get_method(&actor, method).ok()?;
Some(func.is_query())
})
.flatten()
}
pub fn oql_query_redirect(did: Option<&str>) -> Option<String> {
if did.is_some_and(has_oql) {
Some(
"this canister exposes an OQL query surface, so its data is READ with an OQL query, \
NOT a raw Candid `method` query call. Do this instead, in order: (1) `icp_oql_guide` \
for the JSON dialect (once), (2) `get_canister_oql_schema` for the entity and field \
names, (3) call `canister_query` again with the `oql` argument (a JSON query object) \
instead of `method`. This canister gates data by the caller's principal, so pass the \
app's `derivation_origin` (from open_app / resolve_app) — an anonymous read is rejected. \
UPDATE calls (state changes) go through `canister_update_call`."
.to_string(),
)
} else {
None
}
}
pub(crate) const MAX_OQL_ENTITIES: usize = 40;
pub(crate) const MAX_OQL_COLUMNS: usize = 256;
pub(crate) const MAX_OQL_ROWS: usize = 1_000;
pub fn anonymous_empty_note(what: &str, add_hint: &str) -> String {
format!(
"Read anonymously (as principal 2vxsx-fae) and {what} came back empty. This canister \
gates data by the CALLER's principal, so empty here most likely means \"not authenticated \
as your account\", NOT \"no data\". Re-run this exact call adding {add_hint} to read as \
your account — if you don't have it yet, open_app / resolve_app resolves it from the app's \
URL or name."
)
}
pub fn oql_schema_is_empty(schema_json: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(schema_json) {
Ok(v) => v
.get("entities")
.and_then(|e| e.as_array())
.is_some_and(|a| a.is_empty()),
Err(_) => false,
}
}
pub fn oql_entity_names(schema_json: &str) -> Vec<String> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(schema_json) else {
return Vec::new();
};
let Some(arr) = v.get("entities").and_then(|e| e.as_array()) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for e in arr {
if let Some(name) = e.get("name").and_then(|n| n.as_str()) {
if !out.iter().any(|n| n == name) {
out.push(name.to_string());
if out.len() >= MAX_OQL_ENTITIES {
break;
}
}
}
}
out
}
pub fn oql_query_start(query_json: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(query_json)
.ok()
.and_then(|v| v.get("start").and_then(|s| s.as_str()).map(str::to_string))
}
const MAX_FUZZY_NAME_LEN: usize = 128;
pub fn closest_entity(start: &str, entities: &[String]) -> Option<String> {
let lc = start.to_lowercase();
if let Some(e) = entities.iter().find(|e| e.to_lowercase() == lc) {
return Some((*e).clone());
}
if let Some(e) = entities.iter().find(|e| {
let el = e.to_lowercase();
el == format!("{lc}s") || format!("{el}s") == lc
}) {
return Some((*e).clone());
}
if lc.len() > MAX_FUZZY_NAME_LEN {
return None;
}
let lc_chars = lc.chars().count();
let mut best: Option<(usize, &String)> = None;
for e in entities {
let el = e.to_lowercase();
let bound = (lc.len().min(el.len()) / 3).clamp(1, 3);
if lc_chars.abs_diff(el.chars().count()) > bound {
continue;
}
let d = levenshtein(&lc, &el);
if d <= bound && best.map_or(true, |(bd, _)| d < bd) {
best = Some((d, e));
}
}
best.map(|(_, e)| e.clone())
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub fn oql_query_examples(
canister_id: &str,
schema_json: &str,
derivation_origin: Option<&str>,
account: Option<&str>,
) -> Vec<String> {
oql_entity_names(schema_json)
.into_iter()
.map(|entity| {
let mut args = serde_json::Map::new();
args.insert("canister_id".into(), serde_json::Value::String(canister_id.to_string()));
let query = serde_json::json!({ "start": entity, "limit": 10 }).to_string();
args.insert("oql".into(), serde_json::Value::String(query));
if let Some(o) = derivation_origin {
args.insert("derivation_origin".into(), serde_json::Value::String(o.to_string()));
}
if let Some(a) = account {
args.insert("account".into(), serde_json::Value::String(a.to_string()));
}
format!("canister_query {}", serde_json::Value::Object(args))
})
.collect()
}
pub fn candid_reply_is_empty(reply: &str) -> bool {
let t = reply.trim();
if t == "()" {
return true;
}
let inner = t
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.map(str::trim)
.unwrap_or(t);
match inner {
"" | "null" | "none" => true,
_ => {
let no_ws: String = inner.chars().filter(|c| !c.is_whitespace()).collect();
matches!(no_ws.as_str(), "vec{}" | "optvec{}")
|| no_ws.eq_ignore_ascii_case("variant{none}")
}
}
}
pub fn normalize_oql_query(query: &str) -> Result<String, String> {
if query.len() > MAX_CANDID_TEXT_BYTES {
return Err(format!(
"the OQL query is too large ({} bytes; limit {MAX_CANDID_TEXT_BYTES})",
query.len()
));
}
let value: serde_json::Value =
serde_json::from_str(query).map_err(|e| format!("`query` must be valid JSON: {e}"))?;
if !value.is_object() {
return Err(
"`query` must be a JSON object, e.g. {\"start\":\"employee\",\"limit\":10}".to_string(),
);
}
Ok(value.to_string())
}
pub fn encode_text_arg(text: &str) -> Result<Vec<u8>, String> {
IDLArgs::new(&[IDLValue::Text(text.to_string())])
.to_bytes()
.map_err(|e| format!("could not encode the query argument: {e}"))
}
pub fn encode_unit_arg() -> Result<Vec<u8>, String> {
IDLArgs::new(&[])
.to_bytes()
.map_err(|e| format!("could not encode arguments: {e}"))
}
pub enum OqlResult {
Table {
columns: Vec<String>,
rows: Vec<Vec<String>>,
has_more: bool,
},
QueryError(String),
TooManyColumns { column_count: usize },
Unrecognized(String),
}
enum TableOutcome {
Table {
columns: Vec<String>,
rows: Vec<Vec<String>>,
rows_truncated: bool,
},
TooWide { column_count: usize },
}
pub fn parse_execute_reply(did: Option<&str>, reply: &[u8]) -> OqlResult {
on_deep_stack(move || {
let decoded = match did.and_then(|d| decode_args_with_did(d, "execute", reply)) {
Some(args) => args,
None => match IDLArgs::from_bytes_with_config(reply, &reply_decoder_config()) {
Ok(args) => args,
Err(e) => return OqlResult::Unrecognized(format!("(undecodable reply: {e})")),
},
};
match decoded.args.into_iter().next() {
Some(val) => extract_oql(&val).unwrap_or_else(|| OqlResult::Unrecognized(val.to_string())),
None => OqlResult::Unrecognized("(empty reply)".to_string()),
}
})
.unwrap_or_else(|| OqlResult::Unrecognized("(could not spawn a thread to decode the reply)".to_string()))
}
pub fn decode_text_reply(reply: &[u8]) -> String {
on_deep_stack(|| {
let args = match IDLArgs::from_bytes_with_config(reply, &reply_decoder_config()) {
Ok(a) => a,
Err(e) => return format!("(undecodable reply: {e})"),
};
match args.args.as_slice() {
[] => "(empty reply)".to_string(),
[IDLValue::Text(s)] => s.clone(),
[single] => single.to_string(),
_ => args.to_string(),
}
})
.unwrap_or_else(|| "(could not spawn a thread to decode the reply)".to_string())
}
pub fn decode_schema_reply(reply: &[u8]) -> String {
let text = decode_text_reply(reply);
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or(text),
Err(_) => text,
}
}
pub fn api_doc_method(did: &str) -> Option<&'static str> {
if guard_candid_text("the `candid` interface", did).is_err() {
return None;
}
on_deep_stack(|| {
let Ok((env, Some(actor))) = candid_parser::utils::CandidSource::Text(did).load() else {
return None;
};
["getApiDoc", "get_api_doc"]
.into_iter()
.find(|m| env.get_method(&actor, m).is_ok())
})
.flatten()
}
fn decode_args_with_did(did: &str, method: &str, bytes: &[u8]) -> Option<IDLArgs> {
guard_candid_text("the `candid` interface", did).ok()?;
let (env, actor) = candid_parser::utils::CandidSource::Text(did).load().ok()?;
let actor = actor?;
let func = env.get_method(&actor, method).ok()?;
IDLArgs::from_bytes_with_types_with_config(bytes, &env, &func.rets, &reply_decoder_config()).ok()
}
fn extract_oql(val: &IDLValue) -> Option<OqlResult> {
match val {
IDLValue::Record(fields) => {
let rows_val = field_by_name(fields, "rows")?;
let has_more = matches!(field_by_name(fields, "hasMore"), Some(IDLValue::Bool(true)));
match rows_to_table(rows_val)? {
TableOutcome::Table { columns, rows, rows_truncated } => Some(OqlResult::Table {
columns,
rows,
has_more: has_more || rows_truncated,
}),
TableOutcome::TooWide { column_count } => {
Some(OqlResult::TooManyColumns { column_count })
}
}
}
IDLValue::Variant(var) => {
let arm = &var.0;
let name = label_name(&arm.id);
if name.eq_ignore_ascii_case("ok") || name.eq_ignore_ascii_case("success") {
extract_oql(&arm.val)
} else if name.eq_ignore_ascii_case("err") || name.eq_ignore_ascii_case("error") {
Some(OqlResult::QueryError(cell_scalar(&arm.val)))
} else {
None
}
}
_ => None,
}
}
fn rows_to_table(rows_val: &IDLValue) -> Option<TableOutcome> {
let IDLValue::Vec(rows) = rows_val else {
return None;
};
if rows.is_empty() {
return Some(TableOutcome::Table {
columns: Vec::new(),
rows: Vec::new(),
rows_truncated: false,
});
}
let mut columns: Vec<String> = Vec::new();
let mut col_pos: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut out_rows: Vec<Vec<String>> = Vec::new();
let mut rows_truncated = false;
for row in rows {
if out_rows.len() >= MAX_OQL_ROWS {
rows_truncated = true;
break;
}
let IDLValue::Vec(cells) = row else {
return None;
};
if columns.is_empty() {
let mut named = 0usize;
let mut over_wide = false;
for cell in cells {
if let IDLValue::Record(cf) = cell {
if let Some(IDLValue::Text(name)) = field_by_name(cf, "name") {
named += 1;
if !col_pos.contains_key(name.as_str()) {
if columns.len() == MAX_OQL_COLUMNS {
over_wide = true;
} else {
col_pos.insert(name.clone(), columns.len());
columns.push(name.clone());
}
}
}
}
}
if named == 0 {
return None;
}
if over_wide {
return Some(TableOutcome::TooWide { column_count: named });
}
}
let mut aligned = vec![String::new(); columns.len()];
let mut filled = vec![false; columns.len()];
for cell in cells {
if let IDLValue::Record(cf) = cell {
if let Some(IDLValue::Text(name)) = field_by_name(cf, "name") {
if let Some(&pos) = col_pos.get(name.as_str()) {
if !filled[pos] {
filled[pos] = true;
aligned[pos] =
field_by_name(cf, "value").map(cell_scalar).unwrap_or_default();
}
}
}
}
}
out_rows.push(aligned);
}
Some(TableOutcome::Table { columns, rows: out_rows, rows_truncated })
}
fn cell_scalar(v: &IDLValue) -> String {
match v {
IDLValue::Variant(var) => cell_scalar(&var.0.val),
IDLValue::Opt(inner) => cell_scalar(inner),
IDLValue::Text(s) => s.clone(),
IDLValue::Principal(p) => p.to_text(),
other => other.to_string(),
}
}
fn field_by_name<'a>(fields: &'a [IDLField], name: &str) -> Option<&'a IDLValue> {
fields
.iter()
.find(|f| matches!(&f.id, Label::Named(n) if n == name))
.map(|f| &f.val)
}
fn label_name(l: &Label) -> String {
match l {
Label::Named(s) => s.clone(),
Label::Id(n) | Label::Unnamed(n) => n.to_string(),
}
}
pub fn render_table(columns: &[String], rows: &[Vec<String>], has_more: bool) -> String {
let esc = |s: &str| s.replace('\\', "\\\\").replace('|', "\\|").replace('\n', " ");
if columns.is_empty() {
return format!(
"0 columns / {} row(s){}.",
rows.len(),
if has_more { " — more available" } else { "" }
);
}
let mut out = String::new();
out.push_str("| ");
out.push_str(&columns.iter().map(|c| esc(c)).collect::<Vec<_>>().join(" | "));
out.push_str(" |\n|");
for _ in columns {
out.push_str(" --- |");
}
out.push('\n');
for row in rows {
let cells: Vec<String> = (0..columns.len())
.map(|i| esc(row.get(i).map(String::as_str).unwrap_or("")))
.collect();
out.push_str("| ");
out.push_str(&cells.join(" | "));
out.push_str(" |\n");
}
out.push_str(&format!(
"\n{} row(s){}.",
rows.len(),
if has_more {
" — more available; re-query with a higher `offset` to page"
} else {
""
}
));
out
}
pub async fn raw_call(
agent: &Agent,
canister: Principal,
method: &str,
arg: Vec<u8>,
is_query: bool,
) -> Result<Vec<u8>, ic_agent::AgentError> {
if is_query {
agent.query(&canister, method).with_arg(arg).call().await
} else {
agent.update(&canister, method).with_arg(arg).call_and_wait().await
}
}
#[cfg(test)]
mod tests {
#[test]
fn decode_reply_rejects_a_vec_null_bomb_instead_of_aborting() {
fn uleb128(mut n: u64) -> Vec<u8> {
let mut out = Vec::new();
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
if n == 0 {
out.push(b);
break;
}
out.push(b | 0x80);
}
out
}
let mut bomb = vec![0x44, 0x49, 0x44, 0x4c, 0x01, 0x6d, 0x7f, 0x01, 0x00];
bomb.extend(uleb128(40_000_000));
assert!(bomb.len() <= 16, "the bomb is tiny on the wire: {} bytes", bomb.len());
let out = super::decode_reply(None, "m", &bomb);
assert!(out.contains("not decodable"), "expected the decode-error path, got: {out:.120}");
}
#[test]
fn candid_guard_rejects_deep_and_oversized() {
use super::{guard_candid_text, MAX_CANDID_TEXT_BYTES};
assert!(guard_candid_text("v", "()").is_ok());
assert!(guard_candid_text("v", "(record { a = opt 1; b = vec { 1; 2; 3 } })").is_ok());
let deep_opt = format!("{}1", "opt ".repeat(5000));
assert!(guard_candid_text("v", &deep_opt).is_err(), "deep opt-chain must be refused");
assert!(guard_candid_text("v", &"{".repeat(200)).is_err(), "deep brackets must be refused");
let deep_mixed = format!(
"{}1{}",
"opt record { a = ".repeat(100),
" }".repeat(100),
);
assert!(
guard_candid_text("v", &deep_mixed).is_err(),
"deep opt-record nesting must be refused (no prefix under-count)"
);
assert!(
guard_candid_text("v", "(opt record { a = opt variant { b = vec { 1; 2 } } })").is_ok()
);
let big = "0,".repeat(MAX_CANDID_TEXT_BYTES);
assert!(guard_candid_text("v", &big).is_err(), "oversized input must be refused");
assert!(guard_candid_text("v", &format!("\"{}\"", "(".repeat(10_000))).is_ok());
assert!(guard_candid_text("v", &format!("(record {{ {} }})", "a = opt 1; ".repeat(1000))).is_ok());
}
#[test]
fn candid_guard_is_comment_aware() {
use super::guard_candid_text;
for hidden in ["//\"\n", "/*\"*/", "/* /* \" */ */"] {
assert!(
candid_parser::parse_idl_args(&format!("({hidden}opt opt 0)")).is_ok(),
"candid_parser is expected to skip {hidden:?} and parse what follows"
);
}
let deep = format!("{}0", "opt ".repeat(5000));
for hidden in [
"//\"\n",
"// \" trailing text\n",
"/*\"*/",
"/* \" */",
"/* /* \" */ */", "//\"\n//\"\n",
] {
let attack = format!("({hidden}{deep})");
assert!(
guard_candid_text("v", &attack).is_err(),
"quote hidden in {hidden:?} must not disable the depth scan"
);
assert!(
guard_candid_text("v", &format!("{hidden}{}", "{".repeat(200))).is_err(),
"quote hidden in {hidden:?} must not disable bracket counting"
);
}
let deep_commented = format!(
"{}1{}",
"opt /* c */ record // c\n { a = ".repeat(100),
" }".repeat(100),
);
assert!(
guard_candid_text("v", &deep_commented).is_err(),
"a comment between `opt` and `record {{` must not drop the prefix"
);
assert!(guard_candid_text(
"v",
"// the service\nservice : { /* a method */ f : (nat) -> (nat) query; }"
)
.is_ok());
assert!(guard_candid_text("v", "(opt /* c */ record /* c */ { a = opt 1 })").is_ok());
assert!(guard_candid_text("v", &format!("(\"// {}\")", "(".repeat(10_000))).is_ok());
assert!(guard_candid_text("v", &format!("(\"/* {}\")", "{".repeat(10_000))).is_ok());
assert!(guard_candid_text("v", &format!("/{}", "(".repeat(200))).is_err());
}
#[test]
fn encode_args_refuses_comment_hidden_deep_nesting() {
use super::{encode_args, MAX_CANDID_DEPTH};
let attack = format!("(//\"\n{}0)", "opt ".repeat(20_000));
let err = encode_args(None, "m", &attack).expect_err("must be refused, not parsed");
assert!(err.contains("nested too deeply"), "unexpected error: {err}");
let ok = format!("({}0)", "opt ".repeat(MAX_CANDID_DEPTH / 2));
assert!(encode_args(None, "m", &ok).is_ok(), "legitimate nesting must still encode");
}
#[test]
fn candid_guard_bounds_type_alias_chains() {
use super::{guard_candid_text, MAX_CANDID_TYPE_DECLS};
let chain = |n: usize, rhs: &dyn Fn(usize) -> String| {
let mut s = String::new();
for i in 0..n {
s.push_str(&format!("type t{i}={};", rhs(i)));
}
s.push_str(&format!("type t{n}=nat;service:{{m:(t0)->(t0)}}"));
s
};
let opt_link = |i: usize| format!("opt t{}", i + 1);
let var_link = |i: usize| format!("t{}", i + 1);
let links: [&dyn Fn(usize) -> String; 2] = [&opt_link, &var_link];
for rhs in links {
let attack = chain(20_000, rhs);
assert!(attack.len() < super::MAX_CANDID_TEXT_BYTES, "vector must fit the byte cap");
let err = guard_candid_text("d", &attack).expect_err("alias chain must be refused");
assert!(err.contains("too many types"), "unexpected error: {err}");
}
let at_limit = chain(MAX_CANDID_TYPE_DECLS - 1, &|i| format!("opt t{}", i + 1));
assert!(
guard_candid_text("d", &at_limit).is_ok(),
"an interface at the declaration limit must still be accepted"
);
let over = chain(MAX_CANDID_TYPE_DECLS, &|i| format!("opt t{}", i + 1));
assert!(guard_candid_text("d", &over).is_err(), "one past the limit must be refused");
let quoted = format!("service:{{m:(record{{{}}})->()}}", "\"type\":nat;".repeat(5_000));
assert!(
guard_candid_text("d", "ed).is_ok(),
"`\"type\"` field names are string content, not declarations"
);
let realistic = format!(
"{}service:{{ get:(t0)->(t0) query; set:(t0)->() }}",
(0..180)
.map(|i| format!("type t{i}=record{{a:nat;b:opt text}};"))
.collect::<String>()
);
assert!(guard_candid_text("d", &realistic).is_ok(), "real interfaces must pass");
}
#[test]
fn interface_entry_points_refuse_alias_chains() {
use super::{
api_doc_method, decode_bytes_with_did, encode_args, has_oql, is_query_method,
MAX_CANDID_TYPE_DECLS,
};
let n = MAX_CANDID_TYPE_DECLS + 100;
let mut did = String::new();
for i in 0..n {
did.push_str(&format!("type t{i}=opt t{};", i + 1));
}
did.push_str(&format!(
"type t{n}=nat;service:{{m:(t0)->(t0) query;schema:()->(text) query;\
execute:(text)->(text) query;getApiDoc:()->(text) query}}"
));
assert!(!has_oql(&did), "an unparseable interface must not advertise OQL");
assert_eq!(api_doc_method(&did), None);
assert_eq!(is_query_method(&did, "m"), None);
assert_eq!(decode_bytes_with_did(&did, "m", &[]), None);
let encoded = encode_args(Some(&did), "m", "(42 : nat)");
assert!(encoded.is_ok(), "an over-limit interface must not fail the call: {encoded:?}");
}
#[test]
fn deep_stack_parses_are_capped_but_all_complete() {
use super::on_deep_stack;
let callers: Vec<_> = (0..64u32)
.map(|i| std::thread::spawn(move || on_deep_stack(|| i * 2)))
.collect();
let got: Vec<_> = callers
.into_iter()
.map(|c| c.join().expect("caller must not panic or hang"))
.collect();
assert_eq!(got, (0..64u32).map(|i| Some(i * 2)).collect::<Vec<_>>());
assert_eq!(on_deep_stack(|| "after"), Some("after"));
let boom = std::panic::catch_unwind(|| on_deep_stack(|| panic!("parser blew up")));
assert!(boom.is_err(), "a panic inside the parse must reach the caller");
assert_eq!(on_deep_stack(|| "after panic"), Some("after panic"));
}
#[test]
fn has_oql_detects_schema_and_execute() {
use super::has_oql;
let oql = r#"
service : {
schema : () -> (text) query;
execute : (text) -> (variant { ok : text; err : text }) query;
unrelated : (nat) -> (nat) query;
}
"#;
assert!(has_oql(oql), "schema + execute should be detected as OQL");
let only_schema = "service : { schema : () -> (text) query; }";
assert!(!has_oql(only_schema), "schema alone is not OQL");
let only_execute = "service : { execute : (text) -> (text) query; }";
assert!(!has_oql(only_execute), "execute alone is not OQL");
let plain = "service : { greet : (text) -> (text) query; }";
assert!(!has_oql(plain), "unrelated interface is not OQL");
let loose = "service : { schema : () -> (blob); execute : (blob) -> (nat); }";
assert!(has_oql(loose), "detection is by method name, not signature");
assert!(!has_oql("not a candid interface at all"), "garbage is not OQL");
assert!(!has_oql(""), "empty is not OQL");
let over_deep = format!(
"service : {{ schema : () -> ({}nat) query; execute : (text) -> (text) query; }}",
"vec ".repeat(5000),
);
assert!(!has_oql(&over_deep), "over-limit interface must fail closed to false");
let shallow_twin = "service : { schema : () -> (nat) query; execute : (text) -> (text) query; }";
assert!(has_oql(shallow_twin), "shallow twin should be detected — isolates the guard as the cause");
}
#[test]
fn is_query_method_classifies_by_candid_mode() {
use super::is_query_method;
let did = "service : { \
balance : (principal) -> (nat) query; \
stats : () -> (text) composite_query; \
transfer : (principal, nat) -> (nat); \
}";
assert_eq!(is_query_method(did, "balance"), Some(true), "query method → Some(true)");
assert_eq!(is_query_method(did, "stats"), Some(true), "composite_query → Some(true)");
assert_eq!(is_query_method(did, "transfer"), Some(false), "update method → Some(false)");
assert_eq!(is_query_method(did, "missing"), None, "undeclared method → None (fail open)");
assert_eq!(is_query_method("not a candid interface", "x"), None, "unparseable → None (fail open)");
let over_deep = format!("service : {{ f : () -> ({}nat) query; }}", "vec ".repeat(5000));
assert_eq!(is_query_method(&over_deep, "f"), None, "over-limit interface → None (fail open)");
}
#[test]
fn oql_query_redirect_blocks_candid_query_on_oql_canisters() {
use super::oql_query_redirect;
let oql = "service : { schema : () -> (text) query; execute : (text) -> (text) query; }";
let plain = "service : { stats : () -> (text) query; }";
let msg = oql_query_redirect(Some(oql)).expect("query on OQL canister must be redirected");
assert!(msg.contains("icp_oql_guide"), "message must point to the OQL guide: {msg}");
assert!(msg.contains("get_canister_oql_schema"), "message must point to the OQL schema tool: {msg}");
assert!(msg.contains("canister_query"), "message must point to canister_query's oql path: {msg}");
assert!(msg.contains("`oql`"), "message must name the oql argument: {msg}");
assert!(msg.contains("derivation_origin"), "message must carry the auth hint (pass the origin): {msg}");
assert!(oql_query_redirect(Some(plain)).is_none(), "non-OQL query must pass through");
assert!(oql_query_redirect(None).is_none(), "unreadable interface must not block");
}
#[cfg(test)]
fn encode_reply(did: &str, method: &str, textual: &str) -> Vec<u8> {
let (env, actor) = candid_parser::utils::CandidSource::Text(did)
.load()
.expect("parse did");
let actor = actor.expect("service");
let func = env.get_method(&actor, method).expect("method");
candid_parser::parse_idl_args(textual)
.expect("parse value")
.to_bytes_with_types(&env, &func.rets)
.expect("encode value")
}
#[test]
fn parse_execute_reply_builds_table() {
use super::{parse_execute_reply, OqlResult};
let did = "service : { \
execute : (text) -> (variant { \
ok : record { hasMore : bool; rows : vec vec record { name : text; value : variant { text : text; num : int } } }; \
err : text \
}) query; \
}";
let ok_reply = encode_reply(
did,
"execute",
"(variant { ok = record { \
hasMore = true; \
rows = vec { \
vec { \
record { name = \"firstName\"; value = variant { text = \"Ada\" } }; \
record { name = \"lastName\"; value = variant { text = \"Lovelace\" } } \
}; \
vec { \
record { name = \"firstName\"; value = variant { text = \"Alan\" } }; \
record { name = \"lastName\"; value = variant { text = \"Turing\" } } \
} \
} \
} })",
);
match parse_execute_reply(Some(did), &ok_reply) {
OqlResult::Table { columns, rows, has_more } => {
assert_eq!(columns, vec!["firstName", "lastName"]);
assert_eq!(rows, vec![
vec!["Ada".to_string(), "Lovelace".to_string()],
vec!["Alan".to_string(), "Turing".to_string()],
]);
assert!(has_more, "hasMore = true must be read");
}
_ => panic!("expected a Table"),
}
let err_reply = encode_reply(did, "execute", "(variant { err = \"bad query\" })");
match parse_execute_reply(Some(did), &err_reply) {
OqlResult::QueryError(msg) => assert_eq!(msg, "bad query"),
_ => panic!("expected a QueryError"),
}
assert!(matches!(
parse_execute_reply(None, &ok_reply),
OqlResult::Unrecognized(_)
));
let empty = encode_reply(
did,
"execute",
"(variant { ok = record { hasMore = false; rows = vec {} } })",
);
match parse_execute_reply(Some(did), &empty) {
OqlResult::Table { columns, rows, has_more } => {
assert!(columns.is_empty() && rows.is_empty() && !has_more, "empty result is a 0-row table");
}
_ => panic!("empty rows should be a Table, not an error/Unrecognized"),
}
}
#[cfg(test)]
fn oql_cell(name: &str, val: &str) -> super::IDLValue {
use super::{IDLField, IDLValue, Label};
IDLValue::Record(vec![
IDLField { id: Label::Named("name".into()), val: IDLValue::Text(name.into()) },
IDLField { id: Label::Named("value".into()), val: IDLValue::Text(val.into()) },
])
}
#[cfg(test)]
fn oql_record(rows: Vec<super::IDLValue>, has_more: bool) -> super::IDLValue {
use super::{IDLField, IDLValue, Label};
IDLValue::Record(vec![
IDLField { id: Label::Named("hasMore".into()), val: IDLValue::Bool(has_more) },
IDLField { id: Label::Named("rows".into()), val: IDLValue::Vec(rows) },
])
}
#[test]
fn rows_to_table_refuses_a_too_wide_first_row() {
use super::{extract_oql, rows_to_table, OqlResult, TableOutcome, IDLValue, MAX_OQL_COLUMNS};
let wide: Vec<IDLValue> = (0..MAX_OQL_COLUMNS + 44)
.map(|c| oql_cell(&format!("c{c}"), "x"))
.collect();
let width = wide.len();
let mut rows: Vec<IDLValue> = vec![IDLValue::Vec(wide)];
rows.extend((0..5).map(|_| IDLValue::Vec(Vec::new())));
match rows_to_table(&IDLValue::Vec(rows.clone())).expect("recognizable") {
TableOutcome::TooWide { column_count } => assert_eq!(column_count, width),
TableOutcome::Table { .. } => panic!("an over-wide first row must be refused, not capped"),
}
match extract_oql(&oql_record(rows, false)).expect("recognizable") {
OqlResult::TooManyColumns { column_count } => assert_eq!(column_count, width),
other => panic!("expected TooManyColumns, got a different arm: {}", oql_variant_name(&other)),
}
}
#[test]
fn rows_to_table_caps_tall_replies_as_pageable() {
use super::{extract_oql, rows_to_table, OqlResult, TableOutcome, IDLValue, MAX_OQL_ROWS};
let make_rows = || {
(0..MAX_OQL_ROWS + 100)
.map(|r| {
IDLValue::Vec(vec![
oql_cell("id", &format!("{r}")),
oql_cell("name", "x"),
])
})
.collect::<Vec<_>>()
};
match rows_to_table(&IDLValue::Vec(make_rows())).expect("recognizable") {
TableOutcome::Table { columns, rows, rows_truncated } => {
assert_eq!(columns, vec!["id".to_string(), "name".to_string()]);
assert_eq!(rows.len(), MAX_OQL_ROWS, "materialized rows capped");
assert!(rows_truncated, "dropped rows must be flagged");
assert!(rows.iter().all(|r| r.len() == 2), "no ragged rows");
}
TableOutcome::TooWide { .. } => panic!("a narrow reply must not be refused"),
}
match extract_oql(&oql_record(make_rows(), false)).expect("recognizable") {
OqlResult::Table { has_more, rows, .. } => {
assert!(has_more, "row truncation must raise has_more even over canister's false");
assert_eq!(rows.len(), MAX_OQL_ROWS);
}
other => panic!("expected a Table, got: {}", oql_variant_name(&other)),
}
}
#[test]
fn rows_to_table_column_cap_is_exclusive() {
use super::{rows_to_table, TableOutcome, IDLValue, MAX_OQL_COLUMNS};
let row_of = |n: usize| {
IDLValue::Vec((0..n).map(|c| oql_cell(&format!("c{c}"), "x")).collect())
};
match rows_to_table(&IDLValue::Vec(vec![row_of(MAX_OQL_COLUMNS)])).expect("recognizable") {
TableOutcome::Table { columns, .. } => assert_eq!(columns.len(), MAX_OQL_COLUMNS),
TableOutcome::TooWide { .. } => panic!("exactly MAX_OQL_COLUMNS must be accepted"),
}
match rows_to_table(&IDLValue::Vec(vec![row_of(MAX_OQL_COLUMNS + 1)])).expect("recognizable") {
TableOutcome::TooWide { column_count } => assert_eq!(column_count, MAX_OQL_COLUMNS + 1),
TableOutcome::Table { .. } => panic!("MAX_OQL_COLUMNS + 1 must be refused"),
}
}
#[test]
fn rows_to_table_row_cap_is_exclusive() {
use super::{rows_to_table, TableOutcome, IDLValue, MAX_OQL_ROWS};
let rows_of = |n: usize| {
IDLValue::Vec((0..n).map(|_| IDLValue::Vec(vec![oql_cell("id", "x")])).collect())
};
match rows_to_table(&rows_of(MAX_OQL_ROWS)).expect("recognizable") {
TableOutcome::Table { rows, rows_truncated, .. } => {
assert_eq!(rows.len(), MAX_OQL_ROWS);
assert!(!rows_truncated, "exactly MAX_OQL_ROWS must not be flagged truncated");
}
TableOutcome::TooWide { .. } => panic!("a 1-column table is never too wide"),
}
match rows_to_table(&rows_of(MAX_OQL_ROWS + 1)).expect("recognizable") {
TableOutcome::Table { rows, rows_truncated, .. } => {
assert_eq!(rows.len(), MAX_OQL_ROWS, "the extra row is not materialized");
assert!(rows_truncated, "one row over the cap must flag truncation");
}
TableOutcome::TooWide { .. } => panic!("a 1-column table is never too wide"),
}
}
#[test]
fn rows_to_table_bounds_a_wide_later_row() {
use super::{rows_to_table, TableOutcome, IDLValue};
let first = IDLValue::Vec(vec![oql_cell("id", "1")]);
let mut wide_cells = vec![oql_cell("id", "2")];
wide_cells.extend((0..5_000).map(|j| oql_cell(&format!("junk{j}"), "z")));
let second = IDLValue::Vec(wide_cells);
match rows_to_table(&IDLValue::Vec(vec![first, second])).expect("recognizable") {
TableOutcome::Table { columns, rows, .. } => {
assert_eq!(columns, vec!["id".to_string()], "later row can't add columns");
assert_eq!(rows, vec![vec!["1".to_string()], vec!["2".to_string()]]);
assert!(rows.iter().all(|r| r.len() == 1), "junk cells dropped, width stays 1");
}
TableOutcome::TooWide { .. } => panic!("a 1-column table is never too wide"),
}
}
#[cfg(test)]
fn oql_variant_name(r: &super::OqlResult) -> &'static str {
use super::OqlResult::*;
match r {
Table { .. } => "Table",
QueryError(_) => "QueryError",
TooManyColumns { .. } => "TooManyColumns",
Unrecognized(_) => "Unrecognized",
}
}
#[test]
fn parse_execute_reply_fails_closed_on_non_oql_shapes() {
use super::{parse_execute_reply, OqlResult};
let weird_did = "service : { execute : (text) -> (variant { weird : text }) query; }";
let weird = encode_reply(weird_did, "execute", "(variant { weird = \"z\" })");
assert!(
matches!(parse_execute_reply(Some(weird_did), &weird), OqlResult::Unrecognized(_)),
"an unknown variant arm must not be treated as an OQL table"
);
let noname_did = "service : { execute : (text) -> (record { hasMore : bool; rows : vec vec record { foo : text } }) query; }";
let noname = encode_reply(
noname_did,
"execute",
"(record { hasMore = false; rows = vec { vec { record { foo = \"x\" } } } })",
);
assert!(
matches!(parse_execute_reply(Some(noname_did), &noname), OqlResult::Unrecognized(_)),
"rows without named cells must degrade to Unrecognized"
);
}
#[test]
fn normalize_oql_query_validates() {
use super::{normalize_oql_query, MAX_CANDID_TEXT_BYTES};
assert!(normalize_oql_query(r#"{"start":"employee","limit":10}"#).is_ok());
assert!(normalize_oql_query(r#"["not","an","object"]"#).is_err(), "array is not an object");
assert!(normalize_oql_query("not json").is_err(), "invalid JSON is rejected");
let huge = format!("{{\"x\":\"{}\"}}", "a".repeat(MAX_CANDID_TEXT_BYTES));
assert!(normalize_oql_query(&huge).is_err(), "oversized query is rejected");
}
#[test]
fn decode_schema_reply_pretty_prints_json() {
use super::decode_schema_reply;
let did = "service : { schema : () -> (text) query; }";
let reply = encode_reply(did, "schema", "(\"{\\\"entities\\\":[]}\")");
let out = decode_schema_reply(&reply);
assert!(out.contains("\"entities\""), "schema JSON should be surfaced: {out}");
assert!(out.contains('\n'), "valid JSON should be pretty-printed: {out}");
}
#[test]
fn api_doc_method_detection() {
use super::api_doc_method;
assert_eq!(api_doc_method("service : { getApiDoc : () -> (text) query; }"), Some("getApiDoc"));
assert_eq!(api_doc_method("service : { get_api_doc : () -> (text) query; }"), Some("get_api_doc"));
assert_eq!(
api_doc_method("service : { getApiDoc : () -> (text) query; get_api_doc : () -> (text) query; }"),
Some("getApiDoc"),
"prefers getApiDoc when both are declared"
);
assert_eq!(api_doc_method("service : { greet : (text) -> (text) query; }"), None);
assert_eq!(api_doc_method("not a candid interface"), None);
}
#[test]
fn decode_text_reply_returns_text_verbatim() {
use super::decode_text_reply;
let did = "service : { getApiDoc : () -> (text) query; }";
let reply = encode_reply(did, "getApiDoc", "(\"# API\\nHow this app behaves.\")");
assert_eq!(decode_text_reply(&reply), "# API\nHow this app behaves.");
let multi_did = "service : { foo : () -> (text, nat) query; }";
let multi = encode_reply(multi_did, "foo", "(\"a\", 5 : nat)");
let out = decode_text_reply(&multi);
assert!(out.contains("a") && out.contains('5'), "multi-value reply keeps all values: {out}");
}
#[test]
fn typeless_reply_paths_bound_deep_nesting() {
use super::{decode_reply, decode_text_reply, on_deep_stack, parse_execute_reply, OqlResult};
use candid::{IDLArgs, IDLValue};
const DEPTH: usize = 2_000;
let bytes = on_deep_stack(|| {
let mut val = IDLValue::Null;
for _ in 0..DEPTH {
val = IDLValue::Opt(Box::new(val));
}
IDLArgs::new(&[val]).to_bytes().expect("encode nested opt reply")
})
.expect("spawn deep stack to encode");
assert!(bytes.len() < 100_000, "nested opt is compact on the wire: {} bytes", bytes.len());
assert!(decode_reply(None, "m", &bytes).contains("opt"));
assert!(decode_text_reply(&bytes).contains("opt"));
assert!(matches!(parse_execute_reply(None, &bytes), OqlResult::Unrecognized(_)));
}
#[test]
fn anonymous_empty_note_is_actionable_and_origin_free() {
use super::anonymous_empty_note;
let note = anonymous_empty_note("this query", "the app's `derivation_origin`");
assert!(note.contains("anonymous"), "must name the anonymous read: {note}");
assert!(note.to_lowercase().contains("not authenticated"), "must name the likely cause: {note}");
assert!(note.contains("this query"), "must echo `what`: {note}");
assert!(note.contains("the app's `derivation_origin`"), "must echo `add_hint`: {note}");
assert!(!note.contains("https://"), "must not bake in a concrete origin: {note}");
}
#[test]
fn oql_schema_is_empty_detects_only_empty_entities() {
use super::oql_schema_is_empty;
assert!(oql_schema_is_empty(r#"{"entities":[]}"#), "empty entities → empty");
assert!(
oql_schema_is_empty("{\n \"entities\": []\n}"),
"pretty-printed empty entities → empty"
);
assert!(!oql_schema_is_empty(r#"{"entities":[{"name":"bookings"}]}"#), "populated → not empty");
assert!(!oql_schema_is_empty("not json"), "unparseable → not treated as empty");
assert!(!oql_schema_is_empty("{}"), "no entities key → not treated as empty");
}
#[test]
fn oql_entity_names_extracts_dedups_and_caps() {
use super::{oql_entity_names, MAX_OQL_ENTITIES};
let names = oql_entity_names(
r#"{"entities":[{"name":"bookings"},{"name":"users"},{"name":"bookings"}]}"#,
);
assert_eq!(names, vec!["bookings", "users"], "in order, de-duplicated");
assert!(oql_entity_names("garbage").is_empty(), "garbage → none");
assert!(oql_entity_names("{}").is_empty(), "no entities → none");
let many: String = (0..(MAX_OQL_ENTITIES + 10))
.map(|i| format!("{{\"name\":\"e{i}\"}}"))
.collect::<Vec<_>>()
.join(",");
let capped = oql_entity_names(&format!("{{\"entities\":[{many}]}}"));
assert_eq!(capped.len(), MAX_OQL_ENTITIES, "entity list is capped");
}
#[test]
fn oql_query_start_extracts_start() {
use super::oql_query_start;
assert_eq!(oql_query_start(r#"{"start":"bookings","limit":10}"#).as_deref(), Some("bookings"));
assert_eq!(oql_query_start(r#"{"limit":10}"#), None, "no start → None");
assert_eq!(oql_query_start("not json"), None, "garbage → None");
}
#[test]
fn closest_entity_repairs_near_misses_only() {
use super::closest_entity;
let entities = vec!["bookings".to_string(), "users".to_string(), "appointments".to_string()];
assert_eq!(closest_entity("booking", &entities).as_deref(), Some("bookings"));
assert_eq!(closest_entity("Users", &entities).as_deref(), Some("users"));
assert_eq!(closest_entity("userz", &entities).as_deref(), Some("users"));
assert_eq!(closest_entity("invoices", &entities), None);
}
#[test]
fn closest_entity_bounds_the_fuzzy_phase() {
use super::{closest_entity, MAX_FUZZY_NAME_LEN};
let entities = vec!["bookings".to_string(), "users".to_string()];
let huge = "u".repeat(MAX_FUZZY_NAME_LEN + 1);
assert_eq!(closest_entity(&huge, &entities), None);
let at_cap_entity = "a".repeat(MAX_FUZZY_NAME_LEN); let at_cap_typo = format!("{}b", "a".repeat(MAX_FUZZY_NAME_LEN - 1)); assert_eq!(
closest_entity(&at_cap_typo, &[at_cap_entity.clone()]).as_deref(),
Some(at_cap_entity.as_str()),
"a near-miss exactly at the cap length must still be suggested"
);
let over_cap_typo = format!("{}b", "a".repeat(MAX_FUZZY_NAME_LEN));
let over_cap_entity = "a".repeat(MAX_FUZZY_NAME_LEN + 1);
assert_eq!(closest_entity(&over_cap_typo, &[over_cap_entity]), None);
let long_entity = "a".repeat(MAX_FUZZY_NAME_LEN * 4);
let big = vec![long_entity.clone()];
assert_eq!(closest_entity(&long_entity, &big).as_deref(), Some(long_entity.as_str()));
assert_eq!(closest_entity(&format!("{long_entity}s"), &big).as_deref(), Some(long_entity.as_str()));
let unicode_entities = vec!["abcde\u{1F4A9}".to_string()]; assert_eq!(
closest_entity("abcdeX", &unicode_entities).as_deref(), Some("abcde\u{1F4A9}"),
"a 1-char edit must survive pruning even when the byte-length diff exceeds the bound"
);
}
#[test]
fn oql_query_examples_are_complete_and_preserve_identity() {
use super::oql_query_examples;
let schema = r#"{"entities":[{"name":"bookings"},{"name":"users"}]}"#;
let ex = oql_query_examples("aaaaa-aa", schema, Some("https://app.example.com"), Some("work"));
assert_eq!(ex.len(), 2, "one example per entity");
assert!(ex[0].starts_with("canister_query "), "names the tool: {}", ex[0]);
assert!(ex[0].contains("aaaaa-aa"), "carries the canister id: {}", ex[0]);
assert!(ex[0].contains("bookings"), "uses the entity as start: {}", ex[0]);
assert!(ex[0].contains("https://app.example.com"), "preserves derivation_origin: {}", ex[0]);
assert!(ex[0].contains("work"), "preserves account: {}", ex[0]);
let anon = oql_query_examples("aaaaa-aa", schema, None, None);
assert!(!anon[0].contains("derivation_origin"), "no origin when read anonymously: {}", anon[0]);
assert!(oql_query_examples("aaaaa-aa", "{}", None, None).is_empty());
let weird = r#"{"entities":[{"name":"we\"ird"}]}"#;
let wex = oql_query_examples("aaaaa-aa", weird, None, None);
assert_eq!(wex.len(), 1);
let args_json = wex[0].strip_prefix("canister_query ").expect("tool prefix");
let args: serde_json::Value = serde_json::from_str(args_json).expect("args must be valid JSON");
let query = args.get("oql").and_then(|q| q.as_str()).expect("oql string");
let parsed: serde_json::Value = serde_json::from_str(query).expect("oql must be valid JSON");
assert_eq!(parsed.get("start").and_then(|s| s.as_str()), Some("we\"ird"), "entity name round-trips");
}
#[test]
fn candid_reply_is_empty_is_conservative() {
use super::candid_reply_is_empty;
assert!(candid_reply_is_empty("()"));
assert!(candid_reply_is_empty("(null)"));
assert!(candid_reply_is_empty("(vec {})"));
assert!(candid_reply_is_empty("(vec{})"));
assert!(candid_reply_is_empty("(opt vec {})"));
assert!(candid_reply_is_empty("(variant { none })"));
assert!(!candid_reply_is_empty("(vec { record { id = 1 } })"));
assert!(!candid_reply_is_empty("(record { balance = 5 : nat })"));
assert!(!candid_reply_is_empty("(opt record { a = 1 })"));
assert!(!candid_reply_is_empty("(\"some text\")"));
assert!(!candid_reply_is_empty("(variant { err = \"not found\" })"));
assert!(!candid_reply_is_empty("(variant { error = \"nope\" })"));
}
}