use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{
Expr, Ident, LitStr, Meta, MetaNameValue, Path, Token, parse::Parser, punctuated::Punctuated,
};
const ENDPOINT_ATTR_HELP: &str = "endpoint attributes must be expressed via requires(...), payload(...), internal, composite, or name = \"...\"";
#[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(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QueryMode {
Plain,
Composite,
}
impl QueryMode {
pub const fn is_composite(self) -> bool {
matches!(self, Self::Composite)
}
}
#[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,
pub query_mode: QueryMode,
}
#[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 query_mode = QueryMode::Plain;
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::Path(path) if path.is_ident("composite") => {
if query_mode.is_composite() {
return Err(syn::Error::new_spanned(
path,
"composite query marker must appear only once",
));
}
forwarded.push(quote!(composite = true));
query_mode = QueryMode::Composite;
}
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 = parse_string_literal(&nv, "endpoint export name")?;
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",
));
}
parse_true_marker(&nv, "internal")?;
internal = true;
}
Meta::NameValue(nv) if nv.path.is_ident("composite") => {
if query_mode.is_composite() {
return Err(syn::Error::new_spanned(
nv,
"composite query marker must appear only once",
));
}
parse_true_marker(&nv, "composite")?;
forwarded.push(quote!(composite = true));
query_mode = QueryMode::Composite;
}
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_ATTR_HELP));
}
Meta::NameValue(nv) => {
return Err(syn::Error::new_spanned(nv, ENDPOINT_ATTR_HELP));
}
}
}
if requires.is_empty() && !internal && forwarded.is_empty() && payload_max_bytes.is_none() {
return Err(syn::Error::new_spanned(
attr,
"expected requires(...), internal, composite, 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,
query_mode,
})
}
fn parse_string_literal<'a>(nv: &'a MetaNameValue, label: &'static str) -> syn::Result<&'a LitStr> {
if let Expr::Lit(expr) = &nv.value
&& let syn::Lit::Str(lit) = &expr.lit
{
return Ok(lit);
}
Err(syn::Error::new_spanned(
nv,
format!("{label} must be a string literal"),
))
}
fn parse_true_marker(nv: &MetaNameValue, marker: &'static str) -> syn::Result<()> {
let value = match &nv.value {
Expr::Lit(expr) => match &expr.lit {
syn::Lit::Bool(lit) => lit.value,
_ => {
return Err(syn::Error::new_spanned(
nv,
format!("{marker} must be set to a boolean literal"),
));
}
},
_ => {
return Err(syn::Error::new_spanned(
nv,
format!("{marker} must be set to a boolean literal"),
));
}
};
if value {
Ok(())
} else {
Err(syn::Error::new_spanned(
nv,
format!("{marker} must be true when specified"),
))
}
}
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,
query_mode: QueryMode::Plain,
}
}
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),
Expr::Group(group) => parse_expr(*group.expr),
Expr::Paren(paren) => parse_expr(*paren.expr),
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; 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> {
short_path_tail(path)?;
builtin_from_path_tail(path)
}
fn builtin_from_path_tail(path: &Path) -> Option<BuiltinPredicate> {
let (module, last) = path_tail(path)?;
let module = module.to_string();
let last = last.to_string();
match (module.as_str(), last.as_str()) {
("app", "allows_updates") => Some(BuiltinPredicate::AppAllowsUpdates),
("app", "is_queryable") => Some(BuiltinPredicate::AppIsQueryable),
("env", "is_prime_subnet") => Some(BuiltinPredicate::SelfIsPrimeSubnet),
("env", "is_prime_root") => Some(BuiltinPredicate::SelfIsPrimeRoot),
("caller", "is_controller") => Some(BuiltinPredicate::CallerIsController),
("caller", "is_parent") => Some(BuiltinPredicate::CallerIsParent),
("caller", "is_child") => Some(BuiltinPredicate::CallerIsChild),
("caller", "is_root") => Some(BuiltinPredicate::CallerIsRoot),
("caller", "is_same_canister") => Some(BuiltinPredicate::CallerIsSameCanister),
("caller", "is_registered_to_subnet") => Some(BuiltinPredicate::CallerIsRegisteredToSubnet),
("caller", "is_whitelisted") => Some(BuiltinPredicate::CallerIsWhitelisted),
("env", "build_ic_only") => Some(BuiltinPredicate::BuildIcOnly),
("env", "build_local_only") => Some(BuiltinPredicate::BuildLocalOnly),
_ => None,
}
}
fn is_authenticated_path(path: &Path) -> bool {
short_path_is(path, "auth", "authenticated")
}
fn caller_role_predicate_label(path: &Path) -> Option<&'static str> {
let (module, last) = short_path_tail(path)?;
if module != "caller" {
return None;
}
if last == "has_role" {
Some("caller::has_role")
} else if last == "has_any_role" {
Some("caller::has_any_role")
} else {
None
}
}
fn is_removed_has_app_role_path(path: &Path) -> bool {
short_path_is(path, "caller", "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_tail(path: &Path) -> Option<(&Ident, &Ident)> {
if path.leading_colon.is_some() {
return None;
}
let mut segments = path.segments.iter().rev();
let last = &segments.next()?.ident;
let module = &segments.next()?.ident;
Some((module, last))
}
fn short_path_tail(path: &Path) -> Option<(&Ident, &Ident)> {
if path.segments.len() == 2 {
path_tail(path)
} else {
None
}
}
fn short_path_is(path: &Path, module: &str, last: &str) -> bool {
short_path_tail(path)
.is_some_and(|(found_module, found_last)| found_module == module && found_last == last)
}
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 composite_query_marker_is_forwarded_without_requires() {
let parsed = parse_args(quote!(composite)).expect("composite-only args should parse");
assert_eq!(parsed.query_mode, QueryMode::Composite);
assert_eq!(parsed.forwarded.len(), 1);
assert_eq!(parsed.forwarded[0].to_string(), "composite = true");
assert!(parsed.requires.is_empty());
assert!(!parsed.internal);
}
#[test]
fn composite_query_true_is_forwarded() {
let parsed = parse_args(quote!(name = "wire_query", composite = true)).expect("parse args");
assert_eq!(parsed.query_mode, QueryMode::Composite);
assert_eq!(parsed.forwarded.len(), 2);
assert!(
parsed
.forwarded
.iter()
.any(|tokens| tokens.to_string() == "composite = true")
);
}
#[test]
fn composite_query_false_is_rejected() {
let err = parse_args(quote!(composite = false)).expect_err("false composite");
assert!(err.to_string().contains("composite must be true"));
}
#[test]
fn duplicate_composite_query_marker_is_rejected() {
let err = parse_args(quote!(composite, composite = true)).expect_err("duplicate");
assert!(err.to_string().contains("must appear only once"));
}
#[test]
fn internal_false_marker_is_rejected() {
let err = parse_args(quote!(internal = false)).expect_err("false internal");
assert!(err.to_string().contains("internal must be true"));
}
#[test]
fn internal_non_boolean_marker_is_rejected() {
let err = parse_args(quote!(internal = "yes")).expect_err("non-boolean internal");
assert!(
err.to_string()
.contains("internal must be set to a boolean literal")
);
}
#[test]
fn composite_non_boolean_marker_is_rejected() {
let err = parse_args(quote!(composite = "yes")).expect_err("non-boolean composite");
assert!(
err.to_string()
.contains("composite must be set to a boolean literal")
);
}
#[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 grouped_access_expression_is_unwrapped() {
let parsed = parse_args(quote!(requires((caller::is_controller())))).expect("parse args");
let AccessExprAst::All(exprs) = &parsed.requires[0] else {
panic!("expected requires(all)");
};
let AccessExprAst::Pred(AccessPredicateAst::Builtin(BuiltinPredicate::CallerIsController)) =
&exprs[0]
else {
panic!("expected caller::is_controller predicate");
};
}
#[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")
);
}
#[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()")
);
}
}