Skip to main content

zeph_plugins/
manager.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Plugin lifecycle management: add, remove, list.
5
6use 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
17/// Maximum number of entries allowed in `plugin.dependencies`.
18///
19/// Prevents a malicious manifest from triggering a fan-out `DoS` via recursive `enable()` calls
20/// across an unbounded dependency graph.
21const MAX_DEPENDENCIES: usize = 64;
22
23/// The tighten-only config overlay safelist. Any key outside this list causes
24/// [`PluginError::UnsafeOverlay`] at install time.
25const CONFIG_SAFELIST: &[&str] = &[
26    "tools.blocked_commands",
27    "tools.allowed_commands",
28    "skills.disambiguation_threshold",
29];
30
31/// Result of a successful `plugin add` operation.
32#[derive(Debug)]
33pub struct AddResult {
34    /// Installed plugin name.
35    pub name: String,
36    /// Absolute path to the installed plugin root.
37    ///
38    /// Callers should pass each entry in `installed_skill_dirs` to
39    /// [`zeph_skills::registry::SkillRegistry::register_hub_dir`] so the registry treats plugin
40    /// subtrees as non-bundled regardless of any residual `.bundled` markers (S2 defense).
41    pub plugin_root: PathBuf,
42    /// Skill names registered from this plugin.
43    pub installed_skills: Vec<String>,
44    /// MCP server IDs declared by this plugin (require agent restart).
45    pub mcp_server_ids: Vec<String>,
46    /// Non-fatal warnings produced at install time.
47    ///
48    /// Currently populated when a plugin's `allowed_commands` overlay will
49    /// have no effect because the host's base `tools.shell.allowed_commands`
50    /// is empty (see issue #3149 — tighten-only semantics mean plugins
51    /// cannot widen an empty base allowlist). Callers should surface these
52    /// to the user alongside the success message (`eprintln!` on the CLI,
53    /// appended to the output string on the TUI).
54    pub warnings: Vec<String>,
55}
56
57/// Result of a successful `plugin remove` operation.
58#[derive(Debug, Default)]
59pub struct RemoveResult {
60    /// Skill names unregistered.
61    pub removed_skills: Vec<String>,
62    /// MCP server IDs that were declared (require agent restart).
63    pub removed_mcp_ids: Vec<String>,
64}
65
66/// Installed plugin metadata as returned by `plugin list`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct InstalledPlugin {
69    /// Plugin name.
70    pub name: String,
71    /// Plugin version.
72    pub version: String,
73    /// Plugin description.
74    pub description: String,
75    /// Absolute path to the installed plugin root.
76    pub path: PathBuf,
77    /// Skill names provided by this plugin (collected at list time to avoid re-reading manifests).
78    pub skill_names: Vec<String>,
79    /// Whether automatic updates are enabled for this plugin.
80    ///
81    /// Mirrors `plugin.auto_update` from the installed manifest. Populated at list time so
82    /// [`PluginManager::check_auto_updates`] can filter candidates without re-reading manifests.
83    pub auto_update: bool,
84}
85
86/// Install-time source metadata persisted as `.plugin-source.toml` alongside `.plugin.toml`.
87///
88/// Separating this from [`crate::manifest::PluginMeta`] keeps the author-facing `plugin.toml`
89/// schema clean: plugin authors never set these fields; they are written exclusively by
90/// [`PluginManager::add_remote`] and read by [`PluginManager::check_auto_updates`].
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct PluginSource {
93    /// URL from which the plugin archive was originally downloaded.
94    ///
95    /// `None` for plugins installed from local paths via [`PluginManager::add`].
96    pub url: Option<String>,
97    /// Lowercase hex SHA-256 of the installed archive bytes.
98    ///
99    /// Used by [`PluginManager::check_auto_updates`] to skip reinstalls when the remote
100    /// archive has not changed.
101    pub sha256: Option<String>,
102}
103
104/// Outcome of a single auto-update attempt.
105///
106/// One `AutoUpdateResult` is returned per plugin that had `auto_update = true`
107/// when [`PluginManager::check_auto_updates`] ran.
108#[derive(Debug)]
109pub struct AutoUpdateResult {
110    /// Plugin name.
111    pub name: String,
112    /// Specific outcome for this plugin.
113    pub status: AutoUpdateStatus,
114}
115
116/// Status of an individual auto-update attempt.
117#[derive(Debug)]
118pub enum AutoUpdateStatus {
119    /// Plugin was successfully updated.
120    Updated {
121        /// Version before the update.
122        old_version: String,
123        /// Version after the update.
124        new_version: String,
125    },
126    /// Remote archive SHA-256 matches the installed copy — no action taken.
127    UpToDate,
128    /// Plugin has no persisted source URL (installed from a local path).
129    NoSource,
130    /// Update failed; plugin remains at its current version.
131    Failed(String),
132}
133
134/// Manages plugin lifecycle: install, remove, list.
135///
136/// All operations are synchronous. Plugin watchers and agent config overlays are
137/// applied separately by the agent bootstrap layer.
138pub struct PluginManager {
139    /// Root directory where plugins are installed (`~/.local/share/zeph/plugins/`).
140    plugins_dir: PathBuf,
141    /// Directory where managed (user-installed) skills live.
142    managed_skills_dir: PathBuf,
143    /// `mcp.allowed_commands` from the agent config. Used to validate plugin MCP entries.
144    mcp_allowed_commands: Vec<String>,
145    /// Host's base `tools.shell.allowed_commands`. Used to warn when a
146    /// plugin overlay will be silently dropped because the base is empty
147    /// (see issue #3149).
148    base_allowed_commands: Vec<String>,
149    /// Path to the integrity registry file. Injected so tests can use isolated paths.
150    integrity_registry_path: PathBuf,
151    /// Timeout in seconds for each HTTP phase of [`Self::add_remote`] (connect + body read).
152    download_timeout_secs: u64,
153}
154
155impl PluginManager {
156    /// Returns the canonical default plugins directory: `~/.local/share/zeph/plugins/`.
157    ///
158    /// Both the CLI and TUI must use this helper so they always point to the same directory.
159    #[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    /// Create a new manager.
168    ///
169    /// # Parameters
170    ///
171    /// - `plugins_dir` — root installation directory for plugins.
172    /// - `managed_skills_dir` — directory for user-managed skills (conflict detection).
173    /// - `mcp_allowed_commands` — allowlist for MCP server commands from agent config.
174    /// - `base_allowed_commands` — host's `tools.shell.allowed_commands`.
175    ///   Used to emit a non-fatal warning when a plugin overlay would be
176    ///   silently dropped at load time (tighten-only invariant).
177    #[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    /// Override the HTTP download timeout used by [`Self::add_remote`].
196    ///
197    /// Each phase (connect and body read) is independently bounded by this value.
198    /// The default is 30 seconds.
199    #[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    /// Override the integrity registry path. Intended for tests only.
206    #[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    /// Install a plugin from a local directory path.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`PluginError`] if the manifest is invalid, the source cannot be read,
218    /// there are skill name conflicts, MCP commands are not allowlisted, or config
219    /// overlay keys are not in the tighten-only safelist.
220    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.
242        validate_plugin_name(&manifest.plugin.name)?;
243
244        // Validate dependency list: enforce count limit and name format.
245        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        // Validate each [[skills]] entry: path must stay within source root and SKILL.md must exist.
256        for entry in &manifest.skills {
257            let skill_path = source_path.join(&entry.path);
258            // Reject path traversal: resolved path must be inside source_path.
259            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            // Ensure the skill directory contains a SKILL.md file.
274            if !skill_path.join("SKILL.md").is_file() {
275                return Err(PluginError::SkillEntryMissing { path: skill_path });
276            }
277        }
278
279        // Validate config overlay keys.
280        validate_overlay_keys(&manifest.config)?;
281
282        // Stage-1: advisory regex scan over each SKILL.md before copying files.
283        // Results are warnings only — they never block installation.
284        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 command allowlist.
301        validate_mcp_commands(&manifest.mcp.servers, &self.mcp_allowed_commands)?;
302
303        // Collect skill names from the plugin source.
304        let skill_names = collect_skill_names(&source_path, &manifest);
305
306        // Check for name conflicts.
307        self.check_skill_conflicts(&skill_names, &manifest.plugin.name)?;
308
309        let dest = self.plugins_dir.join(&manifest.plugin.name);
310
311        // Copy source to destination.
312        copy_dir_all(&source_path, &dest)?;
313
314        // Recursively strip all .bundled markers from the installed tree.
315        strip_bundled_markers(&dest);
316
317        // Write manifest copy at plugin root for future reference.
318        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        // Record integrity digest. Crash between the write above and the save here leaves the
326        // plugin with no registry entry — it will load unverified until reinstalled (M4).
327        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    /// Download and install a plugin from a remote URL with optional SHA-256 integrity pinning.
355    ///
356    /// Downloads the archive at `url`, verifies its SHA-256 digest against `expected_sha256`
357    /// (when provided), extracts it to a temporary directory, and delegates to [`Self::add`].
358    ///
359    /// # Integrity check
360    ///
361    /// When `expected_sha256` is `Some`, the raw archive bytes are hashed with SHA-256 and
362    /// compared against the expected lowercase hex string. If the digests do not match,
363    /// [`PluginError::IntegrityCheckFailed`] is returned and the archive is never extracted.
364    ///
365    /// When `expected_sha256` is `None`, the archive is extracted without verification. Callers
366    /// are encouraged to always supply the expected hash; unverified installs are permitted by
367    /// default for backward compatibility but should be avoided in production.
368    ///
369    /// # Errors
370    ///
371    /// - [`PluginError::DownloadFailed`] — HTTP request failed or returned a non-2xx status.
372    /// - [`PluginError::IntegrityCheckFailed`] — SHA-256 digest mismatch.
373    /// - [`PluginError::InvalidSource`] — archive cannot be extracted.
374    /// - Any error that [`Self::add`] can return.
375    ///
376    /// # Examples
377    ///
378    /// ```rust,no_run
379    /// # use zeph_plugins::PluginManager;
380    /// # async fn example() -> Result<(), zeph_plugins::PluginError> {
381    /// let mgr = PluginManager::new(
382    ///     "/tmp/plugins".into(),
383    ///     "/tmp/managed".into(),
384    ///     vec![],
385    ///     vec![],
386    /// );
387    /// let result = mgr.add_remote(
388    ///     "https://example.com/my-plugin.tar.gz",
389    ///     Some("abc123def456..."),
390    /// ).await?;
391    /// println!("installed: {}", result.name);
392    /// # Ok(())
393    /// # }
394    /// ```
395    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        // Reject non-HTTP(S) schemes to prevent SSRF via file:// or other transports.
404        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        // Verify SHA-256 before extracting anything.
438        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        // Compute the actual SHA-256 for source persistence (even when expected_sha256 is None).
452        let actual_sha256 = crate::integrity::sha256_hex(&bytes);
453
454        // Extract archive to a temporary directory and delegate to `add`.
455        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, // add() does not perform network I/O
476            };
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        // Persist source metadata so check_auto_updates can re-fetch this plugin.
486        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    /// Remove an installed plugin by name.
517    ///
518    /// Refuses to remove the plugin if any enabled plugin depends on it. The caller receives a
519    /// [`PluginError::DependencyRequired`] error with a formatted hint listing the dependents.
520    ///
521    /// # Note on TOCTOU
522    ///
523    /// The dependent check and directory removal are not atomic. In a multi-process environment a
524    /// concurrent enable of a dependent could race with this remove. This is acceptable for a
525    /// single-user CLI tool where plugin operations are manual.
526    ///
527    /// # Errors
528    ///
529    /// - [`PluginError::NotFound`] — plugin is not installed.
530    /// - [`PluginError::DependencyRequired`] — at least one enabled plugin depends on this one.
531    /// - [`PluginError::Io`] — the plugin directory cannot be removed.
532    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        // Remove integrity entry; non-fatal if registry cannot be updated.
567        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    /// List all installed plugins.
582    ///
583    /// # Errors
584    ///
585    /// Returns [`PluginError`] if the plugins directory cannot be read.
586    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    /// Returns all skill directory paths from installed plugins.
632    ///
633    /// # Errors
634    ///
635    /// Returns [`PluginError`] if the plugins directory cannot be read.
636    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            // Skip disabled plugins — their skills must not be loaded.
645            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                    // Reject traversal: dir must stay within the installed plugin root.
656                    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    /// Check and apply updates for all installed plugins with `auto_update = true`.
675    ///
676    /// For each eligible plugin the method:
677    /// 1. Reads `.plugin-source.toml` to retrieve the original download URL and SHA-256.
678    /// 2. Downloads the archive from that URL.
679    /// 3. Compares the downloaded archive's SHA-256 with the stored value.
680    /// 4. If the hashes differ, stages the new version in a temporary directory adjacent to the
681    ///    plugin root, then atomically swaps via `rename` — an interrupted update leaves the
682    ///    plugin intact rather than half-deleted.
683    /// 5. Returns a result per plugin; failures are warnings and never abort startup.
684    ///
685    /// Plugins installed from local paths (no `.plugin-source.toml` or no URL) are skipped
686    /// with [`AutoUpdateStatus::NoSource`].
687    ///
688    /// # Examples
689    ///
690    /// ```rust,no_run
691    /// # use zeph_plugins::PluginManager;
692    /// # async fn run() {
693    /// let mgr = PluginManager::new(
694    ///     "/tmp/plugins".into(),
695    ///     "/tmp/managed".into(),
696    ///     vec![],
697    ///     vec![],
698    /// );
699    /// let results = mgr.check_auto_updates().await;
700    /// for r in &results {
701    ///     println!("{}: {:?}", r.name, r.status);
702    /// }
703    /// # }
704    /// ```
705    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    /// Attempt to update a single plugin. Returns the update status without aborting on error.
735    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        // Reject non-HTTP(S) schemes to prevent SSRF via file:// or other transports.
746        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        // Extract to a staging directory adjacent to the plugin root.
779        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        // Offload all blocking filesystem operations to a dedicated thread.
785        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        // Persist updated source metadata (new SHA-256).
822        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        // Read the new version from the updated manifest.
832        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    /// Download an archive from `url` respecting the per-phase timeout.
851    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    /// Enable an installed plugin by removing its `.disabled` marker file.
874    ///
875    /// Before enabling the target, all plugins listed in `plugin.dependencies` are enabled
876    /// recursively (depth-first). The method detects dependency cycles and returns
877    /// [`PluginError::DependencyCycle`] before touching the filesystem.
878    ///
879    /// A plugin with no `.disabled` marker is considered already enabled; this method is a no-op
880    /// for such plugins (idempotent).
881    ///
882    /// # Errors
883    ///
884    /// - [`PluginError::NotFound`] — plugin is not installed.
885    /// - [`PluginError::MissingDependency`] — a declared dependency is not installed.
886    /// - [`PluginError::DependencyCycle`] — the dependency graph contains a cycle.
887    /// - [`PluginError::Io`] — the `.disabled` marker cannot be removed.
888    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    /// Recursive implementation of [`Self::enable`]; `visiting` tracks the DFS path for cycle
895    /// detection.
896    fn enable_recursive(&self, name: &str, visiting: &mut Vec<String>) -> Result<(), PluginError> {
897        if visiting.iter().any(|v| v == name) {
898            // Build a readable cycle description: A → B → A
899            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        // Already enabled — nothing to do.
915        let disabled_marker = plugin_dir.join(".disabled");
916        if !disabled_marker.exists() {
917            return Ok(());
918        }
919
920        // Load manifest to discover dependencies.
921        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        // Remove the `.disabled` marker to enable this plugin.
938        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    /// Disable an installed plugin by creating a `.disabled` marker file.
948    ///
949    /// Refuses to disable the plugin if any *enabled* plugin depends on it. The caller receives
950    /// a [`PluginError::DependencyRequired`] error with a formatted hint listing the dependents.
951    ///
952    /// Disabling an already-disabled plugin is a no-op (idempotent).
953    ///
954    /// # Note on TOCTOU
955    ///
956    /// The dependent check and marker creation are not atomic. In a multi-process
957    /// environment a concurrent enable of a dependent could race with this disable. This is
958    /// acceptable for a single-user CLI tool where plugin operations are manual.
959    ///
960    /// # Errors
961    ///
962    /// - [`PluginError::NotFound`] — plugin is not installed.
963    /// - [`PluginError::DependencyRequired`] — at least one enabled plugin depends on this one.
964    /// - [`PluginError::Io`] — the `.disabled` marker cannot be written.
965    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        // Already disabled — nothing to do.
977        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    /// Returns the names of all **enabled** plugins that declare `name` as a dependency.
992    ///
993    /// Scans every installed plugin's manifest; a plugin is considered enabled if it has no
994    /// `.disabled` marker file in its directory. The check is O(N) in the number of installed
995    /// plugins and performs one filesystem read per plugin. For typical plugin counts (<50) this
996    /// is negligible.
997    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            // Skip disabled plugins.
1013            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    /// Check that no enabled plugin depends on `name`; return [`PluginError::DependencyRequired`]
1031    /// if any do.
1032    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    /// Like [`Self::check_skill_conflicts`] but skips the plugin currently being updated.
1050    ///
1051    /// Used by the auto-update path to validate the staged replacement without false conflicts
1052    /// with the plugin's own currently-installed skills (which are about to be replaced).
1053    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        // Managed skills: any name in the managed skills dir.
1069        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        // Other installed plugins' skill names — already collected by list_installed.
1084        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
1114/// Read `.plugin-source.toml` from `path` and return it, or `None` on any failure.
1115///
1116/// Failures are logged as debug — missing sidecar is normal for local-install plugins.
1117fn 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
1128/// Validate that `url` uses an `http` or `https` scheme.
1129///
1130/// Rejects `file://`, `data:`, and any other scheme that could be used for SSRF
1131/// or local filesystem exfiltration when fetching plugin archives.
1132///
1133/// # Errors
1134///
1135/// Returns [`PluginError::InvalidSource`] when the URL is unparseable or uses a
1136/// disallowed scheme.
1137pub(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/// Extract archive to `staging`, run all security validations, then swap `staging` with `dest`.
1155///
1156/// Strategy: extract → staging, validate, rename dest → backup, rename staging → dest, delete
1157/// backup. If any step after the extract fails, staging is removed and backup (if present) is
1158/// restored so the installed plugin is never left in a partial state.
1159///
1160/// Note: `rename(2)` is atomic only within the same filesystem. Across different mounts this
1161/// returns `EXDEV`; the backup is restored in that case. For most deployments `plugins_dir` and
1162/// the temp path share the same mount, so `EXDEV` is unlikely in practice.
1163///
1164/// # Errors
1165///
1166/// Returns a human-readable error string on any failure. The caller logs this as a warning.
1167#[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    // Clean up any leftover staging/backup dirs from a previous interrupted attempt.
1182    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 into staging directory (with tar-slip protection).
1188    extract_archive_safe(bytes, staging, url).map_err(|e| e.to_string())?;
1189
1190    // Parse and validate the staged manifest before touching the installed plugin.
1191    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    // Reject name changes: an update must not rename the plugin.
1202    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    // Run the full validation pipeline — same checks as `add()`.
1215    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    // Skill conflict check: build a temporary manager against the staging tree.
1229    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    // Advisory SKILL.md scan (non-blocking — logs warnings only).
1244    scan_skill_entries(staging, &manifest.skills, &manifest.plugin.name);
1245
1246    // Write the normalised .plugin.toml and strip bundled markers.
1247    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    // Atomic swap: rename dest → backup, rename staging → dest.
1254    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        // Restore from backup.
1260        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    // Update integrity registry for the new manifest.
1267    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
1284/// Validate that a plugin name is a safe identifier: `[a-z][a-z0-9-]*`, max 64 chars.
1285pub(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
1322/// Returns a warning message if the plugin's `allowed_commands` overlay
1323/// will be silently dropped because the host's base allowlist is empty.
1324///
1325/// Returns `None` when the overlay is absent or empty, or when the base
1326/// allowlist is non-empty (in which case the overlay will narrow it and
1327/// the existing `tracing::info!` in `apply_resolved` already signals the
1328/// transition at load time).
1329fn 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
1357/// Validate all keys in the `[config]` overlay are in the tighten-only safelist.
1358pub(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
1378/// Validate that all plugin MCP servers declare commands that are in the allowlist.
1379fn 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            // Compare the full command string verbatim — no file_name() fallback.
1386            // Basename matching would allow `/tmp/evil/npx` when allowlist contains `npx`.
1387            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
1399/// Stage-1 advisory scan: run injection/exfiltration regex patterns over each `SKILL.md`.
1400///
1401/// Matches are logged as `WARN` and never block installation. The Stage-2 LLM semantic
1402/// scan (when configured) is the blocking gate.
1403fn 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
1436/// Collect skill names from a plugin source tree according to the manifest's `[[skills]]` entries.
1437///
1438/// Each `[[skills]] path` entry points to a single skill directory that directly contains
1439/// `SKILL.md`. `SkillRegistry::load` expects *parent* directories, so we pass each entry's
1440/// parent and collect only the skills whose directory matches the declared path.
1441fn collect_skill_names(root: &Path, manifest: &PluginManifest) -> Vec<String> {
1442    // Collect unique parent directories so we can batch-load.
1443    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    // Allowed skill directories (resolved absolute paths).
1459    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
1471/// Recursively copy `src` directory to `dst`, creating `dst` if needed.
1472fn 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
1515/// Extract a `.tar.gz` plugin archive into `dest`.
1516///
1517/// Only gzip-compressed tar archives are supported. The format is detected by the gzip magic
1518/// bytes (`0x1f 0x8b`); any other format returns [`PluginError::InvalidSource`].
1519///
1520/// # Errors
1521///
1522/// Returns [`PluginError::InvalidSource`] when the archive format is unrecognized or extraction
1523/// fails.
1524fn 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
1541/// Extract a `.tar.gz` archive with tar-slip protection.
1542///
1543/// Unlike [`extract_archive`], this function rejects:
1544/// - Absolute paths inside the archive.
1545/// - Entries with `..` path components.
1546/// - Symbolic link entries (prevent symlink-based traversal).
1547///
1548/// # Errors
1549///
1550/// Returns [`PluginError::InvalidSource`] when the archive format is unrecognised, extraction
1551/// fails, or a dangerous entry is found.
1552fn 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        // Clone path display string early to avoid borrow conflicts with unpack_in.
1571        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            // Reject absolute paths.
1580            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            // Reject path traversal via `..`.
1587            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        // Reject symbolic links to prevent symlink-based traversal.
1601        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
1619/// Walk the plugin tree and delete every `.bundled` marker file.
1620///
1621/// Read the installed manifest (`.plugin.toml`) from `plugin_dir`.
1622///
1623/// # Errors
1624///
1625/// Returns [`PluginError`] if the file cannot be read or parsed.
1626fn 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
1637/// Plugin skills are third-party and must never be treated as bundled by the scanner.
1638fn 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            // Write a .bundled marker to test stripping.
1662            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        // .bundled markers must not exist in the installed tree.
1721        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        // Find a real bundled skill name to trigger conflict.
1876        let bundled = bundled_skill_names();
1877        if bundled.is_empty() {
1878            // No bundled skills compiled in; skip.
1879            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        // Use canonicalized base to avoid macOS /var → /private/var redirect.
1915        let real_tmp = tmp.path().canonicalize().unwrap();
1916        let source = real_tmp.join("source");
1917
1918        // Create a skill directory that exists but is outside source root via ../escape.
1919        let outside = real_tmp.join("outside-skill");
1920        std::fs::create_dir_all(&outside).unwrap();
1921
1922        // The plugin manifest references ../outside-skill, which canonicalizes to a real path
1923        // outside the source directory — this is what the traversal guard must catch.
1924        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        // Create a broken symlink inside the source directory.
1954        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        // allowed_commands = ["npx"] but plugin declares full path "/tmp/evil/npx".
1984        // Verbatim match must reject this; the old file_name() fallback would have passed it.
1985        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        // Create a managed skill named "my-skill".
2013        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        // Plugin tries to install a skill with the same name.
2022        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        // Install first plugin with "shared-skill".
2048        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        // Install second plugin with the same skill name — must conflict.
2058        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        // base_allowed_commands is empty — overlay will have no effect
2089        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        // base_allowed_commands is non-empty — overlay narrows correctly, no warning
2122        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        // Stray files that must not be treated as installed plugins.
2161        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    // --- validate_plugin_name edge cases ---
2173
2174    #[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    // --- validate_overlay_keys direct tests ---
2251
2252    #[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        // A section value that is not a table (e.g. a string) must be rejected.
2289        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    // --- list_installed sort order ---
2301
2302    #[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        // Install in reverse alphabetical order with unique skill names to avoid cross-plugin
2310        // name conflicts — the sort test only cares about plugin ordering, not skill uniqueness.
2311        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    // --- add() error: SkillEntryMissing when SKILL.md is absent ---
2337
2338    #[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        // Create the plugin manifest that references a skill path, but do NOT write SKILL.md.
2344        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    // --- collect_skill_dirs ---
2367
2368    #[test]
2369    fn collect_skill_dirs_empty_when_no_plugins_installed() {
2370        let tmp = tempfile::tempdir().unwrap();
2371        // Use canonicalized path to work around macOS /var → /private/var symlink.
2372        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        // Canonicalize so that the path prefix check inside collect_skill_dirs works on macOS.
2383        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    // --- extract_archive tests ---
2407
2408    #[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        // Validate that the sha256_hex function used in add_remote produces a consistent result
2423        // and that a mismatch would be detected. (We test the hash function and error variant
2424        // since we cannot call add_remote without an HTTP server in unit tests.)
2425        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        // Confirm the error variant is constructable.
2433        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        // Canonicalize so that the path prefix check inside collect_skill_dirs works on macOS.
2451        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    // --- add_remote tests ---
2472
2473    /// Build an in-memory `.tar.gz` archive of the directory at `source`.
2474    #[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        // Delay > download_timeout_secs (1s) triggers the tokio::time::timeout guard.
2541        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    // --- auto_update and PluginSource tests ---
2603
2604    #[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        // No .plugin-source.toml is written by `add()` — only by `add_remote()`.
2730        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) // once for install, once for check
2772            .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        // Build v0.1.0 archive.
2800        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        // Build v0.2.0 archive.
2820        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        // First call: install (serves v1).
2840        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        // Second call: auto-update check (serves v2).
2850        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        // Installed version must reflect v0.2.0.
2876        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        // Install succeeds.
2902        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        // Auto-update check returns 404.
2912        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        // Plugin must still be installed at the old version.
2932        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        // auto_update = false (default) — check_auto_updates must return an empty list.
2953        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    // --- Security tests ---
2961
2962    #[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        // Manually write a malicious .plugin-source.toml with file:// URL.
3035        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        // Install v0.1.0 as "original-plugin".
3063        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        // Build an "update" archive that renames the plugin to "evil-plugin".
3083        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        // Original plugin must still be installed at v0.1.0.
3133        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        // Verify the path-component check logic used inside extract_archive_safe.
3140        // The tar builder itself rejects `..` entries, so we test the detection logic
3141        // directly by constructing a path and running the same check.
3142        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    // --- dependency enforcement tests ---
3159
3160    fn install_plugin_with_deps(plugins_dir: &Path, managed_dir: &Path, name: &str, deps: &[&str]) {
3161        // Use a canonicalized tmp dir so the path-prefix check in collect_skill_dirs works on
3162        // macOS where /tmp is a symlink to /private/tmp.
3163        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        // Manifest with no dependencies field must deserialize with empty Vec.
3198        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        // Second disable must be a no-op, not an error.
3289        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        // Plugin is already enabled — second enable is a no-op.
3304        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        // Disable both.
3315        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        // Enabling ext must also enable base.
3324        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 alpha → beta, beta → alpha (cycle).
3340        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        // Disable both to force the enable path.
3343        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        // Disable ext — it should no longer block removing base.
3365        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        // base has no enabled dependents now.
3373        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        // Disable the plugin so enable() actually tries to traverse deps.
3387        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        // Canonicalize so the path-prefix check inside collect_skill_dirs works on macOS.
3474        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        // Disable sleeping.
3482        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        // Only the active plugin's skill dirs should appear.
3486        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}