use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use zeph_skills::bundled::bundled_skill_names;
use zeph_skills::registry::SkillRegistry;
use zeph_skills::scanner::scan_skill_body;
use crate::PluginError;
use crate::manifest::{PluginManifest, PluginMcpServer};
const MAX_DEPENDENCIES: usize = 64;
const CONFIG_SAFELIST: &[&str] = &[
"tools.blocked_commands",
"tools.allowed_commands",
"skills.disambiguation_threshold",
];
#[derive(Debug)]
pub struct AddResult {
pub name: String,
pub plugin_root: PathBuf,
pub installed_skills: Vec<String>,
pub mcp_server_ids: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Default)]
pub struct RemoveResult {
pub removed_skills: Vec<String>,
pub removed_mcp_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledPlugin {
pub name: String,
pub version: String,
pub description: String,
pub path: PathBuf,
pub skill_names: Vec<String>,
pub auto_update: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginSource {
pub url: Option<String>,
pub sha256: Option<String>,
}
#[derive(Debug)]
pub struct AutoUpdateResult {
pub name: String,
pub status: AutoUpdateStatus,
}
#[derive(Debug)]
pub enum AutoUpdateStatus {
Updated {
old_version: String,
new_version: String,
},
UpToDate,
NoSource,
Failed(String),
}
pub struct PluginManager {
plugins_dir: PathBuf,
managed_skills_dir: PathBuf,
mcp_allowed_commands: Vec<String>,
base_allowed_commands: Vec<String>,
integrity_registry_path: PathBuf,
download_timeout_secs: u64,
}
impl PluginManager {
#[must_use]
pub fn default_plugins_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("zeph")
.join("plugins")
}
#[must_use]
pub fn new(
plugins_dir: PathBuf,
managed_skills_dir: PathBuf,
mcp_allowed_commands: Vec<String>,
base_allowed_commands: Vec<String>,
) -> Self {
let integrity_registry_path = crate::integrity::IntegrityRegistry::default_path();
Self {
plugins_dir,
managed_skills_dir,
mcp_allowed_commands,
base_allowed_commands,
integrity_registry_path,
download_timeout_secs: 30,
}
}
#[must_use]
pub fn with_download_timeout_secs(mut self, secs: u64) -> Self {
self.download_timeout_secs = secs;
self
}
#[cfg(test)]
#[must_use]
pub fn with_integrity_registry_path(mut self, path: PathBuf) -> Self {
self.integrity_registry_path = path;
self
}
pub fn add(&self, source: &str) -> Result<AddResult, PluginError> {
let _span = tracing::info_span!("plugins.manager.add", plugin.source = %source).entered();
let source_path = PathBuf::from(source);
if !source_path.exists() {
return Err(PluginError::InvalidSource {
path: source.to_owned(),
reason: "path does not exist".to_owned(),
});
}
let manifest_path = source_path.join("plugin.toml");
let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
path: manifest_path.clone(),
source: e,
})?;
let manifest_str = String::from_utf8(manifest_bytes).map_err(|_| {
PluginError::InvalidManifest("plugin.toml is not valid UTF-8".to_owned())
})?;
let manifest: PluginManifest = toml::from_str(&manifest_str)
.map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
validate_plugin_name(&manifest.plugin.name)?;
if manifest.plugin.dependencies.len() > MAX_DEPENDENCIES {
return Err(PluginError::InvalidManifest(format!(
"plugin declares {} dependencies; maximum allowed is {MAX_DEPENDENCIES}",
manifest.plugin.dependencies.len()
)));
}
for dep in &manifest.plugin.dependencies {
validate_plugin_name(dep)?;
}
for entry in &manifest.skills {
let skill_path = source_path.join(&entry.path);
let canonical_source = source_path.canonicalize().map_err(|e| PluginError::Io {
path: source_path.clone(),
source: e,
})?;
let canonical_skill = skill_path.canonicalize().map_err(|e| PluginError::Io {
path: skill_path.clone(),
source: e,
})?;
if !canonical_skill.starts_with(&canonical_source) {
return Err(PluginError::InvalidSource {
path: entry.path.clone(),
reason: "skill path escapes plugin source root".to_owned(),
});
}
if !skill_path.join("SKILL.md").is_file() {
return Err(PluginError::SkillEntryMissing { path: skill_path });
}
}
validate_overlay_keys(&manifest.config)?;
scan_skill_entries(
source_path.as_path(),
&manifest.skills,
&manifest.plugin.name,
);
let mut warnings: Vec<String> = Vec::new();
if let Some(msg) = check_allowed_commands_overlay_effect(
&manifest.config,
&self.base_allowed_commands,
&manifest.plugin.name,
) {
tracing::warn!(plugin = %manifest.plugin.name, "{msg}");
warnings.push(msg);
}
validate_mcp_commands(&manifest.mcp.servers, &self.mcp_allowed_commands)?;
let skill_names = collect_skill_names(&source_path, &manifest);
self.check_skill_conflicts(&skill_names, &manifest.plugin.name)?;
let dest = self.plugins_dir.join(&manifest.plugin.name);
copy_dir_all(&source_path, &dest)?;
strip_bundled_markers(&dest);
let installed_manifest_path = dest.join(".plugin.toml");
let manifest_str = toml::to_string(&manifest)?;
std::fs::write(&installed_manifest_path, &manifest_str).map_err(|e| PluginError::Io {
path: installed_manifest_path.clone(),
source: e,
})?;
let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
if let Err(e) = registry
.record(&manifest.plugin.name, &installed_manifest_path)
.and_then(|()| registry.save(&self.integrity_registry_path))
{
tracing::warn!(plugin = %manifest.plugin.name, error = %e, "failed to update integrity registry after install");
}
let mcp_server_ids: Vec<String> =
manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
tracing::info!(
plugin = %manifest.plugin.name,
skills = ?skill_names,
mcp_servers = ?mcp_server_ids,
"plugin installed"
);
Ok(AddResult {
name: manifest.plugin.name,
plugin_root: dest,
installed_skills: skill_names,
mcp_server_ids,
warnings,
})
}
pub async fn add_remote(
&self,
url: &str,
expected_sha256: Option<&str>,
) -> Result<AddResult, PluginError> {
let span = tracing::info_span!("plugins.manager.add_remote", %url);
let _guard = span.enter();
validate_url_scheme(url)?;
let timeout = std::time::Duration::from_secs(self.download_timeout_secs);
let response = tokio::time::timeout(timeout, reqwest::get(url))
.await
.map_err(|_| PluginError::DownloadFailed {
url: url.to_owned(),
reason: format!("download timed out after {}s", self.download_timeout_secs),
})?
.map_err(|e| PluginError::DownloadFailed {
url: url.to_owned(),
reason: e.to_string(),
})?;
if !response.status().is_success() {
return Err(PluginError::DownloadFailed {
url: url.to_owned(),
reason: format!("HTTP {}", response.status()),
});
}
let bytes = tokio::time::timeout(timeout, response.bytes())
.await
.map_err(|_| PluginError::DownloadFailed {
url: url.to_owned(),
reason: format!("download timed out after {}s", self.download_timeout_secs),
})?
.map_err(|e| PluginError::DownloadFailed {
url: url.to_owned(),
reason: format!("failed to read response body: {e}"),
})?;
if let Some(expected) = expected_sha256 {
let actual = crate::integrity::sha256_hex(&bytes);
if actual != expected.to_ascii_lowercase() {
return Err(PluginError::IntegrityCheckFailed {
expected: expected.to_ascii_lowercase(),
actual,
});
}
tracing::debug!(url, "archive SHA-256 verified");
} else {
tracing::warn!(url, "installing remote plugin without integrity check");
}
let actual_sha256 = crate::integrity::sha256_hex(&bytes);
let tmp = tempfile::tempdir().map_err(|e| PluginError::Io {
path: std::path::PathBuf::from(url),
source: e,
})?;
extract_archive(&bytes, tmp.path(), url)?;
let plugins_dir = self.plugins_dir.clone();
let managed_skills_dir = self.managed_skills_dir.clone();
let mcp_allowed_commands = self.mcp_allowed_commands.clone();
let base_allowed_commands = self.base_allowed_commands.clone();
let integrity_registry_path = self.integrity_registry_path.clone();
let source_str = tmp.path().to_str().unwrap_or(url).to_owned();
let result = tokio::task::spawn_blocking(move || {
let mgr = PluginManager {
plugins_dir,
managed_skills_dir,
mcp_allowed_commands,
base_allowed_commands,
integrity_registry_path,
download_timeout_secs: 0, };
mgr.add(&source_str)
})
.await
.map_err(|e| PluginError::Io {
path: std::path::PathBuf::from(url),
source: std::io::Error::other(e),
})??;
let source = PluginSource {
url: Some(url.to_owned()),
sha256: Some(actual_sha256),
};
let source_path = self
.plugins_dir
.join(&result.name)
.join(".plugin-source.toml");
match toml::to_string(&source) {
Ok(toml_str) => {
if let Err(e) = std::fs::write(&source_path, toml_str) {
tracing::warn!(
plugin = %result.name,
error = %e,
"failed to persist plugin source metadata; auto_update will be skipped"
);
}
}
Err(e) => {
tracing::warn!(
plugin = %result.name,
error = %e,
"failed to serialize plugin source metadata; auto_update will be skipped"
);
}
}
Ok(result)
}
pub fn remove(&self, name: &str) -> Result<RemoveResult, PluginError> {
validate_plugin_name(name)?;
let plugin_dir = self.plugins_dir.join(name);
if !plugin_dir.exists() {
return Err(PluginError::NotFound {
name: name.to_owned(),
});
}
self.guard_no_dependents(name)?;
let manifest_path = plugin_dir.join(".plugin.toml");
let (removed_skills, removed_mcp_ids) = if manifest_path.exists() {
let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
path: manifest_path,
source: e,
})?;
let text = String::from_utf8(bytes).map_err(|_| {
PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned())
})?;
let manifest: PluginManifest =
toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
let skills = collect_skill_names(&plugin_dir, &manifest);
let mcp = manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
(skills, mcp)
} else {
(Vec::new(), Vec::new())
};
std::fs::remove_dir_all(&plugin_dir).map_err(|e| PluginError::Io {
path: plugin_dir,
source: e,
})?;
let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
registry.remove(name);
if let Err(e) = registry.save(&self.integrity_registry_path) {
tracing::warn!(plugin = %name, error = %e, "failed to update integrity registry after remove");
}
tracing::info!(plugin = %name, "plugin removed");
Ok(RemoveResult {
removed_skills,
removed_mcp_ids,
})
}
pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, PluginError> {
if !self.plugins_dir.exists() {
return Ok(Vec::new());
}
let mut plugins = Vec::new();
let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| PluginError::Io {
path: self.plugins_dir.clone(),
source: e,
})?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join(".plugin.toml");
if !manifest_path.exists() {
continue;
}
let Ok(bytes) = std::fs::read(&manifest_path) else {
continue;
};
let Ok(text) = String::from_utf8(bytes) else {
continue;
};
let Ok(manifest): Result<PluginManifest, _> = toml::from_str(&text) else {
continue;
};
let skill_names = collect_skill_names(&path, &manifest);
let auto_update = manifest.plugin.auto_update;
plugins.push(InstalledPlugin {
name: manifest.plugin.name,
version: manifest.plugin.version,
description: manifest.plugin.description,
path,
skill_names,
auto_update,
});
}
plugins.sort_by(|a, b| a.name.cmp(&b.name));
Ok(plugins)
}
pub fn collect_skill_dirs(&self) -> Result<Vec<PathBuf>, PluginError> {
if !self.plugins_dir.exists() {
return Ok(Vec::new());
}
let mut dirs = Vec::new();
let plugins = self.list_installed()?;
for plugin in &plugins {
if plugin.path.join(".disabled").exists() {
continue;
}
let manifest_path = plugin.path.join(".plugin.toml");
if let Ok(bytes) = std::fs::read(&manifest_path)
&& let Ok(text) = String::from_utf8(bytes)
&& let Ok(manifest) = toml::from_str::<PluginManifest>(&text)
{
for entry in &manifest.skills {
let skill_dir = plugin.path.join(&entry.path);
let ok = skill_dir
.canonicalize()
.is_ok_and(|c| c.starts_with(&plugin.path));
if ok {
dirs.push(skill_dir);
} else {
tracing::warn!(
plugin = %plugin.name,
path = %entry.path,
"skipping skill path that escapes plugin root"
);
}
}
}
}
Ok(dirs)
}
pub async fn check_auto_updates(&self) -> Vec<AutoUpdateResult> {
use futures::stream::{self, StreamExt as _};
use tracing::Instrument as _;
async {
let candidates = match self.list_installed() {
Ok(list) => list,
Err(e) => {
tracing::warn!(error = %e, "check_auto_updates: failed to list installed plugins");
return Vec::new();
}
};
stream::iter(candidates.into_iter().filter(|p| p.auto_update))
.map(|plugin| async move {
let status = self.update_one_plugin(&plugin).await;
AutoUpdateResult {
name: plugin.name,
status,
}
})
.buffer_unordered(4)
.collect()
.await
}
.instrument(tracing::info_span!("plugins.manager.check_auto_updates"))
.await
}
async fn update_one_plugin(&self, plugin: &InstalledPlugin) -> AutoUpdateStatus {
let source_path = plugin.path.join(".plugin-source.toml");
let Some(source) = read_plugin_source(&source_path) else {
return AutoUpdateStatus::NoSource;
};
let (Some(url), Some(stored_sha256)) = (source.url, source.sha256) else {
return AutoUpdateStatus::NoSource;
};
if let Err(e) = validate_url_scheme(&url) {
tracing::warn!(plugin = %plugin.name, %url, error = %e, "auto-update: invalid URL scheme");
return AutoUpdateStatus::Failed(format!("invalid URL scheme: {e}"));
}
tracing::debug!(plugin = %plugin.name, %url, "checking for updates");
let timeout = std::time::Duration::from_secs(self.download_timeout_secs);
let bytes = match self.download_archive(&url, timeout).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(plugin = %plugin.name, %url, error = %e, "auto-update download failed");
return AutoUpdateStatus::Failed(e);
}
};
let new_sha256 = crate::integrity::sha256_hex(&bytes);
if new_sha256 == stored_sha256 {
tracing::debug!(plugin = %plugin.name, "auto-update: archive unchanged (SHA-256 match)");
return AutoUpdateStatus::UpToDate;
}
tracing::info!(
plugin = %plugin.name,
old_sha256 = %stored_sha256,
new_sha256 = %new_sha256,
"auto-update: new archive detected, applying update"
);
let old_version = plugin.version.clone();
let staging = self.plugins_dir.join(format!(".staging-{}", plugin.name));
let backup = self.plugins_dir.join(format!(".backup-{}", plugin.name));
let dest = plugin.path.clone();
let plugin_name = plugin.name.clone();
let mcp_allowed = self.mcp_allowed_commands.clone();
let managed_skills_dir = self.managed_skills_dir.clone();
let plugins_dir = self.plugins_dir.clone();
let integrity_registry_path = self.integrity_registry_path.clone();
let url_clone = url.clone();
let base_allowed_commands = self.base_allowed_commands.clone();
let result = tokio::task::spawn_blocking(move || {
apply_staged_update(
&bytes,
&url_clone,
&dest,
&staging,
&backup,
&plugin_name,
&mcp_allowed,
&managed_skills_dir,
&plugins_dir,
&integrity_registry_path,
&base_allowed_commands,
)
})
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(plugin = %plugin.name, error = %e, "auto-update: staged swap failed, original preserved");
return AutoUpdateStatus::Failed(e);
}
Err(e) => {
tracing::warn!(plugin = %plugin.name, error = %e, "auto-update: blocking task panicked");
return AutoUpdateStatus::Failed(format!("update task panicked: {e}"));
}
}
let new_source = PluginSource {
url: Some(url),
sha256: Some(new_sha256),
};
let source_dest = plugin.path.join(".plugin-source.toml");
if let Ok(toml_str) = toml::to_string(&new_source) {
let _ = std::fs::write(&source_dest, toml_str);
}
let new_version = std::fs::read_to_string(plugin.path.join(".plugin.toml"))
.ok()
.and_then(|s| toml::from_str::<crate::manifest::PluginManifest>(&s).ok())
.map_or_else(|| old_version.clone(), |m| m.plugin.version);
tracing::info!(
plugin = %plugin.name,
%old_version,
%new_version,
"auto-update: plugin updated successfully"
);
AutoUpdateStatus::Updated {
old_version,
new_version,
}
}
async fn download_archive(
&self,
url: &str,
timeout: std::time::Duration,
) -> Result<Vec<u8>, String> {
let response = tokio::time::timeout(timeout, reqwest::get(url))
.await
.map_err(|_| format!("download timed out after {}s", timeout.as_secs()))?
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let raw = tokio::time::timeout(timeout, response.bytes())
.await
.map_err(|_| format!("body read timed out after {}s", timeout.as_secs()))?
.map_err(|e| format!("failed to read body: {e}"))?;
Ok(raw.to_vec())
}
pub fn enable(&self, name: &str) -> Result<(), PluginError> {
validate_plugin_name(name)?;
let mut visiting: Vec<String> = Vec::new();
self.enable_recursive(name, &mut visiting)
}
fn enable_recursive(&self, name: &str, visiting: &mut Vec<String>) -> Result<(), PluginError> {
if visiting.iter().any(|v| v == name) {
let mut path = visiting.clone();
path.push(name.to_owned());
return Err(PluginError::DependencyCycle {
name: name.to_owned(),
cycle: path.join(" → "),
});
}
let plugin_dir = self.plugins_dir.join(name);
if !plugin_dir.exists() {
return Err(PluginError::NotFound {
name: name.to_owned(),
});
}
let disabled_marker = plugin_dir.join(".disabled");
if !disabled_marker.exists() {
return Ok(());
}
let manifest = load_installed_manifest(&plugin_dir)?;
visiting.push(name.to_owned());
for dep in &manifest.plugin.dependencies {
let dep_dir = self.plugins_dir.join(dep);
if !dep_dir.exists() {
visiting.pop();
return Err(PluginError::MissingDependency {
name: name.to_owned(),
dependency: dep.clone(),
});
}
self.enable_recursive(dep, visiting)?;
}
visiting.pop();
std::fs::remove_file(&disabled_marker).map_err(|e| PluginError::Io {
path: disabled_marker.clone(),
source: e,
})?;
tracing::info!(plugin = %name, "plugin enabled");
Ok(())
}
pub fn disable(&self, name: &str) -> Result<(), PluginError> {
validate_plugin_name(name)?;
let plugin_dir = self.plugins_dir.join(name);
if !plugin_dir.exists() {
return Err(PluginError::NotFound {
name: name.to_owned(),
});
}
self.guard_no_dependents(name)?;
let disabled_marker = plugin_dir.join(".disabled");
if disabled_marker.exists() {
return Ok(());
}
std::fs::write(&disabled_marker, b"").map_err(|e| PluginError::Io {
path: disabled_marker.clone(),
source: e,
})?;
tracing::info!(plugin = %name, "plugin disabled");
Ok(())
}
fn dependents_of(&self, name: &str) -> Vec<String> {
if !self.plugins_dir.exists() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(&self.plugins_dir) else {
return Vec::new();
};
let mut dependents = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.join(".disabled").exists() {
continue;
}
let Ok(manifest) = load_installed_manifest(&path) else {
continue;
};
if manifest.plugin.name == name {
continue;
}
if manifest.plugin.dependencies.iter().any(|d| d == name) {
dependents.push(manifest.plugin.name);
}
}
dependents.sort();
dependents
}
fn guard_no_dependents(&self, name: &str) -> Result<(), PluginError> {
let dependents = self.dependents_of(name);
if dependents.is_empty() {
return Ok(());
}
let hints = dependents
.iter()
.map(|d| format!(" zeph plugin disable {d}"))
.collect::<Vec<_>>()
.join("\n");
Err(PluginError::DependencyRequired {
name: name.to_owned(),
dependents: dependents.join(", "),
hints,
})
}
pub(crate) fn check_skill_conflicts_for_update(
&self,
skill_names: &[String],
this_plugin: &str,
) -> Result<(), PluginError> {
self.check_skill_conflicts(skill_names, this_plugin)
}
fn check_skill_conflicts(
&self,
skill_names: &[String],
this_plugin: &str,
) -> Result<(), PluginError> {
let bundled = bundled_skill_names();
let managed_registry = {
let dirs: Vec<PathBuf> = if self.managed_skills_dir.exists() {
vec![self.managed_skills_dir.clone()]
} else {
vec![]
};
SkillRegistry::load(&dirs)
};
let managed_names: std::collections::HashSet<String> = managed_registry
.all_meta()
.iter()
.map(|m| m.name.clone())
.collect();
let installed = self.list_installed().unwrap_or_default();
let mut other_plugin_skills: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for plugin in &installed {
if plugin.name == this_plugin {
continue;
}
for name in &plugin.skill_names {
other_plugin_skills.insert(name.clone(), plugin.name.clone());
}
}
for name in skill_names {
if bundled.contains(name) {
return Err(PluginError::SkillNameConflictWithBundled { name: name.clone() });
}
if managed_names.contains(name) {
return Err(PluginError::SkillNameConflictWithManaged { name: name.clone() });
}
if let Some(other) = other_plugin_skills.get(name) {
return Err(PluginError::SkillNameConflictWithPlugin {
name: name.clone(),
plugin: other.clone(),
});
}
}
Ok(())
}
}
fn read_plugin_source(path: &std::path::Path) -> Option<PluginSource> {
let text = std::fs::read_to_string(path).ok()?;
match toml::from_str::<PluginSource>(&text) {
Ok(s) => Some(s),
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "cannot parse .plugin-source.toml");
None
}
}
}
pub(crate) fn validate_url_scheme(url: &str) -> Result<(), PluginError> {
let parsed = reqwest::Url::parse(url).map_err(|_| PluginError::InvalidSource {
path: url.to_owned(),
reason: "URL is not valid".to_owned(),
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(PluginError::InvalidSource {
path: url.to_owned(),
reason: format!(
"URL scheme {:?} is not allowed; only http and https are permitted",
parsed.scheme()
),
});
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply_staged_update(
bytes: &[u8],
url: &str,
dest: &std::path::Path,
staging: &std::path::Path,
backup: &std::path::Path,
installed_plugin_name: &str,
mcp_allowed_commands: &[String],
managed_skills_dir: &std::path::Path,
plugins_dir: &std::path::Path,
integrity_registry_path: &std::path::Path,
base_allowed_commands: &[String],
) -> Result<(), String> {
let _ = std::fs::remove_dir_all(staging);
let _ = std::fs::remove_dir_all(backup);
std::fs::create_dir_all(staging).map_err(|e| format!("failed to create staging dir: {e}"))?;
extract_archive_safe(bytes, staging, url).map_err(|e| e.to_string())?;
let staging_manifest = staging.join("plugin.toml");
if !staging_manifest.exists() {
let _ = std::fs::remove_dir_all(staging);
return Err("extracted archive does not contain plugin.toml".into());
}
let manifest_str = std::fs::read_to_string(&staging_manifest)
.map_err(|e| format!("cannot read staged plugin.toml: {e}"))?;
let manifest: crate::manifest::PluginManifest =
toml::from_str(&manifest_str).map_err(|e| format!("staged plugin.toml invalid: {e}"))?;
if let Err(e) = validate_plugin_name(&manifest.plugin.name) {
let _ = std::fs::remove_dir_all(staging);
return Err(format!("staged manifest has invalid plugin name: {e}"));
}
if manifest.plugin.name != installed_plugin_name {
let _ = std::fs::remove_dir_all(staging);
return Err(format!(
"staged manifest changes plugin name from {:?} to {:?}; update rejected",
installed_plugin_name, manifest.plugin.name
));
}
if let Err(e) = validate_overlay_keys(&manifest.config) {
let _ = std::fs::remove_dir_all(staging);
return Err(format!(
"staged manifest failed config overlay validation: {e}"
));
}
if let Err(e) = validate_mcp_commands(&manifest.mcp.servers, mcp_allowed_commands) {
let _ = std::fs::remove_dir_all(staging);
return Err(format!(
"staged manifest failed MCP command validation: {e}"
));
}
let tmp_mgr = crate::manager::PluginManager::new(
plugins_dir.to_path_buf(),
managed_skills_dir.to_path_buf(),
mcp_allowed_commands.to_vec(),
base_allowed_commands.to_vec(),
);
let staged_skill_names = collect_skill_names(staging, &manifest);
if let Err(e) =
tmp_mgr.check_skill_conflicts_for_update(&staged_skill_names, installed_plugin_name)
{
let _ = std::fs::remove_dir_all(staging);
return Err(format!("staged manifest failed skill conflict check: {e}"));
}
scan_skill_entries(staging, &manifest.skills, &manifest.plugin.name);
let installed_manifest_toml =
toml::to_string(&manifest).map_err(|e| format!("cannot serialize staged manifest: {e}"))?;
std::fs::write(staging.join(".plugin.toml"), &installed_manifest_toml)
.map_err(|e| format!("cannot write staged .plugin.toml: {e}"))?;
strip_bundled_markers(staging);
if dest.exists() {
std::fs::rename(dest, backup)
.map_err(|e| format!("failed to rename plugin dir to backup: {e}"))?;
}
if let Err(e) = std::fs::rename(staging, dest) {
if backup.exists() {
let _ = std::fs::rename(backup, dest);
}
return Err(format!("failed to rename staging dir to dest: {e}"));
}
let installed_manifest_path = dest.join(".plugin.toml");
let mut registry = crate::integrity::IntegrityRegistry::load(integrity_registry_path);
if let Err(e) = registry
.record(&manifest.plugin.name, &installed_manifest_path)
.and_then(|()| registry.save(integrity_registry_path))
{
tracing::warn!(
plugin = %manifest.plugin.name,
error = %e,
"auto-update: failed to update integrity registry after swap"
);
}
let _ = std::fs::remove_dir_all(backup);
Ok(())
}
pub(crate) fn validate_plugin_name(name: &str) -> Result<(), PluginError> {
if name.is_empty() {
return Err(PluginError::InvalidName {
name: name.to_owned(),
reason: "name must not be empty".to_owned(),
});
}
if name.len() > 64 {
return Err(PluginError::InvalidName {
name: name.to_owned(),
reason: "name must not exceed 64 characters".to_owned(),
});
}
if name.contains('/') || name.contains('\\') || name.contains('.') {
return Err(PluginError::InvalidName {
name: name.to_owned(),
reason: "name must not contain path separators or dots".to_owned(),
});
}
if !name.starts_with(|c: char| c.is_ascii_lowercase()) {
return Err(PluginError::InvalidName {
name: name.to_owned(),
reason: "name must start with a lowercase ASCII letter [a-z]".to_owned(),
});
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(PluginError::InvalidName {
name: name.to_owned(),
reason: "name must match [a-z][a-z0-9-]*".to_owned(),
});
}
Ok(())
}
fn check_allowed_commands_overlay_effect(
config: &toml::Value,
base_allowed: &[String],
plugin_name: &str,
) -> Option<String> {
let overlay_has_entries = config
.as_table()
.and_then(|t| t.get("tools"))
.and_then(toml::Value::as_table)
.and_then(|t| t.get("allowed_commands"))
.and_then(toml::Value::as_array)
.is_some_and(|arr| arr.iter().any(toml::Value::is_str));
if !overlay_has_entries {
return None;
}
if !base_allowed.is_empty() {
return None;
}
Some(format!(
"plugin {plugin_name:?} declares allowed_commands overlay but the host \
has no tools.shell.allowed_commands configured; overlay will have no effect \
at load time (tighten-only: plugins cannot widen an empty base allowlist). \
Install proceeds. To use this overlay, set tools.shell.allowed_commands \
in your base config."
))
}
pub(crate) fn validate_overlay_keys(config: &toml::Value) -> Result<(), PluginError> {
let table = match config.as_table() {
Some(t) if !t.is_empty() => t,
_ => return Ok(()),
};
for (section, inner) in table {
let inner_table = inner.as_table().ok_or_else(|| PluginError::UnsafeOverlay {
key: section.clone(),
})?;
for key in inner_table.keys() {
let dotted = format!("{section}.{key}");
if !CONFIG_SAFELIST.contains(&dotted.as_str()) {
return Err(PluginError::UnsafeOverlay { key: dotted });
}
}
}
Ok(())
}
fn validate_mcp_commands(
servers: &[PluginMcpServer],
allowed: &[String],
) -> Result<(), PluginError> {
for server in servers {
if let Some(cmd) = &server.command {
let ok = allowed.iter().any(|a| a == cmd);
if !ok {
return Err(PluginError::DisallowedMcpCommand {
id: server.id.clone(),
command: cmd.clone(),
});
}
}
}
Ok(())
}
fn scan_skill_entries(
source_root: &Path,
entries: &[crate::manifest::SkillEntry],
plugin_name: &str,
) {
let span = tracing::info_span!("plugins.manager.skill_scan", plugin = %plugin_name);
let _guard = span.enter();
for entry in entries {
let skill_md_path = source_root.join(&entry.path).join("SKILL.md");
match std::fs::read_to_string(&skill_md_path) {
Ok(content) => {
let result = scan_skill_body(&content);
if result.has_matches() {
tracing::warn!(
plugin = %plugin_name,
skill = %entry.path,
patterns = ?result.matched_patterns,
"SKILL.md matched injection/exfiltration patterns (advisory)"
);
}
}
Err(e) => {
tracing::warn!(
plugin = %plugin_name,
skill = %entry.path,
error = %e,
"could not read SKILL.md for scan"
);
}
}
}
}
fn collect_skill_names(root: &Path, manifest: &PluginManifest) -> Vec<String> {
let mut parent_dirs: Vec<PathBuf> = manifest
.skills
.iter()
.filter_map(|e| {
let p = root.join(&e.path);
p.parent().map(Path::to_path_buf)
})
.collect();
parent_dirs.sort();
parent_dirs.dedup();
if parent_dirs.is_empty() {
return Vec::new();
}
let allowed: std::collections::HashSet<PathBuf> =
manifest.skills.iter().map(|e| root.join(&e.path)).collect();
let registry = SkillRegistry::load(&parent_dirs);
registry
.all_meta()
.iter()
.filter(|m| allowed.contains(&m.skill_dir))
.map(|m| m.name.clone())
.collect()
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), PluginError> {
if dst.exists() {
std::fs::remove_dir_all(dst).map_err(|e| PluginError::Io {
path: dst.to_path_buf(),
source: e,
})?;
}
std::fs::create_dir_all(dst).map_err(|e| PluginError::Io {
path: dst.to_path_buf(),
source: e,
})?;
for entry in WalkDir::new(src).min_depth(1) {
let entry = entry.map_err(|e| PluginError::Io {
path: src.to_path_buf(),
source: std::io::Error::other(e.to_string()),
})?;
let rel = entry
.path()
.strip_prefix(src)
.expect("walkdir yields paths under src");
let target = dst.join(rel);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&target).map_err(|e| PluginError::Io {
path: target,
source: e,
})?;
} else {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| PluginError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::copy(entry.path(), &target).map_err(|e| PluginError::Io {
path: target,
source: e,
})?;
}
}
Ok(())
}
fn extract_archive(bytes: &[u8], dest: &Path, url: &str) -> Result<(), PluginError> {
if !bytes.starts_with(&[0x1f, 0x8b]) {
return Err(PluginError::InvalidSource {
path: url.to_owned(),
reason: "unsupported archive format: only .tar.gz is supported".to_owned(),
});
}
let gz = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(gz);
archive
.unpack(dest)
.map_err(|e| PluginError::InvalidSource {
path: url.to_owned(),
reason: format!("tar.gz extraction failed: {e}"),
})
}
fn extract_archive_safe(bytes: &[u8], dest: &Path, url: &str) -> Result<(), PluginError> {
if !bytes.starts_with(&[0x1f, 0x8b]) {
return Err(PluginError::InvalidSource {
path: url.to_owned(),
reason: "unsupported archive format: only .tar.gz is supported".to_owned(),
});
}
let gz = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(gz);
let entries = archive.entries().map_err(|e| PluginError::InvalidSource {
path: url.to_owned(),
reason: format!("cannot read tar entries: {e}"),
})?;
for entry in entries {
let mut entry = entry.map_err(|e| PluginError::InvalidSource {
path: url.to_owned(),
reason: format!("tar entry error: {e}"),
})?;
let entry_path_display = entry
.path()
.map_or_else(|_| "<invalid path>".to_owned(), |p| p.display().to_string());
{
let entry_path = entry.path().map_err(|e| PluginError::InvalidSource {
path: url.to_owned(),
reason: format!("invalid entry path: {e}"),
})?;
if entry_path.is_absolute() {
return Err(PluginError::InvalidSource {
path: url.to_owned(),
reason: format!("archive contains absolute path: {}", entry_path.display()),
});
}
if entry_path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
return Err(PluginError::InvalidSource {
path: url.to_owned(),
reason: format!(
"archive contains path traversal component: {}",
entry_path.display()
),
});
}
}
if entry.header().entry_type().is_symlink() {
return Err(PluginError::InvalidSource {
path: url.to_owned(),
reason: format!(
"archive contains a symlink entry: {entry_path_display}; symlinks are not permitted"
),
});
}
entry
.unpack_in(dest)
.map_err(|e| PluginError::InvalidSource {
path: url.to_owned(),
reason: format!("tar extraction failed for {entry_path_display}: {e}"),
})?;
}
Ok(())
}
fn load_installed_manifest(plugin_dir: &Path) -> Result<PluginManifest, PluginError> {
let manifest_path = plugin_dir.join(".plugin.toml");
let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
path: manifest_path.clone(),
source: e,
})?;
let text = String::from_utf8(bytes)
.map_err(|_| PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned()))?;
toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))
}
fn strip_bundled_markers(root: &Path) {
for entry in WalkDir::new(root).into_iter().flatten() {
if entry.file_type().is_file() && entry.file_name().to_str() == Some(".bundled") {
let _ = std::fs::remove_file(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_plugin(dir: &Path, name: &str, manifest_toml: &str, skills: &[(&str, &str)]) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("plugin.toml"), manifest_toml).unwrap();
for (skill_name, body) in skills {
let skill_dir = dir.join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {skill_name}\ndescription: test\n---\n{body}"),
)
.unwrap();
std::fs::write(skill_dir.join(".bundled"), "").unwrap();
}
let _ = name;
}
fn simple_manifest(name: &str, skill: &str) -> String {
format!(
r#"[plugin]
name = "{name}"
version = "0.1.0"
description = "test plugin"
[[skills]]
path = "skills/{skill}"
"#
)
}
#[test]
fn add_and_list_plugin() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"test-plugin",
&simple_manifest("test-plugin", "my-skill"),
&[("my-skill", "Do stuff")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
let result = mgr.add(source.to_str().unwrap()).unwrap();
assert_eq!(result.name, "test-plugin");
assert!(result.installed_skills.contains(&"my-skill".to_owned()));
let installed = mgr.list_installed().unwrap();
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "test-plugin");
}
#[test]
fn bundled_markers_stripped_on_install() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"strip-test",
&simple_manifest("strip-test", "my-skill"),
&[("my-skill", "Body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let has_bundled = WalkDir::new(&plugins_dir)
.into_iter()
.flatten()
.any(|e| e.file_name().to_str() == Some(".bundled"));
assert!(!has_bundled, ".bundled markers were not stripped");
}
#[test]
fn mcp_disallowed_command_fails_install() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "mcp-test"
version = "0.1.0"
description = "test"
[[mcp.servers]]
id = "bad-server"
command = "dangerous-binary"
"#;
write_plugin(&source, "mcp-test", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(matches!(err, PluginError::DisallowedMcpCommand { .. }));
}
#[test]
fn unsafe_config_overlay_fails_install() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "overlay-test"
version = "0.1.0"
description = "test"
[config.llm]
model = "evil"
"#;
write_plugin(&source, "overlay-test", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
}
#[test]
fn max_active_skills_overlay_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "max-skills-test"
version = "0.1.0"
description = "test"
[config.skills]
max_active_skills = 10
"#;
write_plugin(&source, "max-skills-test", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
}
#[test]
fn safe_config_overlay_is_accepted() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "safe-overlay"
version = "0.1.0"
description = "test"
[config.skills]
disambiguation_threshold = 0.05
[config.tools]
blocked_commands = ["rm -rf"]
"#;
write_plugin(&source, "safe-overlay", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let result = mgr.add(source.to_str().unwrap()).unwrap();
assert_eq!(result.name, "safe-overlay");
}
#[test]
fn remove_plugin() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"removable",
&simple_manifest("removable", "my-skill"),
&[("my-skill", "Body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let result = mgr.remove("removable").unwrap();
assert!(result.removed_skills.contains(&"my-skill".to_owned()));
let installed = mgr.list_installed().unwrap();
assert!(installed.is_empty());
}
#[test]
fn remove_nonexistent_plugin_returns_not_found() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let mgr = PluginManager::new(plugins_dir, tmp.path().to_path_buf(), vec![], vec![]);
let err = mgr.remove("no-such-plugin").unwrap_err();
assert!(matches!(err, PluginError::NotFound { .. }));
}
#[test]
fn invalid_plugin_name_with_slash_rejected() {
let err = validate_plugin_name("foo/bar").unwrap_err();
assert!(matches!(err, PluginError::InvalidName { .. }));
}
#[test]
fn plugin_name_with_uppercase_rejected() {
let err = validate_plugin_name("FooBar").unwrap_err();
assert!(matches!(err, PluginError::InvalidName { .. }));
}
#[test]
fn valid_plugin_names_accepted() {
assert!(validate_plugin_name("foo").is_ok());
assert!(validate_plugin_name("foo-bar").is_ok());
assert!(validate_plugin_name("foo123").is_ok());
}
#[test]
fn bundled_skill_conflict_detected() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let bundled = bundled_skill_names();
if bundled.is_empty() {
return;
}
let conflict_name = &bundled[0];
let manifest = format!(
r#"[plugin]
name = "conflict-test"
version = "0.1.0"
description = "test"
[[skills]]
path = "skills/{conflict_name}"
"#
);
write_plugin(
&source,
"conflict-test",
&manifest,
&[(conflict_name, "body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(matches!(
err,
PluginError::SkillNameConflictWithBundled { .. }
));
}
#[test]
fn path_traversal_in_skill_path_rejected() {
let tmp = tempfile::tempdir().unwrap();
let real_tmp = tmp.path().canonicalize().unwrap();
let source = real_tmp.join("source");
let outside = real_tmp.join("outside-skill");
std::fs::create_dir_all(&outside).unwrap();
let manifest = r#"[plugin]
name = "traversal-test"
version = "0.1.0"
description = "test"
[[skills]]
path = "../outside-skill"
"#;
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("plugin.toml"), manifest).unwrap();
let plugins_dir = real_tmp.join("plugins");
let managed_dir = real_tmp.join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::InvalidSource { .. }),
"expected InvalidSource for path traversal, got {err:?}"
);
}
#[test]
#[cfg(unix)]
fn skill_path_canonicalize_failure_returns_io_error() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
let skill_dir = source.join("skills").join("broken-skill");
std::fs::create_dir_all(source.join("skills")).unwrap();
std::os::unix::fs::symlink("/nonexistent/target", &skill_dir).unwrap();
let manifest = r#"[plugin]
name = "broken-link-test"
version = "0.1.0"
description = "test"
[[skills]]
path = "skills/broken-skill"
"#;
std::fs::write(source.join("plugin.toml"), manifest).unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::Io { .. }),
"expected Io error when canonicalize fails on broken symlink, got {err:?}"
);
}
#[test]
fn mcp_basename_bypass_rejected() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "basename-bypass"
version = "0.1.0"
description = "test"
[[mcp.servers]]
id = "evil"
command = "/tmp/evil/npx"
"#;
write_plugin(&source, "basename-bypass", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::DisallowedMcpCommand { .. }),
"expected DisallowedMcpCommand for basename bypass, got {err:?}"
);
}
#[test]
fn managed_skill_conflict_detected() {
let tmp = tempfile::tempdir().unwrap();
let managed_dir = tmp.path().join("managed");
let managed_skill = managed_dir.join("my-skill");
std::fs::create_dir_all(&managed_skill).unwrap();
std::fs::write(
managed_skill.join("SKILL.md"),
"---\nname: my-skill\ndescription: managed\n---\nbody",
)
.unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"conflict-managed",
&simple_manifest("conflict-managed", "my-skill"),
&[("my-skill", "body")],
);
let plugins_dir = tmp.path().join("plugins");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::SkillNameConflictWithManaged { .. }),
"expected SkillNameConflictWithManaged, got {err:?}"
);
}
#[test]
fn cross_plugin_skill_conflict_detected() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let source_a = tmp.path().join("source_a");
write_plugin(
&source_a,
"plugin-a",
&simple_manifest("plugin-a", "shared-skill"),
&[("shared-skill", "body")],
);
mgr.add(source_a.to_str().unwrap()).unwrap();
let source_b = tmp.path().join("source_b");
write_plugin(
&source_b,
"plugin-b",
&simple_manifest("plugin-b", "shared-skill"),
&[("shared-skill", "body")],
);
let err = mgr.add(source_b.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::SkillNameConflictWithPlugin { .. }),
"expected SkillNameConflictWithPlugin, got {err:?}"
);
}
#[test]
fn allowed_commands_overlay_with_empty_base_warns() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "warn-test"
version = "0.1.0"
description = "test"
[config.tools]
allowed_commands = ["curl", "git"]
"#;
write_plugin(&source, "warn-test", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let result = mgr.add(source.to_str().unwrap()).unwrap();
assert_eq!(result.warnings.len(), 1);
let msg = &result.warnings[0];
assert!(
msg.contains("warn-test"),
"warning must contain plugin name"
);
assert!(
msg.contains("allowed_commands"),
"warning must mention allowed_commands"
);
assert!(msg.is_ascii(), "warning message must be ASCII-only");
}
#[test]
fn allowed_commands_overlay_with_non_empty_base_no_warn() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "no-warn-test"
version = "0.1.0"
description = "test"
[config.tools]
allowed_commands = ["curl"]
"#;
write_plugin(&source, "no-warn-test", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(
plugins_dir,
managed_dir,
vec![],
vec!["curl".to_owned(), "git".to_owned()],
);
let result = mgr.add(source.to_str().unwrap()).unwrap();
assert!(result.warnings.is_empty());
}
#[test]
fn empty_allowed_commands_array_no_warn() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "empty-overlay"
version = "0.1.0"
description = "test"
[config.tools]
allowed_commands = []
"#;
write_plugin(&source, "empty-overlay", manifest, &[]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let result = mgr.add(source.to_str().unwrap()).unwrap();
assert!(result.warnings.is_empty());
}
#[test]
fn list_installed_ignores_non_directory_entries() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().to_path_buf();
std::fs::write(plugins_dir.join(".plugin-integrity.toml"), b"plugins = {}").unwrap();
std::fs::write(plugins_dir.join("README.txt"), b"docs").unwrap();
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
assert!(
mgr.list_installed().unwrap().is_empty(),
"non-directory entries inside plugins_dir must not be surfaced as installed plugins"
);
}
#[test]
fn validate_plugin_name_empty_string_rejected() {
let err = validate_plugin_name("").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for empty string, got {err:?}"
);
}
#[test]
fn validate_plugin_name_with_dot_rejected() {
let err = validate_plugin_name("foo.bar").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for name with dot, got {err:?}"
);
}
#[test]
fn validate_plugin_name_with_backslash_rejected() {
let err = validate_plugin_name("foo\\bar").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for name with backslash, got {err:?}"
);
}
#[test]
fn validate_plugin_name_with_space_rejected() {
let err = validate_plugin_name("foo bar").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for name with space, got {err:?}"
);
}
#[test]
fn validate_plugin_name_max_length_boundary() {
assert!(validate_plugin_name(&"a".repeat(64)).is_ok());
let err = validate_plugin_name(&"a".repeat(65)).unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for 65-char name, got {err:?}"
);
}
#[test]
fn validate_plugin_name_leading_dash_rejected() {
let err = validate_plugin_name("-foo").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for leading dash, got {err:?}"
);
}
#[test]
fn validate_plugin_name_leading_digit_rejected() {
let err = validate_plugin_name("123").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for digit-only name, got {err:?}"
);
let err = validate_plugin_name("1abc").unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for digit-prefixed name, got {err:?}"
);
}
#[test]
fn validate_plugin_name_valid_names_accepted() {
assert!(validate_plugin_name("abc").is_ok());
assert!(validate_plugin_name("my-plugin").is_ok());
assert!(validate_plugin_name("plugin123").is_ok());
}
#[test]
fn validate_overlay_keys_empty_config_accepted() {
let config = toml::Value::Table(toml::map::Map::new());
assert!(validate_overlay_keys(&config).is_ok());
}
#[test]
fn validate_overlay_keys_safe_keys_accepted() {
let toml_str = r#"
[tools]
blocked_commands = ["rm -rf /"]
allowed_commands = ["git"]
[skills]
disambiguation_threshold = 0.8
"#;
let config: toml::Value = toml::from_str(toml_str).unwrap();
assert!(validate_overlay_keys(&config).is_ok());
}
#[test]
fn validate_overlay_keys_unsafe_key_rejected() {
let toml_str = r#"
[llm]
model = "evil-model"
"#;
let config: toml::Value = toml::from_str(toml_str).unwrap();
let err = validate_overlay_keys(&config).unwrap_err();
assert!(
matches!(err, PluginError::UnsafeOverlay { ref key } if key == "llm.model"),
"expected UnsafeOverlay with key=\"llm.model\", got {err:?}"
);
}
#[test]
fn validate_overlay_keys_non_table_section_rejected() {
let toml_str = r#"
tools = "not-a-table"
"#;
let config: toml::Value = toml::from_str(toml_str).unwrap();
let err = validate_overlay_keys(&config).unwrap_err();
assert!(
matches!(err, PluginError::UnsafeOverlay { .. }),
"expected UnsafeOverlay for non-table section, got {err:?}"
);
}
#[test]
fn list_installed_returns_plugins_sorted_alphabetically() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let plugins = [
("zeta-plugin", "skill-zeta"),
("beta-plugin", "skill-beta"),
("alpha-plugin", "skill-alpha"),
];
for (name, skill) in &plugins {
let source = tmp.path().join(format!("src-{name}"));
write_plugin(
&source,
name,
&simple_manifest(name, skill),
&[(skill, "body")],
);
mgr.add(source.to_str().unwrap()).unwrap();
}
let installed = mgr.list_installed().unwrap();
let names: Vec<&str> = installed.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
names,
vec!["alpha-plugin", "beta-plugin", "zeta-plugin"],
"list_installed must return plugins in alphabetical order regardless of install order"
);
}
#[test]
fn add_skill_entry_without_skill_md_returns_skill_entry_missing() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
std::fs::create_dir_all(source.join("skills").join("no-skill-md")).unwrap();
let manifest = r#"[plugin]
name = "missing-skill-md"
version = "0.1.0"
description = "test"
[[skills]]
path = "skills/no-skill-md"
"#;
std::fs::write(source.join("plugin.toml"), manifest).unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr.add(source.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::SkillEntryMissing { .. }),
"expected SkillEntryMissing when SKILL.md is absent, got {err:?}"
);
}
#[test]
fn collect_skill_dirs_empty_when_no_plugins_installed() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().canonicalize().unwrap();
let plugins_dir = real.join("plugins");
let mgr = PluginManager::new(plugins_dir, real.clone(), vec![], vec![]);
let dirs = mgr.collect_skill_dirs().unwrap();
assert!(dirs.is_empty());
}
#[test]
fn collect_skill_dirs_returns_installed_skill_paths() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().canonicalize().unwrap();
let plugins_dir = real.join("plugins");
let managed_dir = real.join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let source = real.join("source");
write_plugin(
&source,
"dir-plugin",
&simple_manifest("dir-plugin", "my-skill"),
&[("my-skill", "body")],
);
mgr.add(source.to_str().unwrap()).unwrap();
let dirs = mgr.collect_skill_dirs().unwrap();
assert_eq!(dirs.len(), 1, "expected exactly one skill dir");
assert!(
dirs[0].ends_with("skills/my-skill"),
"skill dir path must end with skills/my-skill, got {:?}",
dirs[0]
);
}
#[test]
fn extract_archive_rejects_non_gz_bytes() {
let fake_bytes = b"PK\x03\x04not a tar.gz";
let tmp = tempfile::tempdir().unwrap();
let err =
extract_archive(fake_bytes, tmp.path(), "http://example.com/plugin.zip").unwrap_err();
assert!(
matches!(err, PluginError::InvalidSource { .. }),
"non-gz archive must return InvalidSource, got {err:?}"
);
}
#[test]
fn sha256_integrity_mismatch_returns_correct_error() {
let archive_bytes = b"fake archive content";
let actual = crate::integrity::sha256_hex(archive_bytes);
let wrong_expected = "0000000000000000000000000000000000000000000000000000000000000000";
assert_ne!(
actual, wrong_expected,
"sha256 of non-zero bytes must not match all-zero expected"
);
let err = PluginError::IntegrityCheckFailed {
expected: wrong_expected.to_owned(),
actual: actual.clone(),
};
assert!(
err.to_string().contains("integrity check failed"),
"error message must mention integrity check"
);
assert!(
err.to_string().contains(&actual),
"error message must contain actual hash"
);
}
#[test]
fn collect_skill_dirs_aggregates_multiple_plugins() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().canonicalize().unwrap();
let plugins_dir = real.join("plugins");
let managed_dir = real.join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
for (plugin_name, skill_name) in &[("plugin-a", "skill-a"), ("plugin-b", "skill-b")] {
let source = real.join(plugin_name);
write_plugin(
&source,
plugin_name,
&simple_manifest(plugin_name, skill_name),
&[(skill_name, "body")],
);
mgr.add(source.to_str().unwrap()).unwrap();
}
let dirs = mgr.collect_skill_dirs().unwrap();
assert_eq!(dirs.len(), 2, "expected two skill dirs from two plugins");
}
#[cfg(test)]
fn build_tar_gz(source: &std::path::Path) -> Vec<u8> {
let buf = Vec::new();
let gz = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
let mut tar = tar::Builder::new(gz);
tar.append_dir_all(".", source).unwrap();
let gz = tar.into_inner().unwrap();
gz.finish().unwrap()
}
#[tokio::test]
async fn add_remote_correct_hash_installs_plugin() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"remote-plugin",
&simple_manifest("remote-plugin", "my-skill"),
&[("my-skill", "Do remote stuff")],
);
let archive = build_tar_gz(&source);
let expected_hash = crate::integrity::sha256_hex(&archive);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive)
.append_header("Content-Type", "application/octet-stream"),
)
.mount(&mock_server)
.await;
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let url = format!("{}/remote-plugin.tar.gz", mock_server.uri());
let result = mgr.add_remote(&url, Some(&expected_hash)).await.unwrap();
assert_eq!(result.name, "remote-plugin");
assert!(result.installed_skills.contains(&"my-skill".to_owned()));
}
#[tokio::test]
async fn add_remote_connect_timeout_returns_download_failed() {
use std::time::Duration;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"timeout-plugin",
&simple_manifest("timeout-plugin", "t-skill"),
&[("t-skill", "body")],
);
let archive = build_tar_gz(&source);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive)
.set_delay(Duration::from_secs(3)),
)
.mount(&mock_server)
.await;
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![])
.with_download_timeout_secs(1);
let url = format!("{}/timeout-plugin.tar.gz", mock_server.uri());
let err = mgr.add_remote(&url, None).await.unwrap_err();
assert!(
matches!(err, PluginError::DownloadFailed { ref reason, .. } if reason.contains("timed out")),
"slow response must produce DownloadFailed with timeout message, got {err:?}"
);
}
#[tokio::test]
async fn add_remote_wrong_hash_returns_integrity_error() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"bad-plugin",
&simple_manifest("bad-plugin", "bad-skill"),
&[("bad-skill", "Body")],
);
let archive = build_tar_gz(&source);
let wrong_hash = "0".repeat(64);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive)
.append_header("Content-Type", "application/octet-stream"),
)
.mount(&mock_server)
.await;
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let url = format!("{}/bad-plugin.tar.gz", mock_server.uri());
let err = mgr.add_remote(&url, Some(&wrong_hash)).await.unwrap_err();
assert!(
matches!(err, PluginError::IntegrityCheckFailed { .. }),
"wrong hash must produce IntegrityCheckFailed, got {err:?}"
);
}
#[tokio::test]
async fn add_remote_persists_plugin_source_sidecar() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"src-plugin",
&simple_manifest("src-plugin", "src-skill"),
&[("src-skill", "body")],
);
let archive = build_tar_gz(&source);
let expected_hash = crate::integrity::sha256_hex(&archive);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive)
.append_header("Content-Type", "application/octet-stream"),
)
.mount(&mock_server)
.await;
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
let url = format!("{}/src-plugin.tar.gz", mock_server.uri());
mgr.add_remote(&url, Some(&expected_hash)).await.unwrap();
let sidecar = plugins_dir.join("src-plugin").join(".plugin-source.toml");
assert!(
sidecar.exists(),
".plugin-source.toml must be written after add_remote"
);
let parsed: PluginSource =
toml::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap();
assert_eq!(parsed.url.as_deref(), Some(url.as_str()));
assert_eq!(parsed.sha256.as_deref(), Some(expected_hash.as_str()));
}
#[test]
fn list_installed_exposes_auto_update_field() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "auto-update-plugin"
version = "0.1.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&source,
"auto-update-plugin",
manifest,
&[("my-skill", "body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let installed = mgr.list_installed().unwrap();
assert_eq!(installed.len(), 1);
assert!(
installed[0].auto_update,
"InstalledPlugin.auto_update must reflect manifest auto_update = true"
);
}
#[test]
fn list_installed_auto_update_defaults_to_false() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"no-update-plugin",
&simple_manifest("no-update-plugin", "skill-a"),
&[("skill-a", "body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let installed = mgr.list_installed().unwrap();
assert!(
!installed[0].auto_update,
"auto_update must default to false"
);
}
#[tokio::test]
async fn check_auto_updates_skips_local_installs() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "local-autoupdate"
version = "0.1.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&source,
"local-autoupdate",
manifest,
&[("my-skill", "body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let results = mgr.check_auto_updates().await;
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].status, AutoUpdateStatus::NoSource),
"local-installed plugin must return NoSource, got {:?}",
results[0].status
);
}
#[tokio::test]
async fn check_auto_updates_up_to_date_when_sha256_unchanged() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "up-to-date-plugin"
version = "0.2.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&source,
"up-to-date-plugin",
manifest,
&[("my-skill", "body")],
);
let archive = build_tar_gz(&source);
let hash = crate::integrity::sha256_hex(&archive);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive.clone())
.append_header("Content-Type", "application/octet-stream"),
)
.expect(2) .mount(&mock_server)
.await;
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let url = format!("{}/plugin.tar.gz", mock_server.uri());
mgr.add_remote(&url, Some(&hash)).await.unwrap();
let results = mgr.check_auto_updates().await;
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].status, AutoUpdateStatus::UpToDate),
"identical archive must yield UpToDate, got {:?}",
results[0].status
);
}
#[tokio::test]
async fn check_auto_updates_applies_update_when_archive_changed() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let src_v1 = tmp.path().join("src-v1");
let manifest_v1 = r#"[plugin]
name = "update-test"
version = "0.1.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&src_v1,
"update-test",
manifest_v1,
&[("my-skill", "v1 body")],
);
let archive_v1 = build_tar_gz(&src_v1);
let hash_v1 = crate::integrity::sha256_hex(&archive_v1);
let src_v2 = tmp.path().join("src-v2");
let manifest_v2 = r#"[plugin]
name = "update-test"
version = "0.2.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&src_v2,
"update-test",
manifest_v2,
&[("my-skill", "v2 body")],
);
let archive_v2 = build_tar_gz(&src_v2);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive_v1)
.append_header("Content-Type", "application/octet-stream"),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive_v2)
.append_header("Content-Type", "application/octet-stream"),
)
.mount(&mock_server)
.await;
let url = format!("{}/plugin.tar.gz", mock_server.uri());
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
mgr.add_remote(&url, Some(&hash_v1)).await.unwrap();
let results = mgr.check_auto_updates().await;
assert_eq!(results.len(), 1);
assert!(
matches!(
&results[0].status,
AutoUpdateStatus::Updated { old_version, new_version }
if old_version == "0.1.0" && new_version == "0.2.0"
),
"changed archive must yield Updated(0.1.0 → 0.2.0), got {:?}",
results[0].status
);
let installed = mgr.list_installed().unwrap();
assert_eq!(installed[0].version, "0.2.0");
}
#[tokio::test]
async fn check_auto_updates_returns_failed_on_http_error() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "fail-update"
version = "0.1.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(&source, "fail-update", manifest, &[("my-skill", "body")]);
let archive = build_tar_gz(&source);
let hash = crate::integrity::sha256_hex(&archive);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive)
.append_header("Content-Type", "application/octet-stream"),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let url = format!("{}/fail-update.tar.gz", mock_server.uri());
mgr.add_remote(&url, Some(&hash)).await.unwrap();
let results = mgr.check_auto_updates().await;
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].status, AutoUpdateStatus::Failed(_)),
"HTTP 404 must yield Failed, got {:?}",
results[0].status
);
let installed = mgr.list_installed().unwrap();
assert_eq!(installed[0].version, "0.1.0");
}
#[tokio::test]
async fn check_auto_updates_skips_plugins_with_auto_update_false() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
write_plugin(
&source,
"no-autoupdate",
&simple_manifest("no-autoupdate", "skill-b"),
&[("skill-b", "body")],
);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let results = mgr.check_auto_updates().await;
assert!(
results.is_empty(),
"auto_update=false plugin must be excluded from results"
);
}
#[test]
fn validate_url_scheme_rejects_file_url() {
let err = validate_url_scheme("file:///etc/passwd").unwrap_err();
assert!(
matches!(err, PluginError::InvalidSource { ref reason, .. } if reason.contains("file")),
"file:// URL must be rejected, got {err:?}"
);
}
#[test]
fn validate_url_scheme_rejects_data_url() {
let err = validate_url_scheme("data:text/plain,hello").unwrap_err();
assert!(
matches!(err, PluginError::InvalidSource { .. }),
"data: URL must be rejected, got {err:?}"
);
}
#[test]
fn validate_url_scheme_accepts_https() {
assert!(validate_url_scheme("https://example.com/plugin.tar.gz").is_ok());
}
#[test]
fn validate_url_scheme_accepts_http() {
assert!(validate_url_scheme("http://example.com/plugin.tar.gz").is_ok());
}
#[test]
fn validate_url_scheme_rejects_invalid_url() {
let err = validate_url_scheme("not a url at all").unwrap_err();
assert!(
matches!(err, PluginError::InvalidSource { .. }),
"invalid URL must return InvalidSource, got {err:?}"
);
}
#[tokio::test]
async fn add_remote_rejects_file_scheme_url() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
let err = mgr
.add_remote("file:///etc/passwd", None)
.await
.unwrap_err();
assert!(
matches!(err, PluginError::InvalidSource { .. }),
"add_remote must reject file:// URL, got {err:?}"
);
}
#[tokio::test]
async fn check_auto_updates_rejects_file_scheme_in_source() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
let manifest = r#"[plugin]
name = "ssrf-test"
version = "0.1.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(&source, "ssrf-test", manifest, &[("my-skill", "body")]);
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
mgr.add(source.to_str().unwrap()).unwrap();
let sidecar = plugins_dir.join("ssrf-test").join(".plugin-source.toml");
std::fs::write(
&sidecar,
r#"url = "file:///etc/passwd"
sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
"#,
)
.unwrap();
let results = mgr.check_auto_updates().await;
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].status, AutoUpdateStatus::Failed(_)),
"file:// URL in source sidecar must yield Failed, got {:?}",
results[0].status
);
}
#[tokio::test]
async fn check_auto_updates_rejects_name_change_in_update() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let managed_dir = tmp.path().join("managed");
let src_v1 = tmp.path().join("src-v1");
let manifest_v1 = r#"[plugin]
name = "original-plugin"
version = "0.1.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&src_v1,
"original-plugin",
manifest_v1,
&[("my-skill", "v1")],
);
let archive_v1 = build_tar_gz(&src_v1);
let hash_v1 = crate::integrity::sha256_hex(&archive_v1);
let src_evil = tmp.path().join("src-evil");
let manifest_evil = r#"[plugin]
name = "evil-plugin"
version = "0.2.0"
description = "test"
auto_update = true
[[skills]]
path = "skills/my-skill"
"#;
write_plugin(
&src_evil,
"evil-plugin",
manifest_evil,
&[("my-skill", "evil")],
);
let archive_evil = build_tar_gz(&src_evil);
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive_v1)
.append_header("Content-Type", "application/octet-stream"),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(archive_evil)
.append_header("Content-Type", "application/octet-stream"),
)
.mount(&mock_server)
.await;
let url = format!("{}/plugin.tar.gz", mock_server.uri());
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
mgr.add_remote(&url, Some(&hash_v1)).await.unwrap();
let results = mgr.check_auto_updates().await;
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].status, AutoUpdateStatus::Failed(_)),
"name change in update archive must yield Failed, got {:?}",
results[0].status
);
let installed = mgr.list_installed().unwrap();
assert_eq!(installed[0].version, "0.1.0");
}
#[test]
fn extract_archive_safe_path_traversal_detection() {
let traversal = std::path::Path::new("subdir/../../../etc/evil");
let has_traversal = traversal
.components()
.any(|c| c == std::path::Component::ParentDir);
assert!(
has_traversal,
"path with .. components must be detected as a traversal attempt"
);
let safe = std::path::Path::new("plugin/skills/my-skill/SKILL.md");
let safe_ok = safe
.components()
.all(|c| c != std::path::Component::ParentDir);
assert!(safe_ok, "safe relative path must pass traversal check");
}
fn install_plugin_with_deps(plugins_dir: &Path, managed_dir: &Path, name: &str, deps: &[&str]) {
let plugin_src_raw = tempfile::tempdir().unwrap();
let plugin_src = plugin_src_raw.path().canonicalize().unwrap();
let deps_toml = deps
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ");
let skill_name = format!("skill-{name}");
let manifest = format!(
"[plugin]\nname = \"{name}\"\nversion = \"0.1.0\"\ndependencies = [{deps_toml}]\n\n[[skills]]\npath = \"skills/{skill_name}\"\n"
);
write_plugin(&plugin_src, name, &manifest, &[(&skill_name, "test skill")]);
let mgr = PluginManager::new(
plugins_dir.to_path_buf(),
managed_dir.to_path_buf(),
vec![],
vec![],
);
mgr.add(plugin_src.to_str().unwrap()).unwrap();
}
#[test]
fn dependencies_field_defaults_to_empty() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let installed = mgr.list_installed().unwrap();
assert_eq!(installed.len(), 1);
let manifest_path = plugins_dir.path().join("base").join(".plugin.toml");
let text = std::fs::read_to_string(manifest_path).unwrap();
let manifest: crate::manifest::PluginManifest = toml::from_str(&text).unwrap();
assert!(manifest.plugin.dependencies.is_empty());
}
#[test]
fn remove_refused_when_dependent_enabled() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let err = mgr.remove("base").unwrap_err();
assert!(
matches!(err, PluginError::DependencyRequired { ref name, .. } if name == "base"),
"expected DependencyRequired, got {err:?}"
);
}
#[test]
fn remove_succeeds_after_dependent_removed() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
mgr.remove("ext").unwrap();
mgr.remove("base").unwrap();
assert!(mgr.list_installed().unwrap().is_empty());
}
#[test]
fn disable_refused_when_dependent_enabled() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let err = mgr.disable("base").unwrap_err();
assert!(
matches!(err, PluginError::DependencyRequired { ref name, .. } if name == "base"),
"expected DependencyRequired, got {err:?}"
);
}
#[test]
fn disable_and_enable_roundtrip() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
mgr.disable("base").unwrap();
assert!(plugins_dir.path().join("base").join(".disabled").exists());
mgr.enable("base").unwrap();
assert!(!plugins_dir.path().join("base").join(".disabled").exists());
}
#[test]
fn disable_idempotent() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
mgr.disable("base").unwrap();
mgr.disable("base").unwrap();
}
#[test]
fn enable_idempotent() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
mgr.enable("base").unwrap();
mgr.enable("base").unwrap();
}
#[test]
fn enable_transitively_enables_dependencies() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
std::fs::write(plugins_dir.path().join("base").join(".disabled"), b"").unwrap();
std::fs::write(plugins_dir.path().join("ext").join(".disabled"), b"").unwrap();
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
mgr.enable("ext").unwrap();
assert!(
!plugins_dir.path().join("base").join(".disabled").exists(),
"base must be enabled"
);
assert!(
!plugins_dir.path().join("ext").join(".disabled").exists(),
"ext must be enabled"
);
}
#[test]
fn enable_detects_dependency_cycle() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "alpha", &["beta"]);
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "beta", &["alpha"]);
std::fs::write(plugins_dir.path().join("alpha").join(".disabled"), b"").unwrap();
std::fs::write(plugins_dir.path().join("beta").join(".disabled"), b"").unwrap();
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let err = mgr.enable("alpha").unwrap_err();
assert!(
matches!(err, PluginError::DependencyCycle { .. }),
"expected DependencyCycle, got {err:?}"
);
}
#[test]
fn disable_ignored_by_dependents_of() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
std::fs::write(plugins_dir.path().join("ext").join(".disabled"), b"").unwrap();
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
mgr.remove("base").unwrap();
}
#[test]
fn enable_returns_missing_dependency_when_dep_not_installed() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
install_plugin_with_deps(
plugins_dir.path(),
managed_dir.path(),
"needs-ghost",
&["nonexistent"],
);
std::fs::write(
plugins_dir.path().join("needs-ghost").join(".disabled"),
b"",
)
.unwrap();
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let err = mgr.enable("needs-ghost").unwrap_err();
assert!(
matches!(
err,
PluginError::MissingDependency {
ref dependency,
..
} if dependency == "nonexistent"
),
"expected MissingDependency, got {err:?}"
);
}
#[test]
fn add_rejects_too_many_dependencies() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
let deps: Vec<String> = (0..=64).map(|i| format!("dep-{i:02}")).collect();
let deps_toml = deps
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ");
let manifest = format!(
"[plugin]\nname = \"bloated\"\nversion = \"0.1.0\"\ndependencies = [{deps_toml}]\n"
);
let plugin_src = tempfile::tempdir().unwrap();
write_plugin(
plugin_src.path(),
"bloated",
&manifest,
&[("skill-a", "test")],
);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let err = mgr.add(plugin_src.path().to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::InvalidManifest(_)),
"expected InvalidManifest for too many dependencies, got {err:?}"
);
}
#[test]
fn add_rejects_invalid_dependency_name() {
let plugins_dir = tempfile::tempdir().unwrap();
let managed_dir = tempfile::tempdir().unwrap();
let manifest =
"[plugin]\nname = \"myplugin\"\nversion = \"0.1.0\"\ndependencies = [\"../evil\"]\n";
let plugin_src = tempfile::tempdir().unwrap();
write_plugin(
plugin_src.path(),
"myplugin",
manifest,
&[("skill-a", "test")],
);
let mgr = PluginManager::new(
plugins_dir.path().to_path_buf(),
managed_dir.path().to_path_buf(),
vec![],
vec![],
);
let err = mgr.add(plugin_src.path().to_str().unwrap()).unwrap_err();
assert!(
matches!(err, PluginError::InvalidName { .. }),
"expected InvalidName for malformed dep name, got {err:?}"
);
}
#[test]
fn collect_skill_dirs_excludes_disabled_plugin() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().canonicalize().unwrap();
let plugins_dir = real.join("plugins");
let managed_dir = real.join("managed");
std::fs::create_dir_all(&plugins_dir).unwrap();
std::fs::create_dir_all(&managed_dir).unwrap();
install_plugin_with_deps(&plugins_dir, &managed_dir, "active", &[]);
install_plugin_with_deps(&plugins_dir, &managed_dir, "sleeping", &[]);
std::fs::write(plugins_dir.join("sleeping").join(".disabled"), b"").unwrap();
let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
let dirs = mgr.collect_skill_dirs().unwrap();
for dir in &dirs {
assert!(
!dir.to_string_lossy().contains("sleeping"),
"disabled plugin skill dir must not appear: {dir:?}"
);
}
assert!(!dirs.is_empty(), "active plugin skills must be present");
}
}