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