use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use syn::visit::{self, Visit};
use syn::{Expr, ExprCall, ExprField, ExprMatch, ExprPath, Item, ItemFn, Lit, Member, Pat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RpcCallerRole {
Agent,
Owner,
Operator,
Host,
}
impl RpcCallerRole {
pub fn as_str(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Owner => "owner",
Self::Operator => "operator",
Self::Host => "host",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedRpcMethod {
pub method: String,
pub documented: bool,
pub role: RpcCallerRole,
}
#[derive(Debug, Clone, Default)]
struct FunctionSignals {
callees: BTreeSet<String>,
host: bool,
owner: bool,
agent: bool,
owner_client: bool,
session_client_id: bool,
}
impl FunctionSignals {
fn merge(&mut self, other: &Self) {
self.host |= other.host;
self.owner |= other.owner;
self.agent |= other.agent;
self.owner_client |= other.owner_client;
self.session_client_id |= other.session_client_id;
self.callees.extend(other.callees.iter().cloned());
}
fn role(&self) -> RpcCallerRole {
if self.host {
RpcCallerRole::Host
} else if self.owner || (self.owner_client && self.session_client_id) {
RpcCallerRole::Owner
} else if self.agent {
RpcCallerRole::Agent
} else {
RpcCallerRole::Operator
}
}
}
#[derive(Default)]
struct SignalVisitor {
signals: FunctionSignals,
}
impl<'ast> Visit<'ast> for SignalVisitor {
fn visit_expr_call(&mut self, node: &'ast ExprCall) {
if let Expr::Path(path) = node.func.as_ref() {
if path.qself.is_none() && path.path.segments.len() == 1 {
let callee = path.path.segments[0].ident.to_string();
match callee.as_str() {
"require_approval_authority" | "require_agent_permissions_authority" => {
self.signals.host = true;
}
"authorize_run_access" => self.signals.owner = true,
_ => {}
}
self.signals.callees.insert(callee);
}
}
visit::visit_expr_call(self, node);
}
fn visit_expr_field(&mut self, node: &'ast ExprField) {
let Member::Named(member) = &node.member else {
visit::visit_expr_field(self, node);
return;
};
if is_path(node.base.as_ref(), "session") {
match member.to_string().as_str() {
"agent_id" => self.signals.agent = true,
"client_id" => self.signals.session_client_id = true,
_ => {}
}
}
visit::visit_expr_field(self, node);
}
fn visit_expr_path(&mut self, node: &'ast ExprPath) {
if node.qself.is_none() && node.path.is_ident("owner_client") {
self.signals.owner_client = true;
}
visit::visit_expr_path(self, node);
}
}
fn starts_with_host_refusal(function: &ItemFn) -> bool {
let Some(syn::Stmt::Expr(Expr::If(guard), _)) = function.block.stmts.first() else {
return false;
};
let Expr::Unary(negated) = guard.cond.as_ref() else {
return false;
};
if !matches!(negated.op, syn::UnOp::Not(_)) {
return false;
}
let Expr::MethodCall(load) = negated.expr.as_ref() else {
return false;
};
let Expr::Field(field) = load.receiver.as_ref() else {
return false;
};
if load.method != "load"
|| !is_path(field.base.as_ref(), "session")
|| !matches!(&field.member, Member::Named(name) if name == "is_host")
{
return false;
}
let [syn::Stmt::Expr(Expr::Return(ret), _)] = guard.then_branch.stmts.as_slice() else {
return false;
};
matches!(ret.expr.as_deref(), Some(Expr::Call(call)) if is_path(call.func.as_ref(), "Err"))
}
fn top_level_function_signals(syntax: &syn::File) -> BTreeMap<String, FunctionSignals> {
syntax
.items
.iter()
.filter_map(|item| {
let Item::Fn(function) = item else {
return None;
};
let mut visitor = SignalVisitor::default();
visitor.visit_block(&function.block);
visitor.signals.host |= starts_with_host_refusal(function);
Some((function.sig.ident.to_string(), visitor.signals))
})
.collect()
}
fn resolved_signals(
direct: &FunctionSignals,
functions: &BTreeMap<String, FunctionSignals>,
) -> FunctionSignals {
fn visit_callee(
name: &str,
functions: &BTreeMap<String, FunctionSignals>,
visiting: &mut BTreeSet<String>,
resolved: &mut FunctionSignals,
) {
if !visiting.insert(name.to_string()) {
return;
}
if let Some(signals) = functions.get(name) {
resolved.merge(signals);
for callee in &signals.callees {
visit_callee(callee, functions, visiting, resolved);
}
}
visiting.remove(name);
}
let mut resolved = direct.clone();
let mut visiting = BTreeSet::new();
for callee in &direct.callees {
visit_callee(callee, functions, &mut visiting, &mut resolved);
}
resolved
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DispatchFunction {
Primary,
DaemonOwnedAuth,
}
struct DispatchVisitor<'functions> {
function: Option<DispatchFunction>,
functions: &'functions BTreeMap<String, FunctionSignals>,
alias_groups: Vec<(Vec<String>, RpcCallerRole)>,
selected_tables: usize,
unsupported_arms: usize,
}
impl<'functions> DispatchVisitor<'functions> {
fn new(functions: &'functions BTreeMap<String, FunctionSignals>) -> Self {
Self {
function: None,
functions,
alias_groups: Vec::new(),
selected_tables: 0,
unsupported_arms: 0,
}
}
}
impl<'ast> Visit<'ast> for DispatchVisitor<'_> {
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
let previous = self.function;
self.function = match node.sig.ident.to_string().as_str() {
"run_dispatch" => Some(DispatchFunction::Primary),
"dispatch_daemon_owned_auth" => Some(DispatchFunction::DaemonOwnedAuth),
_ => None,
};
visit::visit_item_fn(self, node);
self.function = previous;
}
fn visit_expr_match(&mut self, node: &'ast ExprMatch) {
let is_dispatch = match self.function {
Some(DispatchFunction::Primary) => is_as_str_call_on(&node.expr, "method_owned"),
Some(DispatchFunction::DaemonOwnedAuth) => is_path(&node.expr, "method"),
None => false,
};
if is_dispatch {
self.selected_tables += 1;
for arm in &node.arms {
let mut methods = Vec::new();
collect_string_patterns(&arm.pat, &mut methods);
if methods.is_empty() {
if !matches!(&arm.pat, Pat::Wild(_)) {
self.unsupported_arms += 1;
}
} else {
let role = if self.function == Some(DispatchFunction::DaemonOwnedAuth) {
RpcCallerRole::Host
} else {
let mut direct = SignalVisitor::default();
direct.visit_expr(&arm.body);
resolved_signals(&direct.signals, self.functions).role()
};
self.alias_groups.push((methods, role));
}
}
return;
}
visit::visit_expr_match(self, node);
}
}
fn is_as_str_call_on(expression: &Expr, identifier: &str) -> bool {
let Expr::MethodCall(call) = expression else {
return false;
};
call.method == "as_str" && call.args.is_empty() && is_path(&call.receiver, identifier)
}
fn is_path(expression: &Expr, identifier: &str) -> bool {
matches!(
expression,
Expr::Path(path)
if path.qself.is_none()
&& path.path.segments.len() == 1
&& path.path.is_ident(identifier)
)
}
fn collect_string_patterns(pattern: &Pat, out: &mut Vec<String>) {
match pattern {
Pat::Lit(literal) => {
if let Lit::Str(value) = &literal.lit {
out.push(value.value());
}
}
Pat::Or(patterns) => {
for case in &patterns.cases {
collect_string_patterns(case, out);
}
}
Pat::Paren(pattern) => collect_string_patterns(&pattern.pat, out),
_ => {}
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn extract_rpc_methods(
handler_source: &str,
protocol_documentation: &str,
) -> Result<Vec<ExtractedRpcMethod>, String> {
extract_rpc_methods_and_table_count(handler_source, protocol_documentation)
.map(|(methods, _)| methods)
}
pub fn role_for_method(
methods: &[ExtractedRpcMethod],
method: &str,
) -> Result<RpcCallerRole, String> {
methods
.iter()
.find(|item| item.method == method)
.map(|item| item.role)
.ok_or_else(|| format!("cannot derive caller role for unknown method `{method}`"))
}
fn extract_rpc_methods_and_table_count(
handler_source: &str,
protocol_documentation: &str,
) -> Result<(Vec<ExtractedRpcMethod>, usize), String> {
let syntax = syn::parse_file(handler_source)
.map_err(|error| format!("parse car-server-core/src/handler.rs: {error}"))?;
let functions = top_level_function_signals(&syntax);
let mut visitor = DispatchVisitor::new(&functions);
visitor.visit_file(&syntax);
if visitor.alias_groups.is_empty() {
return Err("found no JSON-RPC string-literal dispatch arms".into());
}
if visitor.unsupported_arms != 0 {
return Err(format!(
"found {} non-wildcard JSON-RPC dispatch arm(s) without a string-literal method pattern",
visitor.unsupported_arms
));
}
let selected_tables = visitor.selected_tables;
let mut methods = BTreeMap::new();
for (aliases, role) in visitor.alias_groups {
for method in aliases {
let documented = documentation_mentions(protocol_documentation, &method);
if methods.insert(method.clone(), (documented, role)).is_some() {
return Err(format!(
"duplicate JSON-RPC dispatch method `{method}` in handler.rs"
));
}
}
}
Ok((
methods
.into_iter()
.map(|(method, (documented, role))| ExtractedRpcMethod {
method,
documented,
role,
})
.collect(),
selected_tables,
))
}
fn cross_check_dispatch_table_count(handler_source: &str, selected: usize) -> Result<(), String> {
let textual = count_in_function(
handler_source,
"pub async fn run_dispatch",
"\nasync fn send_response",
"match method_owned.as_str()",
)? + count_in_function(
handler_source,
"async fn dispatch_daemon_owned_auth",
"\nasync fn handle_auth_start",
"match method {",
)?;
if textual != selected {
return Err(format!(
"JSON-RPC dispatch-table cross-check failed: syn selected {selected}, handler text contains {textual}"
));
}
Ok(())
}
fn count_in_function(
source: &str,
start_marker: &str,
end_marker: &str,
needle: &str,
) -> Result<usize, String> {
if source.matches(start_marker).count() != 1 {
return Err(format!(
"JSON-RPC dispatch-table cross-check expected exactly one `{start_marker}`"
));
}
let start = source.find(start_marker).expect("count checked above");
let remainder = &source[start..];
let end = remainder.find(end_marker).ok_or_else(|| {
format!(
"JSON-RPC dispatch-table cross-check found no `{end_marker}` after `{start_marker}`"
)
})?;
Ok(remainder[..end].matches(needle).count())
}
fn documentation_mentions(documentation: &str, method: &str) -> bool {
let mut in_a2a_alias_table = false;
for line in documentation.lines() {
let line = line.trim();
if line == "| v1.0 PascalCase | v0.3 slash form |" {
in_a2a_alias_table = true;
continue;
}
if let Some(heading) = line.strip_prefix("#### ") {
in_a2a_alias_table = false;
if heading.starts_with('`') && code_spans_mention_method(heading, method) {
return true;
}
continue;
}
if line.starts_with('|') {
let mut cells = line.split('|').skip(1);
if cells
.next()
.is_some_and(|cell| code_spans_mention_method(cell, method))
{
return true;
}
if in_a2a_alias_table
&& cells
.next()
.is_some_and(|cell| code_spans_mention_method(cell, method))
{
return true;
}
continue;
}
in_a2a_alias_table = false;
let Some(bullet) = line.strip_prefix("- ") else {
continue;
};
let bullet = bullet.strip_prefix("**").unwrap_or(bullet);
if !bullet.starts_with('`') {
continue;
}
let identifier_prefix = bullet.split_once(" —").map_or(bullet, |(prefix, _)| prefix);
if code_spans_mention_method(identifier_prefix, method) {
return true;
}
}
false
}
fn code_spans_mention_method(text: &str, method: &str) -> bool {
text.split('`')
.skip(1)
.step_by(2)
.any(|code| text_mentions_method(code, method))
}
fn text_mentions_method(text: &str, method: &str) -> bool {
text.match_indices(method).any(|(start, _)| {
let before = text[..start].chars().next_back();
let end = start + method.len();
let after = text[end..].chars().next();
!before.is_some_and(is_method_character) && !after.is_some_and(is_method_character)
})
}
fn is_method_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '/' | '-')
}
pub fn emit_rpc_inventory(manifest_dir: &Path, out_dir: &Path) -> Result<(), String> {
let workspace = manifest_dir
.parent()
.and_then(Path::parent)
.ok_or("car-cli is not under <workspace>/crates/car-cli")?;
let repository = workspace
.parent()
.ok_or("car-rs workspace has no repository parent")?;
let handler = workspace.join("crates/car-server-core/src/handler.rs");
let protocol = repository.join("docs/websocket-protocol.md");
println!("cargo:rerun-if-changed={}", handler.display());
println!("cargo:rerun-if-changed={}", protocol.display());
let source = read(&handler)?;
let documentation = read(&protocol)?;
let (methods, selected_tables) = extract_rpc_methods_and_table_count(&source, &documentation)?;
cross_check_dispatch_table_count(&source, selected_tables)?;
let values = methods
.iter()
.map(|item| {
let role = role_for_method(&methods, &item.method)?;
Ok(serde_json::json!({
"method": item.method,
"documented": item.documented,
"role": role.as_str(),
}))
})
.collect::<Result<Vec<_>, String>>()?;
let mut bytes = serde_json::to_vec_pretty(&values)
.map_err(|error| format!("serialize JSON-RPC capability inventory: {error}"))?;
bytes.push(b'\n');
let output_path = out_dir.join("capability-rpc-methods.json");
std::fs::write(&output_path, bytes)
.map_err(|error| format!("write {}: {error}", output_path.display()))
}
fn read(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|error| format!("read {}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direct_host_refusal_is_distinct_from_host_observation_or_conditional_access() {
let source = r#"
async fn run_dispatch() {
match method_owned.as_str() {
"session.clear_halt" => clear(&session),
"read" => read(&session),
"conditional" => conditional(&session),
"not_refusal" => not_refusal(&session),
_ => (),
}
}
fn clear(session: &Session) {
if !session.is_host.load(Ordering::Acquire) { return Err("host required"); }
Ok(())
}
fn read(session: &Session) { let role = session.is_host.load(Ordering::Acquire); }
fn conditional(session: &Session) {
if bound && !session.is_host.load(Ordering::Acquire) { return Err("bound"); }
}
fn not_refusal(session: &Session) {
if !session.is_host.load(Ordering::Acquire) { return Ok(false); }
}
"#;
let methods = extract_rpc_methods(source, "").unwrap();
assert_eq!(
role_for_method(&methods, "session.clear_halt").unwrap(),
RpcCallerRole::Host
);
for method in ["read", "conditional", "not_refusal"] {
assert_eq!(
role_for_method(&methods, method).unwrap(),
RpcCallerRole::Operator
);
}
}
const FIXTURE: &str = r#"
async fn run_dispatch() {
let _ = "not.a.dispatch.arm";
match unrelated.as_str() {
"also.not_dispatched" => (),
_ => (),
}
match method_owned.as_str() {
"alpha.one" => handle_agent(&session),
"beta/two"
| "BetaTwo"
| "beta.two" => (),
"infer.cancel" => handle_r16_owner(&session),
"runs.cancel" => handle_authorized_owner(),
"permission.approve" => handle_host(&session),
_ => (),
}
}
fn handle_agent(session: &Session) {
let _ = &session.agent_id;
}
fn handle_r16_owner(session: &Session) {
let _ = &session.client_id;
let _ = owner_client;
}
fn handle_authorized_owner() {
authorize_run_access();
}
fn handle_host(session: &Session) {
require_approval_authority(session);
}
async fn dispatch_daemon_owned_auth(method: &str) {
match method {
"session.auth" => (),
_ => (),
}
}
fn another_function(method: &str) {
match method {
"not.dispatched" => (),
_ => (),
}
}
"#;
#[test]
fn extracts_multiline_aliases_and_both_dispatchers_only() {
let documentation = r#"
#### `alpha.one`
| `beta/two` | alias reference |
#### `session.auth`
A cross-reference to `infer.cancel` and `infer.cancel.v1` is not its own entry.
"#;
let methods = extract_rpc_methods(FIXTURE, documentation).unwrap();
assert_eq!(
methods,
vec![
ExtractedRpcMethod {
method: "BetaTwo".into(),
documented: false,
role: RpcCallerRole::Operator,
},
ExtractedRpcMethod {
method: "alpha.one".into(),
documented: true,
role: RpcCallerRole::Agent,
},
ExtractedRpcMethod {
method: "beta.two".into(),
documented: false,
role: RpcCallerRole::Operator,
},
ExtractedRpcMethod {
method: "beta/two".into(),
documented: true,
role: RpcCallerRole::Operator,
},
ExtractedRpcMethod {
method: "infer.cancel".into(),
documented: false,
role: RpcCallerRole::Owner,
},
ExtractedRpcMethod {
method: "permission.approve".into(),
documented: false,
role: RpcCallerRole::Host,
},
ExtractedRpcMethod {
method: "runs.cancel".into(),
documented: false,
role: RpcCallerRole::Owner,
},
ExtractedRpcMethod {
method: "session.auth".into(),
documented: true,
role: RpcCallerRole::Host,
},
]
);
}
#[test]
fn role_lookup_refuses_a_method_absent_from_the_real_dispatcher() {
let methods = extract_rpc_methods(FIXTURE, "").unwrap();
let error = role_for_method(&methods, "not.real").unwrap_err();
assert_eq!(
error,
"cannot derive caller role for unknown method `not.real`"
);
}
#[test]
fn exact_documentation_boundary_rejects_capability_suffixes() {
assert!(!documentation_mentions(
"#### `infer.cancel.v1`",
"infer.cancel"
));
assert!(documentation_mentions(
"#### `infer.cancel`",
"infer.cancel"
));
assert!(documentation_mentions(
"#### `diagnostics.secret_store_activity {}`",
"diagnostics.secret_store_activity"
));
assert!(!documentation_mentions(
"ordinary prose can say verify without documenting the RPC",
"verify"
));
}
#[test]
fn deleting_a_method_section_is_not_masked_by_a_cross_reference() {
let with_section = r#"
#### `infer.cancel`
- **Params**: `{ request_id }`
#### `infer.deadline`
- **Returns**: the same status vocabulary as `infer.cancel`.
"#;
let without_section = r#"
#### `infer.deadline`
- **Returns**: the same status vocabulary as `infer.cancel`.
"#;
assert!(documentation_mentions(with_section, "infer.cancel"));
assert!(!documentation_mentions(without_section, "infer.cancel"));
}
#[test]
fn compact_table_and_bullet_entries_are_dedicated_references() {
let documentation = r#"
| v1.0 PascalCase | v0.3 slash form |
|---|---|
| `SendStreamingMessage` | `message/stream` |
- **`permission.approve`** / **`permission.reject`** — Params `{ fingerprint }`.
"#;
for method in [
"SendStreamingMessage",
"message/stream",
"permission.approve",
"permission.reject",
] {
assert!(documentation_mentions(documentation, method), "{method}");
}
}
#[test]
fn rejects_non_literal_dispatch_arms_and_duplicate_methods() {
let unsupported = FIXTURE.replacen(
"\"infer.cancel\" => handle_r16_owner(&session)",
"METHOD => ()",
1,
);
let error = extract_rpc_methods(&unsupported, "").unwrap_err();
assert!(
error.contains("without a string-literal method pattern"),
"{error}"
);
let duplicate = FIXTURE.replacen("\"session.auth\" => ()", "\"alpha.one\" => ()", 1);
let error = extract_rpc_methods(&duplicate, "").unwrap_err();
assert!(
error.contains("duplicate JSON-RPC dispatch method `alpha.one`"),
"{error}"
);
}
#[test]
fn textual_table_count_detects_a_nested_same_scrutinee_match() {
let fixture = r#"
pub async fn run_dispatch() {
match method_owned.as_str() {
"alpha.one" => (),
_ => (),
}
}
async fn send_response() {}
async fn dispatch_daemon_owned_auth(method: &str) {
match method {
"session.auth" => (),
_ => (),
}
}
async fn handle_auth_start() {}
"#;
let (_, selected) = extract_rpc_methods_and_table_count(fixture, "").unwrap();
assert_eq!(selected, 2);
cross_check_dispatch_table_count(fixture, selected).unwrap();
let nested = fixture.replacen(
"\"alpha.one\" => (),",
"\"alpha.one\" => { match method_owned.as_str() { \"nested\" => (), _ => () } },",
1,
);
let (_, selected) = extract_rpc_methods_and_table_count(&nested, "").unwrap();
assert_eq!(selected, 2);
let error = cross_check_dispatch_table_count(&nested, selected).unwrap_err();
assert!(
error.contains("syn selected 2, handler text contains 3"),
"{error}"
);
}
}