Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
432 lines
12 KiB
Rust
432 lines
12 KiB
Rust
//! Plugin loader for discovering and loading plugins from the filesystem
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Result, anyhow};
|
|
use pinakes_plugin_api::PluginManifest;
|
|
use tracing::{debug, info, warn};
|
|
use walkdir::WalkDir;
|
|
|
|
/// Plugin loader handles discovery and loading of plugins from directories
|
|
pub struct PluginLoader {
|
|
/// Directories to search for plugins
|
|
plugin_dirs: Vec<PathBuf>,
|
|
}
|
|
|
|
impl PluginLoader {
|
|
/// Create a new plugin loader
|
|
#[must_use]
|
|
pub const fn new(plugin_dirs: Vec<PathBuf>) -> Self {
|
|
Self { plugin_dirs }
|
|
}
|
|
|
|
/// Discover all plugins in configured directories
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if a plugin directory cannot be searched.
|
|
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
|
|
let mut manifests = Vec::new();
|
|
|
|
for dir in &self.plugin_dirs {
|
|
if !dir.exists() {
|
|
warn!("Plugin directory does not exist: {:?}", dir);
|
|
continue;
|
|
}
|
|
|
|
info!("Discovering plugins in: {:?}", dir);
|
|
|
|
let found = Self::discover_in_directory(dir);
|
|
info!("Found {} plugins in {:?}", found.len(), dir);
|
|
manifests.extend(found);
|
|
}
|
|
|
|
Ok(manifests)
|
|
}
|
|
|
|
/// Discover plugins in a specific directory
|
|
fn discover_in_directory(dir: &Path) -> Vec<PluginManifest> {
|
|
let mut manifests = Vec::new();
|
|
|
|
// Walk the directory looking for plugin.toml files
|
|
for entry in WalkDir::new(dir)
|
|
.max_depth(3) // Don't go too deep
|
|
.follow_links(false)
|
|
{
|
|
let entry = match entry {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
warn!("Error reading directory entry: {}", e);
|
|
continue;
|
|
},
|
|
};
|
|
|
|
let path = entry.path();
|
|
|
|
// Look for plugin.toml files
|
|
if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) {
|
|
debug!("Found plugin manifest: {:?}", path);
|
|
|
|
match PluginManifest::from_file(path) {
|
|
Ok(manifest) => {
|
|
info!("Loaded manifest for plugin: {}", manifest.plugin.name);
|
|
manifests.push(manifest);
|
|
},
|
|
Err(e) => {
|
|
warn!("Failed to load manifest from {:?}: {}", path, e);
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
manifests
|
|
}
|
|
|
|
/// Resolve the WASM binary path from a manifest
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the WASM binary is not found or its path escapes the
|
|
/// plugin directory.
|
|
pub fn resolve_wasm_path(
|
|
&self,
|
|
manifest: &PluginManifest,
|
|
) -> Result<PathBuf> {
|
|
// The WASM path in the manifest is relative to the manifest file
|
|
// We need to search for it in the plugin directories
|
|
|
|
for dir in &self.plugin_dirs {
|
|
// Look for a directory matching the plugin name
|
|
let plugin_dir = dir.join(&manifest.plugin.name);
|
|
if !plugin_dir.exists() {
|
|
continue;
|
|
}
|
|
|
|
// Check for plugin.toml in this directory
|
|
let manifest_path = plugin_dir.join("plugin.toml");
|
|
if !manifest_path.exists() {
|
|
continue;
|
|
}
|
|
|
|
// Resolve WASM path relative to this directory
|
|
let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm);
|
|
if wasm_path.exists() {
|
|
// Verify the resolved path is within the plugin directory (prevent path
|
|
// traversal)
|
|
let canonical_wasm = wasm_path
|
|
.canonicalize()
|
|
.map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?;
|
|
let canonical_plugin_dir = plugin_dir
|
|
.canonicalize()
|
|
.map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?;
|
|
if !canonical_wasm.starts_with(&canonical_plugin_dir) {
|
|
return Err(anyhow!(
|
|
"WASM binary path escapes plugin directory: {}",
|
|
wasm_path.display()
|
|
));
|
|
}
|
|
return Ok(canonical_wasm);
|
|
}
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"WASM binary not found for plugin: {}",
|
|
manifest.plugin.name
|
|
))
|
|
}
|
|
|
|
/// Download a plugin from a URL
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the URL is not HTTPS, no plugin directories are
|
|
/// configured, the download fails, the archive is too large, or extraction
|
|
/// fails.
|
|
pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> {
|
|
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
|
|
|
// Only allow HTTPS downloads
|
|
if !url.starts_with("https://") {
|
|
return Err(anyhow!(
|
|
"Only HTTPS URLs are allowed for plugin downloads: {url}"
|
|
));
|
|
}
|
|
|
|
let dest_dir = self
|
|
.plugin_dirs
|
|
.first()
|
|
.ok_or_else(|| anyhow!("No plugin directories configured"))?;
|
|
|
|
std::fs::create_dir_all(dest_dir)?;
|
|
|
|
// Download the archive with timeout and size limits
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_mins(5))
|
|
.build()
|
|
.map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?;
|
|
|
|
let response = client
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| anyhow!("Failed to download plugin: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(anyhow!(
|
|
"Plugin download failed with status: {}",
|
|
response.status()
|
|
));
|
|
}
|
|
|
|
// Check content-length header before downloading
|
|
if let Some(content_length) = response.content_length()
|
|
&& content_length > MAX_PLUGIN_SIZE
|
|
{
|
|
return Err(anyhow!(
|
|
"Plugin archive too large: {content_length} bytes (max \
|
|
{MAX_PLUGIN_SIZE} bytes)"
|
|
));
|
|
}
|
|
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| anyhow!("Failed to read plugin response: {e}"))?;
|
|
|
|
// Check actual size after download
|
|
if bytes.len() as u64 > MAX_PLUGIN_SIZE {
|
|
return Err(anyhow!(
|
|
"Plugin archive too large: {} bytes (max {} bytes)",
|
|
bytes.len(),
|
|
MAX_PLUGIN_SIZE
|
|
));
|
|
}
|
|
|
|
// Write archive to a unique temp file
|
|
let temp_archive =
|
|
dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7()));
|
|
std::fs::write(&temp_archive, &bytes)?;
|
|
|
|
// Extract using tar with -C to target directory
|
|
let canonical_dest = dest_dir
|
|
.canonicalize()
|
|
.map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?;
|
|
let output = std::process::Command::new("tar")
|
|
.args([
|
|
"xzf",
|
|
&temp_archive.to_string_lossy(),
|
|
"-C",
|
|
&canonical_dest.to_string_lossy(),
|
|
])
|
|
.output()
|
|
.map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?;
|
|
|
|
// Clean up the archive
|
|
let _ = std::fs::remove_file(&temp_archive);
|
|
|
|
if !output.status.success() {
|
|
return Err(anyhow!(
|
|
"Failed to extract plugin archive: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
|
|
// Validate that all extracted files are within dest_dir
|
|
for entry in WalkDir::new(&canonical_dest).follow_links(false) {
|
|
let entry = entry?;
|
|
let entry_canonical = entry.path().canonicalize()?;
|
|
if !entry_canonical.starts_with(&canonical_dest) {
|
|
return Err(anyhow!(
|
|
"Extracted file escapes destination directory: {}",
|
|
entry.path().display()
|
|
));
|
|
}
|
|
}
|
|
|
|
// Find the extracted plugin directory by looking for plugin.toml
|
|
for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) {
|
|
let entry = entry?;
|
|
if entry.file_name() == "plugin.toml" {
|
|
let plugin_dir = entry
|
|
.path()
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("Invalid plugin.toml location"))?;
|
|
|
|
// Validate the manifest
|
|
let manifest = PluginManifest::from_file(entry.path())?;
|
|
info!("Downloaded and extracted plugin: {}", manifest.plugin.name);
|
|
|
|
return Ok(plugin_dir.to_path_buf());
|
|
}
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"No plugin.toml found after extracting archive from: {url}"
|
|
))
|
|
}
|
|
|
|
/// Validate a plugin package
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the path does not exist, is missing `plugin.toml`,
|
|
/// the WASM binary is not found, or the WASM file is invalid.
|
|
pub fn validate_plugin_package(&self, path: &Path) -> Result<()> {
|
|
// Check that the path exists
|
|
if !path.exists() {
|
|
return Err(anyhow!("Plugin path does not exist: {}", path.display()));
|
|
}
|
|
|
|
// Check for plugin.toml
|
|
let manifest_path = path.join("plugin.toml");
|
|
if !manifest_path.exists() {
|
|
return Err(anyhow!("Missing plugin.toml in {}", path.display()));
|
|
}
|
|
|
|
// Parse and validate manifest
|
|
let manifest = PluginManifest::from_file(&manifest_path)?;
|
|
|
|
// Check that WASM binary exists
|
|
let wasm_path = path.join(&manifest.plugin.binary.wasm);
|
|
if !wasm_path.exists() {
|
|
return Err(anyhow!(
|
|
"WASM binary not found: {}",
|
|
manifest.plugin.binary.wasm
|
|
));
|
|
}
|
|
|
|
// Verify the WASM path is within the plugin directory (prevent path
|
|
// traversal)
|
|
let canonical_wasm = wasm_path.canonicalize()?;
|
|
let canonical_path = path.canonicalize()?;
|
|
if !canonical_wasm.starts_with(&canonical_path) {
|
|
return Err(anyhow!(
|
|
"WASM binary path escapes plugin directory: {}",
|
|
wasm_path.display()
|
|
));
|
|
}
|
|
|
|
// Validate WASM file
|
|
let wasm_bytes = std::fs::read(&wasm_path)?;
|
|
if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" {
|
|
return Err(anyhow!("Invalid WASM file: {}", wasm_path.display()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get plugin directory path for a given plugin name
|
|
#[must_use]
|
|
pub fn get_plugin_dir(&self, plugin_name: &str) -> Option<PathBuf> {
|
|
for dir in &self.plugin_dirs {
|
|
let plugin_dir = dir.join(plugin_name);
|
|
if plugin_dir.exists() {
|
|
return Some(plugin_dir);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_discover_plugins_empty() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
|
|
|
let manifests = loader.discover_plugins().unwrap();
|
|
assert_eq!(manifests.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_discover_plugins_with_manifest() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let plugin_dir = temp_dir.path().join("test-plugin");
|
|
std::fs::create_dir(&plugin_dir).unwrap();
|
|
|
|
// Create a valid manifest
|
|
let manifest_content = r#"
|
|
[plugin]
|
|
name = "test-plugin"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
|
|
|
// Create dummy WASM file
|
|
std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00")
|
|
.unwrap();
|
|
|
|
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
|
let manifests = loader.discover_plugins().unwrap();
|
|
|
|
assert_eq!(manifests.len(), 1);
|
|
assert_eq!(manifests[0].plugin.name, "test-plugin");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_plugin_package() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let plugin_dir = temp_dir.path().join("test-plugin");
|
|
std::fs::create_dir(&plugin_dir).unwrap();
|
|
|
|
// Create a valid manifest
|
|
let manifest_content = r#"
|
|
[plugin]
|
|
name = "test-plugin"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
|
|
|
let loader = PluginLoader::new(vec![]);
|
|
|
|
// Should fail without WASM file
|
|
assert!(loader.validate_plugin_package(&plugin_dir).is_err());
|
|
|
|
// Create valid WASM file (magic number only)
|
|
std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00")
|
|
.unwrap();
|
|
|
|
// Should succeed now
|
|
assert!(loader.validate_plugin_package(&plugin_dir).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_invalid_wasm() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let plugin_dir = temp_dir.path().join("test-plugin");
|
|
std::fs::create_dir(&plugin_dir).unwrap();
|
|
|
|
let manifest_content = r#"
|
|
[plugin]
|
|
name = "test-plugin"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
|
|
|
// Create invalid WASM file
|
|
std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap();
|
|
|
|
let loader = PluginLoader::new(vec![]);
|
|
assert!(loader.validate_plugin_package(&plugin_dir).is_err());
|
|
}
|
|
}
|