Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 2026-08-14

### candid_parser 0.4.1

* Bug fixes:
+ Escape the method names of a service type in the Rust binding. A Candid method name is an arbitrary text value, but `pp_ty_service` emitted it raw between the quotes of a Rust string literal inside `candid::define_service!`. A name containing `"` therefore closed the literal and the macro invocation, and the rest of the name was compiled as Rust — a `.did` file could inject arbitrary items into the bindings generated from it, and from there into the consumer's binary. Names are now escaped with `escape_debug`, as `pp_function` and the `#[serde(rename)]` attributes already were. The value seen by `define_service!` is unchanged, and names that are ordinary identifiers generate byte-identical output.

## 2026-08-11

### Candid 0.10.35
Expand Down
7 changes: 4 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/bench/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion rust/candid_parser/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "candid_parser"
version = "0.4.0"
version = "0.4.1"
edition = "2021"
rust-version.workspace = true
authors = ["DFINITY Team"]
Expand Down Expand Up @@ -46,6 +46,8 @@ console = { workspace = true, optional = true }
goldenfile = "1.1.0"
test-generator = "0.3.0"
rand.workspace = true
# Used to assert the *structure* of the generated Rust bindings, which the goldenfiles cannot.
syn = { version = "2", features = ["full", "parsing", "extra-traits"] }

[features]
random = ["dep:arbitrary", "dep:fake", "dep:rand", "dep:num-traits"]
Expand Down
5 changes: 4 additions & 1 deletion rust/candid_parser/src/bindings/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,11 @@ fn test_{test_name}() {{
TypeInner::Var(_) => self.pp_ty(func, true).append("::ty()"),
_ => unreachable!(),
};
// The method name is emitted as a Rust string literal, so it has to be escaped.
// A Candid method name is an arbitrary text value, and a name containing `"` would
// otherwise close the literal and let the rest of the name be parsed as Rust code.
RcDoc::text("\"")
.append(id)
.append(id.escape_debug().to_string())
.append(kwd("\" :"))
.append(func_doc)
});
Expand Down
24 changes: 24 additions & 0 deletions rust/candid_parser/tests/assets/ok/service_method_escape.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Principal } from '@icp-sdk/core/principal';
import type { ActorMethod } from '@icp-sdk/core/agent';
import type { IDL } from '@icp-sdk/core/candid';

/**
* The method names of a service type are arbitrary Candid text values, but every binding emits
* them into a string literal of the target language. Names containing quotes, backslashes, comment
* markers or newlines have to be escaped for the generated code to stay well-formed.
*/
export type f = ActorMethod<[], undefined>;
export interface inner {
'backslash\\' : f,
'braces { } and parens ( )' : f,
'comment markers // and /* */' : f,
'newline\nand carriage return\r' : f,
'quote\"' : f,
'tab\tand semicolon;' : f,
}
export interface _SERVICE {
'ping' : ActorMethod<[], string>,
'use_inner' : ActorMethod<[Principal], undefined>,
}
export declare const idlFactory: IDL.InterfaceFactory;
export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[];
13 changes: 13 additions & 0 deletions rust/candid_parser/tests/assets/ok/service_method_escape.did
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// The method names of a service type are arbitrary Candid text values, but every binding emits
// them into a string literal of the target language. Names containing quotes, backslashes, comment
// markers or newlines have to be escaped for the generated code to stay well-formed.
type f = func () -> ();
type inner = service {
"backslash\\" : f;
"braces { } and parens ( )" : f;
"comment markers // and /* */" : f;
"newline\nand carriage return\r" : f;
"quote\"" : f;
"tab\tand semicolon;" : f;
};
service : { ping : () -> (text); use_inner : (inner) -> () }
16 changes: 16 additions & 0 deletions rust/candid_parser/tests/assets/ok/service_method_escape.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const idlFactory = ({ IDL }) => {
const f = IDL.Func([], [], []);
const inner = IDL.Service({
'backslash\\' : f,
'braces { } and parens ( )' : f,
'comment markers // and /* */' : f,
'newline\nand carriage return\r' : f,
'quote\"' : f,
'tab\tand semicolon;' : f,
});
return IDL.Service({
'ping' : IDL.Func([], [IDL.Text], []),
'use_inner' : IDL.Func([inner], [], []),
});
};
export const init = ({ IDL }) => { return []; };
29 changes: 29 additions & 0 deletions rust/candid_parser/tests/assets/ok/service_method_escape.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// This is an experimental feature to generate Rust binding from Candid.
// You may want to manually adjust some of the types.
#![allow(dead_code, unused_imports)]
use candid::{self, CandidType, Deserialize, Principal};
use ic_cdk::api::call::CallResult as Result;

candid::define_function!(pub F : () -> ());
candid::define_service!(pub Inner : {
"backslash\\" : F::ty();
"braces { } and parens ( )" : F::ty();
"comment markers // and /* */" : F::ty();
"newline\nand carriage return\r" : F::ty();
"quote\"" : F::ty();
"tab\tand semicolon;" : F::ty();
});

pub struct Service(pub Principal);
impl Service {
pub async fn ping(&self) -> Result<(String,)> {
ic_cdk::call(self.0, "ping", ()).await
}
pub async fn use_inner(&self, arg0: &Inner) -> Result<()> {
ic_cdk::call(self.0, "use_inner", (arg0,)).await
}
}
/// Canister ID: `aaaaa-aa`
pub const CANISTER_ID : Principal = Principal::from_slice(&[]);
pub const service : Service = Service(CANISTER_ID);

18 changes: 18 additions & 0 deletions rust/candid_parser/tests/assets/service_method_escape.did
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// The method names of a service type are arbitrary Candid text values, but every binding emits
// them into a string literal of the target language. Names containing quotes, backslashes, comment
// markers or newlines have to be escaped for the generated code to stay well-formed.
type f = func () -> ();

type inner = service {
"quote\"" : f;
"backslash\\" : f;
"newline\nand carriage return\r" : f;
"tab\tand semicolon;" : f;
"comment markers // and /* */" : f;
"braces { } and parens ( )" : f;
};

service : {
use_inner : (inner) -> ();
ping : () -> (text);
};
2 changes: 1 addition & 1 deletion rust/candid_parser/tests/parse_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ fn compiler_test(resource: &str) {
}
{
match filename.file_name().unwrap().to_str().unwrap() {
"unicode.did" | "escape.did" => check_error(
"unicode.did" | "escape.did" | "service_method_escape.did" => check_error(
|| motoko::compile(&env, &actor, &prog),
"not a valid Motoko id",
),
Expand Down
130 changes: 130 additions & 0 deletions rust/candid_parser/tests/test_rust_bindings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Structural checks on the generated Rust bindings.
//!
//! The goldenfiles in `tests/assets/ok` record what the generator emits, but they cannot state
//! that the output is *safe*: a regression would simply be blessed into the goldenfile. These
//! tests assert the properties instead.

use candid::types::TypeEnv;
use candid_parser::bindings::rust::{compile, Config, ExternalConfig};
use candid_parser::configs::Configs;
use candid_parser::syntax::{IDLMergedProg, IDLProg};
use candid_parser::typing::check_prog;
use std::str::FromStr;

/// Generate Rust bindings for an in-memory Candid program.
fn compile_did(source: &str) -> String {
let prog: IDLProg = source.parse().unwrap();
let mut env = TypeEnv::new();
let actor = check_prog(&mut env, &prog).unwrap();
let merged = IDLMergedProg::new(prog);

let config = Config::new(Configs::from_str("").unwrap());
let mut external = ExternalConfig::default();
external
.0
.insert("canister_id".to_string(), "aaaaa-aa".to_string());
let (content, _unused) = compile(&config, &env, &actor, &merged, external);
content
}

/// Describe the top-level items of a Rust source file, as `kind` or `kind:name` labels.
///
/// Deliberately coarse: the point is to compare the *shape* of two generated files, not to pin
/// down the template, so this survives ordinary changes to what the generator emits.
fn item_shape(source: &str) -> Vec<String> {
let file = syn::parse_file(source)
.unwrap_or_else(|e| panic!("generated bindings are not valid Rust: {e}\n\n{source}"));
file.items
.iter()
.map(|item| match item {
syn::Item::Use(_) => "use".to_string(),
syn::Item::Macro(m) => {
let path = m
.mac
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
format!("macro:{path}!")
}
syn::Item::Const(c) => format!("const:{}", c.ident),
syn::Item::Static(s) => format!("static:{}", s.ident),
syn::Item::Struct(s) => format!("struct:{}", s.ident),
syn::Item::Enum(e) => format!("enum:{}", e.ident),
syn::Item::Type(t) => format!("type:{}", t.ident),
syn::Item::Fn(f) => format!("fn:{}", f.sig.ident),
syn::Item::Mod(m) => format!("mod:{}", m.ident),
syn::Item::Impl(_) => "impl".to_string(),
other => format!("other:{other:?}"),
})
.collect()
}

/// A service type whose method names need escaping, and the same service with plain names.
///
/// The method names of a service *type* are emitted as Rust string literals inside
/// `candid::define_service!`. A Candid method name is an arbitrary text value, so it can contain
/// quotes, backslashes, comment markers and newlines. Escaping them is what keeps a name inside
/// its literal instead of being parsed as Rust.
///
/// The first name is the case that matters: unescaped, it closes both the literal and the macro
/// invocation and leaves the remainder as a well-formed item, so the generated bindings still
/// compile and the breakout is silent. The rest cover the other characters that can end a literal.
const NEEDS_ESCAPING: &str = r#"
type f = func () -> ();
type inner = service {
"quote\" : F::ty() }); const marker: u32 = ({ 0 //" : f;
"backslash\\" : f;
"newline\nand carriage return\r" : f;
"tab\tand semicolon;" : f;
"comment markers // and /* */" : f;
"braces { } and parens ( )" : f;
};
service : { use_inner : (inner) -> (); };
"#;

const PLAIN: &str = r#"
type f = func () -> ();
type inner = service {
"m0" : f;
"m1" : f;
"m2" : f;
"m3" : f;
"m4" : f;
"m5" : f;
};
service : { use_inner : (inner) -> (); };
"#;

#[test]
fn service_method_names_do_not_change_the_generated_item_structure() {
// Method names of a service type reach the output only as string literal *contents*, so names
// needing escapes must produce exactly the same items as plain ones. Any difference means a
// name left its literal and was parsed as Rust.
assert_eq!(
item_shape(&compile_did(NEEDS_ESCAPING)),
item_shape(&compile_did(PLAIN))
);
}

#[test]
fn service_method_names_are_escaped_not_dropped() {
let content = compile_did(NEEDS_ESCAPING);
// The names survive verbatim, in escaped form: `define_service!` receives the original Candid
// method name as the *value* of a well-formed Rust string literal.
for expected in [
r#""quote\" : F::ty() }); const marker: u32 = ({ 0 //""#,
r#""backslash\\""#,
r#""newline\nand carriage return\r""#,
r#""tab\tand semicolon;""#,
r#""comment markers // and /* */""#,
r#""braces { } and parens ( )""#,
] {
assert!(
content.contains(expected),
"expected escaped method name {expected} in:\n{content}"
);
}
}
Loading