1use crate::core::{Finding, ForgeGuardError, ProjectConfig, Severity};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::time::Instant;
13
14pub use Plugin as PluginTrait;
18
19pub type PluginResult = std::result::Result<Vec<Finding>, ForgeGuardError>;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PluginContext {
25 pub config: ProjectConfig,
27 pub source_files: Vec<PathBuf>,
29 #[serde(default)]
31 pub metadata: HashMap<String, String>,
32}
33
34impl PluginContext {
35 pub fn new(config: &ProjectConfig, source_files: Vec<PathBuf>) -> Self {
37 Self {
38 config: config.clone(),
39 source_files,
40 metadata: HashMap::new(),
41 }
42 }
43
44 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
46 self.metadata.insert(key.into(), value.into());
47 self
48 }
49}
50
51pub trait Plugin: Send + Sync {
53 fn name(&self) -> &'static str;
55 fn version(&self) -> &'static str;
57 fn description(&self) -> &'static str;
59 fn execute(&self, ctx: &PluginContext) -> PluginResult;
61 fn supports_offline(&self) -> bool {
63 true
64 }
65 fn requires_rpc(&self) -> bool {
67 false
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct PluginIpcInput {
76 pub protocol_version: String,
78 pub plugin_name: String,
80 pub context: PluginContext,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PluginIpcOutput {
87 pub success: bool,
89 #[serde(default)]
91 pub findings: Vec<PluginIpcFinding>,
92 #[serde(default)]
94 pub error: Option<String>,
95 #[serde(default)]
97 pub stats: PluginExecutionStats,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct PluginIpcFinding {
103 pub title: String,
105 pub description: String,
107 pub severity: String,
109 #[serde(default)]
111 pub file: Option<String>,
112 #[serde(default)]
114 pub line: Option<usize>,
115 #[serde(default)]
117 pub column: Option<usize>,
118 #[serde(default)]
120 pub code_snippet: Option<String>,
121 #[serde(default)]
123 pub recommendation: Option<String>,
124 #[serde(default)]
126 pub category: Option<String>,
127 #[serde(default)]
129 pub blocks_deployment: bool,
130 #[serde(default)]
132 pub references: Vec<String>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct PluginExecutionStats {
138 #[serde(default)]
140 pub files_analyzed: u32,
141 #[serde(default)]
143 pub duration_ms: u64,
144}
145
146fn parse_plugin_finding(f: &PluginIpcFinding) -> Finding {
147 let severity = match f.severity.to_lowercase().as_str() {
148 "critical" => Severity::Critical,
149 "high" => Severity::High,
150 "medium" => Severity::Medium,
151 "low" => Severity::Low,
152 _ => Severity::Informational,
153 };
154 Finding::builder()
155 .title(&f.title)
156 .description(&f.description)
157 .severity(severity)
158 .file(f.file.clone().unwrap_or_default())
159 .location(f.line.unwrap_or(0), f.column.unwrap_or(0))
160 .code(f.code_snippet.as_deref().unwrap_or(""))
161 .recommendation(f.recommendation.as_deref().unwrap_or(""))
162 .category(f.category.as_deref().unwrap_or("Plugin"))
163 .blocks_deployment(f.blocks_deployment)
164 .build()
165}
166
167impl From<PluginIpcFinding> for Finding {
168 fn from(f: PluginIpcFinding) -> Self {
169 parse_plugin_finding(&f)
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct PluginInfo {
178 pub name: String,
179 pub version: String,
180 pub description: String,
181 pub enabled: bool,
182 pub plugin_type: PluginType,
183 pub path: Option<PathBuf>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub enum PluginType {
189 Builtin,
190 External,
191}
192
193#[derive(Debug, Clone)]
197pub struct PluginExecutionResult {
198 pub plugin_name: String,
199 pub findings: Vec<Finding>,
200 pub duration: std::time::Duration,
201 pub success: bool,
202 pub error: Option<String>,
203}
204
205pub struct PluginRegistry {
209 config: ProjectConfig,
210 external_plugins: Vec<PluginInfo>,
212 builtin_plugins: Vec<PluginInfo>,
214 builtin_instances: HashMap<String, Box<dyn Plugin>>,
216}
217
218impl Clone for PluginRegistry {
219 fn clone(&self) -> Self {
220 Self {
225 config: self.config.clone(),
226 external_plugins: self.external_plugins.clone(),
227 builtin_plugins: self.builtin_plugins.clone(),
228 builtin_instances: HashMap::new(),
229 }
230 }
231}
232
233impl std::fmt::Debug for PluginRegistry {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.debug_struct("PluginRegistry")
236 .field("config", &self.config)
237 .field("external_plugins", &self.external_plugins)
238 .field("builtin_plugins", &self.builtin_plugins)
239 .finish()
240 }
241}
242
243impl PluginRegistry {
244 pub fn new(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
246 let mut registry = Self {
247 config: config.clone(),
248 external_plugins: Vec::new(),
249 builtin_plugins: Vec::new(),
250 builtin_instances: HashMap::new(),
251 };
252 registry.scan_plugin_dirs()?;
253 Ok(registry)
254 }
255
256 pub fn register_builtin(&mut self, plugin: Box<dyn Plugin>) {
258 let name = plugin.name().to_string();
259 let version = plugin.version().to_string();
260 let description = plugin.description().to_string();
261 let enabled = !self.config.plugins.disabled.contains(&name);
262
263 self.builtin_plugins.retain(|p| p.name != name);
265
266 self.builtin_plugins.push(PluginInfo {
267 name: name.clone(),
268 version,
269 description,
270 enabled,
271 plugin_type: PluginType::Builtin,
272 path: None,
273 });
274 self.builtin_instances.insert(name, plugin);
275 }
276
277 fn scan_plugin_dirs(&mut self) -> Result<(), ForgeGuardError> {
279 let dirs = &self.config.plugin_dirs;
280 let default_dir = PathBuf::from(".forge-guard/plugins");
282 let search_dirs: Vec<&PathBuf> = if dirs.is_empty() {
283 if default_dir.exists() {
285 vec![&default_dir]
286 } else {
287 Vec::new()
288 }
289 } else {
290 dirs.iter().collect()
291 };
292
293 for dir in search_dirs {
294 if dir.exists() && dir.is_dir() {
295 if let Ok(entries) = std::fs::read_dir(dir) {
296 for entry in entries.flatten() {
297 let path = entry.path();
298 if path.is_dir() {
299 let meta_file = path.join("plugin.toml");
300 if meta_file.exists() {
301 if let Ok(info) = self.load_plugin_meta(&meta_file) {
302 if !self.external_plugins.iter().any(|p| p.name == info.name) {
304 self.external_plugins.push(info);
305 }
306 }
307 }
308 }
309 }
310 }
311 }
312 }
313 Ok(())
314 }
315
316 fn load_plugin_meta(&self, path: &Path) -> Result<PluginInfo, ForgeGuardError> {
318 let content = std::fs::read_to_string(path)?;
319 #[derive(Deserialize)]
320 struct PluginMeta {
321 name: String,
322 version: String,
323 description: String,
324 }
325 let meta: PluginMeta = toml::from_str(&content)?;
326
327 let enabled = !self.config.plugins.disabled.contains(&meta.name);
328 Ok(PluginInfo {
329 name: meta.name,
330 version: meta.version,
331 description: meta.description,
332 enabled,
333 plugin_type: PluginType::External,
334 path: path.parent().map(|p| p.to_path_buf()),
335 })
336 }
337
338 pub fn list_plugins(&self) -> Vec<PluginInfo> {
340 let mut all = self.builtin_plugins.clone();
341 all.extend(self.external_plugins.clone());
342 all.sort_by_key(|p| match p.plugin_type {
344 PluginType::Builtin => 0,
345 PluginType::External => 1,
346 });
347 all
348 }
349
350 pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
352 self.builtin_plugins
353 .iter()
354 .chain(self.external_plugins.iter())
355 .find(|p| p.name == name)
356 .cloned()
357 }
358
359 pub fn plugin_count(&self) -> usize {
361 self.builtin_plugins.len() + self.external_plugins.len()
362 }
363
364 pub fn enabled_count(&self) -> usize {
366 self.list_plugins().iter().filter(|p| p.enabled).count()
367 }
368
369 pub fn execute_all(&self, ctx: &PluginContext) -> Vec<PluginExecutionResult> {
376 let mut results = Vec::new();
377
378 for info in &self.builtin_plugins {
380 if !info.enabled {
381 continue;
382 }
383 if let Some(instance) = self.builtin_instances.get(&info.name) {
384 let result = self.execute_builtin(instance.as_ref(), info, ctx);
385 results.push(result);
386 }
387 }
388
389 for info in &self.external_plugins {
391 if !info.enabled {
392 continue;
393 }
394 let result = self.execute_external(info, ctx);
395 results.push(result);
396 }
397
398 results
399 }
400
401 fn execute_builtin(
403 &self,
404 plugin: &dyn Plugin,
405 info: &PluginInfo,
406 ctx: &PluginContext,
407 ) -> PluginExecutionResult {
408 let start = Instant::now();
409 match plugin.execute(ctx) {
410 Ok(findings) => PluginExecutionResult {
411 plugin_name: info.name.clone(),
412 findings,
413 duration: start.elapsed(),
414 success: true,
415 error: None,
416 },
417 Err(e) => PluginExecutionResult {
418 plugin_name: info.name.clone(),
419 findings: Vec::new(),
420 duration: start.elapsed(),
421 success: false,
422 error: Some(e.to_string()),
423 },
424 }
425 }
426
427 fn execute_external(&self, info: &PluginInfo, ctx: &PluginContext) -> PluginExecutionResult {
429 let start = Instant::now();
430 let plugin_dir = match &info.path {
431 Some(p) => p.clone(),
432 None => {
433 return PluginExecutionResult {
434 plugin_name: info.name.clone(),
435 findings: Vec::new(),
436 duration: start.elapsed(),
437 success: false,
438 error: Some("Plugin path unknown".to_string()),
439 };
440 }
441 };
442
443 let binary = Self::find_plugin_binary(&plugin_dir);
445 if binary.is_none() {
446 return PluginExecutionResult {
447 plugin_name: info.name.clone(),
448 findings: Vec::new(),
449 duration: start.elapsed(),
450 success: false,
451 error: Some(format!(
452 "No executable found in plugin directory: {}",
453 plugin_dir.display()
454 )),
455 };
456 }
457 let binary = binary.unwrap();
458
459 let input = PluginIpcInput {
461 protocol_version: "1.0".to_string(),
462 plugin_name: info.name.clone(),
463 context: ctx.clone(),
464 };
465
466 let input_json = match serde_json::to_string(&input) {
468 Ok(j) => j,
469 Err(e) => {
470 return PluginExecutionResult {
471 plugin_name: info.name.clone(),
472 findings: Vec::new(),
473 duration: start.elapsed(),
474 success: false,
475 error: Some(format!("Failed to serialize IPC input: {}", e)),
476 };
477 }
478 };
479
480 let output = match std::process::Command::new(&binary)
482 .args(["--forge-guard-ipc"])
483 .stdin(std::process::Stdio::piped())
484 .stdout(std::process::Stdio::piped())
485 .stderr(std::process::Stdio::piped())
486 .spawn()
487 {
488 Ok(mut child) => {
489 use std::io::Write;
491 if let Some(mut stdin) = child.stdin.take() {
492 let _ = stdin.write_all(input_json.as_bytes());
493 drop(stdin);
495 }
496
497 match child.wait_with_output() {
499 Ok(output) => output,
500 Err(e) => {
501 return PluginExecutionResult {
502 plugin_name: info.name.clone(),
503 findings: Vec::new(),
504 duration: start.elapsed(),
505 success: false,
506 error: Some(format!("Failed to wait for plugin process: {}", e)),
507 };
508 }
509 }
510 }
511 Err(e) => {
512 return PluginExecutionResult {
513 plugin_name: info.name.clone(),
514 findings: Vec::new(),
515 duration: start.elapsed(),
516 success: false,
517 error: Some(format!("Failed to spawn plugin process: {}", e)),
518 };
519 }
520 };
521
522 if !output.stderr.is_empty() {
524 let stderr = String::from_utf8_lossy(&output.stderr);
525 for line in stderr.lines() {
526 if !line.is_empty() {
527 eprintln!(" [plugin:{}] {}", info.name, line);
528 }
529 }
530 }
531
532 if !output.status.success() {
534 let stderr = String::from_utf8_lossy(&output.stderr);
535 return PluginExecutionResult {
536 plugin_name: info.name.clone(),
537 findings: Vec::new(),
538 duration: start.elapsed(),
539 success: false,
540 error: Some(format!(
541 "Plugin exited with code {}: {}",
542 output.status.code().unwrap_or(-1),
543 stderr.lines().next().unwrap_or("unknown error")
544 )),
545 };
546 }
547
548 let stdout = String::from_utf8_lossy(&output.stdout);
549 match serde_json::from_str::<PluginIpcOutput>(&stdout) {
550 Ok(ipc_output) => {
551 let findings: Vec<Finding> =
552 ipc_output.findings.into_iter().map(|f| f.into()).collect();
553
554 if ipc_output.success {
555 PluginExecutionResult {
556 plugin_name: info.name.clone(),
557 findings,
558 duration: start.elapsed(),
559 success: true,
560 error: None,
561 }
562 } else {
563 PluginExecutionResult {
564 plugin_name: info.name.clone(),
565 findings,
566 duration: start.elapsed(),
567 success: false,
568 error: ipc_output.error,
569 }
570 }
571 }
572 Err(e) => PluginExecutionResult {
573 plugin_name: info.name.clone(),
574 findings: Vec::new(),
575 duration: start.elapsed(),
576 success: false,
577 error: Some(format!(
578 "Failed to parse plugin IPC output: {}. Raw stdout: {}",
579 e,
580 stdout.chars().take(200).collect::<String>()
581 )),
582 },
583 }
584 }
585
586 fn find_plugin_binary(dir: &Path) -> Option<PathBuf> {
589 if let Some(dir_name) = dir.file_name() {
591 let candidates = [
592 dir.join(dir_name),
593 dir.join("target").join("release").join(dir_name),
594 dir.join("target").join("debug").join(dir_name),
595 ];
596 for candidate in &candidates {
597 if candidate.exists() && is_executable(candidate) {
598 return Some(candidate.clone());
599 }
600 }
601 }
602
603 let script_candidates = [
605 dir.join("run.sh"),
606 dir.join("main.py"),
607 dir.join("index.js"),
608 dir.join("plugin"),
609 ];
610 for candidate in &script_candidates {
611 if candidate.exists() {
612 return Some(candidate.clone());
613 }
614 }
615
616 None
617 }
618
619 pub fn enable_plugin(&mut self, name: &str) -> bool {
621 let mut found = false;
622 for plugin in &mut self.builtin_plugins {
623 if plugin.name == name {
624 plugin.enabled = true;
625 found = true;
626 }
627 }
628 for plugin in &mut self.external_plugins {
629 if plugin.name == name {
630 plugin.enabled = true;
631 found = true;
632 }
633 }
634 found
635 }
636
637 pub fn disable_plugin(&mut self, name: &str) -> bool {
639 let mut found = false;
640 for plugin in &mut self.builtin_plugins {
641 if plugin.name == name {
642 plugin.enabled = false;
643 found = true;
644 }
645 }
646 for plugin in &mut self.external_plugins {
647 if plugin.name == name {
648 plugin.enabled = false;
649 found = true;
650 }
651 }
652 found
653 }
654}
655
656#[cfg(unix)]
657fn is_executable(path: &Path) -> bool {
658 use std::os::unix::fs::PermissionsExt;
659 path.is_file()
660 && path
661 .metadata()
662 .map(|m| m.permissions().mode() & 0o111 != 0)
663 .unwrap_or(false)
664}
665
666#[cfg(not(unix))]
667fn is_executable(path: &Path) -> bool {
668 path.is_file()
669}
670
671pub struct ExamplePlugin;
675
676impl Plugin for ExamplePlugin {
677 fn name(&self) -> &'static str {
678 "forge-guard-example"
679 }
680
681 fn version(&self) -> &'static str {
682 "0.1.0"
683 }
684
685 fn description(&self) -> &'static str {
686 "Example plugin demonstrating the plugin API"
687 }
688
689 fn execute(&self, ctx: &PluginContext) -> PluginResult {
690 eprintln!(
691 " [plugin:{}] Analyzing {} source files",
692 self.name(),
693 ctx.source_files.len()
694 );
695 Ok(Vec::new())
696 }
697}
698
699pub struct OfflineGuardPlugin;
701
702impl Plugin for OfflineGuardPlugin {
703 fn name(&self) -> &'static str {
704 "forge-guard-offline-guard"
705 }
706
707 fn version(&self) -> &'static str {
708 "0.1.0"
709 }
710
711 fn description(&self) -> &'static str {
712 "Warns when plugins requiring RPC are enabled but --offline is set"
713 }
714
715 fn execute(&self, ctx: &PluginContext) -> PluginResult {
716 let offline = ctx
717 .metadata
718 .get("offline")
719 .map(|s| s == "true")
720 .unwrap_or(false);
721 if offline {
722 return Ok(Vec::new());
724 }
725 Ok(Vec::new())
726 }
727}
728
729pub fn register_default_plugins(registry: &mut PluginRegistry) {
733 registry.register_builtin(Box::new(ExamplePlugin));
734 registry.register_builtin(Box::new(OfflineGuardPlugin));
735}