1use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use walkdir::WalkDir;
10use zeph_skills::bundled::bundled_skill_names;
11use zeph_skills::registry::SkillRegistry;
12use zeph_skills::scanner::scan_skill_body;
13
14use crate::PluginError;
15use crate::manifest::{PluginManifest, PluginMcpServer};
16
17const MAX_DEPENDENCIES: usize = 64;
22
23const CONFIG_SAFELIST: &[&str] = &[
26 "tools.blocked_commands",
27 "tools.allowed_commands",
28 "skills.disambiguation_threshold",
29];
30
31#[derive(Debug)]
33pub struct AddResult {
34 pub name: String,
36 pub plugin_root: PathBuf,
42 pub installed_skills: Vec<String>,
44 pub mcp_server_ids: Vec<String>,
46 pub warnings: Vec<String>,
55}
56
57#[derive(Debug, Default)]
59pub struct RemoveResult {
60 pub removed_skills: Vec<String>,
62 pub removed_mcp_ids: Vec<String>,
64}
65
66#[derive(Debug, Default)]
71pub struct DisableResult {
72 pub forced_over_dependents: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct InstalledPlugin {
82 pub name: String,
84 pub version: String,
86 pub description: String,
88 pub path: PathBuf,
90 pub skill_names: Vec<String>,
92 pub auto_update: bool,
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct PluginSource {
106 pub url: Option<String>,
110 pub sha256: Option<String>,
115}
116
117#[derive(Debug)]
122pub struct AutoUpdateResult {
123 pub name: String,
125 pub status: AutoUpdateStatus,
127}
128
129#[non_exhaustive]
131#[derive(Debug)]
132pub enum AutoUpdateStatus {
133 Updated {
135 old_version: String,
137 new_version: String,
139 },
140 UpToDate,
142 NoSource,
144 Failed(String),
146}
147
148pub struct PluginManager {
153 plugins_dir: PathBuf,
155 managed_skills_dir: PathBuf,
157 mcp_allowed_commands: Vec<String>,
159 base_allowed_commands: Vec<String>,
163 integrity_registry_path: PathBuf,
165 download_timeout_secs: u64,
167}
168
169impl PluginManager {
170 #[must_use]
174 pub fn default_plugins_dir() -> PathBuf {
175 dirs::data_local_dir()
176 .unwrap_or_else(|| PathBuf::from("~/.local/share"))
177 .join("zeph")
178 .join("plugins")
179 }
180
181 #[must_use]
192 pub fn new(
193 plugins_dir: PathBuf,
194 managed_skills_dir: PathBuf,
195 mcp_allowed_commands: Vec<String>,
196 base_allowed_commands: Vec<String>,
197 ) -> Self {
198 let integrity_registry_path = crate::integrity::IntegrityRegistry::default_path();
199 Self {
200 plugins_dir,
201 managed_skills_dir,
202 mcp_allowed_commands,
203 base_allowed_commands,
204 integrity_registry_path,
205 download_timeout_secs: 30,
206 }
207 }
208
209 #[must_use]
214 pub fn with_download_timeout_secs(mut self, secs: u64) -> Self {
215 self.download_timeout_secs = secs;
216 self
217 }
218
219 #[cfg(test)]
221 #[must_use]
222 pub fn with_integrity_registry_path(mut self, path: PathBuf) -> Self {
223 self.integrity_registry_path = path;
224 self
225 }
226
227 pub fn add(&self, source: &str) -> Result<AddResult, PluginError> {
235 let _span = tracing::info_span!("plugins.manager.add", plugin.source = %source).entered();
236 let source_path = PathBuf::from(source);
237 if !source_path.exists() {
238 return Err(PluginError::InvalidSource {
239 path: source.to_owned(),
240 reason: "path does not exist".to_owned(),
241 });
242 }
243
244 let manifest_path = source_path.join("plugin.toml");
245 let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
246 path: manifest_path.clone(),
247 source: e,
248 })?;
249 let manifest_str = String::from_utf8(manifest_bytes).map_err(|_| {
250 PluginError::InvalidManifest("plugin.toml is not valid UTF-8".to_owned())
251 })?;
252 let manifest: PluginManifest = toml::from_str(&manifest_str)
253 .map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
254
255 validate_plugin_name(&manifest.plugin.name)?;
257
258 if manifest.plugin.dependencies.len() > MAX_DEPENDENCIES {
260 return Err(PluginError::InvalidManifest(format!(
261 "plugin declares {} dependencies; maximum allowed is {MAX_DEPENDENCIES}",
262 manifest.plugin.dependencies.len()
263 )));
264 }
265 for dep in &manifest.plugin.dependencies {
266 validate_plugin_name(dep)?;
267 }
268
269 for entry in &manifest.skills {
271 let skill_path = source_path.join(&entry.path);
272 let canonical_source = source_path.canonicalize().map_err(|e| PluginError::Io {
274 path: source_path.clone(),
275 source: e,
276 })?;
277 let canonical_skill = skill_path.canonicalize().map_err(|e| PluginError::Io {
278 path: skill_path.clone(),
279 source: e,
280 })?;
281 if !canonical_skill.starts_with(&canonical_source) {
282 return Err(PluginError::InvalidSource {
283 path: entry.path.clone(),
284 reason: "skill path escapes plugin source root".to_owned(),
285 });
286 }
287 if !skill_path.join("SKILL.md").is_file() {
289 return Err(PluginError::SkillEntryMissing { path: skill_path });
290 }
291 }
292
293 validate_overlay_keys(&manifest.config)?;
295
296 scan_skill_entries(
299 source_path.as_path(),
300 &manifest.skills,
301 &manifest.plugin.name,
302 );
303
304 let mut warnings: Vec<String> = Vec::new();
305 if let Some(msg) = check_allowed_commands_overlay_effect(
306 &manifest.config,
307 &self.base_allowed_commands,
308 &manifest.plugin.name,
309 ) {
310 tracing::warn!(plugin = %manifest.plugin.name, "{msg}");
311 warnings.push(msg);
312 }
313
314 validate_mcp_commands(&manifest.mcp.servers, &self.mcp_allowed_commands)?;
316
317 let skill_names = collect_skill_names(&source_path, &manifest);
319
320 self.check_skill_conflicts(&skill_names, &manifest.plugin.name)?;
322
323 let dest = self.plugins_dir.join(&manifest.plugin.name);
324
325 copy_dir_all(&source_path, &dest)?;
327
328 strip_bundled_markers(&dest);
330
331 let installed_manifest_path = dest.join(".plugin.toml");
333 let manifest_str = toml::to_string(&manifest)?;
334 std::fs::write(&installed_manifest_path, &manifest_str).map_err(|e| PluginError::Io {
335 path: installed_manifest_path.clone(),
336 source: e,
337 })?;
338
339 let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
342 if let Err(e) = registry
343 .record(&manifest.plugin.name, &installed_manifest_path)
344 .and_then(|()| registry.save(&self.integrity_registry_path))
345 {
346 tracing::warn!(plugin = %manifest.plugin.name, error = %e, "failed to update integrity registry after install");
347 }
348
349 let mcp_server_ids: Vec<String> =
350 manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
351
352 tracing::info!(
353 plugin = %manifest.plugin.name,
354 skills = ?skill_names,
355 mcp_servers = ?mcp_server_ids,
356 "plugin installed"
357 );
358
359 Ok(AddResult {
360 name: manifest.plugin.name,
361 plugin_root: dest,
362 installed_skills: skill_names,
363 mcp_server_ids,
364 warnings,
365 })
366 }
367
368 pub async fn add_remote(
410 &self,
411 url: &str,
412 expected_sha256: Option<&str>,
413 ) -> Result<AddResult, PluginError> {
414 let span = tracing::info_span!("plugins.manager.add_remote", %url);
415 let _guard = span.enter();
416
417 validate_url_scheme(url)?;
419
420 let timeout = std::time::Duration::from_secs(self.download_timeout_secs);
421
422 let response = tokio::time::timeout(timeout, reqwest::get(url))
423 .await
424 .map_err(|_| PluginError::DownloadFailed {
425 url: url.to_owned(),
426 reason: format!("download timed out after {}s", self.download_timeout_secs),
427 })?
428 .map_err(|e| PluginError::DownloadFailed {
429 url: url.to_owned(),
430 reason: e.to_string(),
431 })?;
432
433 if !response.status().is_success() {
434 return Err(PluginError::DownloadFailed {
435 url: url.to_owned(),
436 reason: format!("HTTP {}", response.status()),
437 });
438 }
439
440 let bytes = tokio::time::timeout(timeout, response.bytes())
441 .await
442 .map_err(|_| PluginError::DownloadFailed {
443 url: url.to_owned(),
444 reason: format!("download timed out after {}s", self.download_timeout_secs),
445 })?
446 .map_err(|e| PluginError::DownloadFailed {
447 url: url.to_owned(),
448 reason: format!("failed to read response body: {e}"),
449 })?;
450
451 if let Some(expected) = expected_sha256 {
453 let actual = crate::integrity::sha256_hex(&bytes);
454 if actual != expected.to_ascii_lowercase() {
455 return Err(PluginError::IntegrityCheckFailed {
456 expected: expected.to_ascii_lowercase(),
457 actual,
458 });
459 }
460 tracing::debug!(url, "archive SHA-256 verified");
461 } else {
462 tracing::warn!(url, "installing remote plugin without integrity check");
463 }
464
465 let actual_sha256 = crate::integrity::sha256_hex(&bytes);
467
468 let tmp = tempfile::tempdir().map_err(|e| PluginError::Io {
470 path: std::path::PathBuf::from(url),
471 source: e,
472 })?;
473 extract_archive(&bytes, tmp.path(), url)?;
474
475 let plugins_dir = self.plugins_dir.clone();
476 let managed_skills_dir = self.managed_skills_dir.clone();
477 let mcp_allowed_commands = self.mcp_allowed_commands.clone();
478 let base_allowed_commands = self.base_allowed_commands.clone();
479 let integrity_registry_path = self.integrity_registry_path.clone();
480 let source_str = tmp.path().to_str().unwrap_or(url).to_owned();
481
482 let result = tokio::task::spawn_blocking(move || {
483 let mgr = PluginManager {
484 plugins_dir,
485 managed_skills_dir,
486 mcp_allowed_commands,
487 base_allowed_commands,
488 integrity_registry_path,
489 download_timeout_secs: 0, };
491 mgr.add(&source_str)
492 })
493 .await
494 .map_err(|e| PluginError::Io {
495 path: std::path::PathBuf::from(url),
496 source: std::io::Error::other(e),
497 })??;
498
499 let source = PluginSource {
501 url: Some(url.to_owned()),
502 sha256: Some(actual_sha256),
503 };
504 let source_path = self
505 .plugins_dir
506 .join(&result.name)
507 .join(".plugin-source.toml");
508 match toml::to_string(&source) {
509 Ok(toml_str) => {
510 if let Err(e) = tokio::fs::write(&source_path, toml_str).await {
511 tracing::warn!(
512 plugin = %result.name,
513 error = %e,
514 "failed to persist plugin source metadata; auto_update will be skipped"
515 );
516 }
517 }
518 Err(e) => {
519 tracing::warn!(
520 plugin = %result.name,
521 error = %e,
522 "failed to serialize plugin source metadata; auto_update will be skipped"
523 );
524 }
525 }
526
527 Ok(result)
528 }
529
530 pub fn remove(&self, name: &str) -> Result<RemoveResult, PluginError> {
547 validate_plugin_name(name)?;
548 let plugin_dir = self.plugins_dir.join(name);
549 if !plugin_dir.exists() {
550 return Err(PluginError::NotFound {
551 name: name.to_owned(),
552 });
553 }
554
555 self.guard_no_dependents(name)?;
556
557 let manifest_path = plugin_dir.join(".plugin.toml");
558 let (removed_skills, removed_mcp_ids) = if manifest_path.exists() {
559 let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
560 path: manifest_path,
561 source: e,
562 })?;
563 let text = String::from_utf8(bytes).map_err(|_| {
564 PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned())
565 })?;
566 let manifest: PluginManifest =
567 toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
568 let skills = collect_skill_names(&plugin_dir, &manifest);
569 let mcp = manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
570 (skills, mcp)
571 } else {
572 (Vec::new(), Vec::new())
573 };
574
575 std::fs::remove_dir_all(&plugin_dir).map_err(|e| PluginError::Io {
576 path: plugin_dir,
577 source: e,
578 })?;
579
580 let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
582 registry.remove(name);
583 if let Err(e) = registry.save(&self.integrity_registry_path) {
584 tracing::warn!(plugin = %name, error = %e, "failed to update integrity registry after remove");
585 }
586
587 tracing::info!(plugin = %name, "plugin removed");
588
589 Ok(RemoveResult {
590 removed_skills,
591 removed_mcp_ids,
592 })
593 }
594
595 pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, PluginError> {
601 if !self.plugins_dir.exists() {
602 return Ok(Vec::new());
603 }
604
605 let mut plugins = Vec::new();
606 let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| PluginError::Io {
607 path: self.plugins_dir.clone(),
608 source: e,
609 })?;
610
611 for entry in entries.flatten() {
612 let path = entry.path();
613 if !path.is_dir() {
614 continue;
615 }
616 let manifest_path = path.join(".plugin.toml");
617 if !manifest_path.exists() {
618 continue;
619 }
620 let Ok(bytes) = std::fs::read(&manifest_path) else {
621 continue;
622 };
623 let Ok(text) = String::from_utf8(bytes) else {
624 continue;
625 };
626 let Ok(manifest): Result<PluginManifest, _> = toml::from_str(&text) else {
627 continue;
628 };
629 let skill_names = collect_skill_names(&path, &manifest);
630 let auto_update = manifest.plugin.auto_update;
631 plugins.push(InstalledPlugin {
632 name: manifest.plugin.name,
633 version: manifest.plugin.version,
634 description: manifest.plugin.description,
635 path,
636 skill_names,
637 auto_update,
638 });
639 }
640
641 plugins.sort_by(|a, b| a.name.cmp(&b.name));
642 Ok(plugins)
643 }
644
645 #[tracing::instrument(name = "plugins.manager.collect_skill_dirs", skip_all)]
651 pub fn collect_skill_dirs(&self) -> Result<Vec<PathBuf>, PluginError> {
652 if !self.plugins_dir.exists() {
653 return Ok(Vec::new());
654 }
655
656 let mut dirs = Vec::new();
657 let plugins = self.list_installed()?;
658 for plugin in &plugins {
659 if plugin.path.join(".disabled").exists() {
661 continue;
662 }
663 let manifest_path = plugin.path.join(".plugin.toml");
664 if let Ok(bytes) = std::fs::read(&manifest_path)
665 && let Ok(text) = String::from_utf8(bytes)
666 && let Ok(manifest) = toml::from_str::<PluginManifest>(&text)
667 {
668 for entry in &manifest.skills {
669 let skill_dir = plugin.path.join(&entry.path);
670 let ok = skill_dir
672 .canonicalize()
673 .is_ok_and(|c| c.starts_with(&plugin.path));
674 if ok {
675 dirs.push(skill_dir);
676 } else {
677 tracing::warn!(
678 plugin = %plugin.name,
679 path = %entry.path,
680 "skipping skill path that escapes plugin root"
681 );
682 }
683 }
684 }
685 }
686 Ok(dirs)
687 }
688
689 pub async fn check_auto_updates(&self) -> Vec<AutoUpdateResult> {
721 use futures::stream::{self, StreamExt as _};
722 use tracing::Instrument as _;
723
724 async {
725 let candidates = match self.list_installed() {
726 Ok(list) => list,
727 Err(e) => {
728 tracing::warn!(error = %e, "check_auto_updates: failed to list installed plugins");
729 return Vec::new();
730 }
731 };
732
733 stream::iter(candidates.into_iter().filter(|p| p.auto_update))
734 .map(|plugin| async move {
735 let status = self.update_one_plugin(&plugin).await;
736 AutoUpdateResult {
737 name: plugin.name,
738 status,
739 }
740 })
741 .buffer_unordered(4)
742 .collect()
743 .await
744 }
745 .instrument(tracing::info_span!("plugins.manager.check_auto_updates"))
746 .await
747 }
748
749 #[tracing::instrument(name = "plugins.manager.update_one", skip_all, fields(plugin = %plugin.name))]
751 async fn update_one_plugin(&self, plugin: &InstalledPlugin) -> AutoUpdateStatus {
752 let source_path = plugin.path.join(".plugin-source.toml");
753 let Some(source) = read_plugin_source(&source_path).await else {
754 return AutoUpdateStatus::NoSource;
755 };
756
757 let (Some(url), Some(stored_sha256)) = (source.url, source.sha256) else {
758 return AutoUpdateStatus::NoSource;
759 };
760
761 if let Err(e) = validate_url_scheme(&url) {
763 tracing::warn!(plugin = %plugin.name, %url, error = %e, "auto-update: invalid URL scheme");
764 return AutoUpdateStatus::Failed(format!("invalid URL scheme: {e}"));
765 }
766
767 tracing::debug!(plugin = %plugin.name, %url, "checking for updates");
768
769 let timeout = std::time::Duration::from_secs(self.download_timeout_secs);
770
771 let bytes = match self.download_archive(&url, timeout).await {
772 Ok(b) => b,
773 Err(e) => {
774 tracing::warn!(plugin = %plugin.name, %url, error = %e, "auto-update download failed");
775 return AutoUpdateStatus::Failed(e);
776 }
777 };
778
779 let new_sha256 = crate::integrity::sha256_hex(&bytes);
780 if new_sha256 == stored_sha256 {
781 tracing::debug!(plugin = %plugin.name, "auto-update: archive unchanged (SHA-256 match)");
782 return AutoUpdateStatus::UpToDate;
783 }
784
785 tracing::info!(
786 plugin = %plugin.name,
787 old_sha256 = %stored_sha256,
788 new_sha256 = %new_sha256,
789 "auto-update: new archive detected, applying update"
790 );
791
792 let old_version = plugin.version.clone();
793
794 let staging = self.plugins_dir.join(format!(".staging-{}", plugin.name));
796 let backup = self.plugins_dir.join(format!(".backup-{}", plugin.name));
797 let dest = plugin.path.clone();
798 let plugin_name = plugin.name.clone();
799
800 let mcp_allowed = self.mcp_allowed_commands.clone();
802 let managed_skills_dir = self.managed_skills_dir.clone();
803 let plugins_dir = self.plugins_dir.clone();
804 let integrity_registry_path = self.integrity_registry_path.clone();
805 let url_clone = url.clone();
806 let base_allowed_commands = self.base_allowed_commands.clone();
807
808 let result = tokio::task::spawn_blocking(move || {
809 apply_staged_update(
810 &bytes,
811 &url_clone,
812 &dest,
813 &staging,
814 &backup,
815 &plugin_name,
816 &mcp_allowed,
817 &managed_skills_dir,
818 &plugins_dir,
819 &integrity_registry_path,
820 &base_allowed_commands,
821 )
822 })
823 .await;
824
825 match result {
826 Ok(Ok(())) => {}
827 Ok(Err(e)) => {
828 tracing::warn!(plugin = %plugin.name, error = %e, "auto-update: staged swap failed, original preserved");
829 return AutoUpdateStatus::Failed(e);
830 }
831 Err(e) => {
832 tracing::warn!(plugin = %plugin.name, error = %e, "auto-update: blocking task panicked");
833 return AutoUpdateStatus::Failed(format!("update task panicked: {e}"));
834 }
835 }
836
837 let new_source = PluginSource {
839 url: Some(url),
840 sha256: Some(new_sha256),
841 };
842 let source_dest = plugin.path.join(".plugin-source.toml");
843 if let Ok(toml_str) = toml::to_string(&new_source) {
844 let _ = tokio::fs::write(&source_dest, toml_str).await;
845 }
846
847 let new_version = tokio::fs::read_to_string(plugin.path.join(".plugin.toml"))
849 .await
850 .ok()
851 .and_then(|s| toml::from_str::<crate::manifest::PluginManifest>(&s).ok())
852 .map_or_else(|| old_version.clone(), |m| m.plugin.version);
853
854 tracing::info!(
855 plugin = %plugin.name,
856 %old_version,
857 %new_version,
858 "auto-update: plugin updated successfully"
859 );
860
861 AutoUpdateStatus::Updated {
862 old_version,
863 new_version,
864 }
865 }
866
867 #[tracing::instrument(name = "plugins.manager.download_archive", skip_all, fields(url = %url))]
869 async fn download_archive(
870 &self,
871 url: &str,
872 timeout: std::time::Duration,
873 ) -> Result<Vec<u8>, String> {
874 let response = tokio::time::timeout(timeout, reqwest::get(url))
875 .await
876 .map_err(|_| format!("download timed out after {}s", timeout.as_secs()))?
877 .map_err(|e| e.to_string())?;
878
879 if !response.status().is_success() {
880 return Err(format!("HTTP {}", response.status()));
881 }
882
883 let raw = tokio::time::timeout(timeout, response.bytes())
884 .await
885 .map_err(|_| format!("body read timed out after {}s", timeout.as_secs()))?
886 .map_err(|e| format!("failed to read body: {e}"))?;
887
888 Ok(raw.to_vec())
889 }
890
891 pub fn enable(&self, name: &str) -> Result<(), PluginError> {
907 validate_plugin_name(name)?;
908 let mut visiting: Vec<String> = Vec::new();
909 self.enable_recursive(name, &mut visiting)
910 }
911
912 fn enable_recursive(&self, name: &str, visiting: &mut Vec<String>) -> Result<(), PluginError> {
915 if visiting.iter().any(|v| v == name) {
916 let mut path = visiting.clone();
918 path.push(name.to_owned());
919 return Err(PluginError::DependencyCycle {
920 name: name.to_owned(),
921 cycle: path.join(" → "),
922 });
923 }
924
925 let plugin_dir = self.plugins_dir.join(name);
926 if !plugin_dir.exists() {
927 return Err(PluginError::NotFound {
928 name: name.to_owned(),
929 });
930 }
931
932 let disabled_marker = plugin_dir.join(".disabled");
934 if !disabled_marker.exists() {
935 return Ok(());
936 }
937
938 let manifest = load_installed_manifest(&plugin_dir)?;
940
941 visiting.push(name.to_owned());
942 for dep in &manifest.plugin.dependencies {
943 let dep_dir = self.plugins_dir.join(dep);
944 if !dep_dir.exists() {
945 visiting.pop();
946 return Err(PluginError::MissingDependency {
947 name: name.to_owned(),
948 dependency: dep.clone(),
949 });
950 }
951 self.enable_recursive(dep, visiting)?;
952 }
953 visiting.pop();
954
955 if let Some(requested_by) = visiting.last() {
958 tracing::warn!(
959 plugin = %name,
960 requested_by = %requested_by,
961 "auto-enabling previously-disabled plugin as transitive dependency"
962 );
963 }
964
965 std::fs::remove_file(&disabled_marker).map_err(|e| PluginError::Io {
967 path: disabled_marker.clone(),
968 source: e,
969 })?;
970
971 tracing::info!(plugin = %name, "plugin enabled");
972 Ok(())
973 }
974
975 pub fn disable(&self, name: &str, force: bool) -> Result<DisableResult, PluginError> {
1019 validate_plugin_name(name)?;
1020 let plugin_dir = self.plugins_dir.join(name);
1021 if !plugin_dir.exists() {
1022 return Err(PluginError::NotFound {
1023 name: name.to_owned(),
1024 });
1025 }
1026
1027 let forced_over_dependents = if force {
1028 let dependents = self.dependents_of(name);
1029 if !dependents.is_empty() {
1030 tracing::warn!(
1031 plugin = %name,
1032 dependents = ?dependents,
1033 "force-disabling plugin that has enabled dependents"
1034 );
1035 }
1036 dependents
1037 } else {
1038 self.guard_no_dependents(name)?;
1039 Vec::new()
1040 };
1041
1042 let disabled_marker = plugin_dir.join(".disabled");
1044 if disabled_marker.exists() {
1045 return Ok(DisableResult {
1046 forced_over_dependents,
1047 });
1048 }
1049
1050 std::fs::write(&disabled_marker, b"").map_err(|e| PluginError::Io {
1051 path: disabled_marker.clone(),
1052 source: e,
1053 })?;
1054
1055 tracing::info!(plugin = %name, force, "plugin disabled");
1056 Ok(DisableResult {
1057 forced_over_dependents,
1058 })
1059 }
1060
1061 fn dependents_of(&self, name: &str) -> Vec<String> {
1068 if !self.plugins_dir.exists() {
1069 return Vec::new();
1070 }
1071
1072 let Ok(entries) = std::fs::read_dir(&self.plugins_dir) else {
1073 return Vec::new();
1074 };
1075
1076 let mut dependents = Vec::new();
1077 for entry in entries.flatten() {
1078 let path = entry.path();
1079 if !path.is_dir() {
1080 continue;
1081 }
1082 if path.join(".disabled").exists() {
1084 continue;
1085 }
1086 let Ok(manifest) = load_installed_manifest(&path) else {
1087 continue;
1088 };
1089 if manifest.plugin.name == name {
1090 continue;
1091 }
1092 if manifest.plugin.dependencies.iter().any(|d| d == name) {
1093 dependents.push(manifest.plugin.name);
1094 }
1095 }
1096 dependents.sort();
1097 dependents
1098 }
1099
1100 fn guard_no_dependents(&self, name: &str) -> Result<(), PluginError> {
1103 let dependents = self.dependents_of(name);
1104 if dependents.is_empty() {
1105 return Ok(());
1106 }
1107 let hints = dependents
1108 .iter()
1109 .map(|d| format!(" zeph plugin disable {d}"))
1110 .collect::<Vec<_>>()
1111 .join("\n");
1112 Err(PluginError::DependencyRequired {
1113 name: name.to_owned(),
1114 dependents: dependents.join(", "),
1115 hints,
1116 })
1117 }
1118
1119 pub(crate) fn check_skill_conflicts_for_update(
1124 &self,
1125 skill_names: &[String],
1126 this_plugin: &str,
1127 ) -> Result<(), PluginError> {
1128 self.check_skill_conflicts(skill_names, this_plugin)
1129 }
1130
1131 fn check_skill_conflicts(
1132 &self,
1133 skill_names: &[String],
1134 this_plugin: &str,
1135 ) -> Result<(), PluginError> {
1136 let bundled = bundled_skill_names();
1137
1138 let managed_registry = {
1140 let dirs: Vec<PathBuf> = if self.managed_skills_dir.exists() {
1141 vec![self.managed_skills_dir.clone()]
1142 } else {
1143 vec![]
1144 };
1145 SkillRegistry::load(&dirs)
1146 };
1147 let managed_names: std::collections::HashSet<String> = managed_registry
1148 .all_meta()
1149 .iter()
1150 .map(|m| m.name.clone())
1151 .collect();
1152
1153 let installed = self.list_installed().unwrap_or_default();
1155 let mut other_plugin_skills: std::collections::HashMap<String, String> =
1156 std::collections::HashMap::new();
1157 for plugin in &installed {
1158 if plugin.name == this_plugin {
1159 continue;
1160 }
1161 for name in &plugin.skill_names {
1162 other_plugin_skills.insert(name.clone(), plugin.name.clone());
1163 }
1164 }
1165
1166 for name in skill_names {
1167 if bundled.contains(name) {
1168 return Err(PluginError::SkillNameConflictWithBundled { name: name.clone() });
1169 }
1170 if managed_names.contains(name) {
1171 return Err(PluginError::SkillNameConflictWithManaged { name: name.clone() });
1172 }
1173 if let Some(other) = other_plugin_skills.get(name) {
1174 return Err(PluginError::SkillNameConflictWithPlugin {
1175 name: name.clone(),
1176 plugin: other.clone(),
1177 });
1178 }
1179 }
1180 Ok(())
1181 }
1182}
1183
1184async fn read_plugin_source(path: &std::path::Path) -> Option<PluginSource> {
1188 let text = tokio::fs::read_to_string(path).await.ok()?;
1189 match toml::from_str::<PluginSource>(&text) {
1190 Ok(s) => Some(s),
1191 Err(e) => {
1192 tracing::debug!(path = %path.display(), error = %e, "cannot parse .plugin-source.toml");
1193 None
1194 }
1195 }
1196}
1197
1198pub(crate) fn validate_url_scheme(url: &str) -> Result<(), PluginError> {
1208 let parsed = reqwest::Url::parse(url).map_err(|_| PluginError::InvalidSource {
1209 path: url.to_owned(),
1210 reason: "URL is not valid".to_owned(),
1211 })?;
1212 if !matches!(parsed.scheme(), "http" | "https") {
1213 return Err(PluginError::InvalidSource {
1214 path: url.to_owned(),
1215 reason: format!(
1216 "URL scheme {:?} is not allowed; only http and https are permitted",
1217 parsed.scheme()
1218 ),
1219 });
1220 }
1221 Ok(())
1222}
1223
1224#[allow(clippy::too_many_arguments)]
1238pub(crate) fn apply_staged_update(
1239 bytes: &[u8],
1240 url: &str,
1241 dest: &std::path::Path,
1242 staging: &std::path::Path,
1243 backup: &std::path::Path,
1244 installed_plugin_name: &str,
1245 mcp_allowed_commands: &[String],
1246 managed_skills_dir: &std::path::Path,
1247 plugins_dir: &std::path::Path,
1248 integrity_registry_path: &std::path::Path,
1249 base_allowed_commands: &[String],
1250) -> Result<(), String> {
1251 let _ = std::fs::remove_dir_all(staging);
1253 let _ = std::fs::remove_dir_all(backup);
1254
1255 std::fs::create_dir_all(staging).map_err(|e| format!("failed to create staging dir: {e}"))?;
1256
1257 extract_archive_safe(bytes, staging, url).map_err(|e| e.to_string())?;
1259
1260 let staging_manifest = staging.join("plugin.toml");
1262 if !staging_manifest.exists() {
1263 let _ = std::fs::remove_dir_all(staging);
1264 return Err("extracted archive does not contain plugin.toml".into());
1265 }
1266 let manifest_str = std::fs::read_to_string(&staging_manifest)
1267 .map_err(|e| format!("cannot read staged plugin.toml: {e}"))?;
1268 let manifest: crate::manifest::PluginManifest =
1269 toml::from_str(&manifest_str).map_err(|e| format!("staged plugin.toml invalid: {e}"))?;
1270
1271 if let Err(e) = validate_plugin_name(&manifest.plugin.name) {
1273 let _ = std::fs::remove_dir_all(staging);
1274 return Err(format!("staged manifest has invalid plugin name: {e}"));
1275 }
1276 if manifest.plugin.name != installed_plugin_name {
1277 let _ = std::fs::remove_dir_all(staging);
1278 return Err(format!(
1279 "staged manifest changes plugin name from {:?} to {:?}; update rejected",
1280 installed_plugin_name, manifest.plugin.name
1281 ));
1282 }
1283
1284 if let Err(e) = validate_overlay_keys(&manifest.config) {
1286 let _ = std::fs::remove_dir_all(staging);
1287 return Err(format!(
1288 "staged manifest failed config overlay validation: {e}"
1289 ));
1290 }
1291 if let Err(e) = validate_mcp_commands(&manifest.mcp.servers, mcp_allowed_commands) {
1292 let _ = std::fs::remove_dir_all(staging);
1293 return Err(format!(
1294 "staged manifest failed MCP command validation: {e}"
1295 ));
1296 }
1297
1298 let tmp_mgr = crate::manager::PluginManager::new(
1300 plugins_dir.to_path_buf(),
1301 managed_skills_dir.to_path_buf(),
1302 mcp_allowed_commands.to_vec(),
1303 base_allowed_commands.to_vec(),
1304 );
1305 let staged_skill_names = collect_skill_names(staging, &manifest);
1306 if let Err(e) =
1307 tmp_mgr.check_skill_conflicts_for_update(&staged_skill_names, installed_plugin_name)
1308 {
1309 let _ = std::fs::remove_dir_all(staging);
1310 return Err(format!("staged manifest failed skill conflict check: {e}"));
1311 }
1312
1313 scan_skill_entries(staging, &manifest.skills, &manifest.plugin.name);
1315
1316 let installed_manifest_toml =
1318 toml::to_string(&manifest).map_err(|e| format!("cannot serialize staged manifest: {e}"))?;
1319 std::fs::write(staging.join(".plugin.toml"), &installed_manifest_toml)
1320 .map_err(|e| format!("cannot write staged .plugin.toml: {e}"))?;
1321 strip_bundled_markers(staging);
1322
1323 if dest.exists() {
1325 std::fs::rename(dest, backup)
1326 .map_err(|e| format!("failed to rename plugin dir to backup: {e}"))?;
1327 }
1328 if let Err(e) = std::fs::rename(staging, dest) {
1329 if backup.exists() {
1331 let _ = std::fs::rename(backup, dest);
1332 }
1333 return Err(format!("failed to rename staging dir to dest: {e}"));
1334 }
1335
1336 let installed_manifest_path = dest.join(".plugin.toml");
1338 let mut registry = crate::integrity::IntegrityRegistry::load(integrity_registry_path);
1339 if let Err(e) = registry
1340 .record(&manifest.plugin.name, &installed_manifest_path)
1341 .and_then(|()| registry.save(integrity_registry_path))
1342 {
1343 tracing::warn!(
1344 plugin = %manifest.plugin.name,
1345 error = %e,
1346 "auto-update: failed to update integrity registry after swap"
1347 );
1348 }
1349
1350 let _ = std::fs::remove_dir_all(backup);
1351 Ok(())
1352}
1353
1354pub(crate) fn validate_plugin_name(name: &str) -> Result<(), PluginError> {
1356 if name.is_empty() {
1357 return Err(PluginError::InvalidName {
1358 name: name.to_owned(),
1359 reason: "name must not be empty".to_owned(),
1360 });
1361 }
1362 if name.len() > 64 {
1363 return Err(PluginError::InvalidName {
1364 name: name.to_owned(),
1365 reason: "name must not exceed 64 characters".to_owned(),
1366 });
1367 }
1368 if name.contains('/') || name.contains('\\') || name.contains('.') {
1369 return Err(PluginError::InvalidName {
1370 name: name.to_owned(),
1371 reason: "name must not contain path separators or dots".to_owned(),
1372 });
1373 }
1374 if !name.starts_with(|c: char| c.is_ascii_lowercase()) {
1375 return Err(PluginError::InvalidName {
1376 name: name.to_owned(),
1377 reason: "name must start with a lowercase ASCII letter [a-z]".to_owned(),
1378 });
1379 }
1380 if !name
1381 .chars()
1382 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1383 {
1384 return Err(PluginError::InvalidName {
1385 name: name.to_owned(),
1386 reason: "name must match [a-z][a-z0-9-]*".to_owned(),
1387 });
1388 }
1389 Ok(())
1390}
1391
1392fn check_allowed_commands_overlay_effect(
1400 config: &toml::Value,
1401 base_allowed: &[String],
1402 plugin_name: &str,
1403) -> Option<String> {
1404 let overlay_has_entries = config
1405 .as_table()
1406 .and_then(|t| t.get("tools"))
1407 .and_then(toml::Value::as_table)
1408 .and_then(|t| t.get("allowed_commands"))
1409 .and_then(toml::Value::as_array)
1410 .is_some_and(|arr| arr.iter().any(toml::Value::is_str));
1411
1412 if !overlay_has_entries {
1413 return None;
1414 }
1415 if !base_allowed.is_empty() {
1416 return None;
1417 }
1418 Some(format!(
1419 "plugin {plugin_name:?} declares allowed_commands overlay but the host \
1420 has no tools.shell.allowed_commands configured; overlay will have no effect \
1421 at load time (tighten-only: plugins cannot widen an empty base allowlist). \
1422 Install proceeds. To use this overlay, set tools.shell.allowed_commands \
1423 in your base config."
1424 ))
1425}
1426
1427pub(crate) fn validate_overlay_keys(config: &toml::Value) -> Result<(), PluginError> {
1429 let table = match config.as_table() {
1430 Some(t) if !t.is_empty() => t,
1431 _ => return Ok(()),
1432 };
1433
1434 for (section, inner) in table {
1435 let inner_table = inner.as_table().ok_or_else(|| PluginError::UnsafeOverlay {
1436 key: section.clone(),
1437 })?;
1438 for key in inner_table.keys() {
1439 let dotted = format!("{section}.{key}");
1440 if !CONFIG_SAFELIST.contains(&dotted.as_str()) {
1441 return Err(PluginError::UnsafeOverlay { key: dotted });
1442 }
1443 }
1444 }
1445 Ok(())
1446}
1447
1448fn validate_mcp_commands(
1450 servers: &[PluginMcpServer],
1451 allowed: &[String],
1452) -> Result<(), PluginError> {
1453 for server in servers {
1454 if let Some(cmd) = &server.command {
1455 let ok = allowed.iter().any(|a| a == cmd);
1458 if !ok {
1459 return Err(PluginError::DisallowedMcpCommand {
1460 id: server.id.clone(),
1461 command: cmd.clone(),
1462 });
1463 }
1464 }
1465 }
1466 Ok(())
1467}
1468
1469fn scan_skill_entries(
1474 source_root: &Path,
1475 entries: &[crate::manifest::SkillEntry],
1476 plugin_name: &str,
1477) {
1478 let span = tracing::info_span!("plugins.manager.skill_scan", plugin = %plugin_name);
1479 let _guard = span.enter();
1480 for entry in entries {
1481 let skill_md_path = source_root.join(&entry.path).join("SKILL.md");
1482 match std::fs::read_to_string(&skill_md_path) {
1483 Ok(content) => {
1484 let result = scan_skill_body(&content);
1485 if result.has_matches() {
1486 tracing::warn!(
1487 plugin = %plugin_name,
1488 skill = %entry.path,
1489 patterns = ?result.matched_patterns,
1490 "SKILL.md matched injection/exfiltration patterns (advisory)"
1491 );
1492 }
1493 }
1494 Err(e) => {
1495 tracing::warn!(
1496 plugin = %plugin_name,
1497 skill = %entry.path,
1498 error = %e,
1499 "could not read SKILL.md for scan"
1500 );
1501 }
1502 }
1503 }
1504}
1505
1506fn collect_skill_names(root: &Path, manifest: &PluginManifest) -> Vec<String> {
1512 let mut parent_dirs: Vec<PathBuf> = manifest
1514 .skills
1515 .iter()
1516 .filter_map(|e| {
1517 let p = root.join(&e.path);
1518 p.parent().map(Path::to_path_buf)
1519 })
1520 .collect();
1521 parent_dirs.sort();
1522 parent_dirs.dedup();
1523
1524 if parent_dirs.is_empty() {
1525 return Vec::new();
1526 }
1527
1528 let allowed: std::collections::HashSet<PathBuf> =
1530 manifest.skills.iter().map(|e| root.join(&e.path)).collect();
1531
1532 let registry = SkillRegistry::load(&parent_dirs);
1533 registry
1534 .all_meta()
1535 .iter()
1536 .filter(|m| allowed.contains(&m.skill_dir))
1537 .map(|m| m.name.clone())
1538 .collect()
1539}
1540
1541fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), PluginError> {
1543 if dst.exists() {
1544 std::fs::remove_dir_all(dst).map_err(|e| PluginError::Io {
1545 path: dst.to_path_buf(),
1546 source: e,
1547 })?;
1548 }
1549 std::fs::create_dir_all(dst).map_err(|e| PluginError::Io {
1550 path: dst.to_path_buf(),
1551 source: e,
1552 })?;
1553
1554 for entry in WalkDir::new(src).min_depth(1) {
1555 let entry = entry.map_err(|e| PluginError::Io {
1556 path: src.to_path_buf(),
1557 source: std::io::Error::other(e.to_string()),
1558 })?;
1559 let rel = entry
1560 .path()
1561 .strip_prefix(src)
1562 .expect("walkdir yields paths under src");
1563 let target = dst.join(rel);
1564 if entry.file_type().is_dir() {
1565 std::fs::create_dir_all(&target).map_err(|e| PluginError::Io {
1566 path: target,
1567 source: e,
1568 })?;
1569 } else {
1570 if let Some(parent) = target.parent() {
1571 std::fs::create_dir_all(parent).map_err(|e| PluginError::Io {
1572 path: parent.to_path_buf(),
1573 source: e,
1574 })?;
1575 }
1576 std::fs::copy(entry.path(), &target).map_err(|e| PluginError::Io {
1577 path: target,
1578 source: e,
1579 })?;
1580 }
1581 }
1582 Ok(())
1583}
1584
1585fn extract_archive(bytes: &[u8], dest: &Path, url: &str) -> Result<(), PluginError> {
1595 if !bytes.starts_with(&[0x1f, 0x8b]) {
1596 return Err(PluginError::InvalidSource {
1597 path: url.to_owned(),
1598 reason: "unsupported archive format: only .tar.gz is supported".to_owned(),
1599 });
1600 }
1601 let gz = flate2::read::GzDecoder::new(bytes);
1602 let mut archive = tar::Archive::new(gz);
1603 archive
1604 .unpack(dest)
1605 .map_err(|e| PluginError::InvalidSource {
1606 path: url.to_owned(),
1607 reason: format!("tar.gz extraction failed: {e}"),
1608 })
1609}
1610
1611fn extract_archive_safe(bytes: &[u8], dest: &Path, url: &str) -> Result<(), PluginError> {
1623 if !bytes.starts_with(&[0x1f, 0x8b]) {
1624 return Err(PluginError::InvalidSource {
1625 path: url.to_owned(),
1626 reason: "unsupported archive format: only .tar.gz is supported".to_owned(),
1627 });
1628 }
1629 let gz = flate2::read::GzDecoder::new(bytes);
1630 let mut archive = tar::Archive::new(gz);
1631 let entries = archive.entries().map_err(|e| PluginError::InvalidSource {
1632 path: url.to_owned(),
1633 reason: format!("cannot read tar entries: {e}"),
1634 })?;
1635 for entry in entries {
1636 let mut entry = entry.map_err(|e| PluginError::InvalidSource {
1637 path: url.to_owned(),
1638 reason: format!("tar entry error: {e}"),
1639 })?;
1640 let entry_path_display = entry
1642 .path()
1643 .map_or_else(|_| "<invalid path>".to_owned(), |p| p.display().to_string());
1644 {
1645 let entry_path = entry.path().map_err(|e| PluginError::InvalidSource {
1646 path: url.to_owned(),
1647 reason: format!("invalid entry path: {e}"),
1648 })?;
1649 if entry_path.is_absolute() {
1651 return Err(PluginError::InvalidSource {
1652 path: url.to_owned(),
1653 reason: format!("archive contains absolute path: {}", entry_path.display()),
1654 });
1655 }
1656 if entry_path
1658 .components()
1659 .any(|c| c == std::path::Component::ParentDir)
1660 {
1661 return Err(PluginError::InvalidSource {
1662 path: url.to_owned(),
1663 reason: format!(
1664 "archive contains path traversal component: {}",
1665 entry_path.display()
1666 ),
1667 });
1668 }
1669 }
1670 if entry.header().entry_type().is_symlink() {
1672 return Err(PluginError::InvalidSource {
1673 path: url.to_owned(),
1674 reason: format!(
1675 "archive contains a symlink entry: {entry_path_display}; symlinks are not permitted"
1676 ),
1677 });
1678 }
1679 entry
1680 .unpack_in(dest)
1681 .map_err(|e| PluginError::InvalidSource {
1682 path: url.to_owned(),
1683 reason: format!("tar extraction failed for {entry_path_display}: {e}"),
1684 })?;
1685 }
1686 Ok(())
1687}
1688
1689fn load_installed_manifest(plugin_dir: &Path) -> Result<PluginManifest, PluginError> {
1697 let manifest_path = plugin_dir.join(".plugin.toml");
1698 let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
1699 path: manifest_path.clone(),
1700 source: e,
1701 })?;
1702 let text = String::from_utf8(bytes)
1703 .map_err(|_| PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned()))?;
1704 toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))
1705}
1706
1707fn strip_bundled_markers(root: &Path) {
1709 for entry in WalkDir::new(root).into_iter().flatten() {
1710 if entry.file_type().is_file() && entry.file_name().to_str() == Some(".bundled") {
1711 let _ = std::fs::remove_file(entry.path());
1712 }
1713 }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719
1720 fn write_plugin(dir: &Path, name: &str, manifest_toml: &str, skills: &[(&str, &str)]) {
1721 std::fs::create_dir_all(dir).unwrap();
1722 std::fs::write(dir.join("plugin.toml"), manifest_toml).unwrap();
1723 for (skill_name, body) in skills {
1724 let skill_dir = dir.join("skills").join(skill_name);
1725 std::fs::create_dir_all(&skill_dir).unwrap();
1726 std::fs::write(
1727 skill_dir.join("SKILL.md"),
1728 format!("---\nname: {skill_name}\ndescription: test\n---\n{body}"),
1729 )
1730 .unwrap();
1731 std::fs::write(skill_dir.join(".bundled"), "").unwrap();
1733 }
1734 let _ = name;
1735 }
1736
1737 fn simple_manifest(name: &str, skill: &str) -> String {
1738 format!(
1739 r#"[plugin]
1740name = "{name}"
1741version = "0.1.0"
1742description = "test plugin"
1743
1744[[skills]]
1745path = "skills/{skill}"
1746"#
1747 )
1748 }
1749
1750 #[test]
1751 fn add_and_list_plugin() {
1752 let tmp = tempfile::tempdir().unwrap();
1753 let source = tmp.path().join("source");
1754 write_plugin(
1755 &source,
1756 "test-plugin",
1757 &simple_manifest("test-plugin", "my-skill"),
1758 &[("my-skill", "Do stuff")],
1759 );
1760
1761 let plugins_dir = tmp.path().join("plugins");
1762 let managed_dir = tmp.path().join("managed");
1763 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
1764
1765 let result = mgr.add(source.to_str().unwrap()).unwrap();
1766 assert_eq!(result.name, "test-plugin");
1767 assert!(result.installed_skills.contains(&"my-skill".to_owned()));
1768
1769 let installed = mgr.list_installed().unwrap();
1770 assert_eq!(installed.len(), 1);
1771 assert_eq!(installed[0].name, "test-plugin");
1772 }
1773
1774 #[test]
1775 fn bundled_markers_stripped_on_install() {
1776 let tmp = tempfile::tempdir().unwrap();
1777 let source = tmp.path().join("source");
1778 write_plugin(
1779 &source,
1780 "strip-test",
1781 &simple_manifest("strip-test", "my-skill"),
1782 &[("my-skill", "Body")],
1783 );
1784
1785 let plugins_dir = tmp.path().join("plugins");
1786 let managed_dir = tmp.path().join("managed");
1787 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
1788 mgr.add(source.to_str().unwrap()).unwrap();
1789
1790 let has_bundled = WalkDir::new(&plugins_dir)
1792 .into_iter()
1793 .flatten()
1794 .any(|e| e.file_name().to_str() == Some(".bundled"));
1795 assert!(!has_bundled, ".bundled markers were not stripped");
1796 }
1797
1798 #[test]
1799 fn mcp_disallowed_command_fails_install() {
1800 let tmp = tempfile::tempdir().unwrap();
1801 let source = tmp.path().join("source");
1802 let manifest = r#"[plugin]
1803name = "mcp-test"
1804version = "0.1.0"
1805description = "test"
1806
1807[[mcp.servers]]
1808id = "bad-server"
1809command = "dangerous-binary"
1810"#;
1811 write_plugin(&source, "mcp-test", manifest, &[]);
1812
1813 let plugins_dir = tmp.path().join("plugins");
1814 let managed_dir = tmp.path().join("managed");
1815 let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
1816
1817 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1818 assert!(matches!(err, PluginError::DisallowedMcpCommand { .. }));
1819 }
1820
1821 #[test]
1822 fn unsafe_config_overlay_fails_install() {
1823 let tmp = tempfile::tempdir().unwrap();
1824 let source = tmp.path().join("source");
1825 let manifest = r#"[plugin]
1826name = "overlay-test"
1827version = "0.1.0"
1828description = "test"
1829
1830[config.llm]
1831model = "evil"
1832"#;
1833 write_plugin(&source, "overlay-test", manifest, &[]);
1834
1835 let plugins_dir = tmp.path().join("plugins");
1836 let managed_dir = tmp.path().join("managed");
1837 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1838
1839 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1840 assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
1841 }
1842
1843 #[test]
1844 fn max_active_skills_overlay_is_rejected() {
1845 let tmp = tempfile::tempdir().unwrap();
1846 let source = tmp.path().join("source");
1847 let manifest = r#"[plugin]
1848name = "max-skills-test"
1849version = "0.1.0"
1850description = "test"
1851
1852[config.skills]
1853max_active_skills = 10
1854"#;
1855 write_plugin(&source, "max-skills-test", manifest, &[]);
1856
1857 let plugins_dir = tmp.path().join("plugins");
1858 let managed_dir = tmp.path().join("managed");
1859 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1860
1861 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1862 assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
1863 }
1864
1865 #[test]
1866 fn safe_config_overlay_is_accepted() {
1867 let tmp = tempfile::tempdir().unwrap();
1868 let source = tmp.path().join("source");
1869 let manifest = r#"[plugin]
1870name = "safe-overlay"
1871version = "0.1.0"
1872description = "test"
1873
1874[config.skills]
1875disambiguation_threshold = 0.05
1876
1877[config.tools]
1878blocked_commands = ["rm -rf"]
1879"#;
1880 write_plugin(&source, "safe-overlay", manifest, &[]);
1881
1882 let plugins_dir = tmp.path().join("plugins");
1883 let managed_dir = tmp.path().join("managed");
1884 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1885 let result = mgr.add(source.to_str().unwrap()).unwrap();
1886 assert_eq!(result.name, "safe-overlay");
1887 }
1888
1889 #[test]
1890 fn remove_plugin() {
1891 let tmp = tempfile::tempdir().unwrap();
1892 let source = tmp.path().join("source");
1893 write_plugin(
1894 &source,
1895 "removable",
1896 &simple_manifest("removable", "my-skill"),
1897 &[("my-skill", "Body")],
1898 );
1899
1900 let plugins_dir = tmp.path().join("plugins");
1901 let managed_dir = tmp.path().join("managed");
1902 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
1903 mgr.add(source.to_str().unwrap()).unwrap();
1904
1905 let result = mgr.remove("removable").unwrap();
1906 assert!(result.removed_skills.contains(&"my-skill".to_owned()));
1907
1908 let installed = mgr.list_installed().unwrap();
1909 assert!(installed.is_empty());
1910 }
1911
1912 #[test]
1913 fn remove_nonexistent_plugin_returns_not_found() {
1914 let tmp = tempfile::tempdir().unwrap();
1915 let plugins_dir = tmp.path().join("plugins");
1916 let mgr = PluginManager::new(plugins_dir, tmp.path().to_path_buf(), vec![], vec![]);
1917 let err = mgr.remove("no-such-plugin").unwrap_err();
1918 assert!(matches!(err, PluginError::NotFound { .. }));
1919 }
1920
1921 #[test]
1922 fn invalid_plugin_name_with_slash_rejected() {
1923 let err = validate_plugin_name("foo/bar").unwrap_err();
1924 assert!(matches!(err, PluginError::InvalidName { .. }));
1925 }
1926
1927 #[test]
1928 fn plugin_name_with_uppercase_rejected() {
1929 let err = validate_plugin_name("FooBar").unwrap_err();
1930 assert!(matches!(err, PluginError::InvalidName { .. }));
1931 }
1932
1933 #[test]
1934 fn valid_plugin_names_accepted() {
1935 assert!(validate_plugin_name("foo").is_ok());
1936 assert!(validate_plugin_name("foo-bar").is_ok());
1937 assert!(validate_plugin_name("foo123").is_ok());
1938 }
1939
1940 #[test]
1941 fn bundled_skill_conflict_detected() {
1942 let tmp = tempfile::tempdir().unwrap();
1943 let source = tmp.path().join("source");
1944
1945 let bundled = bundled_skill_names();
1947 if bundled.is_empty() {
1948 return;
1950 }
1951 let conflict_name = &bundled[0];
1952
1953 let manifest = format!(
1954 r#"[plugin]
1955name = "conflict-test"
1956version = "0.1.0"
1957description = "test"
1958
1959[[skills]]
1960path = "skills/{conflict_name}"
1961"#
1962 );
1963 write_plugin(
1964 &source,
1965 "conflict-test",
1966 &manifest,
1967 &[(conflict_name, "body")],
1968 );
1969
1970 let plugins_dir = tmp.path().join("plugins");
1971 let managed_dir = tmp.path().join("managed");
1972 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1973
1974 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1975 assert!(matches!(
1976 err,
1977 PluginError::SkillNameConflictWithBundled { .. }
1978 ));
1979 }
1980
1981 #[test]
1982 fn path_traversal_in_skill_path_rejected() {
1983 let tmp = tempfile::tempdir().unwrap();
1984 let real_tmp = tmp.path().canonicalize().unwrap();
1986 let source = real_tmp.join("source");
1987
1988 let outside = real_tmp.join("outside-skill");
1990 std::fs::create_dir_all(&outside).unwrap();
1991
1992 let manifest = r#"[plugin]
1995name = "traversal-test"
1996version = "0.1.0"
1997description = "test"
1998
1999[[skills]]
2000path = "../outside-skill"
2001"#;
2002 std::fs::create_dir_all(&source).unwrap();
2003 std::fs::write(source.join("plugin.toml"), manifest).unwrap();
2004
2005 let plugins_dir = real_tmp.join("plugins");
2006 let managed_dir = real_tmp.join("managed");
2007 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2008
2009 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2010 assert!(
2011 matches!(err, PluginError::InvalidSource { .. }),
2012 "expected InvalidSource for path traversal, got {err:?}"
2013 );
2014 }
2015
2016 #[test]
2017 #[cfg(unix)]
2018 fn skill_path_canonicalize_failure_returns_io_error() {
2019 let tmp = tempfile::tempdir().unwrap();
2020 let source = tmp.path().join("source");
2021 std::fs::create_dir_all(&source).unwrap();
2022
2023 let skill_dir = source.join("skills").join("broken-skill");
2025 std::fs::create_dir_all(source.join("skills")).unwrap();
2026 std::os::unix::fs::symlink("/nonexistent/target", &skill_dir).unwrap();
2027
2028 let manifest = r#"[plugin]
2029name = "broken-link-test"
2030version = "0.1.0"
2031description = "test"
2032
2033[[skills]]
2034path = "skills/broken-skill"
2035"#;
2036 std::fs::write(source.join("plugin.toml"), manifest).unwrap();
2037
2038 let plugins_dir = tmp.path().join("plugins");
2039 let managed_dir = tmp.path().join("managed");
2040 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2041
2042 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2043 assert!(
2044 matches!(err, PluginError::Io { .. }),
2045 "expected Io error when canonicalize fails on broken symlink, got {err:?}"
2046 );
2047 }
2048
2049 #[test]
2050 fn mcp_basename_bypass_rejected() {
2051 let tmp = tempfile::tempdir().unwrap();
2052 let source = tmp.path().join("source");
2053 let manifest = r#"[plugin]
2056name = "basename-bypass"
2057version = "0.1.0"
2058description = "test"
2059
2060[[mcp.servers]]
2061id = "evil"
2062command = "/tmp/evil/npx"
2063"#;
2064 write_plugin(&source, "basename-bypass", manifest, &[]);
2065
2066 let plugins_dir = tmp.path().join("plugins");
2067 let managed_dir = tmp.path().join("managed");
2068 let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
2069
2070 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2071 assert!(
2072 matches!(err, PluginError::DisallowedMcpCommand { .. }),
2073 "expected DisallowedMcpCommand for basename bypass, got {err:?}"
2074 );
2075 }
2076
2077 #[test]
2078 fn managed_skill_conflict_detected() {
2079 let tmp = tempfile::tempdir().unwrap();
2080 let managed_dir = tmp.path().join("managed");
2081
2082 let managed_skill = managed_dir.join("my-skill");
2084 std::fs::create_dir_all(&managed_skill).unwrap();
2085 std::fs::write(
2086 managed_skill.join("SKILL.md"),
2087 "---\nname: my-skill\ndescription: managed\n---\nbody",
2088 )
2089 .unwrap();
2090
2091 let source = tmp.path().join("source");
2093 write_plugin(
2094 &source,
2095 "conflict-managed",
2096 &simple_manifest("conflict-managed", "my-skill"),
2097 &[("my-skill", "body")],
2098 );
2099
2100 let plugins_dir = tmp.path().join("plugins");
2101 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2102
2103 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2104 assert!(
2105 matches!(err, PluginError::SkillNameConflictWithManaged { .. }),
2106 "expected SkillNameConflictWithManaged, got {err:?}"
2107 );
2108 }
2109
2110 #[test]
2111 fn cross_plugin_skill_conflict_detected() {
2112 let tmp = tempfile::tempdir().unwrap();
2113 let plugins_dir = tmp.path().join("plugins");
2114 let managed_dir = tmp.path().join("managed");
2115 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2116
2117 let source_a = tmp.path().join("source_a");
2119 write_plugin(
2120 &source_a,
2121 "plugin-a",
2122 &simple_manifest("plugin-a", "shared-skill"),
2123 &[("shared-skill", "body")],
2124 );
2125 mgr.add(source_a.to_str().unwrap()).unwrap();
2126
2127 let source_b = tmp.path().join("source_b");
2129 write_plugin(
2130 &source_b,
2131 "plugin-b",
2132 &simple_manifest("plugin-b", "shared-skill"),
2133 &[("shared-skill", "body")],
2134 );
2135 let err = mgr.add(source_b.to_str().unwrap()).unwrap_err();
2136 assert!(
2137 matches!(err, PluginError::SkillNameConflictWithPlugin { .. }),
2138 "expected SkillNameConflictWithPlugin, got {err:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn allowed_commands_overlay_with_empty_base_warns() {
2144 let tmp = tempfile::tempdir().unwrap();
2145 let source = tmp.path().join("source");
2146 let manifest = r#"[plugin]
2147name = "warn-test"
2148version = "0.1.0"
2149description = "test"
2150
2151[config.tools]
2152allowed_commands = ["curl", "git"]
2153"#;
2154 write_plugin(&source, "warn-test", manifest, &[]);
2155
2156 let plugins_dir = tmp.path().join("plugins");
2157 let managed_dir = tmp.path().join("managed");
2158 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2160
2161 let result = mgr.add(source.to_str().unwrap()).unwrap();
2162 assert_eq!(result.warnings.len(), 1);
2163 let msg = &result.warnings[0];
2164 assert!(
2165 msg.contains("warn-test"),
2166 "warning must contain plugin name"
2167 );
2168 assert!(
2169 msg.contains("allowed_commands"),
2170 "warning must mention allowed_commands"
2171 );
2172 assert!(msg.is_ascii(), "warning message must be ASCII-only");
2173 }
2174
2175 #[test]
2176 fn allowed_commands_overlay_with_non_empty_base_no_warn() {
2177 let tmp = tempfile::tempdir().unwrap();
2178 let source = tmp.path().join("source");
2179 let manifest = r#"[plugin]
2180name = "no-warn-test"
2181version = "0.1.0"
2182description = "test"
2183
2184[config.tools]
2185allowed_commands = ["curl"]
2186"#;
2187 write_plugin(&source, "no-warn-test", manifest, &[]);
2188
2189 let plugins_dir = tmp.path().join("plugins");
2190 let managed_dir = tmp.path().join("managed");
2191 let mgr = PluginManager::new(
2193 plugins_dir,
2194 managed_dir,
2195 vec![],
2196 vec!["curl".to_owned(), "git".to_owned()],
2197 );
2198
2199 let result = mgr.add(source.to_str().unwrap()).unwrap();
2200 assert!(result.warnings.is_empty());
2201 }
2202
2203 #[test]
2204 fn empty_allowed_commands_array_no_warn() {
2205 let tmp = tempfile::tempdir().unwrap();
2206 let source = tmp.path().join("source");
2207 let manifest = r#"[plugin]
2208name = "empty-overlay"
2209version = "0.1.0"
2210description = "test"
2211
2212[config.tools]
2213allowed_commands = []
2214"#;
2215 write_plugin(&source, "empty-overlay", manifest, &[]);
2216
2217 let plugins_dir = tmp.path().join("plugins");
2218 let managed_dir = tmp.path().join("managed");
2219 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2220
2221 let result = mgr.add(source.to_str().unwrap()).unwrap();
2222 assert!(result.warnings.is_empty());
2223 }
2224
2225 #[test]
2226 fn list_installed_ignores_non_directory_entries() {
2227 let tmp = tempfile::tempdir().unwrap();
2228 let plugins_dir = tmp.path().to_path_buf();
2229
2230 std::fs::write(plugins_dir.join(".plugin-integrity.toml"), b"plugins = {}").unwrap();
2232 std::fs::write(plugins_dir.join("README.txt"), b"docs").unwrap();
2233
2234 let managed_dir = tmp.path().join("managed");
2235 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2236 assert!(
2237 mgr.list_installed().unwrap().is_empty(),
2238 "non-directory entries inside plugins_dir must not be surfaced as installed plugins"
2239 );
2240 }
2241
2242 #[test]
2245 fn validate_plugin_name_empty_string_rejected() {
2246 let err = validate_plugin_name("").unwrap_err();
2247 assert!(
2248 matches!(err, PluginError::InvalidName { .. }),
2249 "expected InvalidName for empty string, got {err:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn validate_plugin_name_with_dot_rejected() {
2255 let err = validate_plugin_name("foo.bar").unwrap_err();
2256 assert!(
2257 matches!(err, PluginError::InvalidName { .. }),
2258 "expected InvalidName for name with dot, got {err:?}"
2259 );
2260 }
2261
2262 #[test]
2263 fn validate_plugin_name_with_backslash_rejected() {
2264 let err = validate_plugin_name("foo\\bar").unwrap_err();
2265 assert!(
2266 matches!(err, PluginError::InvalidName { .. }),
2267 "expected InvalidName for name with backslash, got {err:?}"
2268 );
2269 }
2270
2271 #[test]
2272 fn validate_plugin_name_with_space_rejected() {
2273 let err = validate_plugin_name("foo bar").unwrap_err();
2274 assert!(
2275 matches!(err, PluginError::InvalidName { .. }),
2276 "expected InvalidName for name with space, got {err:?}"
2277 );
2278 }
2279
2280 #[test]
2281 fn validate_plugin_name_max_length_boundary() {
2282 assert!(validate_plugin_name(&"a".repeat(64)).is_ok());
2283 let err = validate_plugin_name(&"a".repeat(65)).unwrap_err();
2284 assert!(
2285 matches!(err, PluginError::InvalidName { .. }),
2286 "expected InvalidName for 65-char name, got {err:?}"
2287 );
2288 }
2289
2290 #[test]
2291 fn validate_plugin_name_leading_dash_rejected() {
2292 let err = validate_plugin_name("-foo").unwrap_err();
2293 assert!(
2294 matches!(err, PluginError::InvalidName { .. }),
2295 "expected InvalidName for leading dash, got {err:?}"
2296 );
2297 }
2298
2299 #[test]
2300 fn validate_plugin_name_leading_digit_rejected() {
2301 let err = validate_plugin_name("123").unwrap_err();
2302 assert!(
2303 matches!(err, PluginError::InvalidName { .. }),
2304 "expected InvalidName for digit-only name, got {err:?}"
2305 );
2306 let err = validate_plugin_name("1abc").unwrap_err();
2307 assert!(
2308 matches!(err, PluginError::InvalidName { .. }),
2309 "expected InvalidName for digit-prefixed name, got {err:?}"
2310 );
2311 }
2312
2313 #[test]
2314 fn validate_plugin_name_valid_names_accepted() {
2315 assert!(validate_plugin_name("abc").is_ok());
2316 assert!(validate_plugin_name("my-plugin").is_ok());
2317 assert!(validate_plugin_name("plugin123").is_ok());
2318 }
2319
2320 #[test]
2323 fn validate_overlay_keys_empty_config_accepted() {
2324 let config = toml::Value::Table(toml::map::Map::new());
2325 assert!(validate_overlay_keys(&config).is_ok());
2326 }
2327
2328 #[test]
2329 fn validate_overlay_keys_safe_keys_accepted() {
2330 let toml_str = r#"
2331[tools]
2332blocked_commands = ["rm -rf /"]
2333allowed_commands = ["git"]
2334
2335[skills]
2336disambiguation_threshold = 0.8
2337"#;
2338 let config: toml::Value = toml::from_str(toml_str).unwrap();
2339 assert!(validate_overlay_keys(&config).is_ok());
2340 }
2341
2342 #[test]
2343 fn validate_overlay_keys_unsafe_key_rejected() {
2344 let toml_str = r#"
2345[llm]
2346model = "evil-model"
2347"#;
2348 let config: toml::Value = toml::from_str(toml_str).unwrap();
2349 let err = validate_overlay_keys(&config).unwrap_err();
2350 assert!(
2351 matches!(err, PluginError::UnsafeOverlay { ref key } if key == "llm.model"),
2352 "expected UnsafeOverlay with key=\"llm.model\", got {err:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn validate_overlay_keys_non_table_section_rejected() {
2358 let toml_str = r#"
2360tools = "not-a-table"
2361"#;
2362 let config: toml::Value = toml::from_str(toml_str).unwrap();
2363 let err = validate_overlay_keys(&config).unwrap_err();
2364 assert!(
2365 matches!(err, PluginError::UnsafeOverlay { .. }),
2366 "expected UnsafeOverlay for non-table section, got {err:?}"
2367 );
2368 }
2369
2370 #[test]
2373 fn list_installed_returns_plugins_sorted_alphabetically() {
2374 let tmp = tempfile::tempdir().unwrap();
2375 let plugins_dir = tmp.path().join("plugins");
2376 let managed_dir = tmp.path().join("managed");
2377 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2378
2379 let plugins = [
2382 ("zeta-plugin", "skill-zeta"),
2383 ("beta-plugin", "skill-beta"),
2384 ("alpha-plugin", "skill-alpha"),
2385 ];
2386 for (name, skill) in &plugins {
2387 let source = tmp.path().join(format!("src-{name}"));
2388 write_plugin(
2389 &source,
2390 name,
2391 &simple_manifest(name, skill),
2392 &[(skill, "body")],
2393 );
2394 mgr.add(source.to_str().unwrap()).unwrap();
2395 }
2396
2397 let installed = mgr.list_installed().unwrap();
2398 let names: Vec<&str> = installed.iter().map(|p| p.name.as_str()).collect();
2399 assert_eq!(
2400 names,
2401 vec!["alpha-plugin", "beta-plugin", "zeta-plugin"],
2402 "list_installed must return plugins in alphabetical order regardless of install order"
2403 );
2404 }
2405
2406 #[test]
2409 fn add_skill_entry_without_skill_md_returns_skill_entry_missing() {
2410 let tmp = tempfile::tempdir().unwrap();
2411 let source = tmp.path().join("source");
2412
2413 std::fs::create_dir_all(source.join("skills").join("no-skill-md")).unwrap();
2415 let manifest = r#"[plugin]
2416name = "missing-skill-md"
2417version = "0.1.0"
2418description = "test"
2419
2420[[skills]]
2421path = "skills/no-skill-md"
2422"#;
2423 std::fs::write(source.join("plugin.toml"), manifest).unwrap();
2424
2425 let plugins_dir = tmp.path().join("plugins");
2426 let managed_dir = tmp.path().join("managed");
2427 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2428
2429 let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2430 assert!(
2431 matches!(err, PluginError::SkillEntryMissing { .. }),
2432 "expected SkillEntryMissing when SKILL.md is absent, got {err:?}"
2433 );
2434 }
2435
2436 #[test]
2439 fn collect_skill_dirs_empty_when_no_plugins_installed() {
2440 let tmp = tempfile::tempdir().unwrap();
2441 let real = tmp.path().canonicalize().unwrap();
2443 let plugins_dir = real.join("plugins");
2444 let mgr = PluginManager::new(plugins_dir, real.clone(), vec![], vec![]);
2445 let dirs = mgr.collect_skill_dirs().unwrap();
2446 assert!(dirs.is_empty());
2447 }
2448
2449 #[test]
2450 fn collect_skill_dirs_returns_installed_skill_paths() {
2451 let tmp = tempfile::tempdir().unwrap();
2452 let real = tmp.path().canonicalize().unwrap();
2454 let plugins_dir = real.join("plugins");
2455 let managed_dir = real.join("managed");
2456 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2457
2458 let source = real.join("source");
2459 write_plugin(
2460 &source,
2461 "dir-plugin",
2462 &simple_manifest("dir-plugin", "my-skill"),
2463 &[("my-skill", "body")],
2464 );
2465 mgr.add(source.to_str().unwrap()).unwrap();
2466
2467 let dirs = mgr.collect_skill_dirs().unwrap();
2468 assert_eq!(dirs.len(), 1, "expected exactly one skill dir");
2469 assert!(
2470 dirs[0].ends_with("skills/my-skill"),
2471 "skill dir path must end with skills/my-skill, got {:?}",
2472 dirs[0]
2473 );
2474 }
2475
2476 #[test]
2479 fn extract_archive_rejects_non_gz_bytes() {
2480 let fake_bytes = b"PK\x03\x04not a tar.gz";
2481 let tmp = tempfile::tempdir().unwrap();
2482 let err =
2483 extract_archive(fake_bytes, tmp.path(), "http://example.com/plugin.zip").unwrap_err();
2484 assert!(
2485 matches!(err, PluginError::InvalidSource { .. }),
2486 "non-gz archive must return InvalidSource, got {err:?}"
2487 );
2488 }
2489
2490 #[test]
2491 fn sha256_integrity_mismatch_returns_correct_error() {
2492 let archive_bytes = b"fake archive content";
2496 let actual = crate::integrity::sha256_hex(archive_bytes);
2497 let wrong_expected = "0000000000000000000000000000000000000000000000000000000000000000";
2498 assert_ne!(
2499 actual, wrong_expected,
2500 "sha256 of non-zero bytes must not match all-zero expected"
2501 );
2502 let err = PluginError::IntegrityCheckFailed {
2504 expected: wrong_expected.to_owned(),
2505 actual: actual.clone(),
2506 };
2507 assert!(
2508 err.to_string().contains("integrity check failed"),
2509 "error message must mention integrity check"
2510 );
2511 assert!(
2512 err.to_string().contains(&actual),
2513 "error message must contain actual hash"
2514 );
2515 }
2516
2517 #[test]
2518 fn collect_skill_dirs_aggregates_multiple_plugins() {
2519 let tmp = tempfile::tempdir().unwrap();
2520 let real = tmp.path().canonicalize().unwrap();
2522 let plugins_dir = real.join("plugins");
2523 let managed_dir = real.join("managed");
2524 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2525
2526 for (plugin_name, skill_name) in &[("plugin-a", "skill-a"), ("plugin-b", "skill-b")] {
2527 let source = real.join(plugin_name);
2528 write_plugin(
2529 &source,
2530 plugin_name,
2531 &simple_manifest(plugin_name, skill_name),
2532 &[(skill_name, "body")],
2533 );
2534 mgr.add(source.to_str().unwrap()).unwrap();
2535 }
2536
2537 let dirs = mgr.collect_skill_dirs().unwrap();
2538 assert_eq!(dirs.len(), 2, "expected two skill dirs from two plugins");
2539 }
2540
2541 #[cfg(test)]
2545 fn build_tar_gz(source: &std::path::Path) -> Vec<u8> {
2546 let buf = Vec::new();
2547 let gz = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
2548 let mut tar = tar::Builder::new(gz);
2549 tar.append_dir_all(".", source).unwrap();
2550 let gz = tar.into_inner().unwrap();
2551 gz.finish().unwrap()
2552 }
2553
2554 #[tokio::test]
2555 async fn add_remote_correct_hash_installs_plugin() {
2556 use wiremock::matchers::method;
2557 use wiremock::{Mock, MockServer, ResponseTemplate};
2558
2559 let tmp = tempfile::tempdir().unwrap();
2560 let source = tmp.path().join("source");
2561 write_plugin(
2562 &source,
2563 "remote-plugin",
2564 &simple_manifest("remote-plugin", "my-skill"),
2565 &[("my-skill", "Do remote stuff")],
2566 );
2567
2568 let archive = build_tar_gz(&source);
2569 let expected_hash = crate::integrity::sha256_hex(&archive);
2570
2571 let mock_server = MockServer::start().await;
2572 Mock::given(method("GET"))
2573 .respond_with(
2574 ResponseTemplate::new(200)
2575 .set_body_bytes(archive)
2576 .append_header("Content-Type", "application/octet-stream"),
2577 )
2578 .mount(&mock_server)
2579 .await;
2580
2581 let plugins_dir = tmp.path().join("plugins");
2582 let managed_dir = tmp.path().join("managed");
2583 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2584
2585 let url = format!("{}/remote-plugin.tar.gz", mock_server.uri());
2586 let result = mgr.add_remote(&url, Some(&expected_hash)).await.unwrap();
2587 assert_eq!(result.name, "remote-plugin");
2588 assert!(result.installed_skills.contains(&"my-skill".to_owned()));
2589 }
2590
2591 #[tokio::test]
2592 async fn add_remote_connect_timeout_returns_download_failed() {
2593 use std::time::Duration;
2594
2595 use wiremock::matchers::method;
2596 use wiremock::{Mock, MockServer, ResponseTemplate};
2597
2598 let tmp = tempfile::tempdir().unwrap();
2599 let source = tmp.path().join("source");
2600 write_plugin(
2601 &source,
2602 "timeout-plugin",
2603 &simple_manifest("timeout-plugin", "t-skill"),
2604 &[("t-skill", "body")],
2605 );
2606
2607 let archive = build_tar_gz(&source);
2608
2609 let mock_server = MockServer::start().await;
2610 Mock::given(method("GET"))
2612 .respond_with(
2613 ResponseTemplate::new(200)
2614 .set_body_bytes(archive)
2615 .set_delay(Duration::from_secs(3)),
2616 )
2617 .mount(&mock_server)
2618 .await;
2619
2620 let plugins_dir = tmp.path().join("plugins");
2621 let managed_dir = tmp.path().join("managed");
2622 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![])
2623 .with_download_timeout_secs(1);
2624
2625 let url = format!("{}/timeout-plugin.tar.gz", mock_server.uri());
2626 let err = mgr.add_remote(&url, None).await.unwrap_err();
2627 assert!(
2628 matches!(err, PluginError::DownloadFailed { ref reason, .. } if reason.contains("timed out")),
2629 "slow response must produce DownloadFailed with timeout message, got {err:?}"
2630 );
2631 }
2632
2633 #[tokio::test]
2634 async fn add_remote_wrong_hash_returns_integrity_error() {
2635 use wiremock::matchers::method;
2636 use wiremock::{Mock, MockServer, ResponseTemplate};
2637
2638 let tmp = tempfile::tempdir().unwrap();
2639 let source = tmp.path().join("source");
2640 write_plugin(
2641 &source,
2642 "bad-plugin",
2643 &simple_manifest("bad-plugin", "bad-skill"),
2644 &[("bad-skill", "Body")],
2645 );
2646
2647 let archive = build_tar_gz(&source);
2648 let wrong_hash = "0".repeat(64);
2649
2650 let mock_server = MockServer::start().await;
2651 Mock::given(method("GET"))
2652 .respond_with(
2653 ResponseTemplate::new(200)
2654 .set_body_bytes(archive)
2655 .append_header("Content-Type", "application/octet-stream"),
2656 )
2657 .mount(&mock_server)
2658 .await;
2659
2660 let plugins_dir = tmp.path().join("plugins");
2661 let managed_dir = tmp.path().join("managed");
2662 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2663
2664 let url = format!("{}/bad-plugin.tar.gz", mock_server.uri());
2665 let err = mgr.add_remote(&url, Some(&wrong_hash)).await.unwrap_err();
2666 assert!(
2667 matches!(err, PluginError::IntegrityCheckFailed { .. }),
2668 "wrong hash must produce IntegrityCheckFailed, got {err:?}"
2669 );
2670 }
2671
2672 #[tokio::test]
2675 async fn add_remote_persists_plugin_source_sidecar() {
2676 use wiremock::matchers::method;
2677 use wiremock::{Mock, MockServer, ResponseTemplate};
2678
2679 let tmp = tempfile::tempdir().unwrap();
2680 let source = tmp.path().join("source");
2681 write_plugin(
2682 &source,
2683 "src-plugin",
2684 &simple_manifest("src-plugin", "src-skill"),
2685 &[("src-skill", "body")],
2686 );
2687 let archive = build_tar_gz(&source);
2688 let expected_hash = crate::integrity::sha256_hex(&archive);
2689
2690 let mock_server = MockServer::start().await;
2691 Mock::given(method("GET"))
2692 .respond_with(
2693 ResponseTemplate::new(200)
2694 .set_body_bytes(archive)
2695 .append_header("Content-Type", "application/octet-stream"),
2696 )
2697 .mount(&mock_server)
2698 .await;
2699
2700 let plugins_dir = tmp.path().join("plugins");
2701 let managed_dir = tmp.path().join("managed");
2702 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
2703 let url = format!("{}/src-plugin.tar.gz", mock_server.uri());
2704 mgr.add_remote(&url, Some(&expected_hash)).await.unwrap();
2705
2706 let sidecar = plugins_dir.join("src-plugin").join(".plugin-source.toml");
2707 assert!(
2708 sidecar.exists(),
2709 ".plugin-source.toml must be written after add_remote"
2710 );
2711
2712 let parsed: PluginSource =
2713 toml::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap();
2714 assert_eq!(parsed.url.as_deref(), Some(url.as_str()));
2715 assert_eq!(parsed.sha256.as_deref(), Some(expected_hash.as_str()));
2716 }
2717
2718 #[test]
2719 fn list_installed_exposes_auto_update_field() {
2720 let tmp = tempfile::tempdir().unwrap();
2721 let source = tmp.path().join("source");
2722 let manifest = r#"[plugin]
2723name = "auto-update-plugin"
2724version = "0.1.0"
2725description = "test"
2726auto_update = true
2727
2728[[skills]]
2729path = "skills/my-skill"
2730"#;
2731 write_plugin(
2732 &source,
2733 "auto-update-plugin",
2734 manifest,
2735 &[("my-skill", "body")],
2736 );
2737
2738 let plugins_dir = tmp.path().join("plugins");
2739 let managed_dir = tmp.path().join("managed");
2740 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2741 mgr.add(source.to_str().unwrap()).unwrap();
2742
2743 let installed = mgr.list_installed().unwrap();
2744 assert_eq!(installed.len(), 1);
2745 assert!(
2746 installed[0].auto_update,
2747 "InstalledPlugin.auto_update must reflect manifest auto_update = true"
2748 );
2749 }
2750
2751 #[test]
2752 fn list_installed_auto_update_defaults_to_false() {
2753 let tmp = tempfile::tempdir().unwrap();
2754 let source = tmp.path().join("source");
2755 write_plugin(
2756 &source,
2757 "no-update-plugin",
2758 &simple_manifest("no-update-plugin", "skill-a"),
2759 &[("skill-a", "body")],
2760 );
2761
2762 let plugins_dir = tmp.path().join("plugins");
2763 let managed_dir = tmp.path().join("managed");
2764 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2765 mgr.add(source.to_str().unwrap()).unwrap();
2766
2767 let installed = mgr.list_installed().unwrap();
2768 assert!(
2769 !installed[0].auto_update,
2770 "auto_update must default to false"
2771 );
2772 }
2773
2774 #[tokio::test]
2775 async fn check_auto_updates_skips_local_installs() {
2776 let tmp = tempfile::tempdir().unwrap();
2777 let source = tmp.path().join("source");
2778 let manifest = r#"[plugin]
2779name = "local-autoupdate"
2780version = "0.1.0"
2781description = "test"
2782auto_update = true
2783
2784[[skills]]
2785path = "skills/my-skill"
2786"#;
2787 write_plugin(
2788 &source,
2789 "local-autoupdate",
2790 manifest,
2791 &[("my-skill", "body")],
2792 );
2793
2794 let plugins_dir = tmp.path().join("plugins");
2795 let managed_dir = tmp.path().join("managed");
2796 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2797 mgr.add(source.to_str().unwrap()).unwrap();
2798
2799 let results = mgr.check_auto_updates().await;
2801 assert_eq!(results.len(), 1);
2802 assert!(
2803 matches!(results[0].status, AutoUpdateStatus::NoSource),
2804 "local-installed plugin must return NoSource, got {:?}",
2805 results[0].status
2806 );
2807 }
2808
2809 #[tokio::test]
2810 async fn check_auto_updates_up_to_date_when_sha256_unchanged() {
2811 use wiremock::matchers::method;
2812 use wiremock::{Mock, MockServer, ResponseTemplate};
2813
2814 let tmp = tempfile::tempdir().unwrap();
2815 let source = tmp.path().join("source");
2816 let manifest = r#"[plugin]
2817name = "up-to-date-plugin"
2818version = "0.2.0"
2819description = "test"
2820auto_update = true
2821
2822[[skills]]
2823path = "skills/my-skill"
2824"#;
2825 write_plugin(
2826 &source,
2827 "up-to-date-plugin",
2828 manifest,
2829 &[("my-skill", "body")],
2830 );
2831 let archive = build_tar_gz(&source);
2832 let hash = crate::integrity::sha256_hex(&archive);
2833
2834 let mock_server = MockServer::start().await;
2835 Mock::given(method("GET"))
2836 .respond_with(
2837 ResponseTemplate::new(200)
2838 .set_body_bytes(archive.clone())
2839 .append_header("Content-Type", "application/octet-stream"),
2840 )
2841 .expect(2) .mount(&mock_server)
2843 .await;
2844
2845 let plugins_dir = tmp.path().join("plugins");
2846 let managed_dir = tmp.path().join("managed");
2847 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2848 let url = format!("{}/plugin.tar.gz", mock_server.uri());
2849 mgr.add_remote(&url, Some(&hash)).await.unwrap();
2850
2851 let results = mgr.check_auto_updates().await;
2852 assert_eq!(results.len(), 1);
2853 assert!(
2854 matches!(results[0].status, AutoUpdateStatus::UpToDate),
2855 "identical archive must yield UpToDate, got {:?}",
2856 results[0].status
2857 );
2858 }
2859
2860 #[tokio::test]
2861 async fn check_auto_updates_applies_update_when_archive_changed() {
2862 use wiremock::matchers::method;
2863 use wiremock::{Mock, MockServer, ResponseTemplate};
2864
2865 let tmp = tempfile::tempdir().unwrap();
2866 let plugins_dir = tmp.path().join("plugins");
2867 let managed_dir = tmp.path().join("managed");
2868
2869 let src_v1 = tmp.path().join("src-v1");
2871 let manifest_v1 = r#"[plugin]
2872name = "update-test"
2873version = "0.1.0"
2874description = "test"
2875auto_update = true
2876
2877[[skills]]
2878path = "skills/my-skill"
2879"#;
2880 write_plugin(
2881 &src_v1,
2882 "update-test",
2883 manifest_v1,
2884 &[("my-skill", "v1 body")],
2885 );
2886 let archive_v1 = build_tar_gz(&src_v1);
2887 let hash_v1 = crate::integrity::sha256_hex(&archive_v1);
2888
2889 let src_v2 = tmp.path().join("src-v2");
2891 let manifest_v2 = r#"[plugin]
2892name = "update-test"
2893version = "0.2.0"
2894description = "test"
2895auto_update = true
2896
2897[[skills]]
2898path = "skills/my-skill"
2899"#;
2900 write_plugin(
2901 &src_v2,
2902 "update-test",
2903 manifest_v2,
2904 &[("my-skill", "v2 body")],
2905 );
2906 let archive_v2 = build_tar_gz(&src_v2);
2907
2908 let mock_server = MockServer::start().await;
2909 Mock::given(method("GET"))
2911 .respond_with(
2912 ResponseTemplate::new(200)
2913 .set_body_bytes(archive_v1)
2914 .append_header("Content-Type", "application/octet-stream"),
2915 )
2916 .up_to_n_times(1)
2917 .mount(&mock_server)
2918 .await;
2919 Mock::given(method("GET"))
2921 .respond_with(
2922 ResponseTemplate::new(200)
2923 .set_body_bytes(archive_v2)
2924 .append_header("Content-Type", "application/octet-stream"),
2925 )
2926 .mount(&mock_server)
2927 .await;
2928
2929 let url = format!("{}/plugin.tar.gz", mock_server.uri());
2930 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
2931 mgr.add_remote(&url, Some(&hash_v1)).await.unwrap();
2932
2933 let results = mgr.check_auto_updates().await;
2934 assert_eq!(results.len(), 1);
2935 assert!(
2936 matches!(
2937 &results[0].status,
2938 AutoUpdateStatus::Updated { old_version, new_version }
2939 if old_version == "0.1.0" && new_version == "0.2.0"
2940 ),
2941 "changed archive must yield Updated(0.1.0 → 0.2.0), got {:?}",
2942 results[0].status
2943 );
2944
2945 let installed = mgr.list_installed().unwrap();
2947 assert_eq!(installed[0].version, "0.2.0");
2948 }
2949
2950 #[tokio::test]
2951 async fn check_auto_updates_returns_failed_on_http_error() {
2952 use wiremock::matchers::method;
2953 use wiremock::{Mock, MockServer, ResponseTemplate};
2954
2955 let tmp = tempfile::tempdir().unwrap();
2956 let source = tmp.path().join("source");
2957 let manifest = r#"[plugin]
2958name = "fail-update"
2959version = "0.1.0"
2960description = "test"
2961auto_update = true
2962
2963[[skills]]
2964path = "skills/my-skill"
2965"#;
2966 write_plugin(&source, "fail-update", manifest, &[("my-skill", "body")]);
2967 let archive = build_tar_gz(&source);
2968 let hash = crate::integrity::sha256_hex(&archive);
2969
2970 let mock_server = MockServer::start().await;
2971 Mock::given(method("GET"))
2973 .respond_with(
2974 ResponseTemplate::new(200)
2975 .set_body_bytes(archive)
2976 .append_header("Content-Type", "application/octet-stream"),
2977 )
2978 .up_to_n_times(1)
2979 .mount(&mock_server)
2980 .await;
2981 Mock::given(method("GET"))
2983 .respond_with(ResponseTemplate::new(404))
2984 .mount(&mock_server)
2985 .await;
2986
2987 let plugins_dir = tmp.path().join("plugins");
2988 let managed_dir = tmp.path().join("managed");
2989 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2990 let url = format!("{}/fail-update.tar.gz", mock_server.uri());
2991 mgr.add_remote(&url, Some(&hash)).await.unwrap();
2992
2993 let results = mgr.check_auto_updates().await;
2994 assert_eq!(results.len(), 1);
2995 assert!(
2996 matches!(results[0].status, AutoUpdateStatus::Failed(_)),
2997 "HTTP 404 must yield Failed, got {:?}",
2998 results[0].status
2999 );
3000
3001 let installed = mgr.list_installed().unwrap();
3003 assert_eq!(installed[0].version, "0.1.0");
3004 }
3005
3006 #[tokio::test]
3007 async fn check_auto_updates_skips_plugins_with_auto_update_false() {
3008 let tmp = tempfile::tempdir().unwrap();
3009 let source = tmp.path().join("source");
3010 write_plugin(
3011 &source,
3012 "no-autoupdate",
3013 &simple_manifest("no-autoupdate", "skill-b"),
3014 &[("skill-b", "body")],
3015 );
3016
3017 let plugins_dir = tmp.path().join("plugins");
3018 let managed_dir = tmp.path().join("managed");
3019 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
3020 mgr.add(source.to_str().unwrap()).unwrap();
3021
3022 let results = mgr.check_auto_updates().await;
3024 assert!(
3025 results.is_empty(),
3026 "auto_update=false plugin must be excluded from results"
3027 );
3028 }
3029
3030 #[test]
3033 fn validate_url_scheme_rejects_file_url() {
3034 let err = validate_url_scheme("file:///etc/passwd").unwrap_err();
3035 assert!(
3036 matches!(err, PluginError::InvalidSource { ref reason, .. } if reason.contains("file")),
3037 "file:// URL must be rejected, got {err:?}"
3038 );
3039 }
3040
3041 #[test]
3042 fn validate_url_scheme_rejects_data_url() {
3043 let err = validate_url_scheme("data:text/plain,hello").unwrap_err();
3044 assert!(
3045 matches!(err, PluginError::InvalidSource { .. }),
3046 "data: URL must be rejected, got {err:?}"
3047 );
3048 }
3049
3050 #[test]
3051 fn validate_url_scheme_accepts_https() {
3052 assert!(validate_url_scheme("https://example.com/plugin.tar.gz").is_ok());
3053 }
3054
3055 #[test]
3056 fn validate_url_scheme_accepts_http() {
3057 assert!(validate_url_scheme("http://example.com/plugin.tar.gz").is_ok());
3058 }
3059
3060 #[test]
3061 fn validate_url_scheme_rejects_invalid_url() {
3062 let err = validate_url_scheme("not a url at all").unwrap_err();
3063 assert!(
3064 matches!(err, PluginError::InvalidSource { .. }),
3065 "invalid URL must return InvalidSource, got {err:?}"
3066 );
3067 }
3068
3069 #[tokio::test]
3070 async fn add_remote_rejects_file_scheme_url() {
3071 let tmp = tempfile::tempdir().unwrap();
3072 let plugins_dir = tmp.path().join("plugins");
3073 let managed_dir = tmp.path().join("managed");
3074 let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
3075 let err = mgr
3076 .add_remote("file:///etc/passwd", None)
3077 .await
3078 .unwrap_err();
3079 assert!(
3080 matches!(err, PluginError::InvalidSource { .. }),
3081 "add_remote must reject file:// URL, got {err:?}"
3082 );
3083 }
3084
3085 #[tokio::test]
3086 async fn check_auto_updates_rejects_file_scheme_in_source() {
3087 let tmp = tempfile::tempdir().unwrap();
3088 let source = tmp.path().join("source");
3089 let manifest = r#"[plugin]
3090name = "ssrf-test"
3091version = "0.1.0"
3092description = "test"
3093auto_update = true
3094
3095[[skills]]
3096path = "skills/my-skill"
3097"#;
3098 write_plugin(&source, "ssrf-test", manifest, &[("my-skill", "body")]);
3099 let plugins_dir = tmp.path().join("plugins");
3100 let managed_dir = tmp.path().join("managed");
3101 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
3102 mgr.add(source.to_str().unwrap()).unwrap();
3103
3104 let sidecar = plugins_dir.join("ssrf-test").join(".plugin-source.toml");
3106 std::fs::write(
3107 &sidecar,
3108 r#"url = "file:///etc/passwd"
3109sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
3110"#,
3111 )
3112 .unwrap();
3113
3114 let results = mgr.check_auto_updates().await;
3115 assert_eq!(results.len(), 1);
3116 assert!(
3117 matches!(results[0].status, AutoUpdateStatus::Failed(_)),
3118 "file:// URL in source sidecar must yield Failed, got {:?}",
3119 results[0].status
3120 );
3121 }
3122
3123 #[tokio::test]
3124 async fn check_auto_updates_rejects_name_change_in_update() {
3125 use wiremock::matchers::method;
3126 use wiremock::{Mock, MockServer, ResponseTemplate};
3127
3128 let tmp = tempfile::tempdir().unwrap();
3129 let plugins_dir = tmp.path().join("plugins");
3130 let managed_dir = tmp.path().join("managed");
3131
3132 let src_v1 = tmp.path().join("src-v1");
3134 let manifest_v1 = r#"[plugin]
3135name = "original-plugin"
3136version = "0.1.0"
3137description = "test"
3138auto_update = true
3139
3140[[skills]]
3141path = "skills/my-skill"
3142"#;
3143 write_plugin(
3144 &src_v1,
3145 "original-plugin",
3146 manifest_v1,
3147 &[("my-skill", "v1")],
3148 );
3149 let archive_v1 = build_tar_gz(&src_v1);
3150 let hash_v1 = crate::integrity::sha256_hex(&archive_v1);
3151
3152 let src_evil = tmp.path().join("src-evil");
3154 let manifest_evil = r#"[plugin]
3155name = "evil-plugin"
3156version = "0.2.0"
3157description = "test"
3158auto_update = true
3159
3160[[skills]]
3161path = "skills/my-skill"
3162"#;
3163 write_plugin(
3164 &src_evil,
3165 "evil-plugin",
3166 manifest_evil,
3167 &[("my-skill", "evil")],
3168 );
3169 let archive_evil = build_tar_gz(&src_evil);
3170
3171 let mock_server = MockServer::start().await;
3172 Mock::given(method("GET"))
3173 .respond_with(
3174 ResponseTemplate::new(200)
3175 .set_body_bytes(archive_v1)
3176 .append_header("Content-Type", "application/octet-stream"),
3177 )
3178 .up_to_n_times(1)
3179 .mount(&mock_server)
3180 .await;
3181 Mock::given(method("GET"))
3182 .respond_with(
3183 ResponseTemplate::new(200)
3184 .set_body_bytes(archive_evil)
3185 .append_header("Content-Type", "application/octet-stream"),
3186 )
3187 .mount(&mock_server)
3188 .await;
3189
3190 let url = format!("{}/plugin.tar.gz", mock_server.uri());
3191 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
3192 mgr.add_remote(&url, Some(&hash_v1)).await.unwrap();
3193
3194 let results = mgr.check_auto_updates().await;
3195 assert_eq!(results.len(), 1);
3196 assert!(
3197 matches!(results[0].status, AutoUpdateStatus::Failed(_)),
3198 "name change in update archive must yield Failed, got {:?}",
3199 results[0].status
3200 );
3201
3202 let installed = mgr.list_installed().unwrap();
3204 assert_eq!(installed[0].version, "0.1.0");
3205 }
3206
3207 #[test]
3208 fn extract_archive_safe_path_traversal_detection() {
3209 let traversal = std::path::Path::new("subdir/../../../etc/evil");
3213 let has_traversal = traversal
3214 .components()
3215 .any(|c| c == std::path::Component::ParentDir);
3216 assert!(
3217 has_traversal,
3218 "path with .. components must be detected as a traversal attempt"
3219 );
3220
3221 let safe = std::path::Path::new("plugin/skills/my-skill/SKILL.md");
3222 let safe_ok = safe
3223 .components()
3224 .all(|c| c != std::path::Component::ParentDir);
3225 assert!(safe_ok, "safe relative path must pass traversal check");
3226 }
3227
3228 fn install_plugin_with_deps(plugins_dir: &Path, managed_dir: &Path, name: &str, deps: &[&str]) {
3231 let plugin_src_raw = tempfile::tempdir().unwrap();
3234 let plugin_src = plugin_src_raw.path().canonicalize().unwrap();
3235 let deps_toml = deps
3236 .iter()
3237 .map(|d| format!("\"{d}\""))
3238 .collect::<Vec<_>>()
3239 .join(", ");
3240 let skill_name = format!("skill-{name}");
3241 let manifest = format!(
3242 "[plugin]\nname = \"{name}\"\nversion = \"0.1.0\"\ndependencies = [{deps_toml}]\n\n[[skills]]\npath = \"skills/{skill_name}\"\n"
3243 );
3244 write_plugin(&plugin_src, name, &manifest, &[(&skill_name, "test skill")]);
3245 let mgr = PluginManager::new(
3246 plugins_dir.to_path_buf(),
3247 managed_dir.to_path_buf(),
3248 vec![],
3249 vec![],
3250 );
3251 mgr.add(plugin_src.to_str().unwrap()).unwrap();
3252 }
3253
3254 #[test]
3255 fn dependencies_field_defaults_to_empty() {
3256 let plugins_dir = tempfile::tempdir().unwrap();
3257 let managed_dir = tempfile::tempdir().unwrap();
3258 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3259 let mgr = PluginManager::new(
3260 plugins_dir.path().to_path_buf(),
3261 managed_dir.path().to_path_buf(),
3262 vec![],
3263 vec![],
3264 );
3265 let installed = mgr.list_installed().unwrap();
3266 assert_eq!(installed.len(), 1);
3267 let manifest_path = plugins_dir.path().join("base").join(".plugin.toml");
3269 let text = std::fs::read_to_string(manifest_path).unwrap();
3270 let manifest: crate::manifest::PluginManifest = toml::from_str(&text).unwrap();
3271 assert!(manifest.plugin.dependencies.is_empty());
3272 }
3273
3274 #[test]
3275 fn remove_refused_when_dependent_enabled() {
3276 let plugins_dir = tempfile::tempdir().unwrap();
3277 let managed_dir = tempfile::tempdir().unwrap();
3278 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3279 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3280 let mgr = PluginManager::new(
3281 plugins_dir.path().to_path_buf(),
3282 managed_dir.path().to_path_buf(),
3283 vec![],
3284 vec![],
3285 );
3286 let err = mgr.remove("base").unwrap_err();
3287 assert!(
3288 matches!(err, PluginError::DependencyRequired { ref name, .. } if name == "base"),
3289 "expected DependencyRequired, got {err:?}"
3290 );
3291 }
3292
3293 #[test]
3294 fn remove_succeeds_after_dependent_removed() {
3295 let plugins_dir = tempfile::tempdir().unwrap();
3296 let managed_dir = tempfile::tempdir().unwrap();
3297 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3298 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3299 let mgr = PluginManager::new(
3300 plugins_dir.path().to_path_buf(),
3301 managed_dir.path().to_path_buf(),
3302 vec![],
3303 vec![],
3304 );
3305 mgr.remove("ext").unwrap();
3306 mgr.remove("base").unwrap();
3307 assert!(mgr.list_installed().unwrap().is_empty());
3308 }
3309
3310 #[test]
3311 fn disable_refused_when_dependent_enabled() {
3312 let plugins_dir = tempfile::tempdir().unwrap();
3313 let managed_dir = tempfile::tempdir().unwrap();
3314 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3315 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3316 let mgr = PluginManager::new(
3317 plugins_dir.path().to_path_buf(),
3318 managed_dir.path().to_path_buf(),
3319 vec![],
3320 vec![],
3321 );
3322 let err = mgr.disable("base", false).unwrap_err();
3323 assert!(
3324 matches!(err, PluginError::DependencyRequired { ref name, .. } if name == "base"),
3325 "expected DependencyRequired, got {err:?}"
3326 );
3327 }
3328
3329 #[test]
3330 fn disable_and_enable_roundtrip() {
3331 let plugins_dir = tempfile::tempdir().unwrap();
3332 let managed_dir = tempfile::tempdir().unwrap();
3333 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3334 let mgr = PluginManager::new(
3335 plugins_dir.path().to_path_buf(),
3336 managed_dir.path().to_path_buf(),
3337 vec![],
3338 vec![],
3339 );
3340 mgr.disable("base", false).unwrap();
3341 assert!(plugins_dir.path().join("base").join(".disabled").exists());
3342 mgr.enable("base").unwrap();
3343 assert!(!plugins_dir.path().join("base").join(".disabled").exists());
3344 }
3345
3346 #[test]
3347 fn disable_idempotent() {
3348 let plugins_dir = tempfile::tempdir().unwrap();
3349 let managed_dir = tempfile::tempdir().unwrap();
3350 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3351 let mgr = PluginManager::new(
3352 plugins_dir.path().to_path_buf(),
3353 managed_dir.path().to_path_buf(),
3354 vec![],
3355 vec![],
3356 );
3357 mgr.disable("base", false).unwrap();
3358 mgr.disable("base", false).unwrap();
3360 }
3361
3362 #[test]
3363 fn enable_idempotent() {
3364 let plugins_dir = tempfile::tempdir().unwrap();
3365 let managed_dir = tempfile::tempdir().unwrap();
3366 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3367 let mgr = PluginManager::new(
3368 plugins_dir.path().to_path_buf(),
3369 managed_dir.path().to_path_buf(),
3370 vec![],
3371 vec![],
3372 );
3373 mgr.enable("base").unwrap();
3375 mgr.enable("base").unwrap();
3376 }
3377
3378 #[test]
3379 fn enable_transitively_enables_dependencies() {
3380 let plugins_dir = tempfile::tempdir().unwrap();
3381 let managed_dir = tempfile::tempdir().unwrap();
3382 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3383 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3384 std::fs::write(plugins_dir.path().join("base").join(".disabled"), b"").unwrap();
3386 std::fs::write(plugins_dir.path().join("ext").join(".disabled"), b"").unwrap();
3387 let mgr = PluginManager::new(
3388 plugins_dir.path().to_path_buf(),
3389 managed_dir.path().to_path_buf(),
3390 vec![],
3391 vec![],
3392 );
3393 mgr.enable("ext").unwrap();
3395 assert!(
3396 !plugins_dir.path().join("base").join(".disabled").exists(),
3397 "base must be enabled"
3398 );
3399 assert!(
3400 !plugins_dir.path().join("ext").join(".disabled").exists(),
3401 "ext must be enabled"
3402 );
3403 }
3404
3405 #[test]
3406 fn enable_detects_dependency_cycle() {
3407 let plugins_dir = tempfile::tempdir().unwrap();
3408 let managed_dir = tempfile::tempdir().unwrap();
3409 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "alpha", &["beta"]);
3411 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "beta", &["alpha"]);
3412 std::fs::write(plugins_dir.path().join("alpha").join(".disabled"), b"").unwrap();
3414 std::fs::write(plugins_dir.path().join("beta").join(".disabled"), b"").unwrap();
3415 let mgr = PluginManager::new(
3416 plugins_dir.path().to_path_buf(),
3417 managed_dir.path().to_path_buf(),
3418 vec![],
3419 vec![],
3420 );
3421 let err = mgr.enable("alpha").unwrap_err();
3422 assert!(
3423 matches!(err, PluginError::DependencyCycle { .. }),
3424 "expected DependencyCycle, got {err:?}"
3425 );
3426 }
3427
3428 #[test]
3429 fn disable_ignored_by_dependents_of() {
3430 let plugins_dir = tempfile::tempdir().unwrap();
3431 let managed_dir = tempfile::tempdir().unwrap();
3432 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3433 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3434 std::fs::write(plugins_dir.path().join("ext").join(".disabled"), b"").unwrap();
3436 let mgr = PluginManager::new(
3437 plugins_dir.path().to_path_buf(),
3438 managed_dir.path().to_path_buf(),
3439 vec![],
3440 vec![],
3441 );
3442 mgr.remove("base").unwrap();
3444 }
3445
3446 #[test]
3447 fn enable_returns_missing_dependency_when_dep_not_installed() {
3448 let plugins_dir = tempfile::tempdir().unwrap();
3449 let managed_dir = tempfile::tempdir().unwrap();
3450 install_plugin_with_deps(
3451 plugins_dir.path(),
3452 managed_dir.path(),
3453 "needs-ghost",
3454 &["nonexistent"],
3455 );
3456 std::fs::write(
3458 plugins_dir.path().join("needs-ghost").join(".disabled"),
3459 b"",
3460 )
3461 .unwrap();
3462 let mgr = PluginManager::new(
3463 plugins_dir.path().to_path_buf(),
3464 managed_dir.path().to_path_buf(),
3465 vec![],
3466 vec![],
3467 );
3468 let err = mgr.enable("needs-ghost").unwrap_err();
3469 assert!(
3470 matches!(
3471 err,
3472 PluginError::MissingDependency {
3473 ref dependency,
3474 ..
3475 } if dependency == "nonexistent"
3476 ),
3477 "expected MissingDependency, got {err:?}"
3478 );
3479 }
3480
3481 #[test]
3482 fn add_rejects_too_many_dependencies() {
3483 let plugins_dir = tempfile::tempdir().unwrap();
3484 let managed_dir = tempfile::tempdir().unwrap();
3485 let deps: Vec<String> = (0..=64).map(|i| format!("dep-{i:02}")).collect();
3486 let deps_toml = deps
3487 .iter()
3488 .map(|d| format!("\"{d}\""))
3489 .collect::<Vec<_>>()
3490 .join(", ");
3491 let manifest = format!(
3492 "[plugin]\nname = \"bloated\"\nversion = \"0.1.0\"\ndependencies = [{deps_toml}]\n"
3493 );
3494 let plugin_src = tempfile::tempdir().unwrap();
3495 write_plugin(
3496 plugin_src.path(),
3497 "bloated",
3498 &manifest,
3499 &[("skill-a", "test")],
3500 );
3501 let mgr = PluginManager::new(
3502 plugins_dir.path().to_path_buf(),
3503 managed_dir.path().to_path_buf(),
3504 vec![],
3505 vec![],
3506 );
3507 let err = mgr.add(plugin_src.path().to_str().unwrap()).unwrap_err();
3508 assert!(
3509 matches!(err, PluginError::InvalidManifest(_)),
3510 "expected InvalidManifest for too many dependencies, got {err:?}"
3511 );
3512 }
3513
3514 #[test]
3515 fn add_rejects_invalid_dependency_name() {
3516 let plugins_dir = tempfile::tempdir().unwrap();
3517 let managed_dir = tempfile::tempdir().unwrap();
3518 let manifest =
3519 "[plugin]\nname = \"myplugin\"\nversion = \"0.1.0\"\ndependencies = [\"../evil\"]\n";
3520 let plugin_src = tempfile::tempdir().unwrap();
3521 write_plugin(
3522 plugin_src.path(),
3523 "myplugin",
3524 manifest,
3525 &[("skill-a", "test")],
3526 );
3527 let mgr = PluginManager::new(
3528 plugins_dir.path().to_path_buf(),
3529 managed_dir.path().to_path_buf(),
3530 vec![],
3531 vec![],
3532 );
3533 let err = mgr.add(plugin_src.path().to_str().unwrap()).unwrap_err();
3534 assert!(
3535 matches!(err, PluginError::InvalidName { .. }),
3536 "expected InvalidName for malformed dep name, got {err:?}"
3537 );
3538 }
3539
3540 #[test]
3541 fn disable_force_succeeds_despite_dependent() {
3542 let plugins_dir = tempfile::tempdir().unwrap();
3543 let managed_dir = tempfile::tempdir().unwrap();
3544 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3545 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3546 let mgr = PluginManager::new(
3547 plugins_dir.path().to_path_buf(),
3548 managed_dir.path().to_path_buf(),
3549 vec![],
3550 vec![],
3551 );
3552 let result = mgr.disable("base", true).unwrap();
3554 assert!(
3555 result.forced_over_dependents.contains(&"ext".to_owned()),
3556 "forced_over_dependents must list 'ext', got {:?}",
3557 result.forced_over_dependents
3558 );
3559 assert!(
3560 plugins_dir.path().join("base").join(".disabled").exists(),
3561 "base must be disabled after force"
3562 );
3563 }
3564
3565 #[test]
3566 fn disable_force_no_dependents_returns_empty_list() {
3567 let plugins_dir = tempfile::tempdir().unwrap();
3568 let managed_dir = tempfile::tempdir().unwrap();
3569 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "standalone", &[]);
3570 let mgr = PluginManager::new(
3571 plugins_dir.path().to_path_buf(),
3572 managed_dir.path().to_path_buf(),
3573 vec![],
3574 vec![],
3575 );
3576 let result = mgr.disable("standalone", true).unwrap();
3577 assert!(
3578 result.forced_over_dependents.is_empty(),
3579 "no dependents means forced_over_dependents must be empty"
3580 );
3581 }
3582
3583 #[test]
3584 fn disable_force_false_same_as_no_force() {
3585 let plugins_dir = tempfile::tempdir().unwrap();
3586 let managed_dir = tempfile::tempdir().unwrap();
3587 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3588 install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3589 let mgr = PluginManager::new(
3590 plugins_dir.path().to_path_buf(),
3591 managed_dir.path().to_path_buf(),
3592 vec![],
3593 vec![],
3594 );
3595 let err = mgr.disable("base", false).unwrap_err();
3597 assert!(
3598 matches!(err, PluginError::DependencyRequired { .. }),
3599 "expected DependencyRequired with force=false, got {err:?}"
3600 );
3601 }
3602
3603 #[test]
3604 fn collect_skill_dirs_excludes_disabled_plugin() {
3605 let tmp = tempfile::tempdir().unwrap();
3606 let real = tmp.path().canonicalize().unwrap();
3608 let plugins_dir = real.join("plugins");
3609 let managed_dir = real.join("managed");
3610 std::fs::create_dir_all(&plugins_dir).unwrap();
3611 std::fs::create_dir_all(&managed_dir).unwrap();
3612 install_plugin_with_deps(&plugins_dir, &managed_dir, "active", &[]);
3613 install_plugin_with_deps(&plugins_dir, &managed_dir, "sleeping", &[]);
3614 std::fs::write(plugins_dir.join("sleeping").join(".disabled"), b"").unwrap();
3616 let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
3617 let dirs = mgr.collect_skill_dirs().unwrap();
3618 for dir in &dirs {
3620 assert!(
3621 !dir.to_string_lossy().contains("sleeping"),
3622 "disabled plugin skill dir must not appear: {dir:?}"
3623 );
3624 }
3625 assert!(!dirs.is_empty(), "active plugin skills must be present");
3626 }
3627}