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
77/// Plugin ABI v1 entry-point signatures.
78///
79/// `unsafe extern "C" fn` in type position marks that calling through these
80/// pointers is unsafe; neither `rustc`'s `unsafe_code` nor clippy's
81/// `undocumented_unsafe_blocks` fires here, so the contract is stated once,
82/// here. Each pointer is resolved only from a library that
83/// `PluginLoader::load_plugin` has canonicalized and verified as trusted, and is
84/// invoked exclusively through the wrappers in this module (`get_plugin_symbol`,
85/// `NativePlugin::new`, `NativePlugin::execute_ffi`), each of which carries its
86/// own call-site safety justification. Preconditions: the pointer was resolved
87/// from the matching symbol of the loaded library, the plugin's threading
88/// contract (`PluginMetadata::thread_safe`; otherwise serialized by
89/// `NativePlugin::execution_lock`) is honoured, the returned `*const c_char` is
90/// non-null and nul-terminated, and plugin-owned strings are released with the
91/// matching `free_string` symbol.
92type 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/// Plugin execution context passed to plugin functions
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PluginContext {
100    /// Input data for the plugin
101    input: HashMap<String, serde_json::Value>,
102    /// Workspace root path
103    workspace_root: Option<String>,
104    /// Plugin configuration
105    config: HashMap<String, serde_json::Value>,
106}
107
108/// Plugin execution result
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PluginResult {
111    /// Success flag
112    success: bool,
113    /// Output data
114    output: HashMap<String, serde_json::Value>,
115    /// Error message if failed
116    error: Option<String>,
117    /// Optional file references created by the plugin
118    files: Vec<String>,
119}
120
121/// Plugin metadata structure
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PluginMetadata {
124    /// Plugin name (identifier)
125    pub name: String,
126    /// Human-readable description
127    pub description: String,
128    /// Plugin version (semver)
129    version: String,
130    /// Plugin author
131    author: Option<String>,
132    /// Plugin ABI version
133    abi_version: u32,
134    /// When to use this plugin
135    when_to_use: Option<String>,
136    /// When NOT to use this plugin
137    when_not_to_use: Option<String>,
138    /// Allowed tools for this plugin
139    allowed_tools: Option<Vec<String>>,
140    /// Whether the plugin is thread-safe (Sync).
141    /// If false, VT Code serializes all calls to this plugin instance.
142    #[serde(default)]
143    thread_safe: bool,
144}
145
146/// Native plugin trait for type-erased plugin operations
147pub trait NativePluginTrait: Send + Sync + std::fmt::Debug {
148    /// Get plugin metadata
149    fn metadata(&self) -> &PluginMetadata;
150
151    /// Get plugin path
152    fn path(&self) -> &Path;
153
154    /// Execute the plugin with given context
155    fn execute(&self, ctx: &PluginContext) -> Result<PluginResult>;
156}
157
158/// A loaded native plugin
159pub struct NativePlugin {
160    /// Loaded plugin library handle.
161    ///
162    /// # Lifetime invariant
163    ///
164    /// `_library` outlives `self`. `execute_fn` is a raw function pointer copied
165    /// out of this library and is only valid while `_library` remains loaded.
166    /// `NativePlugin` intentionally has no `Drop`: cleanup is delegated to
167    /// `Library`'s own `Drop` (`dlclose`), which runs after `self` is dropped,
168    /// guaranteeing the function pointer is never used after the library unloads.
169    _library: Library,
170    /// Plugin metadata
171    metadata: PluginMetadata,
172    /// Path to the plugin
173    path: PathBuf,
174    /// Plugin execute function pointer
175    execute_fn: PluginExecuteFn,
176    /// Optional plugin-owned deallocator for returned strings
177    free_string_fn: Option<PluginFreeStringFn>,
178    /// Serialize calls if the plugin is not reentrant.
179    execution_lock: Mutex<()>,
180    /// Optimized thread-safety flag from metadata.
181    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    // SAFETY:
195    // 1. `raw_ptr` is guaranteed to be non-null (validated by `ensure_non_null_c_string_ptr`).
196    // 2. We assume the plugin-returned pointer is a valid nul-terminated C string per the plugin ABI.
197    // 3. The reference created by `CStr::from_ptr` is only used to copy the data into a Rust `String`.
198    //    Since we own the only reference during this brief window and copy-then-release,
199    //    we avoid Undefined Behavior related to mutable aliasing that "Unsafe Rust is not C" warns about.
200    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        // SAFETY: The pointer originated from the same plugin instance that provided `free_fn`.
207        // We call it only after we've finished reading the data into our own `String`.
208        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
227/// Resolve a C-ABI symbol from a trusted, already-validated plugin library.
228///
229/// Single wrapper around libloading's symbol lookup; every plugin symbol
230/// passage goes through here. `T` must be a `Copy` function-pointer type. The
231/// returned value is valid only while `library` (`NativePlugin._library`) is loaded.
232fn get_plugin_symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T> {
233    // SAFETY: symbol name and signature are defined by the plugin ABI; we trust
234    // the library (canonicalized/trusted location). `T: Copy` makes the deref safe.
235    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    /// Create a new native plugin from a loaded library
274    fn new(library: Library, path: PathBuf) -> Result<Self> {
275        // Verify ABI version
276        let version_fn = get_plugin_symbol::<PluginVersionFn>(&library, b"vtcode_plugin_version\0")?;
277
278        // SAFETY: function pointer was loaded from a validated ABI symbol.
279        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        // Optional cleanup function for plugin-owned strings.
285        let free_string_fn = get_plugin_symbol::<PluginFreeStringFn>(&library, b"vtcode_plugin_free_string\0").ok();
286
287        // Load metadata
288        let metadata_fn = get_plugin_symbol::<PluginMetadataFn>(&library, b"vtcode_plugin_metadata\0")?;
289
290        // SAFETY: function pointer loaded from the validated ABI symbol.
291        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        // Load execute function
298        let execute_fn = get_plugin_symbol::<PluginExecuteFn>(&library, b"vtcode_plugin_execute\0")?;
299
300        Ok(Self {
301            _library: library,
302            metadata: metadata.clone(), // Clone metadata to store it
303            path,
304            execute_fn,
305            free_string_fn,
306            execution_lock: Mutex::new(()),
307            thread_safe: metadata.thread_safe, // Store the thread_safe flag
308        })
309    }
310
311    /// Execute the plugin with the given context
312    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        // Serialization logic from Antithesis Part 2 (Challenge 2):
317        // If the plugin is not explicitly marked as thread-safe, we must serialize
318        // access through the execution lock to prevent unsynchronized shared access.
319        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            // Reentrant plugins can skip the lock for multi-threaded performance.
328            self.execute_ffi(input_cstr)
329        }
330    }
331
332    /// Internal FFI bridge for plugin execution.
333    ///
334    /// # Safety
335    ///
336    /// This function performs the raw FFI call. Callers must ensure that
337    /// if the plugin is not thread-safe, it is called under a lock.
338    fn execute_ffi(&self, input_cstr: CString) -> Result<PluginResult> {
339        // SAFETY:
340        // 1. The `input_cstr` pointer is valid for the duration of this call.
341        // 2. The `execute_fn` obeys the plugin ABI and expects a nul-terminated string.
342        // 3. VT Code either holds `execution_lock` or the plugin is explicitly reentrant (thread_safe: true).
343        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
367/// Plugin loader responsible for discovering and loading native plugins.
368///
369/// A trusted directory is an application-managed or explicitly user-approved
370/// installation location. Repository-controlled directories must not be added
371/// as trusted directories: loading a dynamic library executes its constructors
372/// before the plugin ABI can be inspected.
373pub struct PluginLoader {
374    /// Trusted plugin directories
375    trusted_dirs: Vec<PathBuf>,
376}
377
378impl PluginLoader {
379    /// Create a new plugin loader
380    pub fn new() -> Self {
381        Self { trusted_dirs: Vec::new() }
382    }
383
384    /// Add an explicitly trusted plugin directory.
385    ///
386    /// This method only enforces path containment after the directory is
387    /// trusted; it does not verify provenance or obtain user consent. Callers
388    /// must not pass paths selected from repository contents.
389    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    /// Get trusted plugin directories
399    pub fn trusted_dirs(&self) -> &[PathBuf] {
400        &self.trusted_dirs
401    }
402
403    /// Load a plugin from a specific path.
404    ///
405    /// # Security
406    ///
407    /// `Library::new` can execute arbitrary native initialization code before
408    /// this function returns. The caller must establish explicit user consent
409    /// and provenance before calling this method; plugin metadata is not an
410    /// authorization mechanism.
411    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        // Find the dynamic library file
417        let lib_path = self.find_library_file(&plugin_path)?;
418        let lib_path = self.ensure_trusted_path(&lib_path, "Plugin library path")?;
419
420        // SAFETY: Loading a dynamic library is inherently unsafe because:
421        // 1. The library code executes with full privileges.
422        // 2. `lib_path` is an existing canonical path under a trusted root, so
423        //    path traversal and symlink escapes were rejected before this point.
424        // 3. The library could have bugs or malicious intent.
425        //
426        // Risk Mitigation:
427        // - Only load from canonicalized trusted directories.
428        // - Verify ABI version compatibility in `NativePlugin::new`.
429        // - Validate metadata format.
430        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    /// Discover and load all plugins in trusted directories.
444    ///
445    /// Loading occurs during discovery, so callers must establish provenance
446    /// and explicit user consent before invoking this method.
447    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    /// Check if a path is in a trusted directory
468    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    /// Find the dynamic library file in a plugin directory
482    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        // Look for plugin.json to confirm this is a plugin directory
488        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        // Look for dynamic library with platform-specific naming
494        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        // Try alternative naming patterns
502        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    /// Get expected library name from plugin metadata
514    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    /// Get alternative library names to try
527    fn get_alternative_library_names(&self, base_name: &str) -> Vec<String> {
528        let mut alternatives = Vec::new();
529
530        // Try with and without "lib" prefix
531        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        // Try different extensions
538        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    /// Discover plugins in a directory
559    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    /// Convert a plugin name to a library filename for the current platform
580    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
606/// Validate a plugin directory structure
607pub fn validate_plugin_structure(plugin_dir: &Path) -> Result<Vec<String>> {
608    let mut errors = Vec::new();
609
610    // Check for plugin.json
611    if !plugin_dir.join("plugin.json").exists() {
612        errors.push("Missing plugin.json".to_string());
613    }
614
615    // Check for dynamic library
616    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    // Validate plugin.json structure
631    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            // Safety: tests pass pointers produced by `CString::into_raw` in this module.
671            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        // Create valid plugin.json
761        std::fs::write(
762            plugin_dir.join("plugin.json"),
763            r#"{"name": "test", "description": "test", "version": "1.0.0"}"#,
764        )
765        .unwrap();
766
767        // Create fake library file
768        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, // OPT-IN to parallel execution
973            },
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        // With thread_safe: true, we expect max concurrency > 1
1002        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}