use crate::apply::{self, Applied};
use crate::discover;
use crate::session::DebugInfo;
use radare2::Core;
use radare2::plugin::{self as r2plugin, CorePlugin, PluginSession};
use std::ffi::CStr;
use std::os::raw::c_char;
use std::path::PathBuf;
pub struct PluginState {
pub applied: Option<Applied>,
pub busy: bool,
pub prev_hooks: Vec<(String, String)>,
}
const CMD_HOOKS: &[&str] = &["cmd.load", "cmd.open", "cmd.prompt"];
const HELP: &[&str] = &[
"Usage: fas load info unload",
" fas autoload a sibling .fas dump",
" fas load path load an explicit FAS dump",
" fas info summarize the last loaded dump",
" fas unload remove owned symbols, functions, xrefs, flags, and source lines",
" fas? this help",
];
pub unsafe extern "C" fn plugin_init(session: *mut PluginSession) -> bool {
let Some(core) = (unsafe { Core::from_session(session) }) else {
return false;
};
core.set_cfg_bool(
"fas.autoload",
true,
"automatically load a sibling FASM .fas dump when a binary is opened",
);
core.set_cfg_bool(
"fas.comments",
true,
"attach original FASM source lines as comments",
);
core.set_cfg_bool(
"fas.analyze",
true,
"create functions at FASM code labels after loading symbols",
);
let mut prev_hooks = Vec::new();
for key in CMD_HOOKS {
let prev = core.cfg_str(key);
wrap_cmd_hook(core, key, &prev);
prev_hooks.push(((*key).to_string(), prev));
}
let state = PluginState {
applied: None,
busy: false,
prev_hooks,
};
if unsafe { r2plugin::install_state(session, state) }.is_err() {
return false;
}
autoload(session, true);
true
}
pub unsafe extern "C" fn plugin_fini(session: *mut PluginSession) -> bool {
let Some(core) = (unsafe { Core::from_session(session) }) else {
return false;
};
if let Some(mut state) = unsafe { r2plugin::take_state::<PluginState>(session) } {
if let Some(applied) = state.applied.take() {
let _ = apply::unload(core, &applied);
}
for (key, prev) in state.prev_hooks {
core.set_cfg_str(&key, &prev);
}
}
true
}
pub unsafe extern "C" fn plugin_call(session: *mut PluginSession, input: *const c_char) -> bool {
if input.is_null() {
return false;
}
let raw = unsafe { CStr::from_ptr(input).to_string_lossy() };
let Some(rest) = strip_command(&raw) else {
return false;
};
dispatch(session, rest.trim());
true
}
fn strip_command(input: &str) -> Option<&str> {
let input = input.trim_start();
let rest = input.strip_prefix("fas")?;
match rest.chars().next() {
None | Some(' ') | Some('.') | Some('?') => Some(rest),
_ => None,
}
}
fn applied_is_none(session: *mut PluginSession) -> bool {
unsafe { r2plugin::state::<PluginState>(session) }.is_none_or(|state| state.applied.is_none())
}
fn dispatch(session: *mut PluginSession, rest: &str) {
let Some(core) = (unsafe { Core::from_session(session) }) else {
return;
};
let silent = rest.starts_with('.');
let body = rest.trim_start_matches('.').trim();
if body.is_empty() {
autoload(session, silent);
return;
}
if body == "?" || body.starts_with('?') {
for line in HELP {
core.echo(line);
}
return;
}
let mut parts = body.splitn(2, char::is_whitespace);
let cmd = parts.next().unwrap_or("");
let arg = parts.next().map(str::trim).filter(|s| !s.is_empty());
match cmd {
"load" => match arg {
Some(path) => load_path(session, PathBuf::from(path), silent),
None => autoload(session, silent),
},
"info" => print_info(core, session),
"unload" => unload(session),
_ => {
if !silent {
core.echo("unknown fas subcommand; try fas?");
}
}
}
}
fn wrap_cmd_hook(core: Core, key: &str, previous: &str) {
let prev = previous.trim();
if prev
.split(';')
.any(|p| p.trim() == "fas." || p.trim() == "fas")
{
return;
}
let wrapped = if prev.is_empty() {
"fas.".to_string()
} else {
format!("fas.;{prev}")
};
core.set_cfg_str(key, &wrapped);
}
fn autoload(session: *mut PluginSession, silent: bool) {
if silent && !applied_is_none(session) {
return;
}
let Some(core) = (unsafe { Core::from_session(session) }) else {
return;
};
if !core.cfg_bool("fas.autoload", true) && silent {
return;
}
let Some(file) = core.current_file() else {
return;
};
match discover::find_for_binary(file.as_path()) {
Some(path) => load_path(session, path, silent),
None => {
if !silent {
core.echo("fas: no sibling .fas dump found");
}
}
}
}
fn load_path(session: *mut PluginSession, path: PathBuf, silent: bool) {
let Some(core) = (unsafe { Core::from_session(session) }) else {
return;
};
let path_str = path.to_string_lossy().into_owned();
let state = unsafe { r2plugin::state_mut::<PluginState>(session) };
if let Some(state) = state {
if state.busy {
return;
}
if state
.applied
.as_ref()
.is_some_and(|applied| applied.fas_path == path_str && applied.target_is_current(core))
{
return;
}
state.busy = true;
}
let info = match DebugInfo::from_path(&path) {
Ok(info) => info,
Err(error) => {
if let Some(state) = unsafe { r2plugin::state_mut::<PluginState>(session) } {
state.busy = false;
}
if !silent {
core.echo(&format!("fas: {error}"));
}
return;
}
};
if silent && !apply::maps_ready(core, &info) {
if let Some(state) = unsafe { r2plugin::state_mut::<PluginState>(session) } {
state.busy = false;
}
return;
}
let previous = unsafe { r2plugin::state_mut::<PluginState>(session) }
.and_then(|state| state.applied.take());
if let Some(previous) = previous {
if !apply::unload(core, &previous) {
if let Some(state) = unsafe { r2plugin::state_mut::<PluginState>(session) } {
state.busy = false;
state.applied = Some(previous);
}
if !silent {
core.echo("fas: replacement refused because existing metadata was modified");
}
return;
}
}
let result = apply::apply(core, &info).map_err(|error| error.to_string());
if let Some(state) = unsafe {
session
.as_mut()
.and_then(|s| (s.data as *mut PluginState).as_mut())
} {
state.busy = false;
if let Ok(applied) = &result {
state.applied = Some(applied.clone());
}
}
match result {
Ok(applied) => core.echo(&format!(
"fas: loaded {} symbols, {} lines, {} functions, {} xrefs from {}",
applied.flags.len(),
applied.line_count,
applied.function_count,
applied.xref_count,
applied.fas_path
)),
Err(e) => {
if !silent {
core.echo(&format!("fas: {e}"));
}
}
}
}
fn print_info(core: Core, session: *mut PluginSession) {
let state = unsafe { r2plugin::state::<PluginState>(session) };
match state.and_then(|s| s.applied.as_ref()) {
Some(a) => core.echo(&format!(
"fas: {} symbols={} native_symbols={} lines={} functions={} xrefs={}",
a.fas_path,
a.flags.len(),
a.native_symbol_count,
a.line_count,
a.function_count,
a.xref_count
)),
None => core.echo("fas: nothing loaded"),
}
}
fn unload(session: *mut PluginSession) {
let Some(core) = (unsafe { Core::from_session(session) }) else {
return;
};
let state = unsafe { r2plugin::state_mut::<PluginState>(session) };
if let Some(state) = state {
if let Some(applied) = state.applied.take() {
if apply::unload(core, &applied) {
core.echo("fas: unloaded");
} else {
state.applied = Some(applied);
core.echo(
"fas: unload refused because the binary changed or metadata was modified",
);
}
return;
}
}
core.echo("fas: nothing to unload");
}
pub static PLUGIN: CorePlugin = CorePlugin {
meta: radare2::plugin_meta!(
"fas",
"FASM .fas symbolic dump loader",
"Hamid R. K. Pishghadam, Cursor AI",
"0.1.0",
"MIT"
),
init: Some(plugin_init),
fini: Some(plugin_fini),
call: Some(plugin_call),
};
radare2::export_core_plugin!(&PLUGIN, "r2fas");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_parser_accepts_only_fas_boundaries() {
assert_eq!(strip_command("fas"), Some(""));
assert_eq!(strip_command(" fas info"), Some(" info"));
assert_eq!(strip_command("fas.unload"), Some(".unload"));
assert_eq!(strip_command("fas?"), Some("?"));
assert_eq!(strip_command("faster"), None);
assert_eq!(strip_command("afas"), None);
}
#[test]
fn help_covers_every_user_command() {
let help = HELP.join("\n");
for command in ["fas load", "fas info", "fas unload", "fas?"] {
assert!(help.contains(command), "missing {command}: {help}");
}
}
}