1#![allow(
2 unsafe_code,
3 reason = "Intentional compatibility, platform, or test-only suppression."
4)]
5
6use anyhow::{Context, Result, anyhow};
59use hashbrown::HashMap;
60use libloading::{Library, Symbol};
61use serde::{Deserialize, Serialize};
62use std::ffi::{CStr, CString};
63#[cfg(unix)]
64use std::os::unix::fs::PermissionsExt;
65use std::path::{Path, PathBuf};
66use std::ptr::NonNull;
67use std::sync::Mutex;
68use tracing::{debug, info, warn};
69use vtcode_commons::canonicalize;
70
71pub const PLUGIN_ABI_VERSION: u32 = 1;
74
75use std::os::raw::c_char;
76
77type PluginVersionFn = unsafe extern "C" fn() -> u32;
93type PluginMetadataFn = unsafe extern "C" fn() -> *const c_char;
94type PluginExecuteFn = unsafe extern "C" fn(*const c_char) -> *const c_char;
95type PluginFreeStringFn = unsafe extern "C" fn(*const c_char);
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PluginContext {
100 input: HashMap<String, serde_json::Value>,
102 workspace_root: Option<String>,
104 config: HashMap<String, serde_json::Value>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PluginResult {
111 success: bool,
113 output: HashMap<String, serde_json::Value>,
115 error: Option<String>,
117 files: Vec<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PluginMetadata {
124 pub name: String,
126 pub description: String,
128 version: String,
130 author: Option<String>,
132 abi_version: u32,
134 when_to_use: Option<String>,
136 when_not_to_use: Option<String>,
138 allowed_tools: Option<Vec<String>>,
140 #[serde(default)]
143 thread_safe: bool,
144}
145
146pub trait NativePluginTrait: Send + Sync + std::fmt::Debug {
148 fn metadata(&self) -> &PluginMetadata;
150
151 fn path(&self) -> &Path;
153
154 fn execute(&self, ctx: &PluginContext) -> Result<PluginResult>;
156}
157
158pub struct NativePlugin {
160 _library: Library,
170 metadata: PluginMetadata,
172 path: PathBuf,
174 execute_fn: PluginExecuteFn,
176 free_string_fn: Option<PluginFreeStringFn>,
178 execution_lock: Mutex<()>,
180 thread_safe: bool,
182}
183
184fn ensure_non_null_c_string_ptr(ptr: *const c_char, context: &'static str) -> Result<NonNull<c_char>> {
185 NonNull::new(ptr.cast_mut()).ok_or_else(|| anyhow!("{context} returned null pointer"))
186}
187
188fn decode_plugin_c_string(
189 ptr: NonNull<c_char>,
190 free_string_fn: Option<PluginFreeStringFn>,
191 utf8_error_context: &'static str,
192) -> Result<String> {
193 let raw_ptr = ptr.as_ptr() as *const c_char;
194 let decoded = unsafe { CStr::from_ptr(raw_ptr) }
201 .to_str()
202 .context(utf8_error_context)
203 .map(str::to_owned);
204
205 if let Some(free_fn) = free_string_fn {
206 unsafe { free_fn(raw_ptr) };
209 }
210
211 decoded
212}
213
214impl std::fmt::Debug for NativePlugin {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 f.debug_struct("NativePlugin")
217 .field("metadata", &self.metadata)
218 .field("path", &self.path)
219 .finish()
220 }
221}
222
223fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf> {
224 canonicalize(path).with_context(|| format!("Failed to resolve {label} '{}'", path.display()))
225}
226
227fn get_plugin_symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T> {
233 let symbol: Symbol<T> = unsafe { library.get(name) }.with_context(|| {
236 format!(
237 "Failed to load plugin symbol: {:?}",
238 String::from_utf8_lossy(name.strip_suffix(b"\0").unwrap_or(name))
239 )
240 })?;
241 Ok(*symbol)
242}
243
244fn normalize_trusted_dir(path: PathBuf) -> PathBuf {
245 canonicalize_existing_path(&path, "trusted plugin directory").unwrap_or_else(|_| {
246 if path.is_absolute() {
247 path
248 } else {
249 std::env::current_dir().map(|cwd| cwd.join(&path)).unwrap_or(path)
250 }
251 })
252}
253
254#[cfg(unix)]
255fn warn_if_insecure_trusted_dir(dir: &Path) {
256 if let Ok(metadata) = std::fs::metadata(dir) {
257 let mode = metadata.permissions().mode();
258 if mode & 0o022 != 0 {
259 let perm_octal = format!("{:o}", mode & 0o777);
260 warn!(
261 dir = ?dir,
262 mode = %perm_octal,
263 "trusted plugin directory is writable by group or others; a non-owner user could plant a malicious plugin via symlink or replacement"
264 );
265 }
266 }
267}
268
269#[cfg(not(unix))]
270fn warn_if_insecure_trusted_dir(_dir: &Path) {}
271
272impl NativePlugin {
273 fn new(library: Library, path: PathBuf) -> Result<Self> {
275 let version_fn = get_plugin_symbol::<PluginVersionFn>(&library, b"vtcode_plugin_version\0")?;
277
278 let abi_version = unsafe { version_fn() };
280 if abi_version != PLUGIN_ABI_VERSION {
281 return Err(anyhow!("Plugin ABI version mismatch: expected {PLUGIN_ABI_VERSION}, got {abi_version}"));
282 }
283
284 let free_string_fn = get_plugin_symbol::<PluginFreeStringFn>(&library, b"vtcode_plugin_free_string\0").ok();
286
287 let metadata_fn = get_plugin_symbol::<PluginMetadataFn>(&library, b"vtcode_plugin_metadata\0")?;
289
290 let metadata_ptr = ensure_non_null_c_string_ptr(unsafe { metadata_fn() }, "Plugin metadata function")?;
292 let metadata_json = decode_plugin_c_string(metadata_ptr, free_string_fn, "Plugin metadata is not valid UTF-8")?;
293
294 let metadata: PluginMetadata =
295 serde_json::from_str(&metadata_json).context("Failed to parse plugin metadata JSON")?;
296
297 let execute_fn = get_plugin_symbol::<PluginExecuteFn>(&library, b"vtcode_plugin_execute\0")?;
299
300 Ok(Self {
301 _library: library,
302 metadata: metadata.clone(), path,
304 execute_fn,
305 free_string_fn,
306 execution_lock: Mutex::new(()),
307 thread_safe: metadata.thread_safe, })
309 }
310
311 fn execute(&self, ctx: &PluginContext) -> Result<PluginResult> {
313 let input_json = serde_json::to_string(ctx).context("Failed to serialize plugin context")?;
314 let input_cstr = CString::new(input_json).context("Plugin context contains internal null bytes")?;
315
316 if !self.thread_safe {
320 let _execution_guard = self
321 .execution_lock
322 .lock()
323 .map_err(|_e| anyhow!("native plugin execution lock poisoned"))?;
324
325 self.execute_ffi(input_cstr)
326 } else {
327 self.execute_ffi(input_cstr)
329 }
330 }
331
332 fn execute_ffi(&self, input_cstr: CString) -> Result<PluginResult> {
339 let result_ptr =
344 ensure_non_null_c_string_ptr(unsafe { (self.execute_fn)(input_cstr.as_ptr()) }, "Plugin execute function")?;
345 let result_json = decode_plugin_c_string(result_ptr, self.free_string_fn, "Plugin result is not valid UTF-8")?;
346
347 let result: PluginResult = serde_json::from_str(&result_json).context("Failed to parse plugin result JSON")?;
348
349 Ok(result)
350 }
351}
352
353impl NativePluginTrait for NativePlugin {
354 fn metadata(&self) -> &PluginMetadata {
355 &self.metadata
356 }
357
358 fn path(&self) -> &Path {
359 &self.path
360 }
361
362 fn execute(&self, ctx: &PluginContext) -> Result<PluginResult> {
363 self.execute(ctx)
364 }
365}
366
367pub struct PluginLoader {
374 trusted_dirs: Vec<PathBuf>,
376}
377
378impl PluginLoader {
379 pub fn new() -> Self {
381 Self { trusted_dirs: Vec::new() }
382 }
383
384 pub fn add_trusted_dir(&mut self, path: PathBuf) -> &mut Self {
390 let path = normalize_trusted_dir(path);
391 if !self.trusted_dirs.contains(&path) {
392 warn_if_insecure_trusted_dir(&path);
393 self.trusted_dirs.push(path);
394 }
395 self
396 }
397
398 pub fn trusted_dirs(&self) -> &[PathBuf] {
400 &self.trusted_dirs
401 }
402
403 pub fn load_plugin(&self, plugin_path: &Path) -> Result<Box<dyn NativePluginTrait>> {
412 debug!("Loading native plugin from {:?}", plugin_path);
413
414 let plugin_path = self.ensure_trusted_path(plugin_path, "Plugin path")?;
415
416 let lib_path = self.find_library_file(&plugin_path)?;
418 let lib_path = self.ensure_trusted_path(&lib_path, "Plugin library path")?;
419
420 let library = unsafe { Library::new(&lib_path) }
431 .with_context(|| format!("Failed to load dynamic library at {lib_path:?}"))?;
432
433 let plugin = NativePlugin::new(library, plugin_path.clone())?;
434
435 info!(
436 "Loaded native plugin '{}' v{} from {:?}",
437 plugin.metadata.name, plugin.metadata.version, plugin_path
438 );
439
440 Ok(Box::new(plugin))
441 }
442
443 pub fn discover_plugins(&self) -> Result<Vec<Box<dyn NativePluginTrait>>> {
448 let mut plugins = Vec::new();
449
450 for dir in &self.trusted_dirs {
451 warn_if_insecure_trusted_dir(dir);
452 if !dir.exists() {
453 continue;
454 }
455
456 match self.discover_plugins_in_dir(dir) {
457 Ok(mut dir_plugins) => plugins.append(&mut dir_plugins),
458 Err(e) => {
459 warn!("Failed to discover plugins in {:?}: {}", dir, e);
460 }
461 }
462 }
463
464 Ok(plugins)
465 }
466
467 fn is_in_trusted_dir(&self, path: &Path) -> bool {
469 self.trusted_dirs.iter().any(|dir| path.starts_with(dir))
470 }
471
472 fn ensure_trusted_path(&self, path: &Path, label: &str) -> Result<PathBuf> {
473 let path = canonicalize_existing_path(path, label)?;
474 if self.is_in_trusted_dir(&path) {
475 Ok(path)
476 } else {
477 Err(anyhow!("{label} {path:?} is not in a trusted directory"))
478 }
479 }
480
481 fn find_library_file(&self, plugin_dir: &Path) -> Result<PathBuf> {
483 if !plugin_dir.is_dir() {
484 return Err(anyhow!("Plugin path is not a directory"));
485 }
486
487 let metadata_path = plugin_dir.join("plugin.json");
489 if !metadata_path.exists() {
490 return Err(anyhow!("No plugin.json found in {plugin_dir:?}"));
491 }
492
493 let lib_name = self.get_library_name_from_metadata(&metadata_path)?;
495
496 let lib_path = plugin_dir.join(&lib_name);
497 if lib_path.exists() {
498 return Ok(lib_path);
499 }
500
501 let alternatives = self.get_alternative_library_names(&lib_name);
503 for alt in alternatives {
504 let alt_path = plugin_dir.join(alt);
505 if alt_path.exists() {
506 return Ok(alt_path);
507 }
508 }
509
510 Err(anyhow!("No dynamic library found in {plugin_dir:?}. Expected one of: {lib_name}, or alternatives"))
511 }
512
513 fn get_library_name_from_metadata(&self, metadata_path: &Path) -> Result<String> {
515 let metadata_content = std::fs::read_to_string(metadata_path).context("Failed to read plugin metadata")?;
516 let metadata: serde_json::Value =
517 serde_json::from_str(&metadata_content).context("Invalid plugin metadata JSON")?;
518
519 let name = metadata["name"]
520 .as_str()
521 .ok_or_else(|| anyhow!("Plugin metadata missing 'name' field"))?;
522
523 Ok(self.library_filename(name))
524 }
525
526 fn get_alternative_library_names(&self, base_name: &str) -> Vec<String> {
528 let mut alternatives = Vec::new();
529
530 if let Some(stripped) = base_name.strip_prefix("lib") {
532 alternatives.push(stripped.to_string());
533 } else {
534 alternatives.push(format!("lib{base_name}"));
535 }
536
537 let base = base_name.strip_prefix("lib").unwrap_or(base_name);
539 #[cfg(target_os = "macos")]
540 {
541 alternatives.push(format!("{base}.dylib"));
542 alternatives.push(format!("lib{base}.dylib"));
543 }
544 #[cfg(target_os = "linux")]
545 {
546 alternatives.push(format!("{}.so", base));
547 alternatives.push(format!("lib{}.so", base));
548 }
549 #[cfg(target_os = "windows")]
550 {
551 alternatives.push(format!("{}.dll", base));
552 alternatives.push(format!("lib{}.dll", base));
553 }
554
555 alternatives
556 }
557
558 fn discover_plugins_in_dir(&self, dir: &Path) -> Result<Vec<Box<dyn NativePluginTrait>>> {
560 let mut plugins = Vec::new();
561
562 for entry in std::fs::read_dir(dir)? {
563 let entry = entry?;
564 let path = entry.path();
565
566 if path.is_dir() && path.join("plugin.json").exists() {
567 match self.load_plugin(&path) {
568 Ok(plugin) => plugins.push(plugin),
569 Err(e) => {
570 warn!("Failed to load plugin at {:?}: {}", path, e);
571 }
572 }
573 }
574 }
575
576 Ok(plugins)
577 }
578
579 pub fn library_filename(&self, name: &str) -> String {
581 #[cfg(target_os = "macos")]
582 {
583 format!("lib{name}.dylib")
584 }
585 #[cfg(target_os = "linux")]
586 {
587 format!("lib{}.so", name)
588 }
589 #[cfg(target_os = "windows")]
590 {
591 format!("{}.dll", name)
592 }
593 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
594 {
595 format!("lib{}", name)
596 }
597 }
598}
599
600impl Default for PluginLoader {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606pub fn validate_plugin_structure(plugin_dir: &Path) -> Result<Vec<String>> {
608 let mut errors = Vec::new();
609
610 if !plugin_dir.join("plugin.json").exists() {
612 errors.push("Missing plugin.json".to_string());
613 }
614
615 let has_lib = std::fs::read_dir(plugin_dir)
617 .map(|entries| {
618 entries.filter_map(|e| e.ok()).any(|entry| {
619 let path = entry.path();
620 let ext = path.extension().and_then(|e| e.to_str());
621 matches!(ext, Some("dylib") | Some("so") | Some("dll"))
622 })
623 })
624 .unwrap_or(false);
625
626 if !has_lib {
627 errors.push("No dynamic library found (.dylib, .so, or .dll)".to_string());
628 }
629
630 if let Ok(content) = std::fs::read_to_string(plugin_dir.join("plugin.json")) {
632 if let Ok(metadata) = serde_json::from_str::<serde_json::Value>(&content) {
633 if metadata["name"].as_str().is_none() {
634 errors.push("plugin.json missing required 'name' field".to_string());
635 }
636 if metadata["description"].as_str().is_none() {
637 errors.push("plugin.json missing required 'description' field".to_string());
638 }
639 if metadata["version"].as_str().is_none() {
640 errors.push("plugin.json missing required 'version' field".to_string());
641 }
642 } else {
643 errors.push("Invalid JSON in plugin.json".to_string());
644 }
645 }
646
647 Ok(errors)
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use serial_test::serial;
654 use std::cell::Cell;
655 use std::sync::Arc;
656 use std::sync::atomic::{AtomicUsize, Ordering};
657 use std::time::Duration;
658 use tempfile::TempDir;
659
660 thread_local! {
661 static TEST_FREE_WAS_CALLED: Cell<bool> = const { Cell::new(false) };
662 }
663
664 static TEST_EXECUTE_ACTIVE_CALLS: AtomicUsize = AtomicUsize::new(0);
665 static TEST_EXECUTE_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(0);
666
667 unsafe extern "C" fn test_free_string(ptr: *const c_char) {
668 TEST_FREE_WAS_CALLED.with(|was_called| was_called.set(true));
669 if !ptr.is_null() {
670 let _ = unsafe { CString::from_raw(ptr as *mut c_char) };
672 }
673 }
674
675 fn create_test_plugin_dir() -> (TempDir, PathBuf) {
676 let temp_dir = TempDir::new().unwrap();
677 let plugin_dir = temp_dir.path().join("test-plugin");
678 std::fs::create_dir(&plugin_dir).unwrap();
679 (temp_dir, plugin_dir)
680 }
681
682 fn write_plugin_metadata(plugin_dir: &Path, name: &str) {
683 std::fs::write(
684 plugin_dir.join("plugin.json"),
685 format!(r#"{{"name":"{name}","description":"test","version":"1.0.0"}}"#),
686 )
687 .unwrap();
688 }
689
690 fn write_fake_library(plugin_dir: &Path, name: &str) -> PathBuf {
691 let loader = PluginLoader::new();
692 let library_path = plugin_dir.join(loader.library_filename(name));
693 std::fs::write(&library_path, b"fake-library").unwrap();
694 library_path
695 }
696
697 fn current_process_library() -> Library {
698 #[cfg(unix)]
699 {
700 libloading::os::unix::Library::this().into()
701 }
702 #[cfg(windows)]
703 {
704 libloading::os::windows::Library::this()
705 .expect("current process library")
706 .into()
707 }
708 }
709
710 fn update_max_concurrency(active_calls: usize) {
711 let mut current_max = TEST_EXECUTE_MAX_CONCURRENCY.load(Ordering::SeqCst);
712 while active_calls > current_max {
713 match TEST_EXECUTE_MAX_CONCURRENCY.compare_exchange(
714 current_max,
715 active_calls,
716 Ordering::SeqCst,
717 Ordering::SeqCst,
718 ) {
719 Ok(_) => break,
720 Err(observed) => current_max = observed,
721 }
722 }
723 }
724
725 unsafe extern "C" fn test_execute_with_delay(_input: *const c_char) -> *const c_char {
726 let active_calls = TEST_EXECUTE_ACTIVE_CALLS.fetch_add(1, Ordering::SeqCst) + 1;
727 update_max_concurrency(active_calls);
728 std::thread::sleep(Duration::from_millis(25));
729 TEST_EXECUTE_ACTIVE_CALLS.fetch_sub(1, Ordering::SeqCst);
730
731 CString::new(r#"{"success":true,"output":{},"error":null,"files":[]}"#)
732 .unwrap()
733 .into_raw()
734 }
735
736 #[test]
737 fn test_validate_plugin_structure_missing_metadata() {
738 let (_temp_dir, plugin_dir) = create_test_plugin_dir();
739 let errors = validate_plugin_structure(&plugin_dir).unwrap();
740 assert!(errors.iter().any(|e| e.contains("plugin.json")));
741 }
742
743 #[test]
744 fn test_validate_plugin_structure_missing_library() {
745 let (_temp_dir, plugin_dir) = create_test_plugin_dir();
746 std::fs::write(
747 plugin_dir.join("plugin.json"),
748 r#"{"name": "test", "description": "test", "version": "1.0.0"}"#,
749 )
750 .unwrap();
751
752 let errors = validate_plugin_structure(&plugin_dir).unwrap();
753 assert!(errors.iter().any(|e| e.contains("dynamic library")));
754 }
755
756 #[test]
757 fn test_validate_plugin_structure_complete() {
758 let (_temp_dir, plugin_dir) = create_test_plugin_dir();
759
760 std::fs::write(
762 plugin_dir.join("plugin.json"),
763 r#"{"name": "test", "description": "test", "version": "1.0.0"}"#,
764 )
765 .unwrap();
766
767 let lib_name = if cfg!(target_os = "macos") {
769 "libtest.dylib"
770 } else if cfg!(target_os = "linux") {
771 "libtest.so"
772 } else {
773 "test.dll"
774 };
775 std::fs::write(plugin_dir.join(lib_name), b"fake").unwrap();
776
777 let errors = validate_plugin_structure(&plugin_dir).unwrap();
778 assert!(errors.is_empty());
779 }
780
781 #[test]
782 fn test_library_filename_platform() {
783 let loader = PluginLoader::new();
784 let filename = loader.library_filename("my-plugin");
785
786 #[cfg(target_os = "macos")]
787 assert_eq!(filename, "libmy-plugin.dylib");
788
789 #[cfg(target_os = "linux")]
790 assert_eq!(filename, "libmy-plugin.so");
791
792 #[cfg(target_os = "windows")]
793 assert_eq!(filename, "my-plugin.dll");
794 }
795
796 #[test]
797 fn test_ensure_non_null_c_string_ptr_rejects_null() {
798 let err = ensure_non_null_c_string_ptr(std::ptr::null::<c_char>(), "Test pointer")
799 .expect_err("null pointer should be rejected");
800 assert!(err.to_string().contains("Test pointer returned null pointer"));
801 }
802
803 #[test]
804 fn test_decode_plugin_c_string_frees_plugin_buffer() {
805 TEST_FREE_WAS_CALLED.with(|was_called| was_called.set(false));
806
807 let raw = CString::new("{\"ok\":true}").expect("valid C string").into_raw();
808 let ptr = NonNull::new(raw).expect("non-null raw pointer");
809
810 let decoded = decode_plugin_c_string(ptr, Some(test_free_string), "Plugin result is not valid UTF-8")
811 .expect("valid UTF-8 payload");
812
813 assert_eq!(decoded, "{\"ok\":true}");
814 TEST_FREE_WAS_CALLED.with(|was_called| assert!(was_called.get()));
815 }
816
817 #[test]
818 fn test_decode_plugin_c_string_invalid_utf8_still_frees_buffer() {
819 TEST_FREE_WAS_CALLED.with(|was_called| was_called.set(false));
820
821 let raw = CString::from_vec_with_nul(vec![0xFF, 0x00])
822 .expect("valid nul-terminated C string")
823 .into_raw();
824 let ptr = NonNull::new(raw).expect("non-null raw pointer");
825
826 let err = decode_plugin_c_string(ptr, Some(test_free_string), "Plugin payload is not valid UTF-8")
827 .expect_err("invalid UTF-8 should fail decoding");
828
829 assert!(err.to_string().contains("Plugin payload is not valid UTF-8"));
830 TEST_FREE_WAS_CALLED.with(|was_called| assert!(was_called.get()));
831 }
832
833 #[test]
834 fn test_load_plugin_rejects_dotdot_escape_from_trusted_root() {
835 let temp_dir = TempDir::new().unwrap();
836 let trusted_root = temp_dir.path().join("trusted");
837 let escaped_plugin_dir = temp_dir.path().join("escaped-plugin");
838 std::fs::create_dir(&trusted_root).unwrap();
839 std::fs::create_dir(&escaped_plugin_dir).unwrap();
840 write_plugin_metadata(&escaped_plugin_dir, "escaped");
841 write_fake_library(&escaped_plugin_dir, "escaped");
842
843 let escaped_path = trusted_root.join("..").join("escaped-plugin");
844
845 let mut loader = PluginLoader::new();
846 loader.add_trusted_dir(trusted_root);
847
848 let err = loader
849 .load_plugin(&escaped_path)
850 .expect_err("path traversal should be rejected");
851
852 assert!(err.to_string().contains("trusted directory"));
853 }
854
855 #[cfg(unix)]
856 #[test]
857 fn test_load_plugin_rejects_symlinked_plugin_dir_escape() {
858 use std::os::unix::fs::symlink;
859
860 let temp_dir = TempDir::new().unwrap();
861 let trusted_root = temp_dir.path().join("trusted");
862 let real_plugin_dir = temp_dir.path().join("external-plugin");
863 let symlinked_plugin_dir = trusted_root.join("linked-plugin");
864 std::fs::create_dir(&trusted_root).unwrap();
865 std::fs::create_dir(&real_plugin_dir).unwrap();
866 write_plugin_metadata(&real_plugin_dir, "linked");
867 write_fake_library(&real_plugin_dir, "linked");
868 symlink(&real_plugin_dir, &symlinked_plugin_dir).unwrap();
869
870 let mut loader = PluginLoader::new();
871 loader.add_trusted_dir(trusted_root);
872
873 let err = loader
874 .load_plugin(&symlinked_plugin_dir)
875 .expect_err("symlink escape should be rejected");
876
877 assert!(err.to_string().contains("trusted directory"));
878 }
879
880 #[cfg(unix)]
881 #[test]
882 fn test_load_plugin_rejects_symlinked_library_escape() {
883 use std::os::unix::fs::symlink;
884
885 let temp_dir = TempDir::new().unwrap();
886 let trusted_root = temp_dir.path().join("trusted");
887 let plugin_dir = trusted_root.join("plugin");
888 let external_dir = temp_dir.path().join("external");
889 std::fs::create_dir(&trusted_root).unwrap();
890 std::fs::create_dir(&plugin_dir).unwrap();
891 std::fs::create_dir(&external_dir).unwrap();
892 write_plugin_metadata(&plugin_dir, "escaped-lib");
893
894 let external_library = write_fake_library(&external_dir, "escaped-lib");
895 let linked_library = plugin_dir.join(PluginLoader::new().library_filename("escaped-lib"));
896 symlink(&external_library, &linked_library).unwrap();
897
898 let mut loader = PluginLoader::new();
899 loader.add_trusted_dir(trusted_root);
900
901 let err = loader
902 .load_plugin(&plugin_dir)
903 .expect_err("library symlink escape should be rejected");
904
905 assert!(err.to_string().contains("trusted directory"));
906 }
907
908 #[test]
909 #[serial]
910 fn test_native_plugin_serializes_concurrent_execution() {
911 TEST_EXECUTE_ACTIVE_CALLS.store(0, Ordering::SeqCst);
912 TEST_EXECUTE_MAX_CONCURRENCY.store(0, Ordering::SeqCst);
913
914 let plugin = Arc::new(NativePlugin {
915 _library: current_process_library(),
916 metadata: PluginMetadata {
917 name: "serialized".to_string(),
918 description: "test plugin".to_string(),
919 version: "1.0.0".to_string(),
920 author: None,
921 abi_version: PLUGIN_ABI_VERSION,
922 when_to_use: None,
923 when_not_to_use: None,
924 allowed_tools: None,
925 thread_safe: false,
926 },
927 path: PathBuf::from("/tmp/serialized-plugin"),
928 execute_fn: test_execute_with_delay,
929 free_string_fn: Some(test_free_string),
930 execution_lock: Mutex::new(()),
931 thread_safe: false,
932 });
933 let ctx = PluginContext {
934 input: HashMap::new(),
935 workspace_root: None,
936 config: HashMap::new(),
937 };
938
939 let handles = (0..4)
940 .map(|_| {
941 let plugin = Arc::clone(&plugin);
942 let ctx = ctx.clone();
943 std::thread::spawn(move || plugin.execute(&ctx).expect("plugin execution"))
944 })
945 .collect::<Vec<_>>();
946
947 for handle in handles {
948 let result = handle.join().expect("thread should complete");
949 assert!(result.success);
950 }
951
952 assert_eq!(TEST_EXECUTE_MAX_CONCURRENCY.load(Ordering::SeqCst), 1);
953 }
954
955 #[test]
956 #[serial]
957 fn test_native_plugin_allows_parallel_execution() {
958 TEST_EXECUTE_ACTIVE_CALLS.store(0, Ordering::SeqCst);
959 TEST_EXECUTE_MAX_CONCURRENCY.store(0, Ordering::SeqCst);
960
961 let plugin = Arc::new(NativePlugin {
962 _library: current_process_library(),
963 metadata: PluginMetadata {
964 name: "parallel".to_string(),
965 description: "reentrant test plugin".to_string(),
966 version: "1.0.0".to_string(),
967 author: None,
968 abi_version: PLUGIN_ABI_VERSION,
969 when_to_use: None,
970 when_not_to_use: None,
971 allowed_tools: None,
972 thread_safe: true, },
974 path: PathBuf::from("/tmp/parallel-plugin"),
975 execute_fn: test_execute_with_delay,
976 free_string_fn: Some(test_free_string),
977 execution_lock: Mutex::new(()),
978 thread_safe: true,
979 });
980
981 let ctx = PluginContext {
982 input: HashMap::new(),
983 workspace_root: None,
984 config: HashMap::new(),
985 };
986
987 let num_threads = 4;
988 let handles = (0..num_threads)
989 .map(|_| {
990 let plugin = Arc::clone(&plugin);
991 let ctx = ctx.clone();
992 std::thread::spawn(move || plugin.execute(&ctx).expect("parallel plugin execution"))
993 })
994 .collect::<Vec<_>>();
995
996 for handle in handles {
997 let result = handle.join().expect("thread join");
998 assert!(result.success);
999 }
1000
1001 assert!(TEST_EXECUTE_MAX_CONCURRENCY.load(Ordering::SeqCst) > 1);
1003 }
1004
1005 #[cfg(unix)]
1006 #[test]
1007 fn test_warn_if_insecure_trusted_dir_warns_on_writable_dir() {
1008 use std::os::unix::fs::PermissionsExt;
1009
1010 let temp_dir = TempDir::new().unwrap();
1011 let writable_dir = temp_dir.path().join("writable");
1012 std::fs::create_dir(&writable_dir).unwrap();
1013 std::fs::set_permissions(&writable_dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1014
1015 warn_if_insecure_trusted_dir(&writable_dir);
1016 }
1017
1018 #[cfg(unix)]
1019 #[test]
1020 fn test_warn_if_insecure_trusted_dir_silent_on_restricted_dir() {
1021 use std::os::unix::fs::PermissionsExt;
1022
1023 let temp_dir = TempDir::new().unwrap();
1024 let restricted_dir = temp_dir.path().join("restricted");
1025 std::fs::create_dir(&restricted_dir).unwrap();
1026 std::fs::set_permissions(&restricted_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1027
1028 warn_if_insecure_trusted_dir(&restricted_dir);
1029 }
1030}