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