use std::path::{Path, PathBuf};
use std::str::FromStr;
use crate::vocabulary::{ALGORITHMS, STRATEGIES, by_name};
use oinkie::birthmarks::{AnalysisType, BirthmarkType};
use oinkie::compare::{Aggregator, PairingStrategy};
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{Implementation, InitializeResult, ServerCapabilities};
use rmcp::{ErrorData, ServerHandler, schemars, tool, tool_handler, tool_router};
use serde::{Deserialize, Serialize};
use super::analysis::{self, Extracted, Score};
use super::info::{self, Vocabulary};
use super::paths::Roots;
const DEFAULT_MAX_PAIRS: usize = 500;
#[derive(Clone)]
pub struct Oinkie {
roots: Roots,
engine: oinkie::Oinkie,
tool_router: rmcp::handler::server::tool::ToolRouter<Self>,
}
impl Oinkie {
pub fn new(roots: Roots, engine: oinkie::Oinkie) -> Self {
Self {
roots,
engine,
tool_router: Self::tool_router(),
}
}
async fn compute<T: Send + 'static>(
&self,
f: impl FnOnce() -> T + Send + 'static,
) -> Result<T, ErrorData> {
let engine = self.engine.clone();
tokio::task::spawn_blocking(move || engine.install(f))
.await
.map_err(joined)
}
}
fn joined(e: tokio::task::JoinError) -> ErrorData {
ErrorData::internal_error(format!("the work did not finish: {e}"), None)
}
fn refuse(e: impl std::fmt::Display) -> ErrorData {
ErrorData::invalid_params(
format!("{e}. Call oinkie_info for the names this accepts."),
None,
)
}
impl Oinkie {
fn resolve_all(&self, files: &[String]) -> Result<Vec<PathBuf>, ErrorData> {
if files.is_empty() {
return Err(ErrorData::invalid_params(
"no files given; there is nothing to do".to_string(),
None,
));
}
files.iter().map(|f| self.roots.resolve(f)).collect()
}
fn dest(&self, dest: Option<&str>) -> Result<Option<PathBuf>, ErrorData> {
dest.map(|d| self.roots.resolve(d)).transpose()
}
fn confine(&self, written: impl IntoIterator<Item = PathBuf>) -> Result<(), ErrorData> {
written.into_iter().try_for_each(|p| {
let text = p.to_str().ok_or_else(|| {
ErrorData::invalid_params(
format!("{}: not a path the server can confine", p.display()),
None,
)
})?;
self.roots.resolve(text).map(|_| ())
})
}
fn confine_run(
&self,
files: &[PathBuf],
dest: &Path,
strategy: &PairingStrategy,
) -> Result<(), ErrorData> {
let birthmarks = analysis::birthmark_files(files, dest).map_err(super::error::to_mcp)?;
self.confine(
std::iter::once(analysis::birthmarks_dir(dest))
.chain(birthmarks)
.chain(analysis::score_files(dest, strategy.compare_count(files))),
)
}
fn strategy(name: Option<&str>) -> Result<PairingStrategy, ErrorData> {
by_name(STRATEGIES, name.unwrap_or("all-and-self")).map_err(refuse)
}
fn aggregator(name: Option<&str>) -> Result<Aggregator, ErrorData> {
Aggregator::from_str(name.unwrap_or("hungarian")).map_err(refuse)
}
fn bound(
strategy: &PairingStrategy,
files: &[PathBuf],
max: Option<usize>,
) -> Result<(), ErrorData> {
let max = max.unwrap_or(DEFAULT_MAX_PAIRS);
let count = strategy.compare_count(files);
if count > max {
return Err(ErrorData::invalid_params(
format!(
"{count} pairs from {} files, which is more than the {max} this will do at \
once. Pass fewer files, choose a strategy that pairs them differently, or \
raise max_pairs deliberately.",
files.len()
),
None,
));
}
Ok(())
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExtractParams {
pub files: Vec<String>,
pub dest: String,
#[serde(default)]
pub birthmark_type: Option<String>,
#[serde(default)]
pub skip: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RunParams {
pub files: Vec<String>,
#[serde(default)]
pub analysis: Option<String>,
#[serde(default)]
pub strategy: Option<String>,
#[serde(default)]
pub aggregator: Option<String>,
#[serde(default)]
pub dest: Option<String>,
#[serde(default)]
pub max_pairs: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CompareParams {
pub files: Vec<String>,
#[serde(default)]
pub algorithm: Option<String>,
#[serde(default)]
pub strategy: Option<String>,
#[serde(default)]
pub aggregator: Option<String>,
#[serde(default)]
pub dest: Option<String>,
#[serde(default)]
pub max_pairs: Option<usize>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct Compared {
pub scores: Vec<Score>,
pub dest: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ExtractedAll {
pub birthmark_type: String,
pub birthmarks: Vec<Extracted>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StatsParams {
pub paths: Vec<String>,
#[serde(default)]
pub recursive: Option<bool>,
#[serde(default)]
pub per_file: Option<bool>,
#[serde(default)]
pub top: Option<usize>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub(crate) struct Statistics {
pub groups: Vec<crate::stats::GroupStats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub files: Option<Vec<crate::stats::FileStats>>,
pub skipped: Vec<crate::stats::Skipped>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReviewParams {
pub score_directory: String,
#[serde(default)]
pub aggregator: Option<String>,
#[serde(default)]
pub dest_file: Option<String>,
#[serde(default)]
pub min_elements: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct Reviewed {
pub aggregator: String,
pub scores: Vec<Score>,
pub dest_file: Option<String>,
pub min_elements: Option<MinElementsApplied>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct MinElementsApplied {
pub given: String,
pub threshold: f64,
}
#[tool_router]
impl Oinkie {
#[tool(
name = "oinkie_info",
description = "The vocabulary oinkie accepts: the birthmark types, the similarity \
algorithms and the shapes they operate on, every canonical \
{birthmark}-{algorithm} analysis name, the pairing strategies and the \
aggregators. Ask this before naming any of them -- the lists are \
generated from the same code that parses the names, and a name not \
derived from them may be refused."
)]
fn oinkie_info(&self) -> Json<Vocabulary> {
Json(info::vocabulary())
}
#[tool(
name = "oinkie_extract",
description = "Extract a birthmark from each lifted program and write it to a \
directory. The birthmarks are what oinkie_compare takes. If you only \
want similarity scores, oinkie_run does both steps and keeps nothing."
)]
async fn oinkie_extract(
&self,
Parameters(params): Parameters<ExtractParams>,
) -> Result<Json<ExtractedAll>, ErrorData> {
let files = self.resolve_all(¶ms.files)?;
let dest = self.roots.resolve(¶ms.dest)?;
let name = params
.birthmark_type
.unwrap_or_else(|| "op-seq".to_string());
let birthmark_type = BirthmarkType::try_from(name.as_str()).map_err(refuse)?;
let skip = params.skip.unwrap_or(false);
self.confine(analysis::extracted_files(&files, &dest).map_err(super::error::to_mcp)?)?;
let birthmarks = self
.compute(move || analysis::extract(&files, &birthmark_type, &dest, skip))
.await?
.map_err(super::error::to_mcp)?;
Ok(Json(ExtractedAll {
birthmark_type: name,
birthmarks,
}))
}
#[tool(
name = "oinkie_run",
description = "Compare lifted programs and report how similar each pair is, in one \
step. Given a dest, the scores and the birthmarks they came from are \
written there. This is the usual way to ask whether one program is a \
copy of another: a high score suggests it is."
)]
async fn oinkie_run(
&self,
Parameters(params): Parameters<RunParams>,
) -> Result<Json<Compared>, ErrorData> {
let files = self.resolve_all(¶ms.files)?;
let dest = self.dest(params.dest.as_deref())?;
let analysis_type =
AnalysisType::try_from(params.analysis.as_deref().unwrap_or("op-set-jaccard"))
.map_err(refuse)?;
let strategy = Self::strategy(params.strategy.as_deref())?;
let aggregator = Self::aggregator(params.aggregator.as_deref())?;
Self::bound(&strategy, &files, params.max_pairs)?;
if let Some(d) = &dest {
self.confine_run(&files, d, &strategy)?;
}
let written = dest.clone();
let comparator = self.engine.comparator(analysis_type.algorithm());
let scores = self
.compute(move || {
analysis::run(
&files,
&analysis_type,
&comparator,
&strategy,
&aggregator,
written.as_deref(),
)
})
.await?
.map_err(super::error::to_mcp)?;
Ok(Json(Compared {
scores,
dest: dest.map(|d| d.display().to_string()),
}))
}
#[tool(
name = "oinkie_compare",
description = "Compare birthmarks that oinkie_extract already wrote, and report how \
similar each pair is. Use this to try a different algorithm without \
re-reading the programs; oinkie_run is the shorter path from programs \
to scores."
)]
async fn oinkie_compare(
&self,
Parameters(params): Parameters<CompareParams>,
) -> Result<Json<Compared>, ErrorData> {
let files = self.resolve_all(¶ms.files)?;
let dest = self.dest(params.dest.as_deref())?;
let algorithm = by_name(ALGORITHMS, params.algorithm.as_deref().unwrap_or("jaccard"))
.map_err(refuse)?;
let strategy = Self::strategy(params.strategy.as_deref())?;
let aggregator = Self::aggregator(params.aggregator.as_deref())?;
Self::bound(&strategy, &files, params.max_pairs)?;
if let Some(d) = &dest {
self.confine(analysis::score_files(d, strategy.compare_count(&files)))?;
}
let written = dest.clone();
let comparator = self.engine.comparator(&algorithm);
let scores = self
.compute(move || {
analysis::compare(
&files,
&comparator,
&strategy,
&aggregator,
written.as_deref(),
)
})
.await?
.map_err(super::error::to_mcp)?;
Ok(Json(Compared {
scores,
dest: dest.map(|d| d.display().to_string()),
}))
}
#[tool(
name = "oinkie_stats",
description = "Summarise a set of birthmarks before comparing them: per group of \
comparable birthmarks, how many files, how many functions each holds, \
how many elements each function's birthmark has, how many are empty, \
and optionally the most frequent elements. Use it to describe a \
dataset, or to choose min_elements for oinkie_review."
)]
async fn oinkie_stats(
&self,
Parameters(params): Parameters<StatsParams>,
) -> Result<Json<Statistics>, ErrorData> {
let inputs = self.resolve_all(¶ms.paths)?;
let recursive = params.recursive.unwrap_or(false);
let found =
tokio::task::spawn_blocking(move || crate::stats::collect_paths(&inputs, recursive))
.await
.map_err(joined)?
.map_err(super::error::to_mcp)?;
self.confine(found.iter().cloned())?;
let top = params.top;
let report = self
.compute(move || crate::stats::compute(&found, top))
.await?;
Ok(Json(Statistics {
groups: report.groups,
files: params.per_file.unwrap_or(false).then_some(report.files),
skipped: report.skipped,
}))
}
#[tool(
name = "oinkie_review",
description = "Recompute the similarity of every pair in a directory of pair CSVs \
similarity CSVs, using a different aggregator, without comparing \
anything again. Use this to ask what the same comparison would have \
scored under 'topn' or 'containment' rather than 'hungarian', or without the functions \
too short to be evidence (min_elements)."
)]
async fn oinkie_review(
&self,
Parameters(params): Parameters<ReviewParams>,
) -> Result<Json<Reviewed>, ErrorData> {
let score_dir = self.roots.resolve(¶ms.score_directory)?;
let dest = params
.dest_file
.as_deref()
.map(|d| self.roots.resolve(d))
.transpose()?;
let name = params.aggregator.unwrap_or_else(|| "hungarian".to_string());
let aggregator = Aggregator::from_str(&name).map_err(|e| {
ErrorData::invalid_params(
format!("{e}. Call oinkie_info for the aggregators this accepts."),
None,
)
})?;
let min_elements = params
.min_elements
.as_deref()
.map(crate::cli::MinElements::from_str)
.transpose()
.map_err(|e| ErrorData::invalid_params(format!("min_elements: {e}"), None))?;
let written = dest.clone();
let roots = self.roots.clone();
let scores = self
.compute(move || {
let start = std::time::Instant::now();
let confine = |p: &Path| -> oinkie::Result<()> {
let refused = |message: String| {
oinkie::Error::Io(
p.to_path_buf(),
std::io::Error::new(std::io::ErrorKind::PermissionDenied, message),
)
};
let text = p
.to_str()
.ok_or_else(|| refused("not a path the server can confine".to_string()))?;
roots
.resolve(text)
.map(|_| ())
.map_err(|e| refused(e.message.to_string()))
};
let review = crate::review::review_all(
&score_dir,
&aggregator,
min_elements.as_ref(),
&confine,
&crate::progress::Bars::new(false),
)?;
let scores = review
.results
.iter()
.map(|r| Score {
index: r.index,
left: r.path1.display().to_string(),
right: r.path2.display().to_string(),
similarity: r.similarity,
duration_ms: r.duration.as_millis() as u64,
})
.collect::<Vec<_>>();
let applied =
review
.min_elements
.as_ref()
.map(|(given, threshold)| MinElementsApplied {
given: given.to_string(),
threshold: *threshold,
});
if let Some(d) = written {
crate::review::store(review, &d, start)?;
}
Ok::<_, oinkie::Error>((scores, applied))
})
.await?
.map_err(super::error::to_mcp)?;
let (scores, min_elements) = scores;
Ok(Json(Reviewed {
aggregator: name,
scores,
dest_file: dest.map(|d| d.display().to_string()),
min_elements,
}))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for Oinkie {
fn get_info(&self) -> InitializeResult {
let mut info = InitializeResult::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.server_info = Implementation::from_build_env();
info.instructions = Some(
"oinkie detects software theft by comparing software birthmarks -- \
characteristics extracted from a program's lifted intermediate \
representation. A high similarity between two birthmarks suggests one \
program is a copy of the other.\n\n\
Inputs are files already lifted to the Oinkie IR, the JSON that \
`oinkie lift` writes. Lifting is deliberately not exposed here: it runs a \
whole decompiler process per binary, for as long as that binary takes, and \
a replacement lifting script is arbitrary code. Run `oinkie lift` yourself \
first -- on a host with Ghidra, or in the `ghidra` image.\n\n\
Call oinkie_info before naming a birthmark type, an algorithm or an \
analysis. The names are precise and the lists are generated from the parser."
.to_string(),
);
info
}
}