mirror of
https://github.com/rizinorg/rizin-mcp.git
synced 2026-11-07 06:46:00 -10:00
* The tests analyze `/bin/ls` and rely on what GNU `ls` is: a C program whose `main` calls many functions and references ASCII strings, with about 1300 strings in the whole file. Since Ubuntu 25.10, and so on the Ubuntu 26.04 that `ubuntu-latest` is moving to, `ls` is the multicall binary of uutils (0.10.0 on 26.04): a Rust program of 11.8 MB whose `main` only dispatches, with 138456 strings in the whole file, 18 MB of `izzj` that the paging test reads once per page. * GNU coreutils stays installed there as the required package gnu-coreutils, which names its programs with a `gnu` prefix, so take `/usr/bin/gnuls` when it exists, in the tests and the benchmark.
324 lines
12 KiB
Rust
324 lines
12 KiB
Rust
// SPDX-FileCopyrightText: 2026 RizinOrg <[email protected]>
|
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
//! The contract between the tools and Rizin: the commands they run exist, and
|
|
//! the JSON fields they read are there with the expected types.
|
|
//!
|
|
//! Rizin publishes no JSON schemas yet (rizinorg/rizin#6543, B2), so these
|
|
//! tests pin what the server relies on. Run nightly against Rizin's `dev`
|
|
//! branch, they turn a renamed command or field into a CI failure instead of
|
|
//! an empty or wrong result in a user's session.
|
|
|
|
use rizin_mcp::config::{ServerConfig, Transport};
|
|
use rizin_mcp::session::{AnalysisLevel, SessionManager};
|
|
use rizin_mcp::status::CmdStatus;
|
|
use serde_json::Value;
|
|
|
|
/// Every Rizin command the tools, resources and backends run, by name.
|
|
const COMMANDS: &[&str] = &[
|
|
// session, status and settings
|
|
"aa", "aaa", "aaaa", "e", "s", // binary information
|
|
"ij", "iej", "iSj", "iSSj", "irj", "iij", "iEj", "isj", "ilj", "icj", "izj", "izzj", "iHj",
|
|
"ihj", "iCj", "iVj", "iRj", // flags
|
|
"flj", "fslj", "fss+", "fss-", "f+", "f-", "fd", // functions and references
|
|
"aflj", "afij", "afvlj", "af", "afn", "afvn", "afs", "afsj", "afsbj", "axtj", "axfj", "agf",
|
|
// printing
|
|
"pdj", "pdfj", "pdr", "pxj", "p8", "pa", "aoj", "ph", "phlj", "plf", "Lcj", "aij",
|
|
// comments, projects, writing
|
|
"CC", "CC-", "CC.", "Ps", "wx", // searching and diffing
|
|
"/xj", "/v1j", "/v2j", "/v4j", "/v8j", "/Rj", "/mj", "/cmj", "cxj", "cfj",
|
|
// FLIRT and RzIL
|
|
"Fl", "Fa", "Fs", "Fd", "Fc", "aezi", "aezsej", // debugger
|
|
"ood", "doc", "dc", "ds", "drj", "db", "db-", "dblj", "dbtj", "dmj", "dij",
|
|
];
|
|
|
|
/// Commands that appeared in Rizin 0.10 and are used only when present.
|
|
const SINCE_0_10: &[&str] = &["_s", "Flj"];
|
|
|
|
/// Commands of optional plugins, which may be missing.
|
|
const PLUGIN_COMMANDS: &[&str] = &["pdg", "pdd", "sil"];
|
|
|
|
/// Held while an in-process session is open: Rizin before 0.10 keeps its
|
|
/// console in globals, so two in-process cores must never overlap.
|
|
#[cfg(feature = "api")]
|
|
static API_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
/// An open session, holding [`API_LOCK`] when in process.
|
|
struct Opened {
|
|
mgr: SessionManager,
|
|
_guard: Option<tokio::sync::MutexGuard<'static, ()>>,
|
|
}
|
|
|
|
impl std::ops::Deref for Opened {
|
|
type Target = SessionManager;
|
|
fn deref(&self) -> &SessionManager {
|
|
&self.mgr
|
|
}
|
|
}
|
|
|
|
fn transports() -> Vec<Transport> {
|
|
#[cfg(feature = "api")]
|
|
{
|
|
vec![Transport::Api, Transport::Pipe]
|
|
}
|
|
#[cfg(not(feature = "api"))]
|
|
{
|
|
vec![Transport::Pipe]
|
|
}
|
|
}
|
|
|
|
async fn open(transport: Transport, target: &str, level: AnalysisLevel) -> Option<Opened> {
|
|
let guard = match transport {
|
|
#[cfg(feature = "api")]
|
|
Transport::Api => Some(API_LOCK.lock().await),
|
|
_ => None,
|
|
};
|
|
let mgr = SessionManager::new(ServerConfig {
|
|
transport,
|
|
rizin_binary: std::env::var("RIZIN_MCP_TEST_BINARY")
|
|
.unwrap_or_else(|_| "rizin".to_string()),
|
|
..Default::default()
|
|
});
|
|
match mgr.open(target, level, false).await {
|
|
Ok(_) => Some(Opened { mgr, _guard: guard }),
|
|
Err(e) => {
|
|
eprintln!("skipping {transport:?}: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn target() -> String {
|
|
// GNU `ls` first: from Ubuntu 25.10 on, `ls` is the multicall binary of
|
|
// uutils, a Rust program of 11.8 MB whose `main` only dispatches, and the
|
|
// GNU one, from the required package gnu-coreutils, is `gnuls`.
|
|
for p in ["/usr/bin/gnuls", "/bin/ls", "/usr/bin/ls", "/bin/cat"] {
|
|
if std::path::Path::new(p).exists() {
|
|
return p.to_string();
|
|
}
|
|
}
|
|
std::env::current_exe()
|
|
.expect("current exe")
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn commands_used_by_the_tools_exist() {
|
|
for t in transports() {
|
|
let Some(mgr) = open(t, "-", AnalysisLevel::None).await else {
|
|
continue;
|
|
};
|
|
// Rizin 0.10 reports command status, and has `_s` for it.
|
|
let modern = mgr
|
|
.exec("_s?")
|
|
.await
|
|
.is_ok_and(|o| o.status == CmdStatus::Ok);
|
|
let expected = COMMANDS.iter().chain(if modern { SINCE_0_10 } else { &[] });
|
|
let mut missing = Vec::new();
|
|
for cmd in expected {
|
|
// The help of a command exists exactly when the command does.
|
|
let out = mgr.exec(&format!("{cmd}?")).await.expect("exec");
|
|
let exists = match out.status {
|
|
CmdStatus::Unknown => !out.output.trim().is_empty(),
|
|
status => !status.is_failure(),
|
|
};
|
|
if !exists {
|
|
missing.push(*cmd);
|
|
}
|
|
}
|
|
assert!(missing.is_empty(), "{t:?}: Rizin lacks {missing:?}");
|
|
}
|
|
}
|
|
|
|
/// The command names in the string literals passed to the command helpers
|
|
/// in `src/server.rs` (placeholders such as `{suffix}` dropped).
|
|
fn commands_in_server_source() -> Vec<String> {
|
|
let src = include_str!("../src/server.rs");
|
|
let src = &src[..src.find("#[cfg(test)]").unwrap_or(src.len())];
|
|
let mut found = Vec::new();
|
|
for call in [".cmd(", ".cmdj(", ".exec(", "cmd_strict(", "cmdj_optional("] {
|
|
for (pos, _) in src.match_indices(call) {
|
|
let rest = src[pos + call.len()..].trim_start();
|
|
let rest = rest.strip_prefix("&format!(").unwrap_or(rest).trim_start();
|
|
let Some(lit) = rest.strip_prefix('"') else {
|
|
continue;
|
|
};
|
|
let lit = &lit[..lit.find('"').unwrap_or(0)];
|
|
for stmt in lit.split(';') {
|
|
let mut name = String::new();
|
|
let mut depth = 0;
|
|
for c in stmt.trim().chars() {
|
|
match c {
|
|
'{' => depth += 1,
|
|
'}' => depth -= 1,
|
|
' ' if depth == 0 => break,
|
|
c if depth == 0 => name.push(c),
|
|
_ => {}
|
|
}
|
|
}
|
|
if !name.is_empty() {
|
|
found.push(name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
#[test]
|
|
fn command_list_covers_the_server() {
|
|
let found = commands_in_server_source();
|
|
assert!(found.len() > 50, "the scan found only {found:?}");
|
|
for name in &found {
|
|
// `/v{size}j` becomes `/vj`.
|
|
let known = COMMANDS.contains(&name.as_str())
|
|
|| SINCE_0_10.contains(&name.as_str())
|
|
|| PLUGIN_COMMANDS.contains(&name.as_str())
|
|
|| (name == "/vj");
|
|
assert!(known, "`{name}` is run by a tool but not in COMMANDS");
|
|
}
|
|
}
|
|
|
|
/// Assert that `v` has `path` (dot-separated; `0` indexes arrays) with a
|
|
/// value of kind `kind`.
|
|
fn require(v: &Value, what: &str, path: &str, kind: &str) {
|
|
let mut cur = v;
|
|
for key in path.split('.') {
|
|
cur = match key.parse::<usize>() {
|
|
Ok(i) => &cur[i],
|
|
Err(_) => &cur[key],
|
|
};
|
|
}
|
|
let ok = match kind {
|
|
"u64" => cur.is_u64(),
|
|
"str" => cur.is_string(),
|
|
"bool" => cur.is_boolean(),
|
|
"array" => cur.is_array(),
|
|
"object" => cur.is_object(),
|
|
_ => unreachable!(),
|
|
};
|
|
assert!(ok, "{what}: `{path}` should be {kind}, got {cur}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn json_fields_the_tools_read() {
|
|
for t in transports() {
|
|
let Some(mgr) = open(t, &target(), AnalysisLevel::Basic).await else {
|
|
continue;
|
|
};
|
|
let j = |cmd: &'static str| {
|
|
let mgr = &mgr;
|
|
async move {
|
|
mgr.cmdj(cmd)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("{t:?} {cmd}: {e}"))
|
|
}
|
|
};
|
|
|
|
let info = j("ij").await;
|
|
require(&info, "ij", "core.size", "u64");
|
|
require(&info, "ij", "bin.baddr", "u64");
|
|
|
|
let funcs = j("aflj").await;
|
|
for f in ["offset", "name", "size"] {
|
|
require(
|
|
&funcs,
|
|
"aflj",
|
|
&format!("0.{f}"),
|
|
if f == "name" { "str" } else { "u64" },
|
|
);
|
|
}
|
|
|
|
// function_summary and the annotation tools
|
|
let afi = j("afij @ main").await;
|
|
for f in ["offset", "size", "nbbs", "cc", "nargs", "nlocals"] {
|
|
require(&afi, "afij", &format!("0.{f}"), "u64");
|
|
}
|
|
require(&afi, "afij", "0.name", "str");
|
|
require(&afi, "afij", "0.signature", "str");
|
|
require(&afi, "afij", "0.noreturn", "bool");
|
|
for refs in ["callrefs", "datarefs"] {
|
|
require(&afi, "afij", &format!("0.{refs}.0.to"), "u64");
|
|
require(&afi, "afij", &format!("0.{refs}.0.type"), "str");
|
|
}
|
|
let callers = j("axtj @ main").await;
|
|
require(&callers, "axtj", "0.from", "u64");
|
|
require(&callers, "axtj", "0.type", "str");
|
|
let vars = j("afvlj @ main").await;
|
|
assert!(vars.is_object(), "{t:?} afvlj: {vars}");
|
|
let sig = j("afsj @ main").await;
|
|
require(&sig, "afsj", "ret", "str");
|
|
require(&sig, "afsj", "args.0.name", "str");
|
|
let strings = j("izj").await;
|
|
for (f, k) in [
|
|
("vaddr", "u64"),
|
|
("size", "u64"),
|
|
("string", "str"),
|
|
("type", "str"),
|
|
] {
|
|
require(&strings, "izj", &format!("0.{f}"), k);
|
|
}
|
|
|
|
// disassemble_function pages `ops`
|
|
let pdf = j("pdfj @ main").await;
|
|
require(&pdf, "pdfj", "ops", "array");
|
|
|
|
// disassemble tells unmapped memory from 0xff bytes
|
|
let mapped = j("aij @ entry0").await;
|
|
require(&mapped, "aij", "read", "str");
|
|
let unmapped = j("aij @ 0xdeadbeef").await;
|
|
assert!(unmapped.get("read").is_none(), "{t:?} aij: {unmapped}");
|
|
|
|
// search_rop and the exact filter, in the first 64 KiB of the
|
|
// largest executable section: the time of `/R` grows with the bytes
|
|
// searched, and the whole multicall `ls` of uutils takes minutes.
|
|
let sections = j("iSj").await;
|
|
let text = sections
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|s| s["perm"].as_str().is_some_and(|p| p.contains('x')))
|
|
.max_by_key(|s| s["vsize"].as_u64())
|
|
.unwrap_or_else(|| panic!("{t:?} iSj: no executable section in {sections}"));
|
|
require(text, "iSj", "vaddr", "u64");
|
|
require(text, "iSj", "vsize", "u64");
|
|
let from = text["vaddr"].as_u64().unwrap_or_default();
|
|
let to = from + text["vsize"].as_u64().unwrap_or_default().min(0x10000);
|
|
let cmd = format!(
|
|
"/Rj \"ret\" @e:gadget.cache=false @e:search.from=0x{from:x} @e:search.to=0x{to:x}"
|
|
);
|
|
let gadgets = mgr
|
|
.cmdj(&cmd)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("{t:?} {cmd}: {e}"));
|
|
require(&gadgets, "/Rj", "0.opcodes.0.offset", "u64");
|
|
require(&gadgets, "/Rj", "0.opcodes.0.opcode", "str");
|
|
|
|
// list_flags and the FLIRT counts
|
|
let spaces = j("fslj").await;
|
|
require(&spaces, "fslj", "0.name", "str");
|
|
require(&spaces, "fslj", "0.count", "u64");
|
|
|
|
// decompilers
|
|
let plugins = j("Lcj").await;
|
|
assert!(plugins.is_array(), "{t:?} Lcj: {plugins}");
|
|
if let Some(p) = plugins.as_array().and_then(|a| a.first()) {
|
|
require(p, "Lcj", "name", "str");
|
|
}
|
|
|
|
// flirt_list (a text table before Rizin 0.10)
|
|
let sigdb = mgr.cmdj("Flj").await.unwrap_or(Value::Array(Vec::new()));
|
|
assert!(sigdb.is_array(), "{t:?} Flj: {sigdb}");
|
|
if let Some(s) = sigdb.as_array().and_then(|a| a.first()) {
|
|
for f in ["bin", "arch", "name", "details"] {
|
|
require(s, "Flj", f, "str");
|
|
}
|
|
require(s, "Flj", "modules", "u64");
|
|
}
|
|
|
|
// function_graph
|
|
let graph = j("agf j @ main").await;
|
|
require(&graph, "agf j", "nodes", "array");
|
|
}
|
|
}
|