Skip to main content

vtcode_skills/
native_plugin.rs

1#![allow(
2    unsafe_code,
3    reason = "Intentional compatibility, platform, or test-only suppression."
4)]
5
6//! Native Plugin System for VT Code Skills
7//!
8//! This module provides support for loading native code plugins as skills using libloading.
9//! Native plugins offer high-performance, pre-compiled skill logic that can be discovered
10//! and loaded dynamically at runtime.
11//!
12//! # Safety
13//!
14//! Loading native code plugins requires careful security considerations:
15//! - Plugins are loaded from canonicalized, explicitly trusted locations only
16//! - Callers must obtain user approval before loading an untrusted plugin
17//! - Plugin signatures can be verified (future enhancement)
18//! - This module does not sandbox plugin execution; native code runs with process privileges
19//! - VT Code serializes plugin FFI calls for ABI v1
20//! - All plugin operations go through VT Code's tool system
21//!
22//! The `unsafe` blocks in this module are necessary for FFI:
23//! - Loading dynamic libraries (`Library::new`)
24//! - Calling plugin functions (`version_fn`, `metadata_fn`, `execute_fn`,
25//!   `free_string_fn`)
26//! - Converting C strings (`CStr::from_ptr`)
27//!
28//! Symbol resolution is centralized in the safe `get_plugin_symbol` wrapper;
29//! the remaining raw calls cannot be made safe without losing the ability to
30//! load arbitrary plugin code. Each call site carries its own `// SAFETY:`
31//! comment documenting the specific invariant that justifies the unsafe
32//! operation.
33//!
34//! # Plugin Structure
35//!
36//! A native plugin skill consists of:
37//! - `plugin.json` - Metadata (name, description, version, author)
38//! - `lib<name>.dylib` (macOS) or `lib<name>.so` (Linux) or `<name>.dll` (Windows)
39//! - Optional: `README.md`, `scripts/`, `templates/`
40//!
41//! # Plugin ABI
42//!
43//! Plugins must export the following C-compatible symbols:
44//! - `vtcode_plugin_version()` - Returns ABI version
45//! - `vtcode_plugin_metadata()` - Returns plugin metadata JSON
46//! - `vtcode_plugin_execute()` - Main execution entry point
47//!
48//! # Example
49//!
50//! ```rust,ignore
51//! use vtcode_core::skills::native_plugin::{NativePlugin, PluginLoader};
52//!
53//! let mut loader = PluginLoader::new();
54//! let plugin = loader.load_plugin("/path/to/plugin").unwrap();
55//! let result = plugin.execute(&input).unwrap();
56//! ```
57
58use 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
71/// Current plugin ABI version
72/// Increment this when breaking changes are made to the plugin interface
73pub 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/// Plugin execution context passed to plugin functions
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PluginContext {
85    /// Input data for the plugin
86    input: HashMap<String, serde_json::Value>,
87    /// Workspace root path
88    workspace_root: Option<String>,
89    /// Plugin configuration
90    config: HashMap<String, serde_json::Value>,
91}
92
93/// Plugin execution result
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct PluginResult {
96    /// Success flag
97    success: bool,
98    /// Output data
99    output: HashMap<String, serde_json::Value>,
100    /// Error message if failed
101    error: Option<String>,
102    /// Optional file references created by the plugin
103    files: Vec<String>,
104}
105
106/// Plugin metadata structure
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PluginMetadata {
109    /// Plugin name (identifier)
110    pub name: String,
111    /// Human-readable description
112    pub description: String,
113    /// Plugin version (semver)
114    version: String,
115    /// Plugin author
116    author: Option<String>,
117    /// Plugin ABI version
118    abi_version: u32,
119    /// When to use this plugin
120    when_to_use: Option<String>,
121    /// When NOT to use this plugin
122    when_not_to_use: Option<String>,
123    /// Allowed tools for this plugin
124    allowed_tools: Option<Vec<String>>,
125    /// Whether the plugin is thread-safe (Sync).
126    /// If false, VT Code serializes all calls to this plugin instance.
127    #[serde(default)]
128    thread_safe: bool,
129}
130
131/// Native plugin trait for type-erased plugin operations
132pub trait NativePluginTrait: Send + Sync + std::fmt::Debug {
133    /// Get plugin metadata
134    fn metadata(&self) -> &PluginMetadata;
135
136    /// Get plugin path
137    fn path(&self) -> &Path;
138
139    /// Execute the plugin with given context
140    fn execute(&self, ctx: &PluginContext) -> Result<PluginResult>;
141}
142
143/// A loaded native plugin
144pub struct NativePlugin {
145    /// Loaded plugin library handle.
146    ///
147    /// # Lifetime invariant
148    ///
149    /// `_library` outlives `self`. `execute_fn` is a raw function pointer copied
150    /// out of this library and is only valid while `_library` remains loaded.
151    /// `NativePlugin` intentionally has no `Drop`: cleanup is delegated to
152    /// `Library`'s own `Drop` (`dlclose`), which runs after `self` is dropped,
153    /// guaranteeing the function pointer is never used after the library unloads.
154    _library: Library,
155    /// Plugin metadata
156    metadata: PluginMetadata,
157    /// Path to the plugin
158    path: PathBuf,
159    /// Plugin execute function pointer
160    execute_fn: PluginExecuteFn,
161    /// Optional plugin-owned deallocator for returned strings
162    free_string_fn: Option<PluginFreeStringFn>,
163    /// Serialize calls if the plugin is not reentrant.
164    execution_lock: Mutex<()>,
165    /// Optimized thread-safety flag from metadata.
166    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    // SAFETY:
180    // 1. `raw_ptr` is guaranteed to be non-null (validated by `ensure_non_null_c_string_ptr`).
181    // 2. We assume the plugin-returned pointer is a valid nul-terminated C string per the plugin ABI.
182    // 3. The reference created by `CStr::from_ptr` is only used to copy the data into a Rust `String`.
183    //    Since we own the only reference during this brief window and copy-then-release,
184    //    we avoid Undefined Behavior related to mutable aliasing that "Unsafe Rust is not C" warns about.
185    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        // SAFETY: The pointer originated from the same plugin instance that provided `free_fn`.
192        // We call it only after we've finished reading the data into our own `String`.
193        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
212/// Resolve a C-ABI symbol from a trusted, already-validated plugin library.
213///
214/// Single wrapper around libloading's symbol lookup; every plugin symbol
215/// passage goes through here. `T` must be a `Copy` function-pointer type. The
216/// returned value is valid only while `library` (`NativePlugin._library`) is loaded.
217fn get_plugin_symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T> {
218    // SAFETY: symbol name and signature are defined by the plugin ABI; we trust
219    // the library (canonicalized/trusted location). `T: Copy` makes the deref safe.
220    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    /// Create a new native plugin from a loaded library
259    fn new(library: Library, path: PathBuf) -> Result<Self> {
260        // Verify ABI version
261        let version_fn = get_plugin_symbol::<PluginVersionFn>(&library, b"vtcode_plugin_version\0")?;
262
263        // SAFETY: function pointer was loaded from a validated ABI symbol.
264        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        // Optional cleanup function for plugin-owned strings.
270        let free_string_fn = get_plugin_symbol::<PluginFreeStringFn>(&library, b"vtcode_plugin_free_string\0").ok();
271
272        // Load metadata
273        let metadata_fn = get_plugin_symbol::<PluginMetadataFn>(&library, b"vtcode_plugin_metadata\0")?;
274
275        // SAFETY: function pointer loaded from the validated ABI symbol.
276        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        // Load execute function
283        let execute_fn = get_plugin_symbol::<PluginExecuteFn>(&library, b"vtcode_plugin_execute\0")?;
284
285        Ok(Self {
286            _library: library,
287            metadata: metadata.clone(), // Clone metadata to store it
288            path,
289            execute_fn,
290            free_string_fn,
291            execution_lock: Mutex::new(()),
292            thread_safe: metadata.thread_safe, // Store the thread_safe flag
293        })
294    }
295
296    /// Execute the plugin with the given context
297    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        // Serialization logic from Antithesis Part 2 (Challenge 2):
302        // If the plugin is not explicitly marked as thread-safe, we must serialize
303        // access through the execution lock to prevent unsynchronized shared access.
304        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            // Reentrant plugins can skip the lock for multi-threaded performance.
313            self.execute_ffi(input_cstr)
314        }
315    }
316
317    /// Internal FFI bridge for plugin execution.
318    ///
319    /// # Safety
320    ///
321    /// This function performs the raw FFI call. Callers must ensure that
322    /// if the plugin is not thread-safe, it is called under a lock.
323    fn execute_ffi(&self, input_cstr: CString) -> Result<PluginResult> {
324        // SAFETY:
325        // 1. The `input_cstr` pointer is valid for the duration of this call.
326        // 2. The `execute_fn` obeys the plugin ABI and expects a nul-terminated string.
327        // 3. VT Code either holds `execution_lock` or the plugin is explicitly reentrant (thread_safe: true).
328        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
352/// Plugin loader responsible for discovering and loading native plugins.
353///
354/// A trusted directory is an application-managed or explicitly user-approved
355/// installation location. Repository-controlled directories must not be added
356/// as trusted directories: loading a dynamic library executes its constructors
357/// before the plugin ABI can be inspected.
358pub struct PluginLoader {
359    /// Trusted plugin directories
360    trusted_dirs: Vec<PathBuf>,
361}
362
363impl PluginLoader {
364    /// Create a new plugin loader
365    pub fn new() -> Self {
366        Self { trusted_dirs: Vec::new() }
367    }
368
369    /// Add an explicitly trusted plugin directory.
370    ///
371    /// This method only enforces path containment after the directory is
372    /// trusted; it does not verify provenance or obtain user consent. Callers
373    /// must not pass paths selected from repository contents.
374    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    /// Get trusted plugin directories
384    pub fn trusted_dirs(&self) -> &[PathBuf] {
385        &self.trusted_dirs
386    }
387
388    /// Load a plugin from a specific path.
389    ///
390    /// # Security
391    ///
392    /// `Library::new` can execute arbitrary native initialization code before
393    /// this function returns. The caller must establish explicit user consent
394    /// and provenance before calling this method; plugin metadata is not an
395    /// authorization mechanism.
396    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        // Find the dynamic library file
402        let lib_path = self.find_library_file(&plugin_path)?;
403        let lib_path = self.ensure_trusted_path(&lib_path, "Plugin library path")?;
404
405        // SAFETY: Loading a dynamic library is inherently unsafe because:
406        // 1. The library code executes with full privileges.
407        // 2. `lib_path` is an existing canonical path under a trusted root, so
408        //    path traversal and symlink escapes were rejected before this point.
409        // 3. The library could have bugs or malicious intent.
410        //
411        // Risk Mitigation:
412        // - Only load from canonicalized trusted directories.
413        // - Verify ABI version compatibility in `NativePlugin::new`.
414        // - Validate metadata format.
415        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    /// Discover and load all plugins in trusted directories.
429    ///
430    /// Loading occurs during discovery, so callers must establish provenance
431    /// and explicit user consent before invoking this method.
432    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    /// Check if a path is in a trusted directory
453    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    /// Find the dynamic library file in a plugin directory
467    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        // Look for plugin.json to confirm this is a plugin directory
473        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        // Look for dynamic library with platform-specific naming
479        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        // Try alternative naming patterns
487        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    /// Get expected library name from plugin metadata
499    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    /// Get alternative library names to try
512    fn get_alternative_library_names(&self, base_name: &str) -> Vec<String> {
513        let mut alternatives = Vec::new();
514
515        // Try with and without "lib" prefix
516        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        // Try different extensions
523        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    /// Discover plugins in a directory
544    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    /// Convert a plugin name to a library filename for the current platform
565    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
591/// Validate a plugin directory structure
592pub fn validate_plugin_structure(plugin_dir: &Path) -> Result<Vec<String>> {
593    let mut errors = Vec::new();
594
595    // Check for plugin.json
596    if !plugin_dir.join("plugin.json").exists() {
597        errors.push("Missing plugin.json".to_string());
598    }
599
600    // Check for dynamic library
601    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    // Validate plugin.json structure
616    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            // Safety: tests pass pointers produced by `CString::into_raw` in this module.
656            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        // Create valid plugin.json
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        // Create fake library file
753        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, // OPT-IN to parallel execution
958            },
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        // With thread_safe: true, we expect max concurrency > 1
987        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}