use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Expr, Ident, LitStr, Meta, Path, Token, parse::Parser, punctuated::Punctuated};
#[derive(Clone, Debug)]
pub enum AuthScopeArg {
Literal(String),
Expr(TokenStream2),
}
#[derive(Clone, Debug)]
pub enum CanisterRoleArg {
Literal(String),
Expr(TokenStream2),
}
#[derive(Clone, Debug)]
pub enum BuiltinPredicate {
AppAllowsUpdates,
AppIsQueryable,
SelfIsPrimeSubnet,
SelfIsPrimeRoot,
CallerIsController,
CallerIsParent,
CallerIsChild,
CallerIsRoot,
CallerIsSameCanister,
CallerHasRole {
role: CanisterRoleArg,
},
CallerHasAnyRole {
roles: Vec<CanisterRoleArg>,
},
CallerIsRegisteredToSubnet,
CallerIsWhitelisted,
Authenticated {
required_scope: Option<AuthScopeArg>,
},
BuildIcOnly,
BuildLocalOnly,
}
#[derive(Clone, Debug)]
pub enum AccessExprAst {
All(Vec<Self>),
Any(Vec<Self>),
Not(Box<Self>),
Pred(AccessPredicateAst),
}
#[derive(Clone, Debug)]
pub enum AccessPredicateAst {
Builtin(BuiltinPredicate),
Custom(TokenStream2),
}
#[derive(Debug)]
pub struct ParsedArgs {
pub forwarded: Vec<TokenStream2>,
pub export_name: Option<LitStr>,
pub payload_max_bytes: Option<TokenStream2>,
pub requires: Vec<AccessExprAst>,
pub requires_async: bool,
pub requires_fallible: bool,
pub internal: bool,
}
#[expect(clippy::too_many_lines)]
pub fn parse_args(attr: TokenStream2) -> syn::Result<ParsedArgs> {
if attr.is_empty() {
return Ok(empty());
}
let metas = Punctuated::<Meta, Token![,]>::parse_terminated
.parse2(attr.clone())
.map_err(|_| syn::Error::new_spanned(&attr, "expected requires(...)"))?;
let mut forwarded = Vec::new();
let mut requires = Vec::new();
let mut internal = false;
let mut saw_name = false;
let mut export_name = None;
let mut payload_max_bytes = None;
for meta in metas {
match meta {
Meta::List(list) if list.path.is_ident("requires") => {
requires.push(parse_requires(&list)?);
}
Meta::List(list) if list.path.is_ident("payload") => {
if payload_max_bytes.is_some() {
return Err(syn::Error::new_spanned(
list,
"payload(...) must appear only once",
));
}
payload_max_bytes = Some(parse_payload_max_bytes(&list)?);
}
Meta::Path(path) if path.is_ident("internal") => {
if internal {
return Err(syn::Error::new_spanned(
path,
"internal endpoint marker must appear only once",
));
}
internal = true;
}
Meta::NameValue(nv) if nv.path.is_ident("name") => {
if saw_name {
return Err(syn::Error::new_spanned(
nv,
"endpoint export name must appear only once",
));
}
let value = match &nv.value {
Expr::Lit(expr) => match &expr.lit {
syn::Lit::Str(lit) => lit,
_ => {
return Err(syn::Error::new_spanned(
nv,
"endpoint export name must be a string literal",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
nv,
"endpoint export name must be a string literal",
));
}
};
forwarded.push(quote!(name = #value));
export_name = Some(value.clone());
saw_name = true;
}
Meta::NameValue(nv) if nv.path.is_ident("internal") => {
if internal {
return Err(syn::Error::new_spanned(
nv,
"internal endpoint marker must appear only once",
));
}
let value = match &nv.value {
Expr::Lit(expr) => match &expr.lit {
syn::Lit::Bool(lit) => lit.value,
_ => {
return Err(syn::Error::new_spanned(
nv,
"internal must be set to a boolean literal",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
nv,
"internal must be set to a boolean literal",
));
}
};
if !value {
return Err(syn::Error::new_spanned(
nv,
"internal must be true when specified",
));
}
internal = true;
}
Meta::List(list) => {
return Err(syn::Error::new_spanned(
list,
"unsupported endpoint clause; use requires(...) or payload(...)",
));
}
Meta::Path(path) => {
return Err(syn::Error::new_spanned(
path,
"endpoint attributes must be expressed via requires(...), payload(...), internal, or name = \"...\"",
));
}
Meta::NameValue(nv) => {
return Err(syn::Error::new_spanned(
nv,
"endpoint attributes must be expressed via requires(...), payload(...), internal, or name = \"...\"",
));
}
}
}
if requires.is_empty() && !internal && forwarded.is_empty() && payload_max_bytes.is_none() {
return Err(syn::Error::new_spanned(
attr,
"expected requires(...), internal, name = \"...\", or payload(...)",
));
}
let requires_async = !requires.is_empty();
let requires_fallible = !requires.is_empty();
Ok(ParsedArgs {
forwarded,
export_name,
payload_max_bytes,
requires,
requires_async,
requires_fallible,
internal,
})
}
const fn empty() -> ParsedArgs {
ParsedArgs {
forwarded: Vec::new(),
export_name: None,
payload_max_bytes: None,
requires: Vec::new(),
requires_async: false,
requires_fallible: false,
internal: false,
}
}
fn parse_requires(list: &syn::MetaList) -> syn::Result<AccessExprAst> {
let exprs = parse_expr_list(&list.tokens)?;
Ok(AccessExprAst::All(exprs))
}
fn parse_payload_max_bytes(list: &syn::MetaList) -> syn::Result<TokenStream2> {
let metas = Punctuated::<Meta, Token![,]>::parse_terminated
.parse2(list.tokens.clone())
.map_err(|_| {
syn::Error::new_spanned(list, "expected payload(max_bytes = <usize expression>)")
})?;
let mut max_bytes = None;
for meta in metas {
match meta {
Meta::NameValue(nv) if nv.path.is_ident("max_bytes") => {
if max_bytes.is_some() {
return Err(syn::Error::new_spanned(
nv,
"payload max_bytes must appear only once",
));
}
let value = nv.value;
max_bytes = Some(quote!(#value));
}
other => {
return Err(syn::Error::new_spanned(
other,
"expected payload(max_bytes = <usize expression>)",
));
}
}
}
max_bytes.ok_or_else(|| {
syn::Error::new_spanned(list, "payload(...) requires max_bytes = <usize expression>")
})
}
fn parse_expr_list(tokens: &TokenStream2) -> syn::Result<Vec<AccessExprAst>> {
let exprs = Punctuated::<Expr, Token![,]>::parse_terminated
.parse2(tokens.clone())
.map_err(|_| {
syn::Error::new_spanned(
tokens,
"expected a comma-separated list of access expressions",
)
})?;
if exprs.is_empty() {
return Err(syn::Error::new_spanned(
tokens,
"expected at least one access expression",
));
}
exprs.into_iter().map(parse_expr).collect()
}
fn parse_expr(expr: Expr) -> syn::Result<AccessExprAst> {
match expr {
Expr::Call(call) => parse_call_expr(call),
other => Err(syn::Error::new_spanned(
other,
"expected access expression call (all/any/not/custom or built-in predicate)",
)),
}
}
#[expect(clippy::too_many_lines)]
fn parse_call_expr(call: syn::ExprCall) -> syn::Result<AccessExprAst> {
let path = match *call.func {
Expr::Path(expr) => expr.path,
other => {
return Err(syn::Error::new_spanned(
other,
"access expressions must be path-based calls",
));
}
};
let name = path_ident(&path)?.to_string();
let mut args = call.args.into_iter();
match name.as_str() {
"all" | "requires" => {
let exprs = parse_expr_args(args)?;
Ok(AccessExprAst::All(exprs))
}
"any" => {
let exprs = parse_expr_args(args)?;
Ok(AccessExprAst::Any(exprs))
}
"not" => {
let expr = args
.next()
.ok_or_else(|| syn::Error::new_spanned(&path, "not(...) requires one argument"))?;
if args.next().is_some() {
return Err(syn::Error::new_spanned(
&path,
"not(...) accepts exactly one argument",
));
}
Ok(AccessExprAst::Not(Box::new(parse_expr(expr)?)))
}
"custom" => {
let expr = args.next().ok_or_else(|| {
syn::Error::new_spanned(&path, "custom(...) requires one argument")
})?;
if args.next().is_some() {
return Err(syn::Error::new_spanned(
&path,
"custom(...) accepts exactly one argument",
));
}
Ok(AccessExprAst::Pred(AccessPredicateAst::Custom(
quote::quote!(#expr),
)))
}
_ => {
if is_authenticated_path(&path) {
let required_scope = match args.next() {
None => None,
Some(scope_expr) => {
if args.next().is_some() {
return Err(syn::Error::new_spanned(
&path,
"authenticated(...) accepts zero arguments or one string literal/path scope",
));
}
let scope = match scope_expr {
Expr::Lit(expr_lit) => match &expr_lit.lit {
syn::Lit::Str(scope_lit) => {
let value = scope_lit.value();
if value.trim().is_empty() {
return Err(syn::Error::new_spanned(
&path,
"authenticated(...) scope must not be empty",
));
}
AuthScopeArg::Literal(value)
}
_ => {
return Err(syn::Error::new_spanned(
expr_lit,
"authenticated(...) scope must be a string literal or path constant",
));
}
},
Expr::Path(expr_path) => AuthScopeArg::Expr(quote::quote!(#expr_path)),
other => {
return Err(syn::Error::new_spanned(
other,
"authenticated(...) scope must be a string literal or path constant",
));
}
};
Some(scope)
}
};
return Ok(AccessExprAst::Pred(AccessPredicateAst::Builtin(
BuiltinPredicate::Authenticated { required_scope },
)));
}
if let Some(label) = caller_role_predicate_label(&path) {
let predicate = if label == "caller::has_any_role" {
BuiltinPredicate::CallerHasAnyRole {
roles: parse_canister_role_list_arg(&path, args, label)?,
}
} else if label == "caller::has_role" {
let role = parse_canister_role_arg(&path, args, label)?;
BuiltinPredicate::CallerHasRole { role }
} else {
unreachable!("caller role predicate label must be exhaustive")
};
return Ok(AccessExprAst::Pred(AccessPredicateAst::Builtin(predicate)));
}
if is_removed_has_app_role_path(&path) {
return Err(syn::Error::new_spanned(
&path,
"caller::has_app_role(...) was removed in Canic 0.40; use root-signed caller::has_role(...) for protected internal endpoints",
));
}
if args.next().is_some() {
return Err(syn::Error::new_spanned(
&path,
"built-in predicates do not accept arguments",
));
}
let builtin = builtin_from_path(&path).ok_or_else(|| {
if builtin_from_path_tail(&path).is_some()
|| is_authenticated_path(&path)
|| is_bare_authenticated_path(&path)
{
return syn::Error::new_spanned(
&path,
"built-in predicates must use short paths like auth::authenticated()",
);
}
syn::Error::new_spanned(
&path,
"unknown access predicate; expected built-in predicate or any/all/not/custom",
)
})?;
Ok(AccessExprAst::Pred(AccessPredicateAst::Builtin(builtin)))
}
}
}
fn parse_canister_role_arg<I>(
path: &Path,
mut args: I,
predicate_label: &'static str,
) -> syn::Result<CanisterRoleArg>
where
I: Iterator<Item = Expr>,
{
let role_expr = args.next().ok_or_else(|| {
syn::Error::new_spanned(
path,
format!("{predicate_label}(...) requires one canister role argument"),
)
})?;
if args.next().is_some() {
return Err(syn::Error::new_spanned(
path,
format!("{predicate_label}(...) accepts exactly one canister role argument"),
));
}
parse_canister_role_expr(path, role_expr, predicate_label)
}
fn parse_canister_role_list_arg<I>(
path: &Path,
mut args: I,
predicate_label: &'static str,
) -> syn::Result<Vec<CanisterRoleArg>>
where
I: Iterator<Item = Expr>,
{
let roles_expr = args.next().ok_or_else(|| {
syn::Error::new_spanned(
path,
format!("{predicate_label}(...) requires one non-empty role array argument"),
)
})?;
if args.next().is_some() {
return Err(syn::Error::new_spanned(
path,
format!("{predicate_label}(...) accepts exactly one role array argument"),
));
}
let Expr::Array(array) = roles_expr else {
return Err(syn::Error::new_spanned(
roles_expr,
format!("{predicate_label}(...) role list must be an array"),
));
};
if array.elems.is_empty() {
return Err(syn::Error::new_spanned(
array,
format!("{predicate_label}(...) role list must not be empty"),
));
}
array
.elems
.into_iter()
.map(|expr| parse_canister_role_expr(path, expr, predicate_label))
.collect()
}
fn parse_canister_role_expr(
path: &Path,
role_expr: Expr,
predicate_label: &'static str,
) -> syn::Result<CanisterRoleArg> {
match role_expr {
Expr::Lit(expr_lit) => match &expr_lit.lit {
syn::Lit::Str(role_lit) => {
let value = role_lit.value();
if value.trim().is_empty() {
return Err(syn::Error::new_spanned(
path,
format!("{predicate_label}(...) role must not be empty"),
));
}
Ok(CanisterRoleArg::Literal(value))
}
_ => Err(syn::Error::new_spanned(
expr_lit,
format!(
"{predicate_label}(...) role must be a string literal or canister role path"
),
)),
},
Expr::Path(expr_path) => Ok(CanisterRoleArg::Expr(quote::quote!(#expr_path))),
other => Err(syn::Error::new_spanned(
other,
format!("{predicate_label}(...) role must be a string literal or canister role path"),
)),
}
}
fn parse_expr_args<I>(args: I) -> syn::Result<Vec<AccessExprAst>>
where
I: IntoIterator<Item = Expr>,
{
let mut out = Vec::new();
for expr in args {
out.push(parse_expr(expr)?);
}
if out.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"expected at least one access expression",
));
}
Ok(out)
}
fn builtin_from_path(path: &Path) -> Option<BuiltinPredicate> {
if path.leading_colon.is_some() {
return None;
}
if path.segments.len() == 1 {
return None;
}
if path.segments.len() != 2 {
return None;
}
builtin_from_path_tail(path)
}
fn builtin_from_path_tail(path: &Path) -> Option<BuiltinPredicate> {
let mut names = path.segments.iter().map(|seg| seg.ident.to_string());
let last = names.next_back()?;
let module = names.next_back();
match (module.as_deref(), last.as_str()) {
(Some("app"), "allows_updates") => Some(BuiltinPredicate::AppAllowsUpdates),
(Some("app"), "is_queryable") => Some(BuiltinPredicate::AppIsQueryable),
(Some("env"), "is_prime_subnet") => Some(BuiltinPredicate::SelfIsPrimeSubnet),
(Some("env"), "is_prime_root") => Some(BuiltinPredicate::SelfIsPrimeRoot),
(Some("caller"), "is_controller") => Some(BuiltinPredicate::CallerIsController),
(Some("caller"), "is_parent") => Some(BuiltinPredicate::CallerIsParent),
(Some("caller"), "is_child") => Some(BuiltinPredicate::CallerIsChild),
(Some("caller"), "is_root") => Some(BuiltinPredicate::CallerIsRoot),
(Some("caller"), "is_same_canister") => Some(BuiltinPredicate::CallerIsSameCanister),
(Some("caller"), "is_registered_to_subnet") => {
Some(BuiltinPredicate::CallerIsRegisteredToSubnet)
}
(Some("caller"), "is_whitelisted") => Some(BuiltinPredicate::CallerIsWhitelisted),
(Some("env"), "build_ic_only") => Some(BuiltinPredicate::BuildIcOnly),
(Some("env"), "build_local_only") => Some(BuiltinPredicate::BuildLocalOnly),
_ => None,
}
}
fn is_authenticated_path(path: &Path) -> bool {
if path.leading_colon.is_some() {
return false;
}
if path.segments.len() != 2 {
return false;
}
let mut names = path.segments.iter().map(|seg| seg.ident.to_string());
let last = names.next_back();
let module = names.next_back();
matches!(
(module.as_deref(), last.as_deref()),
(Some("auth"), Some("authenticated"))
)
}
fn caller_role_predicate_label(path: &Path) -> Option<&'static str> {
if path.leading_colon.is_some() {
return None;
}
if path.segments.len() != 2 {
return None;
}
let mut names = path.segments.iter().map(|seg| seg.ident.to_string());
let last = names.next_back();
let module = names.next_back();
match (module.as_deref(), last.as_deref()) {
(Some("caller"), Some("has_role")) => Some("caller::has_role"),
(Some("caller"), Some("has_any_role")) => Some("caller::has_any_role"),
_ => None,
}
}
fn is_removed_has_app_role_path(path: &Path) -> bool {
if path.leading_colon.is_some() || path.segments.len() != 2 {
return false;
}
let mut names = path.segments.iter().map(|seg| seg.ident.to_string());
let last = names.next_back();
let module = names.next_back();
matches!(
(module.as_deref(), last.as_deref()),
(Some("caller"), Some("has_app_role"))
)
}
fn is_bare_authenticated_path(path: &Path) -> bool {
if path.leading_colon.is_some() {
return false;
}
path.segments.len() == 1
&& path
.segments
.last()
.is_some_and(|seg| seg.ident == "authenticated")
}
fn path_ident(path: &Path) -> syn::Result<&Ident> {
for segment in &path.segments {
if !segment.arguments.is_empty() {
return Err(syn::Error::new_spanned(
path,
"DSL symbols do not accept arguments",
));
}
}
path.segments
.last()
.map(|segment| &segment.ident)
.ok_or_else(|| syn::Error::new_spanned(path, "expected a DSL symbol"))
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn name_only_is_forwarded_without_requires() {
let parsed = parse_args(quote!(name = "icrc10_supported_standards"))
.expect("name-only args should parse");
assert_eq!(parsed.forwarded.len(), 1);
assert_eq!(
parsed.export_name.as_ref().map(LitStr::value).as_deref(),
Some("icrc10_supported_standards")
);
assert!(parsed.requires.is_empty());
assert!(!parsed.internal);
}
#[test]
fn payload_max_bytes_is_parsed() {
let parsed =
parse_args(quote!(payload(max_bytes = 64 * 1024))).expect("payload args should parse");
assert_eq!(
parsed.payload_max_bytes.expect("payload limit").to_string(),
"64 * 1024"
);
}
#[test]
fn duplicate_name_is_rejected() {
let err = parse_args(quote!(name = "a", name = "b")).expect_err("duplicate name");
assert!(err.to_string().contains("must appear only once"));
}
#[test]
fn duplicate_payload_is_rejected() {
let err = parse_args(quote!(payload(max_bytes = 1024), payload(max_bytes = 2048)))
.expect_err("duplicate payload");
assert!(err.to_string().contains("must appear only once"));
}
#[test]
fn authenticated_allows_no_scope_argument() {
let parsed = parse_args(quote!(requires(auth::authenticated()))).expect("parse args");
let AccessExprAst::All(exprs) = &parsed.requires[0] else {
panic!("expected requires(all)");
};
let AccessExprAst::Pred(AccessPredicateAst::Builtin(BuiltinPredicate::Authenticated {
required_scope,
})) = &exprs[0]
else {
panic!("expected authenticated predicate");
};
assert!(required_scope.is_none());
}
#[test]
fn authenticated_allows_string_scope_argument() {
let parsed =
parse_args(quote!(requires(auth::authenticated("scope:test")))).expect("parse args");
let AccessExprAst::All(exprs) = &parsed.requires[0] else {
panic!("expected requires(all)");
};
let AccessExprAst::Pred(AccessPredicateAst::Builtin(BuiltinPredicate::Authenticated {
required_scope,
})) = &exprs[0]
else {
panic!("expected authenticated predicate");
};
let Some(AuthScopeArg::Literal(required_scope)) = required_scope else {
panic!("expected literal scope");
};
assert_eq!(required_scope, "scope:test");
}
#[test]
fn authenticated_allows_path_scope_argument() {
let parsed =
parse_args(quote!(requires(auth::authenticated(cap::VERIFY)))).expect("parse args");
let AccessExprAst::All(exprs) = &parsed.requires[0] else {
panic!("expected requires(all)");
};
let AccessExprAst::Pred(AccessPredicateAst::Builtin(BuiltinPredicate::Authenticated {
required_scope,
})) = &exprs[0]
else {
panic!("expected authenticated predicate");
};
let Some(AuthScopeArg::Expr(required_scope)) = required_scope else {
panic!("expected expr scope");
};
assert_eq!(required_scope.to_string(), "cap :: VERIFY");
}
#[test]
fn attested_role_allows_path_role_argument() {
let parsed = parse_args(quote!(
internal,
requires(caller::has_role(canister::PROJECT_HUB))
))
.expect("parse args");
let AccessExprAst::All(exprs) = &parsed.requires[0] else {
panic!("expected requires(all)");
};
let AccessExprAst::Pred(AccessPredicateAst::Builtin(BuiltinPredicate::CallerHasRole {
role,
})) = &exprs[0]
else {
panic!("expected attested caller role predicate");
};
let CanisterRoleArg::Expr(role) = role else {
panic!("expected path role");
};
assert_eq!(role.to_string(), "canister :: PROJECT_HUB");
}
#[test]
fn attested_any_role_allows_role_array_argument() {
let parsed = parse_args(quote!(
internal,
requires(caller::has_any_role([canister::PROJECT_HUB, "admin_hub"]))
))
.expect("parse args");
let AccessExprAst::All(exprs) = &parsed.requires[0] else {
panic!("expected requires(all)");
};
let AccessExprAst::Pred(AccessPredicateAst::Builtin(BuiltinPredicate::CallerHasAnyRole {
roles,
})) = &exprs[0]
else {
panic!("expected attested any-role predicate");
};
assert_eq!(roles.len(), 2);
}
#[test]
fn attested_any_role_rejects_empty_role_array() {
let err = parse_args(quote!(internal, requires(caller::has_any_role([]))))
.expect_err("empty role array must fail");
assert!(
err.to_string()
.contains("caller::has_any_role(...) role list must not be empty")
);
}
#[test]
fn app_role_predicate_is_removed() {
let err = parse_args(quote!(
internal,
requires(caller::has_app_role("project_hub"))
))
.expect_err("removed app role predicate must fail");
assert!(
err.to_string()
.contains("caller::has_app_role(...) was removed in Canic 0.40")
);
}
#[test]
fn authenticated_rejects_multiple_arguments() {
let err = parse_args(quote!(requires(auth::authenticated("a", "b"))))
.expect_err("authenticated with two args must fail");
assert!(err.to_string().contains(
"authenticated(...) accepts zero arguments or one string literal/path scope"
));
}
#[test]
fn authenticated_rejects_bare_alias_path() {
let err = parse_args(quote!(requires(authenticated()))).expect_err("bare alias must fail");
assert!(
err.to_string()
.contains("built-in predicates must use short paths like auth::authenticated()")
);
}
}