use crate::core::{Finding, ForgeGuardError, ProjectConfig, Severity};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
pub use Plugin as PluginTrait;
pub type PluginResult = std::result::Result<Vec<Finding>, ForgeGuardError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginContext {
pub config: ProjectConfig,
pub source_files: Vec<PathBuf>,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
impl PluginContext {
pub fn new(config: &ProjectConfig, source_files: Vec<PathBuf>) -> Self {
Self {
config: config.clone(),
source_files,
metadata: HashMap::new(),
}
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
}
pub trait Plugin: Send + Sync {
fn name(&self) -> &'static str;
fn version(&self) -> &'static str;
fn description(&self) -> &'static str;
fn execute(&self, ctx: &PluginContext) -> PluginResult;
fn supports_offline(&self) -> bool {
true
}
fn requires_rpc(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginIpcInput {
pub protocol_version: String,
pub plugin_name: String,
pub context: PluginContext,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginIpcOutput {
pub success: bool,
#[serde(default)]
pub findings: Vec<PluginIpcFinding>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub stats: PluginExecutionStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginIpcFinding {
pub title: String,
pub description: String,
pub severity: String,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub line: Option<usize>,
#[serde(default)]
pub column: Option<usize>,
#[serde(default)]
pub code_snippet: Option<String>,
#[serde(default)]
pub recommendation: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub blocks_deployment: bool,
#[serde(default)]
pub references: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginExecutionStats {
#[serde(default)]
pub files_analyzed: u32,
#[serde(default)]
pub duration_ms: u64,
}
fn parse_plugin_finding(f: &PluginIpcFinding) -> Finding {
let severity = match f.severity.to_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"medium" => Severity::Medium,
"low" => Severity::Low,
_ => Severity::Informational,
};
Finding::builder()
.title(&f.title)
.description(&f.description)
.severity(severity)
.file(f.file.clone().unwrap_or_default())
.location(f.line.unwrap_or(0), f.column.unwrap_or(0))
.code(f.code_snippet.as_deref().unwrap_or(""))
.recommendation(f.recommendation.as_deref().unwrap_or(""))
.category(f.category.as_deref().unwrap_or("Plugin"))
.blocks_deployment(f.blocks_deployment)
.build()
}
impl From<PluginIpcFinding> for Finding {
fn from(f: PluginIpcFinding) -> Self {
parse_plugin_finding(&f)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
pub description: String,
pub enabled: bool,
pub plugin_type: PluginType,
pub path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum PluginType {
Builtin,
External,
}
#[derive(Debug, Clone)]
pub struct PluginExecutionResult {
pub plugin_name: String,
pub findings: Vec<Finding>,
pub duration: std::time::Duration,
pub success: bool,
pub error: Option<String>,
}
pub struct PluginRegistry {
config: ProjectConfig,
external_plugins: Vec<PluginInfo>,
builtin_plugins: Vec<PluginInfo>,
builtin_instances: HashMap<String, Box<dyn Plugin>>,
}
impl Clone for PluginRegistry {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
external_plugins: self.external_plugins.clone(),
builtin_plugins: self.builtin_plugins.clone(),
builtin_instances: HashMap::new(),
}
}
}
impl std::fmt::Debug for PluginRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PluginRegistry")
.field("config", &self.config)
.field("external_plugins", &self.external_plugins)
.field("builtin_plugins", &self.builtin_plugins)
.finish()
}
}
impl PluginRegistry {
pub fn new(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
let mut registry = Self {
config: config.clone(),
external_plugins: Vec::new(),
builtin_plugins: Vec::new(),
builtin_instances: HashMap::new(),
};
registry.scan_plugin_dirs()?;
Ok(registry)
}
pub fn register_builtin(&mut self, plugin: Box<dyn Plugin>) {
let name = plugin.name().to_string();
let version = plugin.version().to_string();
let description = plugin.description().to_string();
let enabled = !self.config.plugins.disabled.contains(&name);
self.builtin_plugins.retain(|p| p.name != name);
self.builtin_plugins.push(PluginInfo {
name: name.clone(),
version,
description,
enabled,
plugin_type: PluginType::Builtin,
path: None,
});
self.builtin_instances.insert(name, plugin);
}
fn scan_plugin_dirs(&mut self) -> Result<(), ForgeGuardError> {
let dirs = &self.config.plugin_dirs;
let default_dir = PathBuf::from(".forge-guard/plugins");
let search_dirs: Vec<&PathBuf> = if dirs.is_empty() {
if default_dir.exists() {
vec![&default_dir]
} else {
Vec::new()
}
} else {
dirs.iter().collect()
};
for dir in search_dirs {
if dir.exists() && dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let meta_file = path.join("plugin.toml");
if meta_file.exists() {
if let Ok(info) = self.load_plugin_meta(&meta_file) {
if !self.external_plugins.iter().any(|p| p.name == info.name) {
self.external_plugins.push(info);
}
}
}
}
}
}
}
}
Ok(())
}
fn load_plugin_meta(&self, path: &Path) -> Result<PluginInfo, ForgeGuardError> {
let content = std::fs::read_to_string(path)?;
#[derive(Deserialize)]
struct PluginMeta {
name: String,
version: String,
description: String,
}
let meta: PluginMeta = toml::from_str(&content)?;
let enabled = !self.config.plugins.disabled.contains(&meta.name);
Ok(PluginInfo {
name: meta.name,
version: meta.version,
description: meta.description,
enabled,
plugin_type: PluginType::External,
path: path.parent().map(|p| p.to_path_buf()),
})
}
pub fn list_plugins(&self) -> Vec<PluginInfo> {
let mut all = self.builtin_plugins.clone();
all.extend(self.external_plugins.clone());
all.sort_by_key(|p| match p.plugin_type {
PluginType::Builtin => 0,
PluginType::External => 1,
});
all
}
pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
self.builtin_plugins
.iter()
.chain(self.external_plugins.iter())
.find(|p| p.name == name)
.cloned()
}
pub fn plugin_count(&self) -> usize {
self.builtin_plugins.len() + self.external_plugins.len()
}
pub fn enabled_count(&self) -> usize {
self.list_plugins().iter().filter(|p| p.enabled).count()
}
pub fn execute_all(&self, ctx: &PluginContext) -> Vec<PluginExecutionResult> {
let mut results = Vec::new();
for info in &self.builtin_plugins {
if !info.enabled {
continue;
}
if let Some(instance) = self.builtin_instances.get(&info.name) {
let result = self.execute_builtin(instance.as_ref(), info, ctx);
results.push(result);
}
}
for info in &self.external_plugins {
if !info.enabled {
continue;
}
let result = self.execute_external(info, ctx);
results.push(result);
}
results
}
fn execute_builtin(
&self,
plugin: &dyn Plugin,
info: &PluginInfo,
ctx: &PluginContext,
) -> PluginExecutionResult {
let start = Instant::now();
match plugin.execute(ctx) {
Ok(findings) => PluginExecutionResult {
plugin_name: info.name.clone(),
findings,
duration: start.elapsed(),
success: true,
error: None,
},
Err(e) => PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(e.to_string()),
},
}
}
fn execute_external(&self, info: &PluginInfo, ctx: &PluginContext) -> PluginExecutionResult {
let start = Instant::now();
let plugin_dir = match &info.path {
Some(p) => p.clone(),
None => {
return PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some("Plugin path unknown".to_string()),
};
}
};
let binary = Self::find_plugin_binary(&plugin_dir);
if binary.is_none() {
return PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(format!(
"No executable found in plugin directory: {}",
plugin_dir.display()
)),
};
}
let binary = binary.unwrap();
let input = PluginIpcInput {
protocol_version: "1.0".to_string(),
plugin_name: info.name.clone(),
context: ctx.clone(),
};
let input_json = match serde_json::to_string(&input) {
Ok(j) => j,
Err(e) => {
return PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(format!("Failed to serialize IPC input: {}", e)),
};
}
};
let output = match std::process::Command::new(&binary)
.args(["--forge-guard-ipc"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(mut child) => {
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input_json.as_bytes());
drop(stdin);
}
match child.wait_with_output() {
Ok(output) => output,
Err(e) => {
return PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(format!("Failed to wait for plugin process: {}", e)),
};
}
}
}
Err(e) => {
return PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(format!("Failed to spawn plugin process: {}", e)),
};
}
};
if !output.stderr.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.is_empty() {
eprintln!(" [plugin:{}] {}", info.name, line);
}
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(format!(
"Plugin exited with code {}: {}",
output.status.code().unwrap_or(-1),
stderr.lines().next().unwrap_or("unknown error")
)),
};
}
let stdout = String::from_utf8_lossy(&output.stdout);
match serde_json::from_str::<PluginIpcOutput>(&stdout) {
Ok(ipc_output) => {
let findings: Vec<Finding> =
ipc_output.findings.into_iter().map(|f| f.into()).collect();
if ipc_output.success {
PluginExecutionResult {
plugin_name: info.name.clone(),
findings,
duration: start.elapsed(),
success: true,
error: None,
}
} else {
PluginExecutionResult {
plugin_name: info.name.clone(),
findings,
duration: start.elapsed(),
success: false,
error: ipc_output.error,
}
}
}
Err(e) => PluginExecutionResult {
plugin_name: info.name.clone(),
findings: Vec::new(),
duration: start.elapsed(),
success: false,
error: Some(format!(
"Failed to parse plugin IPC output: {}. Raw stdout: {}",
e,
stdout.chars().take(200).collect::<String>()
)),
},
}
}
fn find_plugin_binary(dir: &Path) -> Option<PathBuf> {
if let Some(dir_name) = dir.file_name() {
let candidates = [
dir.join(dir_name),
dir.join("target").join("release").join(dir_name),
dir.join("target").join("debug").join(dir_name),
];
for candidate in &candidates {
if candidate.exists() && is_executable(candidate) {
return Some(candidate.clone());
}
}
}
let script_candidates = [
dir.join("run.sh"),
dir.join("main.py"),
dir.join("index.js"),
dir.join("plugin"),
];
for candidate in &script_candidates {
if candidate.exists() {
return Some(candidate.clone());
}
}
None
}
pub fn enable_plugin(&mut self, name: &str) -> bool {
let mut found = false;
for plugin in &mut self.builtin_plugins {
if plugin.name == name {
plugin.enabled = true;
found = true;
}
}
for plugin in &mut self.external_plugins {
if plugin.name == name {
plugin.enabled = true;
found = true;
}
}
found
}
pub fn disable_plugin(&mut self, name: &str) -> bool {
let mut found = false;
for plugin in &mut self.builtin_plugins {
if plugin.name == name {
plugin.enabled = false;
found = true;
}
}
for plugin in &mut self.external_plugins {
if plugin.name == name {
plugin.enabled = false;
found = true;
}
}
found
}
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.is_file()
&& path
.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
pub struct ExamplePlugin;
impl Plugin for ExamplePlugin {
fn name(&self) -> &'static str {
"forge-guard-example"
}
fn version(&self) -> &'static str {
"0.1.0"
}
fn description(&self) -> &'static str {
"Example plugin demonstrating the plugin API"
}
fn execute(&self, ctx: &PluginContext) -> PluginResult {
eprintln!(
" [plugin:{}] Analyzing {} source files",
self.name(),
ctx.source_files.len()
);
Ok(Vec::new())
}
}
pub struct OfflineGuardPlugin;
impl Plugin for OfflineGuardPlugin {
fn name(&self) -> &'static str {
"forge-guard-offline-guard"
}
fn version(&self) -> &'static str {
"0.1.0"
}
fn description(&self) -> &'static str {
"Warns when plugins requiring RPC are enabled but --offline is set"
}
fn execute(&self, ctx: &PluginContext) -> PluginResult {
let offline = ctx
.metadata
.get("offline")
.map(|s| s == "true")
.unwrap_or(false);
if offline {
return Ok(Vec::new());
}
Ok(Vec::new())
}
}
pub fn register_default_plugins(registry: &mut PluginRegistry) {
registry.register_builtin(Box::new(ExamplePlugin));
registry.register_builtin(Box::new(OfflineGuardPlugin));
}