mirror of
https://github.com/rizinorg/rizin-mcp.git
synced 2026-11-07 06:46:00 -10:00
Fix misleading errors of disassemble, il_function, FLIRT and decompile
* `disassemble` at an unmapped address said "no output for a command expecting JSON"; with command status it now fails with Rizin's "Failed to read chunk ... at 0xdeadbeef" (covered by a new test). * `il_function`: `plf` fails without a message where there is no function, so say so when `afij` finds none; empty output of the text command is the new `EmptyOutput`, not `EmptyJson`. * `flirt_apply` reported "Found 0 FLIRT signatures" for a missing file, since `Fs` returns OK and only logs "Can't open": check that a plain path exists, fail on logged errors, and return the number of matched functions. * `flirt_apply_sigdb` checked its filter, a substring of the sigdb paths, with the path validator, rejecting `@` and other characters; quote it instead. It now returns the number of functions named (counted in the `flirt` flag space) and the signature files applied. * `decompile` moved to the next engine on any error and ended with "install the plugin" even after a timeout, retrying a wedged session twice. Only a missing plugin (NONEXISTINGCMD) or an empty result moves on now; any other failure is returned as is. The skipped engines and why are in the structured result. * A pipe to Rizin before 0.10 sees a failure only as no output plus an error message: `il_function` checks for a function on empty output as well, and `decompile` treats an error message without output as a failure of that engine.
This commit is contained in:
1 parent
6ab5e8af66
commit
410424658e
5 files changed
+224
-33
No files matched your search
@@ -144,7 +144,9 @@ produces the full listing, which the server then slices.
|
||||
- **FLIRT.** The `flirt_*` tools wrap Rizin's built-in `F` command family:
|
||||
`flirt_list`/`flirt_apply_sigdb` work against the signature database (sigdb)
|
||||
shipped with Rizin, while `flirt_apply`/`flirt_dump`/`flirt_create` work with
|
||||
individual `.sig`/`.pat` files. (`function_signature_bytes` is unrelated to
|
||||
individual `.sig`/`.pat` files. The two apply tools return how many functions
|
||||
the signatures named; a missing file is an error, even though Rizin's `Fs`
|
||||
itself reports success for it. (`function_signature_bytes` is unrelated to
|
||||
FLIRT — it just returns a function's masked byte pattern.)
|
||||
- **Silhouette.** `silhouette`/`silhouette_test` drive the
|
||||
[rz-silhouette](https://github.com/rizinorg/rz-silhouette) plugin's `sil`
|
||||
|
||||
@@ -86,6 +86,13 @@ pub enum RizinError {
|
||||
detail: String,
|
||||
},
|
||||
|
||||
/// A text command succeeded but printed nothing.
|
||||
#[error("rizin printed nothing for: {cmd}")]
|
||||
EmptyOutput {
|
||||
/// The command.
|
||||
cmd: String,
|
||||
},
|
||||
|
||||
/// Rizin ran the command and reported that it failed.
|
||||
#[error("rizin command failed ({status}): {cmd}{}", fmt_detail(.detail))]
|
||||
CommandFailed {
|
||||
@@ -175,6 +182,7 @@ impl RizinError {
|
||||
| InvalidArgument { .. }
|
||||
| IllegalCommand { .. }
|
||||
| EmptyJson { .. }
|
||||
| EmptyOutput { .. }
|
||||
| FileNotFound(_)
|
||||
| OpenFailed { .. }
|
||||
| NotPermitted(_) => ErrorData::invalid_params(self.to_string(), None),
|
||||
|
||||
+3
-1
@@ -341,7 +341,9 @@ pub struct FlirtFileParams {
|
||||
/// Parameters for `flirt_apply_sigdb`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct FlirtSigdbParams {
|
||||
/// Optional filter to select a subset of signatures from the sigdb.
|
||||
/// Optional filter selecting the signature files whose sigdb path contains
|
||||
/// it (e.g. `elf/x86/64` or `libc`); without one, the files matching the
|
||||
/// binary's format, architecture, and bits are applied.
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
+130
-31
@@ -307,6 +307,27 @@ impl RizinServer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a function covers the location of `suffix` (an `at_suffix`).
|
||||
async fn has_function(&self, suffix: &str) -> bool {
|
||||
self.manager
|
||||
.cmdj(&format!("afij{suffix}"))
|
||||
.await
|
||||
.is_ok_and(|v| v.as_array().is_some_and(|a| !a.is_empty()))
|
||||
}
|
||||
|
||||
/// The number of flags in the `flirt` flag space, which FLIRT matches
|
||||
/// create.
|
||||
async fn flirt_flag_count(&self) -> Result<u64, ErrorData> {
|
||||
let spaces = self.cmdj_optional("fslj", json!([])).await?;
|
||||
Ok(spaces
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|s| s.get("name").and_then(Value::as_str) == Some("flirt"))
|
||||
.and_then(|s| s.get("count").and_then(Value::as_u64))
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Map a missing plugin command to a hint naming the plugin.
|
||||
fn plugin_hint(e: RizinError, hint: &str) -> ErrorData {
|
||||
match e {
|
||||
@@ -754,22 +775,47 @@ impl RizinServer {
|
||||
}
|
||||
};
|
||||
|
||||
// Only a missing plugin (or an empty result) moves on to the next
|
||||
// engine: an engine that ran and failed (e.g. no function there) would
|
||||
// fail the same way in the next one, and a timeout or dead session
|
||||
// must not be retried.
|
||||
let mut skipped = serde_json::Map::new();
|
||||
for (engine, cmd) in &candidates {
|
||||
match self.manager.cmd(cmd).await {
|
||||
Ok(out) if !out.trim().is_empty() => {
|
||||
return Ok(self.text_result_meta(out, json!({ "decompiler": engine })));
|
||||
let out = self.manager.exec(cmd).await.mcp()?;
|
||||
match crate::backend::check(cmd, out) {
|
||||
Ok(out) if !out.output.trim().is_empty() => {
|
||||
return Ok(self.text_result_meta(
|
||||
out.output,
|
||||
json!({ "decompiler": engine, "skipped": skipped }),
|
||||
));
|
||||
}
|
||||
Ok(_) => {} // empty: plugin missing or nothing to show, try next
|
||||
Err(e @ (RizinError::NoFileOpen | RizinError::SessionClosed)) => {
|
||||
return Err(e.to_error_data());
|
||||
// A pipe to Rizin before 0.10 sees a failure only as no output
|
||||
// and an error message.
|
||||
Ok(out) if out.messages.iter().any(|m| m.starts_with("ERROR")) => {
|
||||
return Err(RizinError::CommandFailed {
|
||||
cmd: cmd.clone(),
|
||||
status: out.status,
|
||||
detail: out.detail(),
|
||||
}
|
||||
.to_error_data());
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(_) => {
|
||||
skipped.insert(engine.to_string(), json!("no output"));
|
||||
}
|
||||
Err(RizinError::CommandFailed {
|
||||
status: CmdStatus::NonExistingCmd,
|
||||
..
|
||||
}) => {
|
||||
skipped.insert(engine.to_string(), json!("not installed"));
|
||||
}
|
||||
Err(e) => return Err(e.to_error_data()),
|
||||
}
|
||||
}
|
||||
|
||||
let hint = if which == "auto" || which == "ghidra" || which == "jsdec" {
|
||||
"no decompiler output. Install the rz-ghidra (pdg) or jsdec (pdd) plugin, or use \
|
||||
decompiler=\"pseudo\"."
|
||||
let missing = skipped.values().any(|v| v == "not installed");
|
||||
let hint = if missing {
|
||||
"no decompiler output: the requested engine is not installed. Install the \
|
||||
rz-ghidra (pdg) or jsdec (pdd) plugin, or use decompiler=\"pseudo\"."
|
||||
} else {
|
||||
"no decompiler output for the function at this location."
|
||||
};
|
||||
@@ -1135,40 +1181,76 @@ impl RizinServer {
|
||||
#[tool(
|
||||
description = "Apply FLIRT signatures from Rizin's signature database (sigdb) to the \
|
||||
loaded binary, naming matched library functions. An optional filter narrows which \
|
||||
signatures are tried (Fa)."
|
||||
signature files are tried. Returns the number of matched functions and the files \
|
||||
applied (Fa)."
|
||||
)]
|
||||
async fn flirt_apply_sigdb(
|
||||
&self,
|
||||
Parameters(p): Parameters<FlirtSigdbParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
// The filter is a substring of the signature files' sigdb paths
|
||||
// (e.g. `elf/x86/64`), so it is quoted rather than path-checked.
|
||||
let cmd = match &p.filter {
|
||||
Some(f) => format!("Fa {}", validate_path(f).mcp()?),
|
||||
Some(f) => format!("Fa {}", quote_arg(f).mcp()?),
|
||||
None => "Fa".to_string(),
|
||||
};
|
||||
let before = self.flirt_flag_count().await?;
|
||||
let out = self.manager.cmd(&cmd).await.mcp()?;
|
||||
Ok(self.text_result(if out.trim().is_empty() {
|
||||
"Applied sigdb signatures (no new matches reported).".to_string()
|
||||
} else {
|
||||
out
|
||||
}))
|
||||
let applied = self.flirt_flag_count().await?.saturating_sub(before);
|
||||
let files: Vec<&str> = out
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("Applying "))
|
||||
.collect();
|
||||
Ok(self.json_result(json!({
|
||||
"matched_functions": applied,
|
||||
"signature_files": files,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Apply a FLIRT signature file (`Fs`).
|
||||
#[tool(
|
||||
description = "Open a FLIRT signature file (.sig or .pat) and apply its signatures to \
|
||||
the loaded binary, naming matched functions (Fs)."
|
||||
description = "Open a FLIRT signature file (.sig or .pat; a glob is accepted) and apply \
|
||||
its signatures to the loaded binary, naming matched functions. Returns the number of \
|
||||
matched functions (Fs)."
|
||||
)]
|
||||
async fn flirt_apply(
|
||||
&self,
|
||||
Parameters(p): Parameters<FlirtFileParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let path = validate_path(&p.path).mcp()?;
|
||||
let out = self.manager.cmd(&format!("Fs {path}")).await.mcp()?;
|
||||
Ok(self.text_result(if out.trim().is_empty() {
|
||||
format!("Applied FLIRT signatures from {path}.")
|
||||
} else {
|
||||
out
|
||||
}))
|
||||
// `Fs` takes a glob; a plain path must exist.
|
||||
if !path.contains(['*', '?', '[']) && !std::path::Path::new(&path).is_file() {
|
||||
return Err(RizinError::FileNotFound(path).to_error_data());
|
||||
}
|
||||
let cmd = format!("Fs {path}");
|
||||
let out = self.manager.exec(&cmd).await.mcp()?;
|
||||
let out = crate::backend::check(&cmd, out).mcp()?;
|
||||
// `Fs` reports success even when it cannot read a file, saying so only
|
||||
// in an error message.
|
||||
let errors: Vec<&str> = out
|
||||
.messages
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|m| m.starts_with("ERROR"))
|
||||
.collect();
|
||||
if !errors.is_empty() {
|
||||
return Err(RizinError::CommandFailed {
|
||||
cmd,
|
||||
status: CmdStatus::Error,
|
||||
detail: errors.join("\n"),
|
||||
}
|
||||
.to_error_data());
|
||||
}
|
||||
let matched = out
|
||||
.output
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|n| n.parse::<u64>().ok());
|
||||
Ok(self.json_result(json!({
|
||||
"path": path,
|
||||
"matched_functions": matched,
|
||||
"output": out.output.trim(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Dump a FLIRT signature file (`Fd`).
|
||||
@@ -1274,13 +1356,30 @@ impl RizinServer {
|
||||
Parameters(p): Parameters<AtParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let suffix = at_suffix(&p.at).mcp()?;
|
||||
let out = self.manager.cmd(&format!("plf{suffix}")).await.mcp()?;
|
||||
if out.trim().is_empty() {
|
||||
return Err(RizinError::EmptyJson {
|
||||
cmd: "plf".into(),
|
||||
detail: String::new(),
|
||||
let cmd = format!("plf{suffix}");
|
||||
// `plf` fails without saying why (and before Rizin 0.10, a pipe only
|
||||
// sees no output); the usual reason is that there is no function.
|
||||
let no_function = || {
|
||||
RizinError::InvalidArgument {
|
||||
arg: p.at.clone().unwrap_or_else(|| "the current seek".into()),
|
||||
reason: "no function there (analyze it first, e.g. with analyze_function)".into(),
|
||||
}
|
||||
.to_error_data());
|
||||
.to_error_data()
|
||||
};
|
||||
let out = match self.manager.cmd(&cmd).await {
|
||||
Ok(out) => out,
|
||||
Err(RizinError::CommandFailed { detail, .. })
|
||||
if detail.is_empty() && !self.has_function(&suffix).await =>
|
||||
{
|
||||
return Err(no_function());
|
||||
}
|
||||
Err(e) => return Err(e.to_error_data()),
|
||||
};
|
||||
if out.trim().is_empty() {
|
||||
if !self.has_function(&suffix).await {
|
||||
return Err(no_function());
|
||||
}
|
||||
return Err(RizinError::EmptyOutput { cmd }.to_error_data());
|
||||
}
|
||||
Ok(self.text_result(out))
|
||||
}
|
||||
|
||||
@@ -522,3 +522,83 @@ async fn disabled_tools_are_not_offered() {
|
||||
assert_eq!(err.code.0, -32602, "{err:?}");
|
||||
assert!(err.message.contains("not found"), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_say_what_went_wrong() {
|
||||
let pat = std::env::temp_dir().join(format!("rizin-mcp-test-{}.pat", std::process::id()));
|
||||
let pat = pat.to_string_lossy().into_owned();
|
||||
for t in transports() {
|
||||
let s = Session::start(t, |c| c.allow_write = true).await;
|
||||
s.ok(
|
||||
"open_file",
|
||||
json!({ "path": target(), "analysis": "basic" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
// An unmapped address fails with Rizin's reason, not just "no
|
||||
// output" (which a pipe to Rizin before 0.10 still says, but with the
|
||||
// reason attached).
|
||||
let err = s
|
||||
.call("disassemble", json!({ "at": "0xdeadbeef" }))
|
||||
.await
|
||||
.expect_err("unmapped");
|
||||
assert!(matches!(err.code.0, -32001 | -32602), "{t:?}: {err:?}");
|
||||
assert!(
|
||||
err.message.contains("Failed to read") && err.message.contains("0xdeadbeef"),
|
||||
"{t:?}: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
// `plf` fails silently where there is no function; the tool says so.
|
||||
let err = s
|
||||
.call("il_function", json!({ "at": "0x0" }))
|
||||
.await
|
||||
.expect_err("no function");
|
||||
assert!(
|
||||
err.message.contains("no function"),
|
||||
"{t:?}: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
// A missing signature file is not "Found 0 FLIRT signatures".
|
||||
let err = s
|
||||
.call("flirt_apply", json!({ "path": "/nonexistent.sig" }))
|
||||
.await
|
||||
.expect_err("missing file");
|
||||
assert!(
|
||||
err.message.contains("file not found"),
|
||||
"{t:?}: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
// Signatures made from this binary match it again.
|
||||
s.ok("flirt_create", json!({ "path": pat })).await;
|
||||
let applied = s.ok("flirt_apply", json!({ "path": pat })).await;
|
||||
assert!(
|
||||
applied["matched_functions"].as_u64().unwrap() > 0,
|
||||
"{t:?}: {applied}"
|
||||
);
|
||||
|
||||
// A sigdb filter may contain `@` and other shell characters.
|
||||
let r = s
|
||||
.ok("flirt_apply_sigdb", json!({ "filter": "x86@64;px" }))
|
||||
.await;
|
||||
assert_eq!(r["matched_functions"], 0, "{t:?}: {r}");
|
||||
|
||||
// A missing engine is skipped; one that fails is reported, not
|
||||
// retried with the next engine under a misleading "install" hint.
|
||||
let d = s
|
||||
.call("decompile", json!({ "at": "main" }))
|
||||
.await
|
||||
.expect("decompile");
|
||||
let meta = d.structured_content.unwrap();
|
||||
assert_eq!(meta["decompiler"], "pseudo", "{t:?}: {meta}");
|
||||
let err = s
|
||||
.call("decompile", json!({ "at": "0x0", "decompiler": "pseudo" }))
|
||||
.await
|
||||
.expect_err("no function");
|
||||
assert_eq!(err.code.0, -32001, "{t:?}: {err:?}");
|
||||
assert!(err.message.contains("function"), "{t:?}: {}", err.message);
|
||||
}
|
||||
let _ = std::fs::remove_file(&pat);
|
||||
}
|
||||
Reference in new issue
Block a user