treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
parent
764aafa88d
commit
3ccddce7fd
178 changed files with 58342 additions and 54241 deletions
|
|
@ -1,334 +1,345 @@
|
|||
//! 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 std::path::{Path, PathBuf};
|
||||
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>,
|
||||
/// Directories to search for plugins
|
||||
plugin_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl PluginLoader {
|
||||
/// Create a new plugin loader
|
||||
pub fn new(plugin_dirs: Vec<PathBuf>) -> Self {
|
||||
Self { plugin_dirs }
|
||||
/// Create a new plugin loader
|
||||
pub fn new(plugin_dirs: Vec<PathBuf>) -> Self {
|
||||
Self { plugin_dirs }
|
||||
}
|
||||
|
||||
/// Discover all plugins in configured directories
|
||||
pub async 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);
|
||||
|
||||
match self.discover_in_directory(dir).await {
|
||||
Ok(found) => {
|
||||
info!("Found {} plugins in {:?}", found.len(), dir);
|
||||
manifests.extend(found);
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Error discovering plugins in {:?}: {}", dir, e);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover all plugins in configured directories
|
||||
pub async fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
|
||||
let mut manifests = Vec::new();
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
for dir in &self.plugin_dirs {
|
||||
if !dir.exists() {
|
||||
warn!("Plugin directory does not exist: {:?}", dir);
|
||||
continue;
|
||||
}
|
||||
/// Discover plugins in a specific directory
|
||||
async fn discover_in_directory(
|
||||
&self,
|
||||
dir: &Path,
|
||||
) -> Result<Vec<PluginManifest>> {
|
||||
let mut manifests = Vec::new();
|
||||
|
||||
info!("Discovering plugins in: {:?}", dir);
|
||||
|
||||
match self.discover_in_directory(dir).await {
|
||||
Ok(found) => {
|
||||
info!("Found {} plugins in {:?}", found.len(), dir);
|
||||
manifests.extend(found);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error discovering plugins in {:?}: {}", dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
/// Discover plugins in a specific directory
|
||||
async fn discover_in_directory(&self, dir: &Path) -> Result<Vec<PluginManifest>> {
|
||||
let mut manifests = Vec::new();
|
||||
|
||||
// Walk the directory looking for plugin.toml files
|
||||
for entry in WalkDir::new(dir)
|
||||
// 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 entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Error reading directory entry: {}", e);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
},
|
||||
}
|
||||
|
||||
Ok(manifests)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the WASM binary path from a manifest
|
||||
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
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
/// Resolve the WASM binary path from a manifest
|
||||
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
|
||||
|
||||
// Check for plugin.toml in this directory
|
||||
let manifest_path = plugin_dir.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
));
|
||||
}
|
||||
return Ok(canonical_wasm);
|
||||
}
|
||||
// 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
|
||||
));
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"WASM binary not found for plugin: {}",
|
||||
manifest.plugin.name
|
||||
))
|
||||
return Ok(canonical_wasm);
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a plugin from a URL
|
||||
pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> {
|
||||
// Only allow HTTPS downloads
|
||||
if !url.starts_with("https://") {
|
||||
return Err(anyhow!(
|
||||
"Only HTTPS URLs are allowed for plugin downloads: {}",
|
||||
url
|
||||
));
|
||||
}
|
||||
Err(anyhow!(
|
||||
"WASM binary not found for plugin: {}",
|
||||
manifest.plugin.name
|
||||
))
|
||||
}
|
||||
|
||||
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_secs(300))
|
||||
.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
|
||||
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
if let Some(content_length) = response.content_length()
|
||||
&& content_length > MAX_PLUGIN_SIZE
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Plugin archive too large: {} bytes (max {} bytes)",
|
||||
content_length,
|
||||
MAX_PLUGIN_SIZE
|
||||
));
|
||||
}
|
||||
|
||||
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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
))
|
||||
/// Download a plugin from a URL
|
||||
pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> {
|
||||
// Only allow HTTPS downloads
|
||||
if !url.starts_with("https://") {
|
||||
return Err(anyhow!(
|
||||
"Only HTTPS URLs are allowed for plugin downloads: {}",
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
/// Validate a plugin package
|
||||
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));
|
||||
}
|
||||
let dest_dir = self
|
||||
.plugin_dirs
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("No plugin directories configured"))?;
|
||||
|
||||
// Check for plugin.toml
|
||||
let manifest_path = path.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
return Err(anyhow!("Missing plugin.toml in {:?}", path));
|
||||
}
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
|
||||
// Parse and validate manifest
|
||||
let manifest = PluginManifest::from_file(&manifest_path)?;
|
||||
// Download the archive with timeout and size limits
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
// 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
|
||||
));
|
||||
}
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to download plugin: {}", e))?;
|
||||
|
||||
// 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
|
||||
));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Plugin download failed with status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
/// Get plugin directory path for a given plugin name
|
||||
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
|
||||
// Check content-length header before downloading
|
||||
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
if let Some(content_length) = response.content_length()
|
||||
&& content_length > MAX_PLUGIN_SIZE
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Plugin archive too large: {} bytes (max {} bytes)",
|
||||
content_length,
|
||||
MAX_PLUGIN_SIZE
|
||||
));
|
||||
}
|
||||
|
||||
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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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));
|
||||
}
|
||||
|
||||
// Check for plugin.toml
|
||||
let manifest_path = path.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
return Err(anyhow!("Missing plugin.toml in {:?}", path));
|
||||
}
|
||||
|
||||
// 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
|
||||
));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get plugin directory path for a given plugin name
|
||||
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 super::*;
|
||||
use tempfile::TempDir;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_plugins_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||
use super::*;
|
||||
|
||||
let manifests = loader.discover_plugins().await.unwrap();
|
||||
assert_eq!(manifests.len(), 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_discover_plugins_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||
|
||||
#[tokio::test]
|
||||
async 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();
|
||||
let manifests = loader.discover_plugins().await.unwrap();
|
||||
assert_eq!(manifests.len(), 0);
|
||||
}
|
||||
|
||||
// Create a valid manifest
|
||||
let manifest_content = r#"
|
||||
#[tokio::test]
|
||||
async 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"
|
||||
|
|
@ -338,26 +349,27 @@ kind = ["media_type"]
|
|||
[plugin.binary]
|
||||
wasm = "plugin.wasm"
|
||||
"#;
|
||||
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
||||
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();
|
||||
// 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().await.unwrap();
|
||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||
let manifests = loader.discover_plugins().await.unwrap();
|
||||
|
||||
assert_eq!(manifests.len(), 1);
|
||||
assert_eq!(manifests[0].plugin.name, "test-plugin");
|
||||
}
|
||||
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();
|
||||
#[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#"
|
||||
// Create a valid manifest
|
||||
let manifest_content = r#"
|
||||
[plugin]
|
||||
name = "test-plugin"
|
||||
version = "1.0.0"
|
||||
|
|
@ -367,27 +379,28 @@ kind = ["media_type"]
|
|||
[plugin.binary]
|
||||
wasm = "plugin.wasm"
|
||||
"#;
|
||||
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
||||
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
||||
|
||||
let loader = PluginLoader::new(vec![]);
|
||||
let loader = PluginLoader::new(vec![]);
|
||||
|
||||
// Should fail without WASM file
|
||||
assert!(loader.validate_plugin_package(&plugin_dir).is_err());
|
||||
// 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();
|
||||
// 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());
|
||||
}
|
||||
// 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();
|
||||
#[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#"
|
||||
let manifest_content = r#"
|
||||
[plugin]
|
||||
name = "test-plugin"
|
||||
version = "1.0.0"
|
||||
|
|
@ -397,12 +410,12 @@ kind = ["media_type"]
|
|||
[plugin.binary]
|
||||
wasm = "plugin.wasm"
|
||||
"#;
|
||||
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
||||
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();
|
||||
// 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());
|
||||
}
|
||||
let loader = PluginLoader::new(vec![]);
|
||||
assert!(loader.validate_plugin_package(&plugin_dir).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! Plugin system for Pinakes
|
||||
//!
|
||||
//! This module provides a comprehensive plugin architecture that allows extending
|
||||
//! Pinakes with custom media types, metadata extractors, search backends, and more.
|
||||
//! This module provides a comprehensive plugin architecture that allows
|
||||
//! extending Pinakes with custom media types, metadata extractors, search
|
||||
//! backends, and more.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
|
|
@ -10,10 +11,10 @@
|
|||
//! - Hot-reload support for development
|
||||
//! - Automatic plugin discovery from configured directories
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use pinakes_plugin_api::{PluginContext, PluginMetadata};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
|
|
@ -29,391 +30,419 @@ pub use security::CapabilityEnforcer;
|
|||
|
||||
/// Plugin manager coordinates plugin lifecycle and operations
|
||||
pub struct PluginManager {
|
||||
/// Plugin registry
|
||||
registry: Arc<RwLock<PluginRegistry>>,
|
||||
/// Plugin registry
|
||||
registry: Arc<RwLock<PluginRegistry>>,
|
||||
|
||||
/// WASM runtime for executing plugins
|
||||
runtime: Arc<WasmRuntime>,
|
||||
/// WASM runtime for executing plugins
|
||||
runtime: Arc<WasmRuntime>,
|
||||
|
||||
/// Plugin loader for discovery and loading
|
||||
loader: PluginLoader,
|
||||
/// Plugin loader for discovery and loading
|
||||
loader: PluginLoader,
|
||||
|
||||
/// Capability enforcer for security
|
||||
enforcer: CapabilityEnforcer,
|
||||
/// Capability enforcer for security
|
||||
enforcer: CapabilityEnforcer,
|
||||
|
||||
/// Plugin data directory
|
||||
data_dir: PathBuf,
|
||||
/// Plugin data directory
|
||||
data_dir: PathBuf,
|
||||
|
||||
/// Plugin cache directory
|
||||
cache_dir: PathBuf,
|
||||
/// Plugin cache directory
|
||||
cache_dir: PathBuf,
|
||||
|
||||
/// Configuration
|
||||
config: PluginManagerConfig,
|
||||
/// Configuration
|
||||
config: PluginManagerConfig,
|
||||
}
|
||||
|
||||
/// Configuration for the plugin manager
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginManagerConfig {
|
||||
/// Directories to search for plugins
|
||||
pub plugin_dirs: Vec<PathBuf>,
|
||||
/// Directories to search for plugins
|
||||
pub plugin_dirs: Vec<PathBuf>,
|
||||
|
||||
/// Whether to enable hot-reload (for development)
|
||||
pub enable_hot_reload: bool,
|
||||
/// Whether to enable hot-reload (for development)
|
||||
pub enable_hot_reload: bool,
|
||||
|
||||
/// Whether to allow unsigned plugins
|
||||
pub allow_unsigned: bool,
|
||||
/// Whether to allow unsigned plugins
|
||||
pub allow_unsigned: bool,
|
||||
|
||||
/// Maximum number of concurrent plugin operations
|
||||
pub max_concurrent_ops: usize,
|
||||
/// Maximum number of concurrent plugin operations
|
||||
pub max_concurrent_ops: usize,
|
||||
|
||||
/// Plugin timeout in seconds
|
||||
pub plugin_timeout_secs: u64,
|
||||
/// Plugin timeout in seconds
|
||||
pub plugin_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for PluginManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_dirs: vec![],
|
||||
enable_hot_reload: false,
|
||||
allow_unsigned: false,
|
||||
max_concurrent_ops: 4,
|
||||
plugin_timeout_secs: 30,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_dirs: vec![],
|
||||
enable_hot_reload: false,
|
||||
allow_unsigned: false,
|
||||
max_concurrent_ops: 4,
|
||||
plugin_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::config::PluginsConfig> for PluginManagerConfig {
|
||||
fn from(cfg: crate::config::PluginsConfig) -> Self {
|
||||
Self {
|
||||
plugin_dirs: cfg.plugin_dirs,
|
||||
enable_hot_reload: cfg.enable_hot_reload,
|
||||
allow_unsigned: cfg.allow_unsigned,
|
||||
max_concurrent_ops: cfg.max_concurrent_ops,
|
||||
plugin_timeout_secs: cfg.plugin_timeout_secs,
|
||||
}
|
||||
fn from(cfg: crate::config::PluginsConfig) -> Self {
|
||||
Self {
|
||||
plugin_dirs: cfg.plugin_dirs,
|
||||
enable_hot_reload: cfg.enable_hot_reload,
|
||||
allow_unsigned: cfg.allow_unsigned,
|
||||
max_concurrent_ops: cfg.max_concurrent_ops,
|
||||
plugin_timeout_secs: cfg.plugin_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Create a new plugin manager
|
||||
pub fn new(data_dir: PathBuf, cache_dir: PathBuf, config: PluginManagerConfig) -> Result<Self> {
|
||||
// Ensure directories exist
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
/// Create a new plugin manager
|
||||
pub fn new(
|
||||
data_dir: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
config: PluginManagerConfig,
|
||||
) -> Result<Self> {
|
||||
// Ensure directories exist
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
|
||||
let runtime = Arc::new(WasmRuntime::new()?);
|
||||
let registry = Arc::new(RwLock::new(PluginRegistry::new()));
|
||||
let loader = PluginLoader::new(config.plugin_dirs.clone());
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
let runtime = Arc::new(WasmRuntime::new()?);
|
||||
let registry = Arc::new(RwLock::new(PluginRegistry::new()));
|
||||
let loader = PluginLoader::new(config.plugin_dirs.clone());
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
|
||||
Ok(Self {
|
||||
registry,
|
||||
runtime,
|
||||
loader,
|
||||
enforcer,
|
||||
data_dir,
|
||||
cache_dir,
|
||||
config,
|
||||
Ok(Self {
|
||||
registry,
|
||||
runtime,
|
||||
loader,
|
||||
enforcer,
|
||||
data_dir,
|
||||
cache_dir,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover and load all plugins from configured directories
|
||||
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
|
||||
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
||||
|
||||
let manifests = self.loader.discover_plugins().await?;
|
||||
let mut loaded_plugins = Vec::new();
|
||||
|
||||
for manifest in manifests {
|
||||
match self.load_plugin_from_manifest(&manifest).await {
|
||||
Ok(plugin_id) => {
|
||||
info!("Loaded plugin: {}", plugin_id);
|
||||
loaded_plugins.push(plugin_id);
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Failed to load plugin {}: {}", manifest.plugin.name, e);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(loaded_plugins)
|
||||
}
|
||||
|
||||
/// Load a plugin from a manifest file
|
||||
async fn load_plugin_from_manifest(
|
||||
&self,
|
||||
manifest: &pinakes_plugin_api::PluginManifest,
|
||||
) -> Result<String> {
|
||||
let plugin_id = manifest.plugin_id();
|
||||
|
||||
// Validate plugin_id to prevent path traversal
|
||||
if plugin_id.contains('/')
|
||||
|| plugin_id.contains('\\')
|
||||
|| plugin_id.contains("..")
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
||||
}
|
||||
|
||||
// Check if already loaded
|
||||
{
|
||||
let registry = self.registry.read().await;
|
||||
if registry.is_loaded(&plugin_id) {
|
||||
return Ok(plugin_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate capabilities
|
||||
let capabilities = manifest.to_capabilities();
|
||||
self.enforcer.validate_capabilities(&capabilities)?;
|
||||
|
||||
// Create plugin context
|
||||
let plugin_data_dir = self.data_dir.join(&plugin_id);
|
||||
let plugin_cache_dir = self.cache_dir.join(&plugin_id);
|
||||
tokio::fs::create_dir_all(&plugin_data_dir).await?;
|
||||
tokio::fs::create_dir_all(&plugin_cache_dir).await?;
|
||||
|
||||
let context = PluginContext {
|
||||
data_dir: plugin_data_dir,
|
||||
cache_dir: plugin_cache_dir,
|
||||
config: manifest
|
||||
.config
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
serde_json::to_value(v).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
"failed to serialize config value for key {}: {}",
|
||||
k,
|
||||
e
|
||||
);
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
capabilities: capabilities.clone(),
|
||||
};
|
||||
|
||||
// Load WASM binary
|
||||
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
||||
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?;
|
||||
|
||||
// Initialize plugin
|
||||
let init_succeeded = match wasm_plugin
|
||||
.call_function("initialize", &[])
|
||||
.await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e);
|
||||
false
|
||||
},
|
||||
};
|
||||
|
||||
// Register plugin
|
||||
let metadata = PluginMetadata {
|
||||
id: plugin_id.clone(),
|
||||
name: manifest.plugin.name.clone(),
|
||||
version: manifest.plugin.version.clone(),
|
||||
author: manifest.plugin.author.clone().unwrap_or_default(),
|
||||
description: manifest
|
||||
.plugin
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
api_version: manifest.plugin.api_version.clone(),
|
||||
capabilities_required: capabilities,
|
||||
};
|
||||
|
||||
// Derive manifest_path from the loader's plugin directories
|
||||
let manifest_path = self
|
||||
.loader
|
||||
.get_plugin_dir(&manifest.plugin.name)
|
||||
.map(|dir| dir.join("plugin.toml"));
|
||||
|
||||
let registered = RegisteredPlugin {
|
||||
id: plugin_id.clone(),
|
||||
metadata,
|
||||
wasm_plugin,
|
||||
manifest: manifest.clone(),
|
||||
manifest_path,
|
||||
enabled: init_succeeded,
|
||||
};
|
||||
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.register(registered)?;
|
||||
|
||||
Ok(plugin_id)
|
||||
}
|
||||
|
||||
/// Install a plugin from a file or URL
|
||||
pub async fn install_plugin(&self, source: &str) -> Result<String> {
|
||||
info!("Installing plugin from: {}", source);
|
||||
|
||||
// Download/copy plugin to plugins directory
|
||||
let plugin_path =
|
||||
if source.starts_with("http://") || source.starts_with("https://") {
|
||||
// Download from URL
|
||||
self.loader.download_plugin(source).await?
|
||||
} else {
|
||||
// Copy from local file
|
||||
PathBuf::from(source)
|
||||
};
|
||||
|
||||
// Load the manifest
|
||||
let manifest_path = plugin_path.join("plugin.toml");
|
||||
let manifest =
|
||||
pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?;
|
||||
|
||||
// Load the plugin
|
||||
self.load_plugin_from_manifest(&manifest).await
|
||||
}
|
||||
|
||||
/// Uninstall a plugin
|
||||
pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
// Validate plugin_id to prevent path traversal
|
||||
if plugin_id.contains('/')
|
||||
|| plugin_id.contains('\\')
|
||||
|| plugin_id.contains("..")
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
||||
}
|
||||
|
||||
/// Discover and load all plugins from configured directories
|
||||
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
|
||||
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
||||
info!("Uninstalling plugin: {}", plugin_id);
|
||||
|
||||
let manifests = self.loader.discover_plugins().await?;
|
||||
let mut loaded_plugins = Vec::new();
|
||||
// Shutdown plugin first
|
||||
self.shutdown_plugin(plugin_id).await?;
|
||||
|
||||
for manifest in manifests {
|
||||
match self.load_plugin_from_manifest(&manifest).await {
|
||||
Ok(plugin_id) => {
|
||||
info!("Loaded plugin: {}", plugin_id);
|
||||
loaded_plugins.push(plugin_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load plugin {}: {}", manifest.plugin.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove from registry
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(plugin_id)?;
|
||||
|
||||
Ok(loaded_plugins)
|
||||
// Remove plugin data and cache
|
||||
let plugin_data_dir = self.data_dir.join(plugin_id);
|
||||
let plugin_cache_dir = self.cache_dir.join(plugin_id);
|
||||
|
||||
if plugin_data_dir.exists() {
|
||||
std::fs::remove_dir_all(&plugin_data_dir)?;
|
||||
}
|
||||
if plugin_cache_dir.exists() {
|
||||
std::fs::remove_dir_all(&plugin_cache_dir)?;
|
||||
}
|
||||
|
||||
/// Load a plugin from a manifest file
|
||||
async fn load_plugin_from_manifest(
|
||||
&self,
|
||||
manifest: &pinakes_plugin_api::PluginManifest,
|
||||
) -> Result<String> {
|
||||
let plugin_id = manifest.plugin_id();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Validate plugin_id to prevent path traversal
|
||||
if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") {
|
||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
||||
}
|
||||
/// Enable a plugin
|
||||
pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.enable(plugin_id)
|
||||
}
|
||||
|
||||
// Check if already loaded
|
||||
{
|
||||
let registry = self.registry.read().await;
|
||||
if registry.is_loaded(&plugin_id) {
|
||||
return Ok(plugin_id);
|
||||
}
|
||||
}
|
||||
/// Disable a plugin
|
||||
pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.disable(plugin_id)
|
||||
}
|
||||
|
||||
// Validate capabilities
|
||||
let capabilities = manifest.to_capabilities();
|
||||
self.enforcer.validate_capabilities(&capabilities)?;
|
||||
/// Shutdown a specific plugin
|
||||
pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
debug!("Shutting down plugin: {}", plugin_id);
|
||||
|
||||
// Create plugin context
|
||||
let plugin_data_dir = self.data_dir.join(&plugin_id);
|
||||
let plugin_cache_dir = self.cache_dir.join(&plugin_id);
|
||||
tokio::fs::create_dir_all(&plugin_data_dir).await?;
|
||||
tokio::fs::create_dir_all(&plugin_cache_dir).await?;
|
||||
let registry = self.registry.read().await;
|
||||
if let Some(plugin) = registry.get(plugin_id) {
|
||||
plugin.wasm_plugin.call_function("shutdown", &[]).await.ok();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Plugin not found: {}", plugin_id))
|
||||
}
|
||||
}
|
||||
|
||||
let context = PluginContext {
|
||||
data_dir: plugin_data_dir,
|
||||
cache_dir: plugin_cache_dir,
|
||||
config: manifest
|
||||
.config
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
serde_json::to_value(v).unwrap_or_else(|e| {
|
||||
tracing::warn!("failed to serialize config value for key {}: {}", k, e);
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
capabilities: capabilities.clone(),
|
||||
};
|
||||
/// Shutdown all plugins
|
||||
pub async fn shutdown_all(&self) -> Result<()> {
|
||||
info!("Shutting down all plugins");
|
||||
|
||||
// Load WASM binary
|
||||
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
||||
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?;
|
||||
let registry = self.registry.read().await;
|
||||
let plugin_ids: Vec<String> =
|
||||
registry.list_all().iter().map(|p| p.id.clone()).collect();
|
||||
|
||||
// Initialize plugin
|
||||
let init_succeeded = match wasm_plugin.call_function("initialize", &[]).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Register plugin
|
||||
let metadata = PluginMetadata {
|
||||
id: plugin_id.clone(),
|
||||
name: manifest.plugin.name.clone(),
|
||||
version: manifest.plugin.version.clone(),
|
||||
author: manifest.plugin.author.clone().unwrap_or_default(),
|
||||
description: manifest.plugin.description.clone().unwrap_or_default(),
|
||||
api_version: manifest.plugin.api_version.clone(),
|
||||
capabilities_required: capabilities,
|
||||
};
|
||||
|
||||
// Derive manifest_path from the loader's plugin directories
|
||||
let manifest_path = self
|
||||
.loader
|
||||
.get_plugin_dir(&manifest.plugin.name)
|
||||
.map(|dir| dir.join("plugin.toml"));
|
||||
|
||||
let registered = RegisteredPlugin {
|
||||
id: plugin_id.clone(),
|
||||
metadata,
|
||||
wasm_plugin,
|
||||
manifest: manifest.clone(),
|
||||
manifest_path,
|
||||
enabled: init_succeeded,
|
||||
};
|
||||
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.register(registered)?;
|
||||
|
||||
Ok(plugin_id)
|
||||
for plugin_id in plugin_ids {
|
||||
if let Err(e) = self.shutdown_plugin(&plugin_id).await {
|
||||
error!("Failed to shutdown plugin {}: {}", plugin_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a plugin from a file or URL
|
||||
pub async fn install_plugin(&self, source: &str) -> Result<String> {
|
||||
info!("Installing plugin from: {}", source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Download/copy plugin to plugins directory
|
||||
let plugin_path = if source.starts_with("http://") || source.starts_with("https://") {
|
||||
// Download from URL
|
||||
self.loader.download_plugin(source).await?
|
||||
} else {
|
||||
// Copy from local file
|
||||
PathBuf::from(source)
|
||||
};
|
||||
/// Get list of all registered plugins
|
||||
pub async fn list_plugins(&self) -> Vec<PluginMetadata> {
|
||||
let registry = self.registry.read().await;
|
||||
registry
|
||||
.list_all()
|
||||
.iter()
|
||||
.map(|p| p.metadata.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Load the manifest
|
||||
let manifest_path = plugin_path.join("plugin.toml");
|
||||
let manifest = pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?;
|
||||
/// Get plugin metadata by ID
|
||||
pub async fn get_plugin(&self, plugin_id: &str) -> Option<PluginMetadata> {
|
||||
let registry = self.registry.read().await;
|
||||
registry.get(plugin_id).map(|p| p.metadata.clone())
|
||||
}
|
||||
|
||||
// Load the plugin
|
||||
self.load_plugin_from_manifest(&manifest).await
|
||||
/// Check if a plugin is loaded and enabled
|
||||
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
||||
let registry = self.registry.read().await;
|
||||
registry.is_enabled(plugin_id).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reload a plugin (for hot-reload during development)
|
||||
pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
if !self.config.enable_hot_reload {
|
||||
return Err(anyhow::anyhow!("Hot-reload is disabled"));
|
||||
}
|
||||
|
||||
/// Uninstall a plugin
|
||||
pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
// Validate plugin_id to prevent path traversal
|
||||
if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") {
|
||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
||||
}
|
||||
info!("Reloading plugin: {}", plugin_id);
|
||||
|
||||
info!("Uninstalling plugin: {}", plugin_id);
|
||||
// Re-read the manifest from disk if possible, falling back to cached
|
||||
// version
|
||||
let manifest = {
|
||||
let registry = self.registry.read().await;
|
||||
let plugin = registry
|
||||
.get(plugin_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Plugin not found"))?;
|
||||
if let Some(ref manifest_path) = plugin.manifest_path {
|
||||
pinakes_plugin_api::PluginManifest::from_file(manifest_path)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to re-read manifest from disk, using cached: {}", e);
|
||||
plugin.manifest.clone()
|
||||
})
|
||||
} else {
|
||||
plugin.manifest.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Shutdown plugin first
|
||||
self.shutdown_plugin(plugin_id).await?;
|
||||
|
||||
// Remove from registry
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(plugin_id)?;
|
||||
|
||||
// Remove plugin data and cache
|
||||
let plugin_data_dir = self.data_dir.join(plugin_id);
|
||||
let plugin_cache_dir = self.cache_dir.join(plugin_id);
|
||||
|
||||
if plugin_data_dir.exists() {
|
||||
std::fs::remove_dir_all(&plugin_data_dir)?;
|
||||
}
|
||||
if plugin_cache_dir.exists() {
|
||||
std::fs::remove_dir_all(&plugin_cache_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Shutdown and unload current version
|
||||
self.shutdown_plugin(plugin_id).await?;
|
||||
{
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(plugin_id)?;
|
||||
}
|
||||
|
||||
/// Enable a plugin
|
||||
pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.enable(plugin_id)
|
||||
}
|
||||
// Reload from manifest
|
||||
self.load_plugin_from_manifest(&manifest).await?;
|
||||
|
||||
/// Disable a plugin
|
||||
pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.disable(plugin_id)
|
||||
}
|
||||
|
||||
/// Shutdown a specific plugin
|
||||
pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
debug!("Shutting down plugin: {}", plugin_id);
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
if let Some(plugin) = registry.get(plugin_id) {
|
||||
plugin.wasm_plugin.call_function("shutdown", &[]).await.ok();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Plugin not found: {}", plugin_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown all plugins
|
||||
pub async fn shutdown_all(&self) -> Result<()> {
|
||||
info!("Shutting down all plugins");
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
let plugin_ids: Vec<String> = registry.list_all().iter().map(|p| p.id.clone()).collect();
|
||||
|
||||
for plugin_id in plugin_ids {
|
||||
if let Err(e) = self.shutdown_plugin(&plugin_id).await {
|
||||
error!("Failed to shutdown plugin {}: {}", plugin_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get list of all registered plugins
|
||||
pub async fn list_plugins(&self) -> Vec<PluginMetadata> {
|
||||
let registry = self.registry.read().await;
|
||||
registry
|
||||
.list_all()
|
||||
.iter()
|
||||
.map(|p| p.metadata.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get plugin metadata by ID
|
||||
pub async fn get_plugin(&self, plugin_id: &str) -> Option<PluginMetadata> {
|
||||
let registry = self.registry.read().await;
|
||||
registry.get(plugin_id).map(|p| p.metadata.clone())
|
||||
}
|
||||
|
||||
/// Check if a plugin is loaded and enabled
|
||||
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
||||
let registry = self.registry.read().await;
|
||||
registry.is_enabled(plugin_id).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reload a plugin (for hot-reload during development)
|
||||
pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
if !self.config.enable_hot_reload {
|
||||
return Err(anyhow::anyhow!("Hot-reload is disabled"));
|
||||
}
|
||||
|
||||
info!("Reloading plugin: {}", plugin_id);
|
||||
|
||||
// Re-read the manifest from disk if possible, falling back to cached version
|
||||
let manifest = {
|
||||
let registry = self.registry.read().await;
|
||||
let plugin = registry
|
||||
.get(plugin_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Plugin not found"))?;
|
||||
if let Some(ref manifest_path) = plugin.manifest_path {
|
||||
pinakes_plugin_api::PluginManifest::from_file(manifest_path).unwrap_or_else(|e| {
|
||||
warn!("Failed to re-read manifest from disk, using cached: {}", e);
|
||||
plugin.manifest.clone()
|
||||
})
|
||||
} else {
|
||||
plugin.manifest.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Shutdown and unload current version
|
||||
self.shutdown_plugin(plugin_id).await?;
|
||||
{
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(plugin_id)?;
|
||||
}
|
||||
|
||||
// Reload from manifest
|
||||
self.load_plugin_from_manifest(&manifest).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_manager_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let data_dir = temp_dir.path().join("data");
|
||||
let cache_dir = temp_dir.path().join("cache");
|
||||
use super::*;
|
||||
|
||||
let config = PluginManagerConfig::default();
|
||||
let manager = PluginManager::new(data_dir.clone(), cache_dir.clone(), config);
|
||||
#[tokio::test]
|
||||
async fn test_plugin_manager_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let data_dir = temp_dir.path().join("data");
|
||||
let cache_dir = temp_dir.path().join("cache");
|
||||
|
||||
assert!(manager.is_ok());
|
||||
assert!(data_dir.exists());
|
||||
assert!(cache_dir.exists());
|
||||
}
|
||||
let config = PluginManagerConfig::default();
|
||||
let manager =
|
||||
PluginManager::new(data_dir.clone(), cache_dir.clone(), config);
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_plugins_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let data_dir = temp_dir.path().join("data");
|
||||
let cache_dir = temp_dir.path().join("cache");
|
||||
assert!(manager.is_ok());
|
||||
assert!(data_dir.exists());
|
||||
assert!(cache_dir.exists());
|
||||
}
|
||||
|
||||
let config = PluginManagerConfig::default();
|
||||
let manager = PluginManager::new(data_dir, cache_dir, config).unwrap();
|
||||
#[tokio::test]
|
||||
async fn test_list_plugins_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let data_dir = temp_dir.path().join("data");
|
||||
let cache_dir = temp_dir.path().join("cache");
|
||||
|
||||
let plugins = manager.list_plugins().await;
|
||||
assert_eq!(plugins.len(), 0);
|
||||
}
|
||||
let config = PluginManagerConfig::default();
|
||||
let manager = PluginManager::new(data_dir, cache_dir, config).unwrap();
|
||||
|
||||
let plugins = manager.list_plugins().await;
|
||||
assert_eq!(plugins.len(), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,280 +1,282 @@
|
|||
//! Plugin registry for managing loaded plugins
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use pinakes_plugin_api::{PluginManifest, PluginMetadata};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::runtime::WasmPlugin;
|
||||
|
||||
/// A registered plugin with its metadata and runtime state
|
||||
#[derive(Clone)]
|
||||
pub struct RegisteredPlugin {
|
||||
pub id: String,
|
||||
pub metadata: PluginMetadata,
|
||||
pub wasm_plugin: WasmPlugin,
|
||||
pub manifest: PluginManifest,
|
||||
pub manifest_path: Option<PathBuf>,
|
||||
pub enabled: bool,
|
||||
pub id: String,
|
||||
pub metadata: PluginMetadata,
|
||||
pub wasm_plugin: WasmPlugin,
|
||||
pub manifest: PluginManifest,
|
||||
pub manifest_path: Option<PathBuf>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Plugin registry maintains the state of all loaded plugins
|
||||
pub struct PluginRegistry {
|
||||
/// Map of plugin ID to registered plugin
|
||||
plugins: HashMap<String, RegisteredPlugin>,
|
||||
/// Map of plugin ID to registered plugin
|
||||
plugins: HashMap<String, RegisteredPlugin>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
/// Create a new empty registry
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
plugins: HashMap::new(),
|
||||
}
|
||||
/// Create a new empty registry
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
plugins: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new plugin
|
||||
pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> {
|
||||
if self.plugins.contains_key(&plugin.id) {
|
||||
return Err(anyhow!("Plugin already registered: {}", plugin.id));
|
||||
}
|
||||
|
||||
/// Register a new plugin
|
||||
pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> {
|
||||
if self.plugins.contains_key(&plugin.id) {
|
||||
return Err(anyhow!("Plugin already registered: {}", plugin.id));
|
||||
}
|
||||
self.plugins.insert(plugin.id.clone(), plugin);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
self.plugins.insert(plugin.id.clone(), plugin);
|
||||
Ok(())
|
||||
}
|
||||
/// Unregister a plugin by ID
|
||||
pub fn unregister(&mut self, plugin_id: &str) -> Result<()> {
|
||||
self
|
||||
.plugins
|
||||
.remove(plugin_id)
|
||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unregister a plugin by ID
|
||||
pub fn unregister(&mut self, plugin_id: &str) -> Result<()> {
|
||||
self.plugins
|
||||
.remove(plugin_id)
|
||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
||||
Ok(())
|
||||
}
|
||||
/// Get a plugin by ID
|
||||
pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
|
||||
self.plugins.get(plugin_id)
|
||||
}
|
||||
|
||||
/// Get a plugin by ID
|
||||
pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
|
||||
self.plugins.get(plugin_id)
|
||||
}
|
||||
/// Get a mutable reference to a plugin by ID
|
||||
pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> {
|
||||
self.plugins.get_mut(plugin_id)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to a plugin by ID
|
||||
pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> {
|
||||
self.plugins.get_mut(plugin_id)
|
||||
}
|
||||
/// Check if a plugin is loaded
|
||||
pub fn is_loaded(&self, plugin_id: &str) -> bool {
|
||||
self.plugins.contains_key(plugin_id)
|
||||
}
|
||||
|
||||
/// Check if a plugin is loaded
|
||||
pub fn is_loaded(&self, plugin_id: &str) -> bool {
|
||||
self.plugins.contains_key(plugin_id)
|
||||
}
|
||||
/// Check if a plugin is enabled. Returns `None` if the plugin is not found.
|
||||
pub fn is_enabled(&self, plugin_id: &str) -> Option<bool> {
|
||||
self.plugins.get(plugin_id).map(|p| p.enabled)
|
||||
}
|
||||
|
||||
/// Check if a plugin is enabled. Returns `None` if the plugin is not found.
|
||||
pub fn is_enabled(&self, plugin_id: &str) -> Option<bool> {
|
||||
self.plugins.get(plugin_id).map(|p| p.enabled)
|
||||
}
|
||||
/// Enable a plugin
|
||||
pub fn enable(&mut self, plugin_id: &str) -> Result<()> {
|
||||
let plugin = self
|
||||
.plugins
|
||||
.get_mut(plugin_id)
|
||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
||||
|
||||
/// Enable a plugin
|
||||
pub fn enable(&mut self, plugin_id: &str) -> Result<()> {
|
||||
let plugin = self
|
||||
.plugins
|
||||
.get_mut(plugin_id)
|
||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
||||
plugin.enabled = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
plugin.enabled = true;
|
||||
Ok(())
|
||||
}
|
||||
/// Disable a plugin
|
||||
pub fn disable(&mut self, plugin_id: &str) -> Result<()> {
|
||||
let plugin = self
|
||||
.plugins
|
||||
.get_mut(plugin_id)
|
||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
||||
|
||||
/// Disable a plugin
|
||||
pub fn disable(&mut self, plugin_id: &str) -> Result<()> {
|
||||
let plugin = self
|
||||
.plugins
|
||||
.get_mut(plugin_id)
|
||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
||||
plugin.enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
plugin.enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
/// List all registered plugins
|
||||
pub fn list_all(&self) -> Vec<&RegisteredPlugin> {
|
||||
self.plugins.values().collect()
|
||||
}
|
||||
|
||||
/// List all registered plugins
|
||||
pub fn list_all(&self) -> Vec<&RegisteredPlugin> {
|
||||
self.plugins.values().collect()
|
||||
}
|
||||
/// List all enabled plugins
|
||||
pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> {
|
||||
self.plugins.values().filter(|p| p.enabled).collect()
|
||||
}
|
||||
|
||||
/// List all enabled plugins
|
||||
pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> {
|
||||
self.plugins.values().filter(|p| p.enabled).collect()
|
||||
}
|
||||
/// Get plugins by kind (e.g., "media_type", "metadata_extractor")
|
||||
pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> {
|
||||
self
|
||||
.plugins
|
||||
.values()
|
||||
.filter(|p| p.manifest.plugin.kind.contains(&kind.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get plugins by kind (e.g., "media_type", "metadata_extractor")
|
||||
pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> {
|
||||
self.plugins
|
||||
.values()
|
||||
.filter(|p| p.manifest.plugin.kind.contains(&kind.to_string()))
|
||||
.collect()
|
||||
}
|
||||
/// Get count of registered plugins
|
||||
pub fn count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
/// Get count of registered plugins
|
||||
pub fn count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
/// Get count of enabled plugins
|
||||
pub fn count_enabled(&self) -> usize {
|
||||
self.plugins.values().filter(|p| p.enabled).count()
|
||||
}
|
||||
/// Get count of enabled plugins
|
||||
pub fn count_enabled(&self) -> usize {
|
||||
self.plugins.values().filter(|p| p.enabled).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pinakes_plugin_api::Capabilities;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_plugin(id: &str, kind: Vec<String>) -> RegisteredPlugin {
|
||||
let manifest = PluginManifest {
|
||||
plugin: pinakes_plugin_api::manifest::PluginInfo {
|
||||
name: id.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
api_version: "1.0".to_string(),
|
||||
author: Some("Test".to_string()),
|
||||
description: Some("Test plugin".to_string()),
|
||||
homepage: None,
|
||||
license: None,
|
||||
kind,
|
||||
binary: pinakes_plugin_api::manifest::PluginBinary {
|
||||
wasm: "test.wasm".to_string(),
|
||||
entrypoint: None,
|
||||
},
|
||||
dependencies: vec![],
|
||||
},
|
||||
capabilities: Default::default(),
|
||||
config: HashMap::new(),
|
||||
};
|
||||
use pinakes_plugin_api::Capabilities;
|
||||
|
||||
RegisteredPlugin {
|
||||
id: id.to_string(),
|
||||
metadata: PluginMetadata {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: "Test".to_string(),
|
||||
description: "Test plugin".to_string(),
|
||||
api_version: "1.0".to_string(),
|
||||
capabilities_required: Capabilities::default(),
|
||||
},
|
||||
wasm_plugin: WasmPlugin::default(),
|
||||
manifest,
|
||||
manifest_path: None,
|
||||
enabled: true,
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn create_test_plugin(id: &str, kind: Vec<String>) -> RegisteredPlugin {
|
||||
let manifest = PluginManifest {
|
||||
plugin: pinakes_plugin_api::manifest::PluginInfo {
|
||||
name: id.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
api_version: "1.0".to_string(),
|
||||
author: Some("Test".to_string()),
|
||||
description: Some("Test plugin".to_string()),
|
||||
homepage: None,
|
||||
license: None,
|
||||
kind,
|
||||
binary: pinakes_plugin_api::manifest::PluginBinary {
|
||||
wasm: "test.wasm".to_string(),
|
||||
entrypoint: None,
|
||||
},
|
||||
dependencies: vec![],
|
||||
},
|
||||
capabilities: Default::default(),
|
||||
config: HashMap::new(),
|
||||
};
|
||||
|
||||
RegisteredPlugin {
|
||||
id: id.to_string(),
|
||||
metadata: PluginMetadata {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: "Test".to_string(),
|
||||
description: "Test plugin".to_string(),
|
||||
api_version: "1.0".to_string(),
|
||||
capabilities_required: Capabilities::default(),
|
||||
},
|
||||
wasm_plugin: WasmPlugin::default(),
|
||||
manifest,
|
||||
manifest_path: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_register_and_get() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
#[test]
|
||||
fn test_registry_register_and_get() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin =
|
||||
create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
|
||||
registry.register(plugin.clone()).unwrap();
|
||||
registry.register(plugin.clone()).unwrap();
|
||||
|
||||
assert!(registry.is_loaded("test-plugin"));
|
||||
assert!(registry.get("test-plugin").is_some());
|
||||
}
|
||||
assert!(registry.is_loaded("test-plugin"));
|
||||
assert!(registry.get("test-plugin").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_duplicate_register() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
#[test]
|
||||
fn test_registry_duplicate_register() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin =
|
||||
create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
|
||||
registry.register(plugin.clone()).unwrap();
|
||||
let result = registry.register(plugin);
|
||||
registry.register(plugin.clone()).unwrap();
|
||||
let result = registry.register(plugin);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_unregister() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
#[test]
|
||||
fn test_registry_unregister() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin =
|
||||
create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
|
||||
registry.register(plugin).unwrap();
|
||||
registry.unregister("test-plugin").unwrap();
|
||||
registry.register(plugin).unwrap();
|
||||
registry.unregister("test-plugin").unwrap();
|
||||
|
||||
assert!(!registry.is_loaded("test-plugin"));
|
||||
}
|
||||
assert!(!registry.is_loaded("test-plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_enable_disable() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
#[test]
|
||||
fn test_registry_enable_disable() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
let plugin =
|
||||
create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||
|
||||
registry.register(plugin).unwrap();
|
||||
assert_eq!(registry.is_enabled("test-plugin"), Some(true));
|
||||
registry.register(plugin).unwrap();
|
||||
assert_eq!(registry.is_enabled("test-plugin"), Some(true));
|
||||
|
||||
registry.disable("test-plugin").unwrap();
|
||||
assert_eq!(registry.is_enabled("test-plugin"), Some(false));
|
||||
registry.disable("test-plugin").unwrap();
|
||||
assert_eq!(registry.is_enabled("test-plugin"), Some(false));
|
||||
|
||||
registry.enable("test-plugin").unwrap();
|
||||
assert_eq!(registry.is_enabled("test-plugin"), Some(true));
|
||||
registry.enable("test-plugin").unwrap();
|
||||
assert_eq!(registry.is_enabled("test-plugin"), Some(true));
|
||||
|
||||
assert_eq!(registry.is_enabled("nonexistent"), None);
|
||||
}
|
||||
assert_eq!(registry.is_enabled("nonexistent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_get_by_kind() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
#[test]
|
||||
fn test_registry_get_by_kind() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
|
||||
registry
|
||||
.register(create_test_plugin(
|
||||
"plugin1",
|
||||
vec!["media_type".to_string()],
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin(
|
||||
"plugin2",
|
||||
vec!["metadata_extractor".to_string()],
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin(
|
||||
"plugin3",
|
||||
vec!["media_type".to_string()],
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin("plugin1", vec![
|
||||
"media_type".to_string(),
|
||||
]))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin("plugin2", vec![
|
||||
"metadata_extractor".to_string(),
|
||||
]))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin("plugin3", vec![
|
||||
"media_type".to_string(),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let media_type_plugins = registry.get_by_kind("media_type");
|
||||
assert_eq!(media_type_plugins.len(), 2);
|
||||
let media_type_plugins = registry.get_by_kind("media_type");
|
||||
assert_eq!(media_type_plugins.len(), 2);
|
||||
|
||||
let extractor_plugins = registry.get_by_kind("metadata_extractor");
|
||||
assert_eq!(extractor_plugins.len(), 1);
|
||||
}
|
||||
let extractor_plugins = registry.get_by_kind("metadata_extractor");
|
||||
assert_eq!(extractor_plugins.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_counts() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
#[test]
|
||||
fn test_registry_counts() {
|
||||
let mut registry = PluginRegistry::new();
|
||||
|
||||
registry
|
||||
.register(create_test_plugin(
|
||||
"plugin1",
|
||||
vec!["media_type".to_string()],
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin(
|
||||
"plugin2",
|
||||
vec!["media_type".to_string()],
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin("plugin1", vec![
|
||||
"media_type".to_string(),
|
||||
]))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(create_test_plugin("plugin2", vec![
|
||||
"media_type".to_string(),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(registry.count(), 2);
|
||||
assert_eq!(registry.count_enabled(), 2);
|
||||
assert_eq!(registry.count(), 2);
|
||||
assert_eq!(registry.count_enabled(), 2);
|
||||
|
||||
registry.disable("plugin1").unwrap();
|
||||
assert_eq!(registry.count(), 2);
|
||||
assert_eq!(registry.count_enabled(), 1);
|
||||
}
|
||||
registry.disable("plugin1").unwrap();
|
||||
assert_eq!(registry.count(), 2);
|
||||
assert_eq!(registry.count_enabled(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,341 +1,357 @@
|
|||
//! Capability-based security for plugins
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use pinakes_plugin_api::Capabilities;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Capability enforcer validates and enforces plugin capabilities
|
||||
pub struct CapabilityEnforcer {
|
||||
/// Maximum allowed memory per plugin (bytes)
|
||||
max_memory_limit: usize,
|
||||
/// Maximum allowed memory per plugin (bytes)
|
||||
max_memory_limit: usize,
|
||||
|
||||
/// Maximum allowed CPU time per plugin (milliseconds)
|
||||
max_cpu_time_limit: u64,
|
||||
/// Maximum allowed CPU time per plugin (milliseconds)
|
||||
max_cpu_time_limit: u64,
|
||||
|
||||
/// Allowed filesystem read paths (system-wide)
|
||||
allowed_read_paths: Vec<PathBuf>,
|
||||
/// Allowed filesystem read paths (system-wide)
|
||||
allowed_read_paths: Vec<PathBuf>,
|
||||
|
||||
/// Allowed filesystem write paths (system-wide)
|
||||
allowed_write_paths: Vec<PathBuf>,
|
||||
/// Allowed filesystem write paths (system-wide)
|
||||
allowed_write_paths: Vec<PathBuf>,
|
||||
|
||||
/// Whether to allow network access by default
|
||||
allow_network_default: bool,
|
||||
/// Whether to allow network access by default
|
||||
allow_network_default: bool,
|
||||
}
|
||||
|
||||
impl CapabilityEnforcer {
|
||||
/// Create a new capability enforcer with default limits
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_memory_limit: 512 * 1024 * 1024, // 512 MB
|
||||
max_cpu_time_limit: 60 * 1000, // 60 seconds
|
||||
allowed_read_paths: vec![],
|
||||
allowed_write_paths: vec![],
|
||||
allow_network_default: false,
|
||||
}
|
||||
/// Create a new capability enforcer with default limits
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_memory_limit: 512 * 1024 * 1024, // 512 MB
|
||||
max_cpu_time_limit: 60 * 1000, // 60 seconds
|
||||
allowed_read_paths: vec![],
|
||||
allowed_write_paths: vec![],
|
||||
allow_network_default: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set maximum memory limit
|
||||
pub fn with_max_memory(mut self, bytes: usize) -> Self {
|
||||
self.max_memory_limit = bytes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum CPU time limit
|
||||
pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
|
||||
self.max_cpu_time_limit = milliseconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add allowed read path
|
||||
pub fn allow_read_path(mut self, path: PathBuf) -> Self {
|
||||
self.allowed_read_paths.push(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add allowed write path
|
||||
pub fn allow_write_path(mut self, path: PathBuf) -> Self {
|
||||
self.allowed_write_paths.push(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set default network access policy
|
||||
pub fn with_network_default(mut self, allow: bool) -> Self {
|
||||
self.allow_network_default = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate capabilities requested by a plugin
|
||||
pub fn validate_capabilities(
|
||||
&self,
|
||||
capabilities: &Capabilities,
|
||||
) -> Result<()> {
|
||||
// Validate memory limit
|
||||
if let Some(memory) = capabilities.max_memory_bytes
|
||||
&& memory > self.max_memory_limit
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Requested memory ({} bytes) exceeds limit ({} bytes)",
|
||||
memory,
|
||||
self.max_memory_limit
|
||||
));
|
||||
}
|
||||
|
||||
/// Set maximum memory limit
|
||||
pub fn with_max_memory(mut self, bytes: usize) -> Self {
|
||||
self.max_memory_limit = bytes;
|
||||
self
|
||||
// Validate CPU time limit
|
||||
if let Some(cpu_time) = capabilities.max_cpu_time_ms
|
||||
&& cpu_time > self.max_cpu_time_limit
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Requested CPU time ({} ms) exceeds limit ({} ms)",
|
||||
cpu_time,
|
||||
self.max_cpu_time_limit
|
||||
));
|
||||
}
|
||||
|
||||
/// Set maximum CPU time limit
|
||||
pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
|
||||
self.max_cpu_time_limit = milliseconds;
|
||||
self
|
||||
// Validate filesystem access
|
||||
self.validate_filesystem_access(capabilities)?;
|
||||
|
||||
// Validate network access
|
||||
if capabilities.network.enabled && !self.allow_network_default {
|
||||
return Err(anyhow!(
|
||||
"Plugin requests network access, but network access is disabled by \
|
||||
policy"
|
||||
));
|
||||
}
|
||||
|
||||
/// Add allowed read path
|
||||
pub fn allow_read_path(mut self, path: PathBuf) -> Self {
|
||||
self.allowed_read_paths.push(path);
|
||||
self
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate filesystem access capabilities
|
||||
fn validate_filesystem_access(
|
||||
&self,
|
||||
capabilities: &Capabilities,
|
||||
) -> Result<()> {
|
||||
// Check read paths
|
||||
for path in &capabilities.filesystem.read {
|
||||
if !self.is_read_allowed(path) {
|
||||
return Err(anyhow!(
|
||||
"Plugin requests read access to {:?} which is not in allowed paths",
|
||||
path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Add allowed write path
|
||||
pub fn allow_write_path(mut self, path: PathBuf) -> Self {
|
||||
self.allowed_write_paths.push(path);
|
||||
self
|
||||
// Check write paths
|
||||
for path in &capabilities.filesystem.write {
|
||||
if !self.is_write_allowed(path) {
|
||||
return Err(anyhow!(
|
||||
"Plugin requests write access to {:?} which is not in allowed paths",
|
||||
path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Set default network access policy
|
||||
pub fn with_network_default(mut self, allow: bool) -> Self {
|
||||
self.allow_network_default = allow;
|
||||
self
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a path is allowed for reading
|
||||
pub fn is_read_allowed(&self, path: &Path) -> bool {
|
||||
if self.allowed_read_paths.is_empty() {
|
||||
return false; // deny-all when unconfigured
|
||||
}
|
||||
let Ok(canonical) = path.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
self.allowed_read_paths.iter().any(|allowed| {
|
||||
allowed
|
||||
.canonicalize()
|
||||
.is_ok_and(|a| canonical.starts_with(a))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a path is allowed for writing
|
||||
pub fn is_write_allowed(&self, path: &Path) -> bool {
|
||||
if self.allowed_write_paths.is_empty() {
|
||||
return false; // deny-all when unconfigured
|
||||
}
|
||||
let canonical = if path.exists() {
|
||||
path.canonicalize().ok()
|
||||
} else {
|
||||
path
|
||||
.parent()
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.map(|p| p.join(path.file_name().unwrap_or_default()))
|
||||
};
|
||||
let Some(canonical) = canonical else {
|
||||
return false;
|
||||
};
|
||||
self.allowed_write_paths.iter().any(|allowed| {
|
||||
allowed
|
||||
.canonicalize()
|
||||
.is_ok_and(|a| canonical.starts_with(a))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if network access is allowed for a plugin
|
||||
pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
|
||||
capabilities.network.enabled && self.allow_network_default
|
||||
}
|
||||
|
||||
/// Check if a specific domain is allowed
|
||||
pub fn is_domain_allowed(
|
||||
&self,
|
||||
capabilities: &Capabilities,
|
||||
domain: &str,
|
||||
) -> bool {
|
||||
if !capabilities.network.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Validate capabilities requested by a plugin
|
||||
pub fn validate_capabilities(&self, capabilities: &Capabilities) -> Result<()> {
|
||||
// Validate memory limit
|
||||
if let Some(memory) = capabilities.max_memory_bytes
|
||||
&& memory > self.max_memory_limit
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Requested memory ({} bytes) exceeds limit ({} bytes)",
|
||||
memory,
|
||||
self.max_memory_limit
|
||||
));
|
||||
}
|
||||
|
||||
// Validate CPU time limit
|
||||
if let Some(cpu_time) = capabilities.max_cpu_time_ms
|
||||
&& cpu_time > self.max_cpu_time_limit
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Requested CPU time ({} ms) exceeds limit ({} ms)",
|
||||
cpu_time,
|
||||
self.max_cpu_time_limit
|
||||
));
|
||||
}
|
||||
|
||||
// Validate filesystem access
|
||||
self.validate_filesystem_access(capabilities)?;
|
||||
|
||||
// Validate network access
|
||||
if capabilities.network.enabled && !self.allow_network_default {
|
||||
return Err(anyhow!(
|
||||
"Plugin requests network access, but network access is disabled by policy"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// If no domain restrictions, allow all domains
|
||||
if capabilities.network.allowed_domains.is_none() {
|
||||
return self.allow_network_default;
|
||||
}
|
||||
|
||||
/// Validate filesystem access capabilities
|
||||
fn validate_filesystem_access(&self, capabilities: &Capabilities) -> Result<()> {
|
||||
// Check read paths
|
||||
for path in &capabilities.filesystem.read {
|
||||
if !self.is_read_allowed(path) {
|
||||
return Err(anyhow!(
|
||||
"Plugin requests read access to {:?} which is not in allowed paths",
|
||||
path
|
||||
));
|
||||
}
|
||||
}
|
||||
// Check against allowed domains list
|
||||
capabilities
|
||||
.network
|
||||
.allowed_domains
|
||||
.as_ref()
|
||||
.map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Check write paths
|
||||
for path in &capabilities.filesystem.write {
|
||||
if !self.is_write_allowed(path) {
|
||||
return Err(anyhow!(
|
||||
"Plugin requests write access to {:?} which is not in allowed paths",
|
||||
path
|
||||
));
|
||||
}
|
||||
}
|
||||
/// Get effective memory limit for a plugin
|
||||
pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize {
|
||||
capabilities
|
||||
.max_memory_bytes
|
||||
.unwrap_or(self.max_memory_limit)
|
||||
.min(self.max_memory_limit)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a path is allowed for reading
|
||||
pub fn is_read_allowed(&self, path: &Path) -> bool {
|
||||
if self.allowed_read_paths.is_empty() {
|
||||
return false; // deny-all when unconfigured
|
||||
}
|
||||
let Ok(canonical) = path.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
self.allowed_read_paths.iter().any(|allowed| {
|
||||
allowed
|
||||
.canonicalize()
|
||||
.is_ok_and(|a| canonical.starts_with(a))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a path is allowed for writing
|
||||
pub fn is_write_allowed(&self, path: &Path) -> bool {
|
||||
if self.allowed_write_paths.is_empty() {
|
||||
return false; // deny-all when unconfigured
|
||||
}
|
||||
let canonical = if path.exists() {
|
||||
path.canonicalize().ok()
|
||||
} else {
|
||||
path.parent()
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.map(|p| p.join(path.file_name().unwrap_or_default()))
|
||||
};
|
||||
let Some(canonical) = canonical else {
|
||||
return false;
|
||||
};
|
||||
self.allowed_write_paths.iter().any(|allowed| {
|
||||
allowed
|
||||
.canonicalize()
|
||||
.is_ok_and(|a| canonical.starts_with(a))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if network access is allowed for a plugin
|
||||
pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
|
||||
capabilities.network.enabled && self.allow_network_default
|
||||
}
|
||||
|
||||
/// Check if a specific domain is allowed
|
||||
pub fn is_domain_allowed(&self, capabilities: &Capabilities, domain: &str) -> bool {
|
||||
if !capabilities.network.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no domain restrictions, allow all domains
|
||||
if capabilities.network.allowed_domains.is_none() {
|
||||
return self.allow_network_default;
|
||||
}
|
||||
|
||||
// Check against allowed domains list
|
||||
capabilities
|
||||
.network
|
||||
.allowed_domains
|
||||
.as_ref()
|
||||
.map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get effective memory limit for a plugin
|
||||
pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize {
|
||||
capabilities
|
||||
.max_memory_bytes
|
||||
.unwrap_or(self.max_memory_limit)
|
||||
.min(self.max_memory_limit)
|
||||
}
|
||||
|
||||
/// Get effective CPU time limit for a plugin
|
||||
pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 {
|
||||
capabilities
|
||||
.max_cpu_time_ms
|
||||
.unwrap_or(self.max_cpu_time_limit)
|
||||
.min(self.max_cpu_time_limit)
|
||||
}
|
||||
/// Get effective CPU time limit for a plugin
|
||||
pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 {
|
||||
capabilities
|
||||
.max_cpu_time_ms
|
||||
.unwrap_or(self.max_cpu_time_limit)
|
||||
.min(self.max_cpu_time_limit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CapabilityEnforcer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use pinakes_plugin_api::{FilesystemCapability, NetworkCapability};
|
||||
#[allow(unused_imports)]
|
||||
use pinakes_plugin_api::{FilesystemCapability, NetworkCapability};
|
||||
|
||||
#[test]
|
||||
fn test_validate_memory_limit() {
|
||||
let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB
|
||||
use super::*;
|
||||
|
||||
let mut caps = Capabilities::default();
|
||||
caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK
|
||||
assert!(enforcer.validate_capabilities(&caps).is_ok());
|
||||
#[test]
|
||||
fn test_validate_memory_limit() {
|
||||
let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB
|
||||
|
||||
caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit
|
||||
assert!(enforcer.validate_capabilities(&caps).is_err());
|
||||
}
|
||||
let mut caps = Capabilities::default();
|
||||
caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK
|
||||
assert!(enforcer.validate_capabilities(&caps).is_ok());
|
||||
|
||||
#[test]
|
||||
fn test_validate_cpu_time_limit() {
|
||||
let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds
|
||||
caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit
|
||||
assert!(enforcer.validate_capabilities(&caps).is_err());
|
||||
}
|
||||
|
||||
let mut caps = Capabilities::default();
|
||||
caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK
|
||||
assert!(enforcer.validate_capabilities(&caps).is_ok());
|
||||
#[test]
|
||||
fn test_validate_cpu_time_limit() {
|
||||
let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds
|
||||
|
||||
caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit
|
||||
assert!(enforcer.validate_capabilities(&caps).is_err());
|
||||
}
|
||||
let mut caps = Capabilities::default();
|
||||
caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK
|
||||
assert!(enforcer.validate_capabilities(&caps).is_ok());
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_read_allowed() {
|
||||
// Use real temp directories so canonicalize works
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let allowed_dir = tmp.path().join("allowed");
|
||||
std::fs::create_dir_all(&allowed_dir).unwrap();
|
||||
let test_file = allowed_dir.join("test.txt");
|
||||
std::fs::write(&test_file, "test").unwrap();
|
||||
caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit
|
||||
assert!(enforcer.validate_capabilities(&caps).is_err());
|
||||
}
|
||||
|
||||
let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir.clone());
|
||||
#[test]
|
||||
fn test_filesystem_read_allowed() {
|
||||
// Use real temp directories so canonicalize works
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let allowed_dir = tmp.path().join("allowed");
|
||||
std::fs::create_dir_all(&allowed_dir).unwrap();
|
||||
let test_file = allowed_dir.join("test.txt");
|
||||
std::fs::write(&test_file, "test").unwrap();
|
||||
|
||||
assert!(enforcer.is_read_allowed(&test_file));
|
||||
assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd")));
|
||||
}
|
||||
let enforcer =
|
||||
CapabilityEnforcer::new().allow_read_path(allowed_dir.clone());
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_read_denied_when_empty() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt")));
|
||||
}
|
||||
assert!(enforcer.is_read_allowed(&test_file));
|
||||
assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_write_allowed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let output_dir = tmp.path().join("output");
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
// Existing file in allowed dir
|
||||
let existing = output_dir.join("file.txt");
|
||||
std::fs::write(&existing, "test").unwrap();
|
||||
#[test]
|
||||
fn test_filesystem_read_denied_when_empty() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt")));
|
||||
}
|
||||
|
||||
let enforcer = CapabilityEnforcer::new().allow_write_path(output_dir.clone());
|
||||
#[test]
|
||||
fn test_filesystem_write_allowed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let output_dir = tmp.path().join("output");
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
// Existing file in allowed dir
|
||||
let existing = output_dir.join("file.txt");
|
||||
std::fs::write(&existing, "test").unwrap();
|
||||
|
||||
assert!(enforcer.is_write_allowed(&existing));
|
||||
// New file in allowed dir (parent exists)
|
||||
assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt")));
|
||||
assert!(!enforcer.is_write_allowed(Path::new("/etc/config")));
|
||||
}
|
||||
let enforcer =
|
||||
CapabilityEnforcer::new().allow_write_path(output_dir.clone());
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_write_denied_when_empty() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt")));
|
||||
}
|
||||
assert!(enforcer.is_write_allowed(&existing));
|
||||
// New file in allowed dir (parent exists)
|
||||
assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt")));
|
||||
assert!(!enforcer.is_write_allowed(Path::new("/etc/config")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_allowed() {
|
||||
let enforcer = CapabilityEnforcer::new().with_network_default(true);
|
||||
#[test]
|
||||
fn test_filesystem_write_denied_when_empty() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt")));
|
||||
}
|
||||
|
||||
let mut caps = Capabilities::default();
|
||||
caps.network.enabled = true;
|
||||
#[test]
|
||||
fn test_network_allowed() {
|
||||
let enforcer = CapabilityEnforcer::new().with_network_default(true);
|
||||
|
||||
assert!(enforcer.is_network_allowed(&caps));
|
||||
let mut caps = Capabilities::default();
|
||||
caps.network.enabled = true;
|
||||
|
||||
caps.network.enabled = false;
|
||||
assert!(!enforcer.is_network_allowed(&caps));
|
||||
}
|
||||
assert!(enforcer.is_network_allowed(&caps));
|
||||
|
||||
#[test]
|
||||
fn test_domain_restrictions() {
|
||||
let enforcer = CapabilityEnforcer::new().with_network_default(true);
|
||||
caps.network.enabled = false;
|
||||
assert!(!enforcer.is_network_allowed(&caps));
|
||||
}
|
||||
|
||||
let mut caps = Capabilities::default();
|
||||
caps.network.enabled = true;
|
||||
caps.network.allowed_domains = Some(vec![
|
||||
"api.example.com".to_string(),
|
||||
"cdn.example.com".to_string(),
|
||||
]);
|
||||
#[test]
|
||||
fn test_domain_restrictions() {
|
||||
let enforcer = CapabilityEnforcer::new().with_network_default(true);
|
||||
|
||||
assert!(enforcer.is_domain_allowed(&caps, "api.example.com"));
|
||||
assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com"));
|
||||
assert!(!enforcer.is_domain_allowed(&caps, "evil.com"));
|
||||
}
|
||||
let mut caps = Capabilities::default();
|
||||
caps.network.enabled = true;
|
||||
caps.network.allowed_domains = Some(vec![
|
||||
"api.example.com".to_string(),
|
||||
"cdn.example.com".to_string(),
|
||||
]);
|
||||
|
||||
#[test]
|
||||
fn test_get_effective_limits() {
|
||||
let enforcer = CapabilityEnforcer::new()
|
||||
.with_max_memory(100 * 1024 * 1024)
|
||||
.with_max_cpu_time(30_000);
|
||||
assert!(enforcer.is_domain_allowed(&caps, "api.example.com"));
|
||||
assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com"));
|
||||
assert!(!enforcer.is_domain_allowed(&caps, "evil.com"));
|
||||
}
|
||||
|
||||
let mut caps = Capabilities::default();
|
||||
#[test]
|
||||
fn test_get_effective_limits() {
|
||||
let enforcer = CapabilityEnforcer::new()
|
||||
.with_max_memory(100 * 1024 * 1024)
|
||||
.with_max_cpu_time(30_000);
|
||||
|
||||
// No limits specified - use defaults
|
||||
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
||||
let mut caps = Capabilities::default();
|
||||
|
||||
// Plugin requests lower limits - use plugin's
|
||||
caps.max_memory_bytes = Some(50 * 1024 * 1024);
|
||||
caps.max_cpu_time_ms = Some(10_000);
|
||||
assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024);
|
||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000);
|
||||
// No limits specified - use defaults
|
||||
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
||||
|
||||
// Plugin requests higher limits - cap at system max
|
||||
caps.max_memory_bytes = Some(200 * 1024 * 1024);
|
||||
caps.max_cpu_time_ms = Some(60_000);
|
||||
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
||||
}
|
||||
// Plugin requests lower limits - use plugin's
|
||||
caps.max_memory_bytes = Some(50 * 1024 * 1024);
|
||||
caps.max_cpu_time_ms = Some(10_000);
|
||||
assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024);
|
||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000);
|
||||
|
||||
// Plugin requests higher limits - cap at system max
|
||||
caps.max_memory_bytes = Some(200 * 1024 * 1024);
|
||||
caps.max_cpu_time_ms = Some(60_000);
|
||||
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
||||
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue