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/// Result of a successful `plugin disable` operation.
67///
68/// When `--force` is used and dependents exist, the disable proceeds and the list of
69/// overridden dependents is returned so callers can surface a warning to the user.
70#[derive(Debug, Default)]
71pub struct DisableResult {
72    /// Names of enabled plugins that depended on the disabled plugin.
73    ///
74    /// Non-empty only when the operation was forced past a dependency guard.
75    /// Callers should warn the user that these plugins may misbehave until re-enabled.
76    pub forced_over_dependents: Vec<String>,
77}
78
79/// Installed plugin metadata as returned by `plugin list`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct InstalledPlugin {
82    /// Plugin name.
83    pub name: String,
84    /// Plugin version.
85    pub version: String,
86    /// Plugin description.
87    pub description: String,
88    /// Absolute path to the installed plugin root.
89    pub path: PathBuf,
90    /// Skill names provided by this plugin (collected at list time to avoid re-reading manifests).
91    pub skill_names: Vec<String>,
92    /// Whether automatic updates are enabled for this plugin.
93    ///
94    /// Mirrors `plugin.auto_update` from the installed manifest. Populated at list time so
95    /// [`PluginManager::check_auto_updates`] can filter candidates without re-reading manifests.
96    pub auto_update: bool,
97}
98
99/// Install-time source metadata persisted as `.plugin-source.toml` alongside `.plugin.toml`.
100///
101/// Separating this from [`crate::manifest::PluginMeta`] keeps the author-facing `plugin.toml`
102/// schema clean: plugin authors never set these fields; they are written exclusively by
103/// [`PluginManager::add_remote`] and read by [`PluginManager::check_auto_updates`].
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct PluginSource {
106    /// URL from which the plugin archive was originally downloaded.
107    ///
108    /// `None` for plugins installed from local paths via [`PluginManager::add`].
109    pub url: Option<String>,
110    /// Lowercase hex SHA-256 of the installed archive bytes.
111    ///
112    /// Used by [`PluginManager::check_auto_updates`] to skip reinstalls when the remote
113    /// archive has not changed.
114    pub sha256: Option<String>,
115}
116
117/// Outcome of a single auto-update attempt.
118///
119/// One `AutoUpdateResult` is returned per plugin that had `auto_update = true`
120/// when [`PluginManager::check_auto_updates`] ran.
121#[derive(Debug)]
122pub struct AutoUpdateResult {
123    /// Plugin name.
124    pub name: String,
125    /// Specific outcome for this plugin.
126    pub status: AutoUpdateStatus,
127}
128
129/// Status of an individual auto-update attempt.
130#[non_exhaustive]
131#[derive(Debug)]
132pub enum AutoUpdateStatus {
133    /// Plugin was successfully updated.
134    Updated {
135        /// Version before the update.
136        old_version: String,
137        /// Version after the update.
138        new_version: String,
139    },
140    /// Remote archive SHA-256 matches the installed copy — no action taken.
141    UpToDate,
142    /// Plugin has no persisted source URL (installed from a local path).
143    NoSource,
144    /// Update failed; plugin remains at its current version.
145    Failed(String),
146}
147
148/// Manages plugin lifecycle: install, remove, list.
149///
150/// All operations are synchronous. Plugin watchers and agent config overlays are
151/// applied separately by the agent bootstrap layer.
152pub struct PluginManager {
153    /// Root directory where plugins are installed (`~/.local/share/zeph/plugins/`).
154    plugins_dir: PathBuf,
155    /// Directory where managed (user-installed) skills live.
156    managed_skills_dir: PathBuf,
157    /// `mcp.allowed_commands` from the agent config. Used to validate plugin MCP entries.
158    mcp_allowed_commands: Vec<String>,
159    /// Host's base `tools.shell.allowed_commands`. Used to warn when a
160    /// plugin overlay will be silently dropped because the base is empty
161    /// (see issue #3149).
162    base_allowed_commands: Vec<String>,
163    /// Path to the integrity registry file. Injected so tests can use isolated paths.
164    integrity_registry_path: PathBuf,
165    /// Timeout in seconds for each HTTP phase of [`Self::add_remote`] (connect + body read).
166    download_timeout_secs: u64,
167}
168
169impl PluginManager {
170    /// Returns the canonical default plugins directory: `~/.local/share/zeph/plugins/`.
171    ///
172    /// Both the CLI and TUI must use this helper so they always point to the same directory.
173    #[must_use]
174    pub fn default_plugins_dir() -> PathBuf {
175        dirs::data_local_dir()
176            .unwrap_or_else(|| PathBuf::from("~/.local/share"))
177            .join("zeph")
178            .join("plugins")
179    }
180
181    /// Create a new manager.
182    ///
183    /// # Parameters
184    ///
185    /// - `plugins_dir` — root installation directory for plugins.
186    /// - `managed_skills_dir` — directory for user-managed skills (conflict detection).
187    /// - `mcp_allowed_commands` — allowlist for MCP server commands from agent config.
188    /// - `base_allowed_commands` — host's `tools.shell.allowed_commands`.
189    ///   Used to emit a non-fatal warning when a plugin overlay would be
190    ///   silently dropped at load time (tighten-only invariant).
191    #[must_use]
192    pub fn new(
193        plugins_dir: PathBuf,
194        managed_skills_dir: PathBuf,
195        mcp_allowed_commands: Vec<String>,
196        base_allowed_commands: Vec<String>,
197    ) -> Self {
198        let integrity_registry_path = crate::integrity::IntegrityRegistry::default_path();
199        Self {
200            plugins_dir,
201            managed_skills_dir,
202            mcp_allowed_commands,
203            base_allowed_commands,
204            integrity_registry_path,
205            download_timeout_secs: 30,
206        }
207    }
208
209    /// Override the HTTP download timeout used by [`Self::add_remote`].
210    ///
211    /// Each phase (connect and body read) is independently bounded by this value.
212    /// The default is 30 seconds.
213    #[must_use]
214    pub fn with_download_timeout_secs(mut self, secs: u64) -> Self {
215        self.download_timeout_secs = secs;
216        self
217    }
218
219    /// Override the integrity registry path. Intended for tests only.
220    #[cfg(test)]
221    #[must_use]
222    pub fn with_integrity_registry_path(mut self, path: PathBuf) -> Self {
223        self.integrity_registry_path = path;
224        self
225    }
226
227    /// Install a plugin from a local directory path.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`PluginError`] if the manifest is invalid, the source cannot be read,
232    /// there are skill name conflicts, MCP commands are not allowlisted, or config
233    /// overlay keys are not in the tighten-only safelist.
234    pub fn add(&self, source: &str) -> Result<AddResult, PluginError> {
235        let _span = tracing::info_span!("plugins.manager.add", plugin.source = %source).entered();
236        let source_path = PathBuf::from(source);
237        if !source_path.exists() {
238            return Err(PluginError::InvalidSource {
239                path: source.to_owned(),
240                reason: "path does not exist".to_owned(),
241            });
242        }
243
244        let manifest_path = source_path.join("plugin.toml");
245        let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
246            path: manifest_path.clone(),
247            source: e,
248        })?;
249        let manifest_str = String::from_utf8(manifest_bytes).map_err(|_| {
250            PluginError::InvalidManifest("plugin.toml is not valid UTF-8".to_owned())
251        })?;
252        let manifest: PluginManifest = toml::from_str(&manifest_str)
253            .map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
254
255        // Validate plugin name.
256        validate_plugin_name(&manifest.plugin.name)?;
257
258        // Validate dependency list: enforce count limit and name format.
259        if manifest.plugin.dependencies.len() > MAX_DEPENDENCIES {
260            return Err(PluginError::InvalidManifest(format!(
261                "plugin declares {} dependencies; maximum allowed is {MAX_DEPENDENCIES}",
262                manifest.plugin.dependencies.len()
263            )));
264        }
265        for dep in &manifest.plugin.dependencies {
266            validate_plugin_name(dep)?;
267        }
268
269        // Validate each [[skills]] entry: path must stay within source root and SKILL.md must exist.
270        for entry in &manifest.skills {
271            let skill_path = source_path.join(&entry.path);
272            // Reject path traversal: resolved path must be inside source_path.
273            let canonical_source = source_path.canonicalize().map_err(|e| PluginError::Io {
274                path: source_path.clone(),
275                source: e,
276            })?;
277            let canonical_skill = skill_path.canonicalize().map_err(|e| PluginError::Io {
278                path: skill_path.clone(),
279                source: e,
280            })?;
281            if !canonical_skill.starts_with(&canonical_source) {
282                return Err(PluginError::InvalidSource {
283                    path: entry.path.clone(),
284                    reason: "skill path escapes plugin source root".to_owned(),
285                });
286            }
287            // Ensure the skill directory contains a SKILL.md file.
288            if !skill_path.join("SKILL.md").is_file() {
289                return Err(PluginError::SkillEntryMissing { path: skill_path });
290            }
291        }
292
293        // Validate config overlay keys.
294        validate_overlay_keys(&manifest.config)?;
295
296        // Stage-1: advisory regex scan over each SKILL.md before copying files.
297        // Results are warnings only — they never block installation.
298        scan_skill_entries(
299            source_path.as_path(),
300            &manifest.skills,
301            &manifest.plugin.name,
302        );
303
304        let mut warnings: Vec<String> = Vec::new();
305        if let Some(msg) = check_allowed_commands_overlay_effect(
306            &manifest.config,
307            &self.base_allowed_commands,
308            &manifest.plugin.name,
309        ) {
310            tracing::warn!(plugin = %manifest.plugin.name, "{msg}");
311            warnings.push(msg);
312        }
313
314        // Validate MCP command allowlist.
315        validate_mcp_commands(&manifest.mcp.servers, &self.mcp_allowed_commands)?;
316
317        // Collect skill names from the plugin source.
318        let skill_names = collect_skill_names(&source_path, &manifest);
319
320        // Check for name conflicts.
321        self.check_skill_conflicts(&skill_names, &manifest.plugin.name)?;
322
323        let dest = self.plugins_dir.join(&manifest.plugin.name);
324
325        // Copy source to destination.
326        copy_dir_all(&source_path, &dest)?;
327
328        // Recursively strip all .bundled markers from the installed tree.
329        strip_bundled_markers(&dest);
330
331        // Write manifest copy at plugin root for future reference.
332        let installed_manifest_path = dest.join(".plugin.toml");
333        let manifest_str = toml::to_string(&manifest)?;
334        std::fs::write(&installed_manifest_path, &manifest_str).map_err(|e| PluginError::Io {
335            path: installed_manifest_path.clone(),
336            source: e,
337        })?;
338
339        // Record integrity digest. Crash between the write above and the save here leaves the
340        // plugin with no registry entry — it will load unverified until reinstalled (M4).
341        let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
342        if let Err(e) = registry
343            .record(&manifest.plugin.name, &installed_manifest_path)
344            .and_then(|()| registry.save(&self.integrity_registry_path))
345        {
346            tracing::warn!(plugin = %manifest.plugin.name, error = %e, "failed to update integrity registry after install");
347        }
348
349        let mcp_server_ids: Vec<String> =
350            manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
351
352        tracing::info!(
353            plugin = %manifest.plugin.name,
354            skills = ?skill_names,
355            mcp_servers = ?mcp_server_ids,
356            "plugin installed"
357        );
358
359        Ok(AddResult {
360            name: manifest.plugin.name,
361            plugin_root: dest,
362            installed_skills: skill_names,
363            mcp_server_ids,
364            warnings,
365        })
366    }
367
368    /// Download and install a plugin from a remote URL with optional SHA-256 integrity pinning.
369    ///
370    /// Downloads the archive at `url`, verifies its SHA-256 digest against `expected_sha256`
371    /// (when provided), extracts it to a temporary directory, and delegates to [`Self::add`].
372    ///
373    /// # Integrity check
374    ///
375    /// When `expected_sha256` is `Some`, the raw archive bytes are hashed with SHA-256 and
376    /// compared against the expected lowercase hex string. If the digests do not match,
377    /// [`PluginError::IntegrityCheckFailed`] is returned and the archive is never extracted.
378    ///
379    /// When `expected_sha256` is `None`, the archive is extracted without verification. Callers
380    /// are encouraged to always supply the expected hash; unverified installs are permitted by
381    /// default for backward compatibility but should be avoided in production.
382    ///
383    /// # Errors
384    ///
385    /// - [`PluginError::DownloadFailed`] — HTTP request failed or returned a non-2xx status.
386    /// - [`PluginError::IntegrityCheckFailed`] — SHA-256 digest mismatch.
387    /// - [`PluginError::InvalidSource`] — archive cannot be extracted.
388    /// - Any error that [`Self::add`] can return.
389    ///
390    /// # Examples
391    ///
392    /// ```rust,no_run
393    /// # use zeph_plugins::PluginManager;
394    /// # async fn example() -> Result<(), zeph_plugins::PluginError> {
395    /// let mgr = PluginManager::new(
396    ///     "/tmp/plugins".into(),
397    ///     "/tmp/managed".into(),
398    ///     vec![],
399    ///     vec![],
400    /// );
401    /// let result = mgr.add_remote(
402    ///     "https://example.com/my-plugin.tar.gz",
403    ///     Some("abc123def456..."),
404    /// ).await?;
405    /// println!("installed: {}", result.name);
406    /// # Ok(())
407    /// # }
408    /// ```
409    pub async fn add_remote(
410        &self,
411        url: &str,
412        expected_sha256: Option<&str>,
413    ) -> Result<AddResult, PluginError> {
414        let span = tracing::info_span!("plugins.manager.add_remote", %url);
415        let _guard = span.enter();
416
417        // Reject non-HTTP(S) schemes to prevent SSRF via file:// or other transports.
418        validate_url_scheme(url)?;
419
420        let timeout = std::time::Duration::from_secs(self.download_timeout_secs);
421
422        let response = tokio::time::timeout(timeout, reqwest::get(url))
423            .await
424            .map_err(|_| PluginError::DownloadFailed {
425                url: url.to_owned(),
426                reason: format!("download timed out after {}s", self.download_timeout_secs),
427            })?
428            .map_err(|e| PluginError::DownloadFailed {
429                url: url.to_owned(),
430                reason: e.to_string(),
431            })?;
432
433        if !response.status().is_success() {
434            return Err(PluginError::DownloadFailed {
435                url: url.to_owned(),
436                reason: format!("HTTP {}", response.status()),
437            });
438        }
439
440        let bytes = tokio::time::timeout(timeout, response.bytes())
441            .await
442            .map_err(|_| PluginError::DownloadFailed {
443                url: url.to_owned(),
444                reason: format!("download timed out after {}s", self.download_timeout_secs),
445            })?
446            .map_err(|e| PluginError::DownloadFailed {
447                url: url.to_owned(),
448                reason: format!("failed to read response body: {e}"),
449            })?;
450
451        // Verify SHA-256 before extracting anything.
452        if let Some(expected) = expected_sha256 {
453            let actual = crate::integrity::sha256_hex(&bytes);
454            if actual != expected.to_ascii_lowercase() {
455                return Err(PluginError::IntegrityCheckFailed {
456                    expected: expected.to_ascii_lowercase(),
457                    actual,
458                });
459            }
460            tracing::debug!(url, "archive SHA-256 verified");
461        } else {
462            tracing::warn!(url, "installing remote plugin without integrity check");
463        }
464
465        // Compute the actual SHA-256 for source persistence (even when expected_sha256 is None).
466        let actual_sha256 = crate::integrity::sha256_hex(&bytes);
467
468        // Extract archive to a temporary directory and delegate to `add`.
469        let tmp = tempfile::tempdir().map_err(|e| PluginError::Io {
470            path: std::path::PathBuf::from(url),
471            source: e,
472        })?;
473        extract_archive(&bytes, tmp.path(), url)?;
474
475        let plugins_dir = self.plugins_dir.clone();
476        let managed_skills_dir = self.managed_skills_dir.clone();
477        let mcp_allowed_commands = self.mcp_allowed_commands.clone();
478        let base_allowed_commands = self.base_allowed_commands.clone();
479        let integrity_registry_path = self.integrity_registry_path.clone();
480        let source_str = tmp.path().to_str().unwrap_or(url).to_owned();
481
482        let result = tokio::task::spawn_blocking(move || {
483            let mgr = PluginManager {
484                plugins_dir,
485                managed_skills_dir,
486                mcp_allowed_commands,
487                base_allowed_commands,
488                integrity_registry_path,
489                download_timeout_secs: 0, // add() does not perform network I/O
490            };
491            mgr.add(&source_str)
492        })
493        .await
494        .map_err(|e| PluginError::Io {
495            path: std::path::PathBuf::from(url),
496            source: std::io::Error::other(e),
497        })??;
498
499        // Persist source metadata so check_auto_updates can re-fetch this plugin.
500        let source = PluginSource {
501            url: Some(url.to_owned()),
502            sha256: Some(actual_sha256),
503        };
504        let source_path = self
505            .plugins_dir
506            .join(&result.name)
507            .join(".plugin-source.toml");
508        match toml::to_string(&source) {
509            Ok(toml_str) => {
510                if let Err(e) = tokio::fs::write(&source_path, toml_str).await {
511                    tracing::warn!(
512                        plugin = %result.name,
513                        error = %e,
514                        "failed to persist plugin source metadata; auto_update will be skipped"
515                    );
516                }
517            }
518            Err(e) => {
519                tracing::warn!(
520                    plugin = %result.name,
521                    error = %e,
522                    "failed to serialize plugin source metadata; auto_update will be skipped"
523                );
524            }
525        }
526
527        Ok(result)
528    }
529
530    /// Remove an installed plugin by name.
531    ///
532    /// Refuses to remove the plugin if any enabled plugin depends on it. The caller receives a
533    /// [`PluginError::DependencyRequired`] error with a formatted hint listing the dependents.
534    ///
535    /// # Note on TOCTOU
536    ///
537    /// The dependent check and directory removal are not atomic. In a multi-process environment a
538    /// concurrent enable of a dependent could race with this remove. This is acceptable for a
539    /// single-user CLI tool where plugin operations are manual.
540    ///
541    /// # Errors
542    ///
543    /// - [`PluginError::NotFound`] — plugin is not installed.
544    /// - [`PluginError::DependencyRequired`] — at least one enabled plugin depends on this one.
545    /// - [`PluginError::Io`] — the plugin directory cannot be removed.
546    pub fn remove(&self, name: &str) -> Result<RemoveResult, PluginError> {
547        validate_plugin_name(name)?;
548        let plugin_dir = self.plugins_dir.join(name);
549        if !plugin_dir.exists() {
550            return Err(PluginError::NotFound {
551                name: name.to_owned(),
552            });
553        }
554
555        self.guard_no_dependents(name)?;
556
557        let manifest_path = plugin_dir.join(".plugin.toml");
558        let (removed_skills, removed_mcp_ids) = if manifest_path.exists() {
559            let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
560                path: manifest_path,
561                source: e,
562            })?;
563            let text = String::from_utf8(bytes).map_err(|_| {
564                PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned())
565            })?;
566            let manifest: PluginManifest =
567                toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
568            let skills = collect_skill_names(&plugin_dir, &manifest);
569            let mcp = manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
570            (skills, mcp)
571        } else {
572            (Vec::new(), Vec::new())
573        };
574
575        std::fs::remove_dir_all(&plugin_dir).map_err(|e| PluginError::Io {
576            path: plugin_dir,
577            source: e,
578        })?;
579
580        // Remove integrity entry; non-fatal if registry cannot be updated.
581        let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
582        registry.remove(name);
583        if let Err(e) = registry.save(&self.integrity_registry_path) {
584            tracing::warn!(plugin = %name, error = %e, "failed to update integrity registry after remove");
585        }
586
587        tracing::info!(plugin = %name, "plugin removed");
588
589        Ok(RemoveResult {
590            removed_skills,
591            removed_mcp_ids,
592        })
593    }
594
595    /// List all installed plugins.
596    ///
597    /// # Errors
598    ///
599    /// Returns [`PluginError`] if the plugins directory cannot be read.
600    pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, PluginError> {
601        if !self.plugins_dir.exists() {
602            return Ok(Vec::new());
603        }
604
605        let mut plugins = Vec::new();
606        let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| PluginError::Io {
607            path: self.plugins_dir.clone(),
608            source: e,
609        })?;
610
611        for entry in entries.flatten() {
612            let path = entry.path();
613            if !path.is_dir() {
614                continue;
615            }
616            let manifest_path = path.join(".plugin.toml");
617            if !manifest_path.exists() {
618                continue;
619            }
620            let Ok(bytes) = std::fs::read(&manifest_path) else {
621                continue;
622            };
623            let Ok(text) = String::from_utf8(bytes) else {
624                continue;
625            };
626            let Ok(manifest): Result<PluginManifest, _> = toml::from_str(&text) else {
627                continue;
628            };
629            let skill_names = collect_skill_names(&path, &manifest);
630            let auto_update = manifest.plugin.auto_update;
631            plugins.push(InstalledPlugin {
632                name: manifest.plugin.name,
633                version: manifest.plugin.version,
634                description: manifest.plugin.description,
635                path,
636                skill_names,
637                auto_update,
638            });
639        }
640
641        plugins.sort_by(|a, b| a.name.cmp(&b.name));
642        Ok(plugins)
643    }
644
645    /// Returns all skill directory paths from installed plugins.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`PluginError`] if the plugins directory cannot be read.
650    #[tracing::instrument(name = "plugins.manager.collect_skill_dirs", skip_all)]
651    pub fn collect_skill_dirs(&self) -> Result<Vec<PathBuf>, PluginError> {
652        if !self.plugins_dir.exists() {
653            return Ok(Vec::new());
654        }
655
656        let mut dirs = Vec::new();
657        let plugins = self.list_installed()?;
658        for plugin in &plugins {
659            // Skip disabled plugins — their skills must not be loaded.
660            if plugin.path.join(".disabled").exists() {
661                continue;
662            }
663            let manifest_path = plugin.path.join(".plugin.toml");
664            if let Ok(bytes) = std::fs::read(&manifest_path)
665                && let Ok(text) = String::from_utf8(bytes)
666                && let Ok(manifest) = toml::from_str::<PluginManifest>(&text)
667            {
668                for entry in &manifest.skills {
669                    let skill_dir = plugin.path.join(&entry.path);
670                    // Reject traversal: dir must stay within the installed plugin root.
671                    let ok = skill_dir
672                        .canonicalize()
673                        .is_ok_and(|c| c.starts_with(&plugin.path));
674                    if ok {
675                        dirs.push(skill_dir);
676                    } else {
677                        tracing::warn!(
678                            plugin = %plugin.name,
679                            path = %entry.path,
680                            "skipping skill path that escapes plugin root"
681                        );
682                    }
683                }
684            }
685        }
686        Ok(dirs)
687    }
688
689    /// Check and apply updates for all installed plugins with `auto_update = true`.
690    ///
691    /// For each eligible plugin the method:
692    /// 1. Reads `.plugin-source.toml` to retrieve the original download URL and SHA-256.
693    /// 2. Downloads the archive from that URL.
694    /// 3. Compares the downloaded archive's SHA-256 with the stored value.
695    /// 4. If the hashes differ, stages the new version in a temporary directory adjacent to the
696    ///    plugin root, then atomically swaps via `rename` — an interrupted update leaves the
697    ///    plugin intact rather than half-deleted.
698    /// 5. Returns a result per plugin; failures are warnings and never abort startup.
699    ///
700    /// Plugins installed from local paths (no `.plugin-source.toml` or no URL) are skipped
701    /// with [`AutoUpdateStatus::NoSource`].
702    ///
703    /// # Examples
704    ///
705    /// ```rust,no_run
706    /// # use zeph_plugins::PluginManager;
707    /// # async fn run() {
708    /// let mgr = PluginManager::new(
709    ///     "/tmp/plugins".into(),
710    ///     "/tmp/managed".into(),
711    ///     vec![],
712    ///     vec![],
713    /// );
714    /// let results = mgr.check_auto_updates().await;
715    /// for r in &results {
716    ///     println!("{}: {:?}", r.name, r.status);
717    /// }
718    /// # }
719    /// ```
720    pub async fn check_auto_updates(&self) -> Vec<AutoUpdateResult> {
721        use futures::stream::{self, StreamExt as _};
722        use tracing::Instrument as _;
723
724        async {
725            let candidates = match self.list_installed() {
726                Ok(list) => list,
727                Err(e) => {
728                    tracing::warn!(error = %e, "check_auto_updates: failed to list installed plugins");
729                    return Vec::new();
730                }
731            };
732
733            stream::iter(candidates.into_iter().filter(|p| p.auto_update))
734                .map(|plugin| async move {
735                    let status = self.update_one_plugin(&plugin).await;
736                    AutoUpdateResult {
737                        name: plugin.name,
738                        status,
739                    }
740                })
741                .buffer_unordered(4)
742                .collect()
743                .await
744        }
745        .instrument(tracing::info_span!("plugins.manager.check_auto_updates"))
746        .await
747    }
748
749    /// Attempt to update a single plugin. Returns the update status without aborting on error.
750    #[tracing::instrument(name = "plugins.manager.update_one", skip_all, fields(plugin = %plugin.name))]
751    async fn update_one_plugin(&self, plugin: &InstalledPlugin) -> AutoUpdateStatus {
752        let source_path = plugin.path.join(".plugin-source.toml");
753        let Some(source) = read_plugin_source(&source_path).await else {
754            return AutoUpdateStatus::NoSource;
755        };
756
757        let (Some(url), Some(stored_sha256)) = (source.url, source.sha256) else {
758            return AutoUpdateStatus::NoSource;
759        };
760
761        // Reject non-HTTP(S) schemes to prevent SSRF via file:// or other transports.
762        if let Err(e) = validate_url_scheme(&url) {
763            tracing::warn!(plugin = %plugin.name, %url, error = %e, "auto-update: invalid URL scheme");
764            return AutoUpdateStatus::Failed(format!("invalid URL scheme: {e}"));
765        }
766
767        tracing::debug!(plugin = %plugin.name, %url, "checking for updates");
768
769        let timeout = std::time::Duration::from_secs(self.download_timeout_secs);
770
771        let bytes = match self.download_archive(&url, timeout).await {
772            Ok(b) => b,
773            Err(e) => {
774                tracing::warn!(plugin = %plugin.name, %url, error = %e, "auto-update download failed");
775                return AutoUpdateStatus::Failed(e);
776            }
777        };
778
779        let new_sha256 = crate::integrity::sha256_hex(&bytes);
780        if new_sha256 == stored_sha256 {
781            tracing::debug!(plugin = %plugin.name, "auto-update: archive unchanged (SHA-256 match)");
782            return AutoUpdateStatus::UpToDate;
783        }
784
785        tracing::info!(
786            plugin = %plugin.name,
787            old_sha256 = %stored_sha256,
788            new_sha256 = %new_sha256,
789            "auto-update: new archive detected, applying update"
790        );
791
792        let old_version = plugin.version.clone();
793
794        // Extract to a staging directory adjacent to the plugin root.
795        let staging = self.plugins_dir.join(format!(".staging-{}", plugin.name));
796        let backup = self.plugins_dir.join(format!(".backup-{}", plugin.name));
797        let dest = plugin.path.clone();
798        let plugin_name = plugin.name.clone();
799
800        // Offload all blocking filesystem operations to a dedicated thread.
801        let mcp_allowed = self.mcp_allowed_commands.clone();
802        let managed_skills_dir = self.managed_skills_dir.clone();
803        let plugins_dir = self.plugins_dir.clone();
804        let integrity_registry_path = self.integrity_registry_path.clone();
805        let url_clone = url.clone();
806        let base_allowed_commands = self.base_allowed_commands.clone();
807
808        let result = tokio::task::spawn_blocking(move || {
809            apply_staged_update(
810                &bytes,
811                &url_clone,
812                &dest,
813                &staging,
814                &backup,
815                &plugin_name,
816                &mcp_allowed,
817                &managed_skills_dir,
818                &plugins_dir,
819                &integrity_registry_path,
820                &base_allowed_commands,
821            )
822        })
823        .await;
824
825        match result {
826            Ok(Ok(())) => {}
827            Ok(Err(e)) => {
828                tracing::warn!(plugin = %plugin.name, error = %e, "auto-update: staged swap failed, original preserved");
829                return AutoUpdateStatus::Failed(e);
830            }
831            Err(e) => {
832                tracing::warn!(plugin = %plugin.name, error = %e, "auto-update: blocking task panicked");
833                return AutoUpdateStatus::Failed(format!("update task panicked: {e}"));
834            }
835        }
836
837        // Persist updated source metadata (new SHA-256).
838        let new_source = PluginSource {
839            url: Some(url),
840            sha256: Some(new_sha256),
841        };
842        let source_dest = plugin.path.join(".plugin-source.toml");
843        if let Ok(toml_str) = toml::to_string(&new_source) {
844            let _ = tokio::fs::write(&source_dest, toml_str).await;
845        }
846
847        // Read the new version from the updated manifest.
848        let new_version = tokio::fs::read_to_string(plugin.path.join(".plugin.toml"))
849            .await
850            .ok()
851            .and_then(|s| toml::from_str::<crate::manifest::PluginManifest>(&s).ok())
852            .map_or_else(|| old_version.clone(), |m| m.plugin.version);
853
854        tracing::info!(
855            plugin = %plugin.name,
856            %old_version,
857            %new_version,
858            "auto-update: plugin updated successfully"
859        );
860
861        AutoUpdateStatus::Updated {
862            old_version,
863            new_version,
864        }
865    }
866
867    /// Download an archive from `url` respecting the per-phase timeout.
868    #[tracing::instrument(name = "plugins.manager.download_archive", skip_all, fields(url = %url))]
869    async fn download_archive(
870        &self,
871        url: &str,
872        timeout: std::time::Duration,
873    ) -> Result<Vec<u8>, String> {
874        let response = tokio::time::timeout(timeout, reqwest::get(url))
875            .await
876            .map_err(|_| format!("download timed out after {}s", timeout.as_secs()))?
877            .map_err(|e| e.to_string())?;
878
879        if !response.status().is_success() {
880            return Err(format!("HTTP {}", response.status()));
881        }
882
883        let raw = tokio::time::timeout(timeout, response.bytes())
884            .await
885            .map_err(|_| format!("body read timed out after {}s", timeout.as_secs()))?
886            .map_err(|e| format!("failed to read body: {e}"))?;
887
888        Ok(raw.to_vec())
889    }
890
891    /// Enable an installed plugin by removing its `.disabled` marker file.
892    ///
893    /// Before enabling the target, all plugins listed in `plugin.dependencies` are enabled
894    /// recursively (depth-first). The method detects dependency cycles and returns
895    /// [`PluginError::DependencyCycle`] before touching the filesystem.
896    ///
897    /// A plugin with no `.disabled` marker is considered already enabled; this method is a no-op
898    /// for such plugins (idempotent).
899    ///
900    /// # Errors
901    ///
902    /// - [`PluginError::NotFound`] — plugin is not installed.
903    /// - [`PluginError::MissingDependency`] — a declared dependency is not installed.
904    /// - [`PluginError::DependencyCycle`] — the dependency graph contains a cycle.
905    /// - [`PluginError::Io`] — the `.disabled` marker cannot be removed.
906    pub fn enable(&self, name: &str) -> Result<(), PluginError> {
907        validate_plugin_name(name)?;
908        let mut visiting: Vec<String> = Vec::new();
909        self.enable_recursive(name, &mut visiting)
910    }
911
912    /// Recursive implementation of [`Self::enable`]; `visiting` tracks the DFS path for cycle
913    /// detection.
914    fn enable_recursive(&self, name: &str, visiting: &mut Vec<String>) -> Result<(), PluginError> {
915        if visiting.iter().any(|v| v == name) {
916            // Build a readable cycle description: A → B → A
917            let mut path = visiting.clone();
918            path.push(name.to_owned());
919            return Err(PluginError::DependencyCycle {
920                name: name.to_owned(),
921                cycle: path.join(" → "),
922            });
923        }
924
925        let plugin_dir = self.plugins_dir.join(name);
926        if !plugin_dir.exists() {
927            return Err(PluginError::NotFound {
928                name: name.to_owned(),
929            });
930        }
931
932        // Already enabled — nothing to do.
933        let disabled_marker = plugin_dir.join(".disabled");
934        if !disabled_marker.exists() {
935            return Ok(());
936        }
937
938        // Load manifest to discover dependencies.
939        let manifest = load_installed_manifest(&plugin_dir)?;
940
941        visiting.push(name.to_owned());
942        for dep in &manifest.plugin.dependencies {
943            let dep_dir = self.plugins_dir.join(dep);
944            if !dep_dir.exists() {
945                visiting.pop();
946                return Err(PluginError::MissingDependency {
947                    name: name.to_owned(),
948                    dependency: dep.clone(),
949                });
950            }
951            self.enable_recursive(dep, visiting)?;
952        }
953        visiting.pop();
954
955        // When re-enabling a plugin as a transitive dependency (visiting is non-empty), warn so
956        // the operator knows that something they explicitly disabled was brought back.
957        if let Some(requested_by) = visiting.last() {
958            tracing::warn!(
959                plugin = %name,
960                requested_by = %requested_by,
961                "auto-enabling previously-disabled plugin as transitive dependency"
962            );
963        }
964
965        // Remove the `.disabled` marker to enable this plugin.
966        std::fs::remove_file(&disabled_marker).map_err(|e| PluginError::Io {
967            path: disabled_marker.clone(),
968            source: e,
969        })?;
970
971        tracing::info!(plugin = %name, "plugin enabled");
972        Ok(())
973    }
974
975    /// Disable an installed plugin by creating a `.disabled` marker file.
976    ///
977    /// Refuses to disable the plugin if any *enabled* plugin depends on it, unless `force` is
978    /// `true`. When `force` is `true` the operation proceeds regardless, and the returned
979    /// [`DisableResult`] lists the dependents that were overridden so callers can warn the user.
980    ///
981    /// Disabling an already-disabled plugin is a no-op (idempotent).
982    ///
983    /// # Note on TOCTOU
984    ///
985    /// The dependent check and marker creation are not atomic. In a multi-process
986    /// environment a concurrent enable of a dependent could race with this disable. This is
987    /// acceptable for a single-user CLI tool where plugin operations are manual.
988    ///
989    /// # Errors
990    ///
991    /// - [`PluginError::NotFound`] — plugin is not installed.
992    /// - [`PluginError::DependencyRequired`] — at least one enabled plugin depends on this one
993    ///   and `force` is `false`.
994    /// - [`PluginError::Io`] — the `.disabled` marker cannot be written.
995    ///
996    /// # Examples
997    ///
998    /// ```rust,no_run
999    /// # use zeph_plugins::PluginManager;
1000    /// # fn main() -> Result<(), zeph_plugins::PluginError> {
1001    /// let mgr = PluginManager::new(
1002    ///     "/tmp/plugins".into(),
1003    ///     "/tmp/managed".into(),
1004    ///     vec![],
1005    ///     vec![],
1006    /// );
1007    /// // Normal disable — fails if any enabled plugin depends on "my-plugin".
1008    /// mgr.disable("my-plugin", false)?;
1009    ///
1010    /// // Forced disable — proceeds even if dependents exist.
1011    /// let result = mgr.disable("my-plugin", true)?;
1012    /// if !result.forced_over_dependents.is_empty() {
1013    ///     eprintln!("Warning: disabled despite dependents: {:?}", result.forced_over_dependents);
1014    /// }
1015    /// # Ok(())
1016    /// # }
1017    /// ```
1018    pub fn disable(&self, name: &str, force: bool) -> Result<DisableResult, PluginError> {
1019        validate_plugin_name(name)?;
1020        let plugin_dir = self.plugins_dir.join(name);
1021        if !plugin_dir.exists() {
1022            return Err(PluginError::NotFound {
1023                name: name.to_owned(),
1024            });
1025        }
1026
1027        let forced_over_dependents = if force {
1028            let dependents = self.dependents_of(name);
1029            if !dependents.is_empty() {
1030                tracing::warn!(
1031                    plugin = %name,
1032                    dependents = ?dependents,
1033                    "force-disabling plugin that has enabled dependents"
1034                );
1035            }
1036            dependents
1037        } else {
1038            self.guard_no_dependents(name)?;
1039            Vec::new()
1040        };
1041
1042        // Already disabled — nothing to do.
1043        let disabled_marker = plugin_dir.join(".disabled");
1044        if disabled_marker.exists() {
1045            return Ok(DisableResult {
1046                forced_over_dependents,
1047            });
1048        }
1049
1050        std::fs::write(&disabled_marker, b"").map_err(|e| PluginError::Io {
1051            path: disabled_marker.clone(),
1052            source: e,
1053        })?;
1054
1055        tracing::info!(plugin = %name, force, "plugin disabled");
1056        Ok(DisableResult {
1057            forced_over_dependents,
1058        })
1059    }
1060
1061    /// Returns the names of all **enabled** plugins that declare `name` as a dependency.
1062    ///
1063    /// Scans every installed plugin's manifest; a plugin is considered enabled if it has no
1064    /// `.disabled` marker file in its directory. The check is O(N) in the number of installed
1065    /// plugins and performs one filesystem read per plugin. For typical plugin counts (<50) this
1066    /// is negligible.
1067    fn dependents_of(&self, name: &str) -> Vec<String> {
1068        if !self.plugins_dir.exists() {
1069            return Vec::new();
1070        }
1071
1072        let Ok(entries) = std::fs::read_dir(&self.plugins_dir) else {
1073            return Vec::new();
1074        };
1075
1076        let mut dependents = Vec::new();
1077        for entry in entries.flatten() {
1078            let path = entry.path();
1079            if !path.is_dir() {
1080                continue;
1081            }
1082            // Skip disabled plugins.
1083            if path.join(".disabled").exists() {
1084                continue;
1085            }
1086            let Ok(manifest) = load_installed_manifest(&path) else {
1087                continue;
1088            };
1089            if manifest.plugin.name == name {
1090                continue;
1091            }
1092            if manifest.plugin.dependencies.iter().any(|d| d == name) {
1093                dependents.push(manifest.plugin.name);
1094            }
1095        }
1096        dependents.sort();
1097        dependents
1098    }
1099
1100    /// Check that no enabled plugin depends on `name`; return [`PluginError::DependencyRequired`]
1101    /// if any do.
1102    fn guard_no_dependents(&self, name: &str) -> Result<(), PluginError> {
1103        let dependents = self.dependents_of(name);
1104        if dependents.is_empty() {
1105            return Ok(());
1106        }
1107        let hints = dependents
1108            .iter()
1109            .map(|d| format!("  zeph plugin disable {d}"))
1110            .collect::<Vec<_>>()
1111            .join("\n");
1112        Err(PluginError::DependencyRequired {
1113            name: name.to_owned(),
1114            dependents: dependents.join(", "),
1115            hints,
1116        })
1117    }
1118
1119    /// Like [`Self::check_skill_conflicts`] but skips the plugin currently being updated.
1120    ///
1121    /// Used by the auto-update path to validate the staged replacement without false conflicts
1122    /// with the plugin's own currently-installed skills (which are about to be replaced).
1123    pub(crate) fn check_skill_conflicts_for_update(
1124        &self,
1125        skill_names: &[String],
1126        this_plugin: &str,
1127    ) -> Result<(), PluginError> {
1128        self.check_skill_conflicts(skill_names, this_plugin)
1129    }
1130
1131    fn check_skill_conflicts(
1132        &self,
1133        skill_names: &[String],
1134        this_plugin: &str,
1135    ) -> Result<(), PluginError> {
1136        let bundled = bundled_skill_names();
1137
1138        // Managed skills: any name in the managed skills dir.
1139        let managed_registry = {
1140            let dirs: Vec<PathBuf> = if self.managed_skills_dir.exists() {
1141                vec![self.managed_skills_dir.clone()]
1142            } else {
1143                vec![]
1144            };
1145            SkillRegistry::load(&dirs)
1146        };
1147        let managed_names: std::collections::HashSet<String> = managed_registry
1148            .all_meta()
1149            .iter()
1150            .map(|m| m.name.clone())
1151            .collect();
1152
1153        // Other installed plugins' skill names — already collected by list_installed.
1154        let installed = self.list_installed().unwrap_or_default();
1155        let mut other_plugin_skills: std::collections::HashMap<String, String> =
1156            std::collections::HashMap::new();
1157        for plugin in &installed {
1158            if plugin.name == this_plugin {
1159                continue;
1160            }
1161            for name in &plugin.skill_names {
1162                other_plugin_skills.insert(name.clone(), plugin.name.clone());
1163            }
1164        }
1165
1166        for name in skill_names {
1167            if bundled.contains(name) {
1168                return Err(PluginError::SkillNameConflictWithBundled { name: name.clone() });
1169            }
1170            if managed_names.contains(name) {
1171                return Err(PluginError::SkillNameConflictWithManaged { name: name.clone() });
1172            }
1173            if let Some(other) = other_plugin_skills.get(name) {
1174                return Err(PluginError::SkillNameConflictWithPlugin {
1175                    name: name.clone(),
1176                    plugin: other.clone(),
1177                });
1178            }
1179        }
1180        Ok(())
1181    }
1182}
1183
1184/// Read `.plugin-source.toml` from `path` and return it, or `None` on any failure.
1185///
1186/// Failures are logged as debug — missing sidecar is normal for local-install plugins.
1187async fn read_plugin_source(path: &std::path::Path) -> Option<PluginSource> {
1188    let text = tokio::fs::read_to_string(path).await.ok()?;
1189    match toml::from_str::<PluginSource>(&text) {
1190        Ok(s) => Some(s),
1191        Err(e) => {
1192            tracing::debug!(path = %path.display(), error = %e, "cannot parse .plugin-source.toml");
1193            None
1194        }
1195    }
1196}
1197
1198/// Validate that `url` uses an `http` or `https` scheme.
1199///
1200/// Rejects `file://`, `data:`, and any other scheme that could be used for SSRF
1201/// or local filesystem exfiltration when fetching plugin archives.
1202///
1203/// # Errors
1204///
1205/// Returns [`PluginError::InvalidSource`] when the URL is unparseable or uses a
1206/// disallowed scheme.
1207pub(crate) fn validate_url_scheme(url: &str) -> Result<(), PluginError> {
1208    let parsed = reqwest::Url::parse(url).map_err(|_| PluginError::InvalidSource {
1209        path: url.to_owned(),
1210        reason: "URL is not valid".to_owned(),
1211    })?;
1212    if !matches!(parsed.scheme(), "http" | "https") {
1213        return Err(PluginError::InvalidSource {
1214            path: url.to_owned(),
1215            reason: format!(
1216                "URL scheme {:?} is not allowed; only http and https are permitted",
1217                parsed.scheme()
1218            ),
1219        });
1220    }
1221    Ok(())
1222}
1223
1224/// Extract archive to `staging`, run all security validations, then swap `staging` with `dest`.
1225///
1226/// Strategy: extract → staging, validate, rename dest → backup, rename staging → dest, delete
1227/// backup. If any step after the extract fails, staging is removed and backup (if present) is
1228/// restored so the installed plugin is never left in a partial state.
1229///
1230/// Note: `rename(2)` is atomic only within the same filesystem. Across different mounts this
1231/// returns `EXDEV`; the backup is restored in that case. For most deployments `plugins_dir` and
1232/// the temp path share the same mount, so `EXDEV` is unlikely in practice.
1233///
1234/// # Errors
1235///
1236/// Returns a human-readable error string on any failure. The caller logs this as a warning.
1237#[allow(clippy::too_many_arguments)]
1238pub(crate) fn apply_staged_update(
1239    bytes: &[u8],
1240    url: &str,
1241    dest: &std::path::Path,
1242    staging: &std::path::Path,
1243    backup: &std::path::Path,
1244    installed_plugin_name: &str,
1245    mcp_allowed_commands: &[String],
1246    managed_skills_dir: &std::path::Path,
1247    plugins_dir: &std::path::Path,
1248    integrity_registry_path: &std::path::Path,
1249    base_allowed_commands: &[String],
1250) -> Result<(), String> {
1251    // Clean up any leftover staging/backup dirs from a previous interrupted attempt.
1252    let _ = std::fs::remove_dir_all(staging);
1253    let _ = std::fs::remove_dir_all(backup);
1254
1255    std::fs::create_dir_all(staging).map_err(|e| format!("failed to create staging dir: {e}"))?;
1256
1257    // Extract archive into staging directory (with tar-slip protection).
1258    extract_archive_safe(bytes, staging, url).map_err(|e| e.to_string())?;
1259
1260    // Parse and validate the staged manifest before touching the installed plugin.
1261    let staging_manifest = staging.join("plugin.toml");
1262    if !staging_manifest.exists() {
1263        let _ = std::fs::remove_dir_all(staging);
1264        return Err("extracted archive does not contain plugin.toml".into());
1265    }
1266    let manifest_str = std::fs::read_to_string(&staging_manifest)
1267        .map_err(|e| format!("cannot read staged plugin.toml: {e}"))?;
1268    let manifest: crate::manifest::PluginManifest =
1269        toml::from_str(&manifest_str).map_err(|e| format!("staged plugin.toml invalid: {e}"))?;
1270
1271    // Reject name changes: an update must not rename the plugin.
1272    if let Err(e) = validate_plugin_name(&manifest.plugin.name) {
1273        let _ = std::fs::remove_dir_all(staging);
1274        return Err(format!("staged manifest has invalid plugin name: {e}"));
1275    }
1276    if manifest.plugin.name != installed_plugin_name {
1277        let _ = std::fs::remove_dir_all(staging);
1278        return Err(format!(
1279            "staged manifest changes plugin name from {:?} to {:?}; update rejected",
1280            installed_plugin_name, manifest.plugin.name
1281        ));
1282    }
1283
1284    // Run the full validation pipeline — same checks as `add()`.
1285    if let Err(e) = validate_overlay_keys(&manifest.config) {
1286        let _ = std::fs::remove_dir_all(staging);
1287        return Err(format!(
1288            "staged manifest failed config overlay validation: {e}"
1289        ));
1290    }
1291    if let Err(e) = validate_mcp_commands(&manifest.mcp.servers, mcp_allowed_commands) {
1292        let _ = std::fs::remove_dir_all(staging);
1293        return Err(format!(
1294            "staged manifest failed MCP command validation: {e}"
1295        ));
1296    }
1297
1298    // Skill conflict check: build a temporary manager against the staging tree.
1299    let tmp_mgr = crate::manager::PluginManager::new(
1300        plugins_dir.to_path_buf(),
1301        managed_skills_dir.to_path_buf(),
1302        mcp_allowed_commands.to_vec(),
1303        base_allowed_commands.to_vec(),
1304    );
1305    let staged_skill_names = collect_skill_names(staging, &manifest);
1306    if let Err(e) =
1307        tmp_mgr.check_skill_conflicts_for_update(&staged_skill_names, installed_plugin_name)
1308    {
1309        let _ = std::fs::remove_dir_all(staging);
1310        return Err(format!("staged manifest failed skill conflict check: {e}"));
1311    }
1312
1313    // Advisory SKILL.md scan (non-blocking — logs warnings only).
1314    scan_skill_entries(staging, &manifest.skills, &manifest.plugin.name);
1315
1316    // Write the normalised .plugin.toml and strip bundled markers.
1317    let installed_manifest_toml =
1318        toml::to_string(&manifest).map_err(|e| format!("cannot serialize staged manifest: {e}"))?;
1319    std::fs::write(staging.join(".plugin.toml"), &installed_manifest_toml)
1320        .map_err(|e| format!("cannot write staged .plugin.toml: {e}"))?;
1321    strip_bundled_markers(staging);
1322
1323    // Atomic swap: rename dest → backup, rename staging → dest.
1324    if dest.exists() {
1325        std::fs::rename(dest, backup)
1326            .map_err(|e| format!("failed to rename plugin dir to backup: {e}"))?;
1327    }
1328    if let Err(e) = std::fs::rename(staging, dest) {
1329        // Restore from backup.
1330        if backup.exists() {
1331            let _ = std::fs::rename(backup, dest);
1332        }
1333        return Err(format!("failed to rename staging dir to dest: {e}"));
1334    }
1335
1336    // Update integrity registry for the new manifest.
1337    let installed_manifest_path = dest.join(".plugin.toml");
1338    let mut registry = crate::integrity::IntegrityRegistry::load(integrity_registry_path);
1339    if let Err(e) = registry
1340        .record(&manifest.plugin.name, &installed_manifest_path)
1341        .and_then(|()| registry.save(integrity_registry_path))
1342    {
1343        tracing::warn!(
1344            plugin = %manifest.plugin.name,
1345            error = %e,
1346            "auto-update: failed to update integrity registry after swap"
1347        );
1348    }
1349
1350    let _ = std::fs::remove_dir_all(backup);
1351    Ok(())
1352}
1353
1354/// Validate that a plugin name is a safe identifier: `[a-z][a-z0-9-]*`, max 64 chars.
1355pub(crate) fn validate_plugin_name(name: &str) -> Result<(), PluginError> {
1356    if name.is_empty() {
1357        return Err(PluginError::InvalidName {
1358            name: name.to_owned(),
1359            reason: "name must not be empty".to_owned(),
1360        });
1361    }
1362    if name.len() > 64 {
1363        return Err(PluginError::InvalidName {
1364            name: name.to_owned(),
1365            reason: "name must not exceed 64 characters".to_owned(),
1366        });
1367    }
1368    if name.contains('/') || name.contains('\\') || name.contains('.') {
1369        return Err(PluginError::InvalidName {
1370            name: name.to_owned(),
1371            reason: "name must not contain path separators or dots".to_owned(),
1372        });
1373    }
1374    if !name.starts_with(|c: char| c.is_ascii_lowercase()) {
1375        return Err(PluginError::InvalidName {
1376            name: name.to_owned(),
1377            reason: "name must start with a lowercase ASCII letter [a-z]".to_owned(),
1378        });
1379    }
1380    if !name
1381        .chars()
1382        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1383    {
1384        return Err(PluginError::InvalidName {
1385            name: name.to_owned(),
1386            reason: "name must match [a-z][a-z0-9-]*".to_owned(),
1387        });
1388    }
1389    Ok(())
1390}
1391
1392/// Returns a warning message if the plugin's `allowed_commands` overlay
1393/// will be silently dropped because the host's base allowlist is empty.
1394///
1395/// Returns `None` when the overlay is absent or empty, or when the base
1396/// allowlist is non-empty (in which case the overlay will narrow it and
1397/// the existing `tracing::info!` in `apply_resolved` already signals the
1398/// transition at load time).
1399fn check_allowed_commands_overlay_effect(
1400    config: &toml::Value,
1401    base_allowed: &[String],
1402    plugin_name: &str,
1403) -> Option<String> {
1404    let overlay_has_entries = config
1405        .as_table()
1406        .and_then(|t| t.get("tools"))
1407        .and_then(toml::Value::as_table)
1408        .and_then(|t| t.get("allowed_commands"))
1409        .and_then(toml::Value::as_array)
1410        .is_some_and(|arr| arr.iter().any(toml::Value::is_str));
1411
1412    if !overlay_has_entries {
1413        return None;
1414    }
1415    if !base_allowed.is_empty() {
1416        return None;
1417    }
1418    Some(format!(
1419        "plugin {plugin_name:?} declares allowed_commands overlay but the host \
1420         has no tools.shell.allowed_commands configured; overlay will have no effect \
1421         at load time (tighten-only: plugins cannot widen an empty base allowlist). \
1422         Install proceeds. To use this overlay, set tools.shell.allowed_commands \
1423         in your base config."
1424    ))
1425}
1426
1427/// Validate all keys in the `[config]` overlay are in the tighten-only safelist.
1428pub(crate) fn validate_overlay_keys(config: &toml::Value) -> Result<(), PluginError> {
1429    let table = match config.as_table() {
1430        Some(t) if !t.is_empty() => t,
1431        _ => return Ok(()),
1432    };
1433
1434    for (section, inner) in table {
1435        let inner_table = inner.as_table().ok_or_else(|| PluginError::UnsafeOverlay {
1436            key: section.clone(),
1437        })?;
1438        for key in inner_table.keys() {
1439            let dotted = format!("{section}.{key}");
1440            if !CONFIG_SAFELIST.contains(&dotted.as_str()) {
1441                return Err(PluginError::UnsafeOverlay { key: dotted });
1442            }
1443        }
1444    }
1445    Ok(())
1446}
1447
1448/// Validate that all plugin MCP servers declare commands that are in the allowlist.
1449fn validate_mcp_commands(
1450    servers: &[PluginMcpServer],
1451    allowed: &[String],
1452) -> Result<(), PluginError> {
1453    for server in servers {
1454        if let Some(cmd) = &server.command {
1455            // Compare the full command string verbatim — no file_name() fallback.
1456            // Basename matching would allow `/tmp/evil/npx` when allowlist contains `npx`.
1457            let ok = allowed.iter().any(|a| a == cmd);
1458            if !ok {
1459                return Err(PluginError::DisallowedMcpCommand {
1460                    id: server.id.clone(),
1461                    command: cmd.clone(),
1462                });
1463            }
1464        }
1465    }
1466    Ok(())
1467}
1468
1469/// Stage-1 advisory scan: run injection/exfiltration regex patterns over each `SKILL.md`.
1470///
1471/// Matches are logged as `WARN` and never block installation. The Stage-2 LLM semantic
1472/// scan (when configured) is the blocking gate.
1473fn scan_skill_entries(
1474    source_root: &Path,
1475    entries: &[crate::manifest::SkillEntry],
1476    plugin_name: &str,
1477) {
1478    let span = tracing::info_span!("plugins.manager.skill_scan", plugin = %plugin_name);
1479    let _guard = span.enter();
1480    for entry in entries {
1481        let skill_md_path = source_root.join(&entry.path).join("SKILL.md");
1482        match std::fs::read_to_string(&skill_md_path) {
1483            Ok(content) => {
1484                let result = scan_skill_body(&content);
1485                if result.has_matches() {
1486                    tracing::warn!(
1487                        plugin = %plugin_name,
1488                        skill = %entry.path,
1489                        patterns = ?result.matched_patterns,
1490                        "SKILL.md matched injection/exfiltration patterns (advisory)"
1491                    );
1492                }
1493            }
1494            Err(e) => {
1495                tracing::warn!(
1496                    plugin = %plugin_name,
1497                    skill = %entry.path,
1498                    error = %e,
1499                    "could not read SKILL.md for scan"
1500                );
1501            }
1502        }
1503    }
1504}
1505
1506/// Collect skill names from a plugin source tree according to the manifest's `[[skills]]` entries.
1507///
1508/// Each `[[skills]] path` entry points to a single skill directory that directly contains
1509/// `SKILL.md`. `SkillRegistry::load` expects *parent* directories, so we pass each entry's
1510/// parent and collect only the skills whose directory matches the declared path.
1511fn collect_skill_names(root: &Path, manifest: &PluginManifest) -> Vec<String> {
1512    // Collect unique parent directories so we can batch-load.
1513    let mut parent_dirs: Vec<PathBuf> = manifest
1514        .skills
1515        .iter()
1516        .filter_map(|e| {
1517            let p = root.join(&e.path);
1518            p.parent().map(Path::to_path_buf)
1519        })
1520        .collect();
1521    parent_dirs.sort();
1522    parent_dirs.dedup();
1523
1524    if parent_dirs.is_empty() {
1525        return Vec::new();
1526    }
1527
1528    // Allowed skill directories (resolved absolute paths).
1529    let allowed: std::collections::HashSet<PathBuf> =
1530        manifest.skills.iter().map(|e| root.join(&e.path)).collect();
1531
1532    let registry = SkillRegistry::load(&parent_dirs);
1533    registry
1534        .all_meta()
1535        .iter()
1536        .filter(|m| allowed.contains(&m.skill_dir))
1537        .map(|m| m.name.clone())
1538        .collect()
1539}
1540
1541/// Recursively copy `src` directory to `dst`, creating `dst` if needed.
1542fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), PluginError> {
1543    if dst.exists() {
1544        std::fs::remove_dir_all(dst).map_err(|e| PluginError::Io {
1545            path: dst.to_path_buf(),
1546            source: e,
1547        })?;
1548    }
1549    std::fs::create_dir_all(dst).map_err(|e| PluginError::Io {
1550        path: dst.to_path_buf(),
1551        source: e,
1552    })?;
1553
1554    for entry in WalkDir::new(src).min_depth(1) {
1555        let entry = entry.map_err(|e| PluginError::Io {
1556            path: src.to_path_buf(),
1557            source: std::io::Error::other(e.to_string()),
1558        })?;
1559        let rel = entry
1560            .path()
1561            .strip_prefix(src)
1562            .expect("walkdir yields paths under src");
1563        let target = dst.join(rel);
1564        if entry.file_type().is_dir() {
1565            std::fs::create_dir_all(&target).map_err(|e| PluginError::Io {
1566                path: target,
1567                source: e,
1568            })?;
1569        } else {
1570            if let Some(parent) = target.parent() {
1571                std::fs::create_dir_all(parent).map_err(|e| PluginError::Io {
1572                    path: parent.to_path_buf(),
1573                    source: e,
1574                })?;
1575            }
1576            std::fs::copy(entry.path(), &target).map_err(|e| PluginError::Io {
1577                path: target,
1578                source: e,
1579            })?;
1580        }
1581    }
1582    Ok(())
1583}
1584
1585/// Extract a `.tar.gz` plugin archive into `dest`.
1586///
1587/// Only gzip-compressed tar archives are supported. The format is detected by the gzip magic
1588/// bytes (`0x1f 0x8b`); any other format returns [`PluginError::InvalidSource`].
1589///
1590/// # Errors
1591///
1592/// Returns [`PluginError::InvalidSource`] when the archive format is unrecognized or extraction
1593/// fails.
1594fn extract_archive(bytes: &[u8], dest: &Path, url: &str) -> Result<(), PluginError> {
1595    if !bytes.starts_with(&[0x1f, 0x8b]) {
1596        return Err(PluginError::InvalidSource {
1597            path: url.to_owned(),
1598            reason: "unsupported archive format: only .tar.gz is supported".to_owned(),
1599        });
1600    }
1601    let gz = flate2::read::GzDecoder::new(bytes);
1602    let mut archive = tar::Archive::new(gz);
1603    archive
1604        .unpack(dest)
1605        .map_err(|e| PluginError::InvalidSource {
1606            path: url.to_owned(),
1607            reason: format!("tar.gz extraction failed: {e}"),
1608        })
1609}
1610
1611/// Extract a `.tar.gz` archive with tar-slip protection.
1612///
1613/// Unlike [`extract_archive`], this function rejects:
1614/// - Absolute paths inside the archive.
1615/// - Entries with `..` path components.
1616/// - Symbolic link entries (prevent symlink-based traversal).
1617///
1618/// # Errors
1619///
1620/// Returns [`PluginError::InvalidSource`] when the archive format is unrecognised, extraction
1621/// fails, or a dangerous entry is found.
1622fn extract_archive_safe(bytes: &[u8], dest: &Path, url: &str) -> Result<(), PluginError> {
1623    if !bytes.starts_with(&[0x1f, 0x8b]) {
1624        return Err(PluginError::InvalidSource {
1625            path: url.to_owned(),
1626            reason: "unsupported archive format: only .tar.gz is supported".to_owned(),
1627        });
1628    }
1629    let gz = flate2::read::GzDecoder::new(bytes);
1630    let mut archive = tar::Archive::new(gz);
1631    let entries = archive.entries().map_err(|e| PluginError::InvalidSource {
1632        path: url.to_owned(),
1633        reason: format!("cannot read tar entries: {e}"),
1634    })?;
1635    for entry in entries {
1636        let mut entry = entry.map_err(|e| PluginError::InvalidSource {
1637            path: url.to_owned(),
1638            reason: format!("tar entry error: {e}"),
1639        })?;
1640        // Clone path display string early to avoid borrow conflicts with unpack_in.
1641        let entry_path_display = entry
1642            .path()
1643            .map_or_else(|_| "<invalid path>".to_owned(), |p| p.display().to_string());
1644        {
1645            let entry_path = entry.path().map_err(|e| PluginError::InvalidSource {
1646                path: url.to_owned(),
1647                reason: format!("invalid entry path: {e}"),
1648            })?;
1649            // Reject absolute paths.
1650            if entry_path.is_absolute() {
1651                return Err(PluginError::InvalidSource {
1652                    path: url.to_owned(),
1653                    reason: format!("archive contains absolute path: {}", entry_path.display()),
1654                });
1655            }
1656            // Reject path traversal via `..`.
1657            if entry_path
1658                .components()
1659                .any(|c| c == std::path::Component::ParentDir)
1660            {
1661                return Err(PluginError::InvalidSource {
1662                    path: url.to_owned(),
1663                    reason: format!(
1664                        "archive contains path traversal component: {}",
1665                        entry_path.display()
1666                    ),
1667                });
1668            }
1669        }
1670        // Reject symbolic links to prevent symlink-based traversal.
1671        if entry.header().entry_type().is_symlink() {
1672            return Err(PluginError::InvalidSource {
1673                path: url.to_owned(),
1674                reason: format!(
1675                    "archive contains a symlink entry: {entry_path_display}; symlinks are not permitted"
1676                ),
1677            });
1678        }
1679        entry
1680            .unpack_in(dest)
1681            .map_err(|e| PluginError::InvalidSource {
1682                path: url.to_owned(),
1683                reason: format!("tar extraction failed for {entry_path_display}: {e}"),
1684            })?;
1685    }
1686    Ok(())
1687}
1688
1689/// Walk the plugin tree and delete every `.bundled` marker file.
1690///
1691/// Read the installed manifest (`.plugin.toml`) from `plugin_dir`.
1692///
1693/// # Errors
1694///
1695/// Returns [`PluginError`] if the file cannot be read or parsed.
1696fn load_installed_manifest(plugin_dir: &Path) -> Result<PluginManifest, PluginError> {
1697    let manifest_path = plugin_dir.join(".plugin.toml");
1698    let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
1699        path: manifest_path.clone(),
1700        source: e,
1701    })?;
1702    let text = String::from_utf8(bytes)
1703        .map_err(|_| PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned()))?;
1704    toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))
1705}
1706
1707/// Plugin skills are third-party and must never be treated as bundled by the scanner.
1708fn strip_bundled_markers(root: &Path) {
1709    for entry in WalkDir::new(root).into_iter().flatten() {
1710        if entry.file_type().is_file() && entry.file_name().to_str() == Some(".bundled") {
1711            let _ = std::fs::remove_file(entry.path());
1712        }
1713    }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718    use super::*;
1719
1720    fn write_plugin(dir: &Path, name: &str, manifest_toml: &str, skills: &[(&str, &str)]) {
1721        std::fs::create_dir_all(dir).unwrap();
1722        std::fs::write(dir.join("plugin.toml"), manifest_toml).unwrap();
1723        for (skill_name, body) in skills {
1724            let skill_dir = dir.join("skills").join(skill_name);
1725            std::fs::create_dir_all(&skill_dir).unwrap();
1726            std::fs::write(
1727                skill_dir.join("SKILL.md"),
1728                format!("---\nname: {skill_name}\ndescription: test\n---\n{body}"),
1729            )
1730            .unwrap();
1731            // Write a .bundled marker to test stripping.
1732            std::fs::write(skill_dir.join(".bundled"), "").unwrap();
1733        }
1734        let _ = name;
1735    }
1736
1737    fn simple_manifest(name: &str, skill: &str) -> String {
1738        format!(
1739            r#"[plugin]
1740name = "{name}"
1741version = "0.1.0"
1742description = "test plugin"
1743
1744[[skills]]
1745path = "skills/{skill}"
1746"#
1747        )
1748    }
1749
1750    #[test]
1751    fn add_and_list_plugin() {
1752        let tmp = tempfile::tempdir().unwrap();
1753        let source = tmp.path().join("source");
1754        write_plugin(
1755            &source,
1756            "test-plugin",
1757            &simple_manifest("test-plugin", "my-skill"),
1758            &[("my-skill", "Do stuff")],
1759        );
1760
1761        let plugins_dir = tmp.path().join("plugins");
1762        let managed_dir = tmp.path().join("managed");
1763        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
1764
1765        let result = mgr.add(source.to_str().unwrap()).unwrap();
1766        assert_eq!(result.name, "test-plugin");
1767        assert!(result.installed_skills.contains(&"my-skill".to_owned()));
1768
1769        let installed = mgr.list_installed().unwrap();
1770        assert_eq!(installed.len(), 1);
1771        assert_eq!(installed[0].name, "test-plugin");
1772    }
1773
1774    #[test]
1775    fn bundled_markers_stripped_on_install() {
1776        let tmp = tempfile::tempdir().unwrap();
1777        let source = tmp.path().join("source");
1778        write_plugin(
1779            &source,
1780            "strip-test",
1781            &simple_manifest("strip-test", "my-skill"),
1782            &[("my-skill", "Body")],
1783        );
1784
1785        let plugins_dir = tmp.path().join("plugins");
1786        let managed_dir = tmp.path().join("managed");
1787        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
1788        mgr.add(source.to_str().unwrap()).unwrap();
1789
1790        // .bundled markers must not exist in the installed tree.
1791        let has_bundled = WalkDir::new(&plugins_dir)
1792            .into_iter()
1793            .flatten()
1794            .any(|e| e.file_name().to_str() == Some(".bundled"));
1795        assert!(!has_bundled, ".bundled markers were not stripped");
1796    }
1797
1798    #[test]
1799    fn mcp_disallowed_command_fails_install() {
1800        let tmp = tempfile::tempdir().unwrap();
1801        let source = tmp.path().join("source");
1802        let manifest = r#"[plugin]
1803name = "mcp-test"
1804version = "0.1.0"
1805description = "test"
1806
1807[[mcp.servers]]
1808id = "bad-server"
1809command = "dangerous-binary"
1810"#;
1811        write_plugin(&source, "mcp-test", manifest, &[]);
1812
1813        let plugins_dir = tmp.path().join("plugins");
1814        let managed_dir = tmp.path().join("managed");
1815        let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
1816
1817        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1818        assert!(matches!(err, PluginError::DisallowedMcpCommand { .. }));
1819    }
1820
1821    #[test]
1822    fn unsafe_config_overlay_fails_install() {
1823        let tmp = tempfile::tempdir().unwrap();
1824        let source = tmp.path().join("source");
1825        let manifest = r#"[plugin]
1826name = "overlay-test"
1827version = "0.1.0"
1828description = "test"
1829
1830[config.llm]
1831model = "evil"
1832"#;
1833        write_plugin(&source, "overlay-test", manifest, &[]);
1834
1835        let plugins_dir = tmp.path().join("plugins");
1836        let managed_dir = tmp.path().join("managed");
1837        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1838
1839        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1840        assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
1841    }
1842
1843    #[test]
1844    fn max_active_skills_overlay_is_rejected() {
1845        let tmp = tempfile::tempdir().unwrap();
1846        let source = tmp.path().join("source");
1847        let manifest = r#"[plugin]
1848name = "max-skills-test"
1849version = "0.1.0"
1850description = "test"
1851
1852[config.skills]
1853max_active_skills = 10
1854"#;
1855        write_plugin(&source, "max-skills-test", manifest, &[]);
1856
1857        let plugins_dir = tmp.path().join("plugins");
1858        let managed_dir = tmp.path().join("managed");
1859        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1860
1861        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1862        assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
1863    }
1864
1865    #[test]
1866    fn safe_config_overlay_is_accepted() {
1867        let tmp = tempfile::tempdir().unwrap();
1868        let source = tmp.path().join("source");
1869        let manifest = r#"[plugin]
1870name = "safe-overlay"
1871version = "0.1.0"
1872description = "test"
1873
1874[config.skills]
1875disambiguation_threshold = 0.05
1876
1877[config.tools]
1878blocked_commands = ["rm -rf"]
1879"#;
1880        write_plugin(&source, "safe-overlay", manifest, &[]);
1881
1882        let plugins_dir = tmp.path().join("plugins");
1883        let managed_dir = tmp.path().join("managed");
1884        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1885        let result = mgr.add(source.to_str().unwrap()).unwrap();
1886        assert_eq!(result.name, "safe-overlay");
1887    }
1888
1889    #[test]
1890    fn remove_plugin() {
1891        let tmp = tempfile::tempdir().unwrap();
1892        let source = tmp.path().join("source");
1893        write_plugin(
1894            &source,
1895            "removable",
1896            &simple_manifest("removable", "my-skill"),
1897            &[("my-skill", "Body")],
1898        );
1899
1900        let plugins_dir = tmp.path().join("plugins");
1901        let managed_dir = tmp.path().join("managed");
1902        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
1903        mgr.add(source.to_str().unwrap()).unwrap();
1904
1905        let result = mgr.remove("removable").unwrap();
1906        assert!(result.removed_skills.contains(&"my-skill".to_owned()));
1907
1908        let installed = mgr.list_installed().unwrap();
1909        assert!(installed.is_empty());
1910    }
1911
1912    #[test]
1913    fn remove_nonexistent_plugin_returns_not_found() {
1914        let tmp = tempfile::tempdir().unwrap();
1915        let plugins_dir = tmp.path().join("plugins");
1916        let mgr = PluginManager::new(plugins_dir, tmp.path().to_path_buf(), vec![], vec![]);
1917        let err = mgr.remove("no-such-plugin").unwrap_err();
1918        assert!(matches!(err, PluginError::NotFound { .. }));
1919    }
1920
1921    #[test]
1922    fn invalid_plugin_name_with_slash_rejected() {
1923        let err = validate_plugin_name("foo/bar").unwrap_err();
1924        assert!(matches!(err, PluginError::InvalidName { .. }));
1925    }
1926
1927    #[test]
1928    fn plugin_name_with_uppercase_rejected() {
1929        let err = validate_plugin_name("FooBar").unwrap_err();
1930        assert!(matches!(err, PluginError::InvalidName { .. }));
1931    }
1932
1933    #[test]
1934    fn valid_plugin_names_accepted() {
1935        assert!(validate_plugin_name("foo").is_ok());
1936        assert!(validate_plugin_name("foo-bar").is_ok());
1937        assert!(validate_plugin_name("foo123").is_ok());
1938    }
1939
1940    #[test]
1941    fn bundled_skill_conflict_detected() {
1942        let tmp = tempfile::tempdir().unwrap();
1943        let source = tmp.path().join("source");
1944
1945        // Find a real bundled skill name to trigger conflict.
1946        let bundled = bundled_skill_names();
1947        if bundled.is_empty() {
1948            // No bundled skills compiled in; skip.
1949            return;
1950        }
1951        let conflict_name = &bundled[0];
1952
1953        let manifest = format!(
1954            r#"[plugin]
1955name = "conflict-test"
1956version = "0.1.0"
1957description = "test"
1958
1959[[skills]]
1960path = "skills/{conflict_name}"
1961"#
1962        );
1963        write_plugin(
1964            &source,
1965            "conflict-test",
1966            &manifest,
1967            &[(conflict_name, "body")],
1968        );
1969
1970        let plugins_dir = tmp.path().join("plugins");
1971        let managed_dir = tmp.path().join("managed");
1972        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1973
1974        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
1975        assert!(matches!(
1976            err,
1977            PluginError::SkillNameConflictWithBundled { .. }
1978        ));
1979    }
1980
1981    #[test]
1982    fn path_traversal_in_skill_path_rejected() {
1983        let tmp = tempfile::tempdir().unwrap();
1984        // Use canonicalized base to avoid macOS /var → /private/var redirect.
1985        let real_tmp = tmp.path().canonicalize().unwrap();
1986        let source = real_tmp.join("source");
1987
1988        // Create a skill directory that exists but is outside source root via ../escape.
1989        let outside = real_tmp.join("outside-skill");
1990        std::fs::create_dir_all(&outside).unwrap();
1991
1992        // The plugin manifest references ../outside-skill, which canonicalizes to a real path
1993        // outside the source directory — this is what the traversal guard must catch.
1994        let manifest = r#"[plugin]
1995name = "traversal-test"
1996version = "0.1.0"
1997description = "test"
1998
1999[[skills]]
2000path = "../outside-skill"
2001"#;
2002        std::fs::create_dir_all(&source).unwrap();
2003        std::fs::write(source.join("plugin.toml"), manifest).unwrap();
2004
2005        let plugins_dir = real_tmp.join("plugins");
2006        let managed_dir = real_tmp.join("managed");
2007        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2008
2009        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2010        assert!(
2011            matches!(err, PluginError::InvalidSource { .. }),
2012            "expected InvalidSource for path traversal, got {err:?}"
2013        );
2014    }
2015
2016    #[test]
2017    #[cfg(unix)]
2018    fn skill_path_canonicalize_failure_returns_io_error() {
2019        let tmp = tempfile::tempdir().unwrap();
2020        let source = tmp.path().join("source");
2021        std::fs::create_dir_all(&source).unwrap();
2022
2023        // Create a broken symlink inside the source directory.
2024        let skill_dir = source.join("skills").join("broken-skill");
2025        std::fs::create_dir_all(source.join("skills")).unwrap();
2026        std::os::unix::fs::symlink("/nonexistent/target", &skill_dir).unwrap();
2027
2028        let manifest = r#"[plugin]
2029name = "broken-link-test"
2030version = "0.1.0"
2031description = "test"
2032
2033[[skills]]
2034path = "skills/broken-skill"
2035"#;
2036        std::fs::write(source.join("plugin.toml"), manifest).unwrap();
2037
2038        let plugins_dir = tmp.path().join("plugins");
2039        let managed_dir = tmp.path().join("managed");
2040        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2041
2042        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2043        assert!(
2044            matches!(err, PluginError::Io { .. }),
2045            "expected Io error when canonicalize fails on broken symlink, got {err:?}"
2046        );
2047    }
2048
2049    #[test]
2050    fn mcp_basename_bypass_rejected() {
2051        let tmp = tempfile::tempdir().unwrap();
2052        let source = tmp.path().join("source");
2053        // allowed_commands = ["npx"] but plugin declares full path "/tmp/evil/npx".
2054        // Verbatim match must reject this; the old file_name() fallback would have passed it.
2055        let manifest = r#"[plugin]
2056name = "basename-bypass"
2057version = "0.1.0"
2058description = "test"
2059
2060[[mcp.servers]]
2061id = "evil"
2062command = "/tmp/evil/npx"
2063"#;
2064        write_plugin(&source, "basename-bypass", manifest, &[]);
2065
2066        let plugins_dir = tmp.path().join("plugins");
2067        let managed_dir = tmp.path().join("managed");
2068        let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
2069
2070        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2071        assert!(
2072            matches!(err, PluginError::DisallowedMcpCommand { .. }),
2073            "expected DisallowedMcpCommand for basename bypass, got {err:?}"
2074        );
2075    }
2076
2077    #[test]
2078    fn managed_skill_conflict_detected() {
2079        let tmp = tempfile::tempdir().unwrap();
2080        let managed_dir = tmp.path().join("managed");
2081
2082        // Create a managed skill named "my-skill".
2083        let managed_skill = managed_dir.join("my-skill");
2084        std::fs::create_dir_all(&managed_skill).unwrap();
2085        std::fs::write(
2086            managed_skill.join("SKILL.md"),
2087            "---\nname: my-skill\ndescription: managed\n---\nbody",
2088        )
2089        .unwrap();
2090
2091        // Plugin tries to install a skill with the same name.
2092        let source = tmp.path().join("source");
2093        write_plugin(
2094            &source,
2095            "conflict-managed",
2096            &simple_manifest("conflict-managed", "my-skill"),
2097            &[("my-skill", "body")],
2098        );
2099
2100        let plugins_dir = tmp.path().join("plugins");
2101        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2102
2103        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2104        assert!(
2105            matches!(err, PluginError::SkillNameConflictWithManaged { .. }),
2106            "expected SkillNameConflictWithManaged, got {err:?}"
2107        );
2108    }
2109
2110    #[test]
2111    fn cross_plugin_skill_conflict_detected() {
2112        let tmp = tempfile::tempdir().unwrap();
2113        let plugins_dir = tmp.path().join("plugins");
2114        let managed_dir = tmp.path().join("managed");
2115        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2116
2117        // Install first plugin with "shared-skill".
2118        let source_a = tmp.path().join("source_a");
2119        write_plugin(
2120            &source_a,
2121            "plugin-a",
2122            &simple_manifest("plugin-a", "shared-skill"),
2123            &[("shared-skill", "body")],
2124        );
2125        mgr.add(source_a.to_str().unwrap()).unwrap();
2126
2127        // Install second plugin with the same skill name — must conflict.
2128        let source_b = tmp.path().join("source_b");
2129        write_plugin(
2130            &source_b,
2131            "plugin-b",
2132            &simple_manifest("plugin-b", "shared-skill"),
2133            &[("shared-skill", "body")],
2134        );
2135        let err = mgr.add(source_b.to_str().unwrap()).unwrap_err();
2136        assert!(
2137            matches!(err, PluginError::SkillNameConflictWithPlugin { .. }),
2138            "expected SkillNameConflictWithPlugin, got {err:?}"
2139        );
2140    }
2141
2142    #[test]
2143    fn allowed_commands_overlay_with_empty_base_warns() {
2144        let tmp = tempfile::tempdir().unwrap();
2145        let source = tmp.path().join("source");
2146        let manifest = r#"[plugin]
2147name = "warn-test"
2148version = "0.1.0"
2149description = "test"
2150
2151[config.tools]
2152allowed_commands = ["curl", "git"]
2153"#;
2154        write_plugin(&source, "warn-test", manifest, &[]);
2155
2156        let plugins_dir = tmp.path().join("plugins");
2157        let managed_dir = tmp.path().join("managed");
2158        // base_allowed_commands is empty — overlay will have no effect
2159        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2160
2161        let result = mgr.add(source.to_str().unwrap()).unwrap();
2162        assert_eq!(result.warnings.len(), 1);
2163        let msg = &result.warnings[0];
2164        assert!(
2165            msg.contains("warn-test"),
2166            "warning must contain plugin name"
2167        );
2168        assert!(
2169            msg.contains("allowed_commands"),
2170            "warning must mention allowed_commands"
2171        );
2172        assert!(msg.is_ascii(), "warning message must be ASCII-only");
2173    }
2174
2175    #[test]
2176    fn allowed_commands_overlay_with_non_empty_base_no_warn() {
2177        let tmp = tempfile::tempdir().unwrap();
2178        let source = tmp.path().join("source");
2179        let manifest = r#"[plugin]
2180name = "no-warn-test"
2181version = "0.1.0"
2182description = "test"
2183
2184[config.tools]
2185allowed_commands = ["curl"]
2186"#;
2187        write_plugin(&source, "no-warn-test", manifest, &[]);
2188
2189        let plugins_dir = tmp.path().join("plugins");
2190        let managed_dir = tmp.path().join("managed");
2191        // base_allowed_commands is non-empty — overlay narrows correctly, no warning
2192        let mgr = PluginManager::new(
2193            plugins_dir,
2194            managed_dir,
2195            vec![],
2196            vec!["curl".to_owned(), "git".to_owned()],
2197        );
2198
2199        let result = mgr.add(source.to_str().unwrap()).unwrap();
2200        assert!(result.warnings.is_empty());
2201    }
2202
2203    #[test]
2204    fn empty_allowed_commands_array_no_warn() {
2205        let tmp = tempfile::tempdir().unwrap();
2206        let source = tmp.path().join("source");
2207        let manifest = r#"[plugin]
2208name = "empty-overlay"
2209version = "0.1.0"
2210description = "test"
2211
2212[config.tools]
2213allowed_commands = []
2214"#;
2215        write_plugin(&source, "empty-overlay", manifest, &[]);
2216
2217        let plugins_dir = tmp.path().join("plugins");
2218        let managed_dir = tmp.path().join("managed");
2219        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2220
2221        let result = mgr.add(source.to_str().unwrap()).unwrap();
2222        assert!(result.warnings.is_empty());
2223    }
2224
2225    #[test]
2226    fn list_installed_ignores_non_directory_entries() {
2227        let tmp = tempfile::tempdir().unwrap();
2228        let plugins_dir = tmp.path().to_path_buf();
2229
2230        // Stray files that must not be treated as installed plugins.
2231        std::fs::write(plugins_dir.join(".plugin-integrity.toml"), b"plugins = {}").unwrap();
2232        std::fs::write(plugins_dir.join("README.txt"), b"docs").unwrap();
2233
2234        let managed_dir = tmp.path().join("managed");
2235        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2236        assert!(
2237            mgr.list_installed().unwrap().is_empty(),
2238            "non-directory entries inside plugins_dir must not be surfaced as installed plugins"
2239        );
2240    }
2241
2242    // --- validate_plugin_name edge cases ---
2243
2244    #[test]
2245    fn validate_plugin_name_empty_string_rejected() {
2246        let err = validate_plugin_name("").unwrap_err();
2247        assert!(
2248            matches!(err, PluginError::InvalidName { .. }),
2249            "expected InvalidName for empty string, got {err:?}"
2250        );
2251    }
2252
2253    #[test]
2254    fn validate_plugin_name_with_dot_rejected() {
2255        let err = validate_plugin_name("foo.bar").unwrap_err();
2256        assert!(
2257            matches!(err, PluginError::InvalidName { .. }),
2258            "expected InvalidName for name with dot, got {err:?}"
2259        );
2260    }
2261
2262    #[test]
2263    fn validate_plugin_name_with_backslash_rejected() {
2264        let err = validate_plugin_name("foo\\bar").unwrap_err();
2265        assert!(
2266            matches!(err, PluginError::InvalidName { .. }),
2267            "expected InvalidName for name with backslash, got {err:?}"
2268        );
2269    }
2270
2271    #[test]
2272    fn validate_plugin_name_with_space_rejected() {
2273        let err = validate_plugin_name("foo bar").unwrap_err();
2274        assert!(
2275            matches!(err, PluginError::InvalidName { .. }),
2276            "expected InvalidName for name with space, got {err:?}"
2277        );
2278    }
2279
2280    #[test]
2281    fn validate_plugin_name_max_length_boundary() {
2282        assert!(validate_plugin_name(&"a".repeat(64)).is_ok());
2283        let err = validate_plugin_name(&"a".repeat(65)).unwrap_err();
2284        assert!(
2285            matches!(err, PluginError::InvalidName { .. }),
2286            "expected InvalidName for 65-char name, got {err:?}"
2287        );
2288    }
2289
2290    #[test]
2291    fn validate_plugin_name_leading_dash_rejected() {
2292        let err = validate_plugin_name("-foo").unwrap_err();
2293        assert!(
2294            matches!(err, PluginError::InvalidName { .. }),
2295            "expected InvalidName for leading dash, got {err:?}"
2296        );
2297    }
2298
2299    #[test]
2300    fn validate_plugin_name_leading_digit_rejected() {
2301        let err = validate_plugin_name("123").unwrap_err();
2302        assert!(
2303            matches!(err, PluginError::InvalidName { .. }),
2304            "expected InvalidName for digit-only name, got {err:?}"
2305        );
2306        let err = validate_plugin_name("1abc").unwrap_err();
2307        assert!(
2308            matches!(err, PluginError::InvalidName { .. }),
2309            "expected InvalidName for digit-prefixed name, got {err:?}"
2310        );
2311    }
2312
2313    #[test]
2314    fn validate_plugin_name_valid_names_accepted() {
2315        assert!(validate_plugin_name("abc").is_ok());
2316        assert!(validate_plugin_name("my-plugin").is_ok());
2317        assert!(validate_plugin_name("plugin123").is_ok());
2318    }
2319
2320    // --- validate_overlay_keys direct tests ---
2321
2322    #[test]
2323    fn validate_overlay_keys_empty_config_accepted() {
2324        let config = toml::Value::Table(toml::map::Map::new());
2325        assert!(validate_overlay_keys(&config).is_ok());
2326    }
2327
2328    #[test]
2329    fn validate_overlay_keys_safe_keys_accepted() {
2330        let toml_str = r#"
2331[tools]
2332blocked_commands = ["rm -rf /"]
2333allowed_commands = ["git"]
2334
2335[skills]
2336disambiguation_threshold = 0.8
2337"#;
2338        let config: toml::Value = toml::from_str(toml_str).unwrap();
2339        assert!(validate_overlay_keys(&config).is_ok());
2340    }
2341
2342    #[test]
2343    fn validate_overlay_keys_unsafe_key_rejected() {
2344        let toml_str = r#"
2345[llm]
2346model = "evil-model"
2347"#;
2348        let config: toml::Value = toml::from_str(toml_str).unwrap();
2349        let err = validate_overlay_keys(&config).unwrap_err();
2350        assert!(
2351            matches!(err, PluginError::UnsafeOverlay { ref key } if key == "llm.model"),
2352            "expected UnsafeOverlay with key=\"llm.model\", got {err:?}"
2353        );
2354    }
2355
2356    #[test]
2357    fn validate_overlay_keys_non_table_section_rejected() {
2358        // A section value that is not a table (e.g. a string) must be rejected.
2359        let toml_str = r#"
2360tools = "not-a-table"
2361"#;
2362        let config: toml::Value = toml::from_str(toml_str).unwrap();
2363        let err = validate_overlay_keys(&config).unwrap_err();
2364        assert!(
2365            matches!(err, PluginError::UnsafeOverlay { .. }),
2366            "expected UnsafeOverlay for non-table section, got {err:?}"
2367        );
2368    }
2369
2370    // --- list_installed sort order ---
2371
2372    #[test]
2373    fn list_installed_returns_plugins_sorted_alphabetically() {
2374        let tmp = tempfile::tempdir().unwrap();
2375        let plugins_dir = tmp.path().join("plugins");
2376        let managed_dir = tmp.path().join("managed");
2377        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2378
2379        // Install in reverse alphabetical order with unique skill names to avoid cross-plugin
2380        // name conflicts — the sort test only cares about plugin ordering, not skill uniqueness.
2381        let plugins = [
2382            ("zeta-plugin", "skill-zeta"),
2383            ("beta-plugin", "skill-beta"),
2384            ("alpha-plugin", "skill-alpha"),
2385        ];
2386        for (name, skill) in &plugins {
2387            let source = tmp.path().join(format!("src-{name}"));
2388            write_plugin(
2389                &source,
2390                name,
2391                &simple_manifest(name, skill),
2392                &[(skill, "body")],
2393            );
2394            mgr.add(source.to_str().unwrap()).unwrap();
2395        }
2396
2397        let installed = mgr.list_installed().unwrap();
2398        let names: Vec<&str> = installed.iter().map(|p| p.name.as_str()).collect();
2399        assert_eq!(
2400            names,
2401            vec!["alpha-plugin", "beta-plugin", "zeta-plugin"],
2402            "list_installed must return plugins in alphabetical order regardless of install order"
2403        );
2404    }
2405
2406    // --- add() error: SkillEntryMissing when SKILL.md is absent ---
2407
2408    #[test]
2409    fn add_skill_entry_without_skill_md_returns_skill_entry_missing() {
2410        let tmp = tempfile::tempdir().unwrap();
2411        let source = tmp.path().join("source");
2412
2413        // Create the plugin manifest that references a skill path, but do NOT write SKILL.md.
2414        std::fs::create_dir_all(source.join("skills").join("no-skill-md")).unwrap();
2415        let manifest = r#"[plugin]
2416name = "missing-skill-md"
2417version = "0.1.0"
2418description = "test"
2419
2420[[skills]]
2421path = "skills/no-skill-md"
2422"#;
2423        std::fs::write(source.join("plugin.toml"), manifest).unwrap();
2424
2425        let plugins_dir = tmp.path().join("plugins");
2426        let managed_dir = tmp.path().join("managed");
2427        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2428
2429        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
2430        assert!(
2431            matches!(err, PluginError::SkillEntryMissing { .. }),
2432            "expected SkillEntryMissing when SKILL.md is absent, got {err:?}"
2433        );
2434    }
2435
2436    // --- collect_skill_dirs ---
2437
2438    #[test]
2439    fn collect_skill_dirs_empty_when_no_plugins_installed() {
2440        let tmp = tempfile::tempdir().unwrap();
2441        // Use canonicalized path to work around macOS /var → /private/var symlink.
2442        let real = tmp.path().canonicalize().unwrap();
2443        let plugins_dir = real.join("plugins");
2444        let mgr = PluginManager::new(plugins_dir, real.clone(), vec![], vec![]);
2445        let dirs = mgr.collect_skill_dirs().unwrap();
2446        assert!(dirs.is_empty());
2447    }
2448
2449    #[test]
2450    fn collect_skill_dirs_returns_installed_skill_paths() {
2451        let tmp = tempfile::tempdir().unwrap();
2452        // Canonicalize so that the path prefix check inside collect_skill_dirs works on macOS.
2453        let real = tmp.path().canonicalize().unwrap();
2454        let plugins_dir = real.join("plugins");
2455        let managed_dir = real.join("managed");
2456        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2457
2458        let source = real.join("source");
2459        write_plugin(
2460            &source,
2461            "dir-plugin",
2462            &simple_manifest("dir-plugin", "my-skill"),
2463            &[("my-skill", "body")],
2464        );
2465        mgr.add(source.to_str().unwrap()).unwrap();
2466
2467        let dirs = mgr.collect_skill_dirs().unwrap();
2468        assert_eq!(dirs.len(), 1, "expected exactly one skill dir");
2469        assert!(
2470            dirs[0].ends_with("skills/my-skill"),
2471            "skill dir path must end with skills/my-skill, got {:?}",
2472            dirs[0]
2473        );
2474    }
2475
2476    // --- extract_archive tests ---
2477
2478    #[test]
2479    fn extract_archive_rejects_non_gz_bytes() {
2480        let fake_bytes = b"PK\x03\x04not a tar.gz";
2481        let tmp = tempfile::tempdir().unwrap();
2482        let err =
2483            extract_archive(fake_bytes, tmp.path(), "http://example.com/plugin.zip").unwrap_err();
2484        assert!(
2485            matches!(err, PluginError::InvalidSource { .. }),
2486            "non-gz archive must return InvalidSource, got {err:?}"
2487        );
2488    }
2489
2490    #[test]
2491    fn sha256_integrity_mismatch_returns_correct_error() {
2492        // Validate that the sha256_hex function used in add_remote produces a consistent result
2493        // and that a mismatch would be detected. (We test the hash function and error variant
2494        // since we cannot call add_remote without an HTTP server in unit tests.)
2495        let archive_bytes = b"fake archive content";
2496        let actual = crate::integrity::sha256_hex(archive_bytes);
2497        let wrong_expected = "0000000000000000000000000000000000000000000000000000000000000000";
2498        assert_ne!(
2499            actual, wrong_expected,
2500            "sha256 of non-zero bytes must not match all-zero expected"
2501        );
2502        // Confirm the error variant is constructable.
2503        let err = PluginError::IntegrityCheckFailed {
2504            expected: wrong_expected.to_owned(),
2505            actual: actual.clone(),
2506        };
2507        assert!(
2508            err.to_string().contains("integrity check failed"),
2509            "error message must mention integrity check"
2510        );
2511        assert!(
2512            err.to_string().contains(&actual),
2513            "error message must contain actual hash"
2514        );
2515    }
2516
2517    #[test]
2518    fn collect_skill_dirs_aggregates_multiple_plugins() {
2519        let tmp = tempfile::tempdir().unwrap();
2520        // Canonicalize so that the path prefix check inside collect_skill_dirs works on macOS.
2521        let real = tmp.path().canonicalize().unwrap();
2522        let plugins_dir = real.join("plugins");
2523        let managed_dir = real.join("managed");
2524        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2525
2526        for (plugin_name, skill_name) in &[("plugin-a", "skill-a"), ("plugin-b", "skill-b")] {
2527            let source = real.join(plugin_name);
2528            write_plugin(
2529                &source,
2530                plugin_name,
2531                &simple_manifest(plugin_name, skill_name),
2532                &[(skill_name, "body")],
2533            );
2534            mgr.add(source.to_str().unwrap()).unwrap();
2535        }
2536
2537        let dirs = mgr.collect_skill_dirs().unwrap();
2538        assert_eq!(dirs.len(), 2, "expected two skill dirs from two plugins");
2539    }
2540
2541    // --- add_remote tests ---
2542
2543    /// Build an in-memory `.tar.gz` archive of the directory at `source`.
2544    #[cfg(test)]
2545    fn build_tar_gz(source: &std::path::Path) -> Vec<u8> {
2546        let buf = Vec::new();
2547        let gz = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
2548        let mut tar = tar::Builder::new(gz);
2549        tar.append_dir_all(".", source).unwrap();
2550        let gz = tar.into_inner().unwrap();
2551        gz.finish().unwrap()
2552    }
2553
2554    #[tokio::test]
2555    async fn add_remote_correct_hash_installs_plugin() {
2556        use wiremock::matchers::method;
2557        use wiremock::{Mock, MockServer, ResponseTemplate};
2558
2559        let tmp = tempfile::tempdir().unwrap();
2560        let source = tmp.path().join("source");
2561        write_plugin(
2562            &source,
2563            "remote-plugin",
2564            &simple_manifest("remote-plugin", "my-skill"),
2565            &[("my-skill", "Do remote stuff")],
2566        );
2567
2568        let archive = build_tar_gz(&source);
2569        let expected_hash = crate::integrity::sha256_hex(&archive);
2570
2571        let mock_server = MockServer::start().await;
2572        Mock::given(method("GET"))
2573            .respond_with(
2574                ResponseTemplate::new(200)
2575                    .set_body_bytes(archive)
2576                    .append_header("Content-Type", "application/octet-stream"),
2577            )
2578            .mount(&mock_server)
2579            .await;
2580
2581        let plugins_dir = tmp.path().join("plugins");
2582        let managed_dir = tmp.path().join("managed");
2583        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2584
2585        let url = format!("{}/remote-plugin.tar.gz", mock_server.uri());
2586        let result = mgr.add_remote(&url, Some(&expected_hash)).await.unwrap();
2587        assert_eq!(result.name, "remote-plugin");
2588        assert!(result.installed_skills.contains(&"my-skill".to_owned()));
2589    }
2590
2591    #[tokio::test]
2592    async fn add_remote_connect_timeout_returns_download_failed() {
2593        use std::time::Duration;
2594
2595        use wiremock::matchers::method;
2596        use wiremock::{Mock, MockServer, ResponseTemplate};
2597
2598        let tmp = tempfile::tempdir().unwrap();
2599        let source = tmp.path().join("source");
2600        write_plugin(
2601            &source,
2602            "timeout-plugin",
2603            &simple_manifest("timeout-plugin", "t-skill"),
2604            &[("t-skill", "body")],
2605        );
2606
2607        let archive = build_tar_gz(&source);
2608
2609        let mock_server = MockServer::start().await;
2610        // Delay > download_timeout_secs (1s) triggers the tokio::time::timeout guard.
2611        Mock::given(method("GET"))
2612            .respond_with(
2613                ResponseTemplate::new(200)
2614                    .set_body_bytes(archive)
2615                    .set_delay(Duration::from_secs(3)),
2616            )
2617            .mount(&mock_server)
2618            .await;
2619
2620        let plugins_dir = tmp.path().join("plugins");
2621        let managed_dir = tmp.path().join("managed");
2622        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![])
2623            .with_download_timeout_secs(1);
2624
2625        let url = format!("{}/timeout-plugin.tar.gz", mock_server.uri());
2626        let err = mgr.add_remote(&url, None).await.unwrap_err();
2627        assert!(
2628            matches!(err, PluginError::DownloadFailed { ref reason, .. } if reason.contains("timed out")),
2629            "slow response must produce DownloadFailed with timeout message, got {err:?}"
2630        );
2631    }
2632
2633    #[tokio::test]
2634    async fn add_remote_wrong_hash_returns_integrity_error() {
2635        use wiremock::matchers::method;
2636        use wiremock::{Mock, MockServer, ResponseTemplate};
2637
2638        let tmp = tempfile::tempdir().unwrap();
2639        let source = tmp.path().join("source");
2640        write_plugin(
2641            &source,
2642            "bad-plugin",
2643            &simple_manifest("bad-plugin", "bad-skill"),
2644            &[("bad-skill", "Body")],
2645        );
2646
2647        let archive = build_tar_gz(&source);
2648        let wrong_hash = "0".repeat(64);
2649
2650        let mock_server = MockServer::start().await;
2651        Mock::given(method("GET"))
2652            .respond_with(
2653                ResponseTemplate::new(200)
2654                    .set_body_bytes(archive)
2655                    .append_header("Content-Type", "application/octet-stream"),
2656            )
2657            .mount(&mock_server)
2658            .await;
2659
2660        let plugins_dir = tmp.path().join("plugins");
2661        let managed_dir = tmp.path().join("managed");
2662        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2663
2664        let url = format!("{}/bad-plugin.tar.gz", mock_server.uri());
2665        let err = mgr.add_remote(&url, Some(&wrong_hash)).await.unwrap_err();
2666        assert!(
2667            matches!(err, PluginError::IntegrityCheckFailed { .. }),
2668            "wrong hash must produce IntegrityCheckFailed, got {err:?}"
2669        );
2670    }
2671
2672    // --- auto_update and PluginSource tests ---
2673
2674    #[tokio::test]
2675    async fn add_remote_persists_plugin_source_sidecar() {
2676        use wiremock::matchers::method;
2677        use wiremock::{Mock, MockServer, ResponseTemplate};
2678
2679        let tmp = tempfile::tempdir().unwrap();
2680        let source = tmp.path().join("source");
2681        write_plugin(
2682            &source,
2683            "src-plugin",
2684            &simple_manifest("src-plugin", "src-skill"),
2685            &[("src-skill", "body")],
2686        );
2687        let archive = build_tar_gz(&source);
2688        let expected_hash = crate::integrity::sha256_hex(&archive);
2689
2690        let mock_server = MockServer::start().await;
2691        Mock::given(method("GET"))
2692            .respond_with(
2693                ResponseTemplate::new(200)
2694                    .set_body_bytes(archive)
2695                    .append_header("Content-Type", "application/octet-stream"),
2696            )
2697            .mount(&mock_server)
2698            .await;
2699
2700        let plugins_dir = tmp.path().join("plugins");
2701        let managed_dir = tmp.path().join("managed");
2702        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
2703        let url = format!("{}/src-plugin.tar.gz", mock_server.uri());
2704        mgr.add_remote(&url, Some(&expected_hash)).await.unwrap();
2705
2706        let sidecar = plugins_dir.join("src-plugin").join(".plugin-source.toml");
2707        assert!(
2708            sidecar.exists(),
2709            ".plugin-source.toml must be written after add_remote"
2710        );
2711
2712        let parsed: PluginSource =
2713            toml::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap();
2714        assert_eq!(parsed.url.as_deref(), Some(url.as_str()));
2715        assert_eq!(parsed.sha256.as_deref(), Some(expected_hash.as_str()));
2716    }
2717
2718    #[test]
2719    fn list_installed_exposes_auto_update_field() {
2720        let tmp = tempfile::tempdir().unwrap();
2721        let source = tmp.path().join("source");
2722        let manifest = r#"[plugin]
2723name = "auto-update-plugin"
2724version = "0.1.0"
2725description = "test"
2726auto_update = true
2727
2728[[skills]]
2729path = "skills/my-skill"
2730"#;
2731        write_plugin(
2732            &source,
2733            "auto-update-plugin",
2734            manifest,
2735            &[("my-skill", "body")],
2736        );
2737
2738        let plugins_dir = tmp.path().join("plugins");
2739        let managed_dir = tmp.path().join("managed");
2740        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2741        mgr.add(source.to_str().unwrap()).unwrap();
2742
2743        let installed = mgr.list_installed().unwrap();
2744        assert_eq!(installed.len(), 1);
2745        assert!(
2746            installed[0].auto_update,
2747            "InstalledPlugin.auto_update must reflect manifest auto_update = true"
2748        );
2749    }
2750
2751    #[test]
2752    fn list_installed_auto_update_defaults_to_false() {
2753        let tmp = tempfile::tempdir().unwrap();
2754        let source = tmp.path().join("source");
2755        write_plugin(
2756            &source,
2757            "no-update-plugin",
2758            &simple_manifest("no-update-plugin", "skill-a"),
2759            &[("skill-a", "body")],
2760        );
2761
2762        let plugins_dir = tmp.path().join("plugins");
2763        let managed_dir = tmp.path().join("managed");
2764        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2765        mgr.add(source.to_str().unwrap()).unwrap();
2766
2767        let installed = mgr.list_installed().unwrap();
2768        assert!(
2769            !installed[0].auto_update,
2770            "auto_update must default to false"
2771        );
2772    }
2773
2774    #[tokio::test]
2775    async fn check_auto_updates_skips_local_installs() {
2776        let tmp = tempfile::tempdir().unwrap();
2777        let source = tmp.path().join("source");
2778        let manifest = r#"[plugin]
2779name = "local-autoupdate"
2780version = "0.1.0"
2781description = "test"
2782auto_update = true
2783
2784[[skills]]
2785path = "skills/my-skill"
2786"#;
2787        write_plugin(
2788            &source,
2789            "local-autoupdate",
2790            manifest,
2791            &[("my-skill", "body")],
2792        );
2793
2794        let plugins_dir = tmp.path().join("plugins");
2795        let managed_dir = tmp.path().join("managed");
2796        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2797        mgr.add(source.to_str().unwrap()).unwrap();
2798
2799        // No .plugin-source.toml is written by `add()` — only by `add_remote()`.
2800        let results = mgr.check_auto_updates().await;
2801        assert_eq!(results.len(), 1);
2802        assert!(
2803            matches!(results[0].status, AutoUpdateStatus::NoSource),
2804            "local-installed plugin must return NoSource, got {:?}",
2805            results[0].status
2806        );
2807    }
2808
2809    #[tokio::test]
2810    async fn check_auto_updates_up_to_date_when_sha256_unchanged() {
2811        use wiremock::matchers::method;
2812        use wiremock::{Mock, MockServer, ResponseTemplate};
2813
2814        let tmp = tempfile::tempdir().unwrap();
2815        let source = tmp.path().join("source");
2816        let manifest = r#"[plugin]
2817name = "up-to-date-plugin"
2818version = "0.2.0"
2819description = "test"
2820auto_update = true
2821
2822[[skills]]
2823path = "skills/my-skill"
2824"#;
2825        write_plugin(
2826            &source,
2827            "up-to-date-plugin",
2828            manifest,
2829            &[("my-skill", "body")],
2830        );
2831        let archive = build_tar_gz(&source);
2832        let hash = crate::integrity::sha256_hex(&archive);
2833
2834        let mock_server = MockServer::start().await;
2835        Mock::given(method("GET"))
2836            .respond_with(
2837                ResponseTemplate::new(200)
2838                    .set_body_bytes(archive.clone())
2839                    .append_header("Content-Type", "application/octet-stream"),
2840            )
2841            .expect(2) // once for install, once for check
2842            .mount(&mock_server)
2843            .await;
2844
2845        let plugins_dir = tmp.path().join("plugins");
2846        let managed_dir = tmp.path().join("managed");
2847        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2848        let url = format!("{}/plugin.tar.gz", mock_server.uri());
2849        mgr.add_remote(&url, Some(&hash)).await.unwrap();
2850
2851        let results = mgr.check_auto_updates().await;
2852        assert_eq!(results.len(), 1);
2853        assert!(
2854            matches!(results[0].status, AutoUpdateStatus::UpToDate),
2855            "identical archive must yield UpToDate, got {:?}",
2856            results[0].status
2857        );
2858    }
2859
2860    #[tokio::test]
2861    async fn check_auto_updates_applies_update_when_archive_changed() {
2862        use wiremock::matchers::method;
2863        use wiremock::{Mock, MockServer, ResponseTemplate};
2864
2865        let tmp = tempfile::tempdir().unwrap();
2866        let plugins_dir = tmp.path().join("plugins");
2867        let managed_dir = tmp.path().join("managed");
2868
2869        // Build v0.1.0 archive.
2870        let src_v1 = tmp.path().join("src-v1");
2871        let manifest_v1 = r#"[plugin]
2872name = "update-test"
2873version = "0.1.0"
2874description = "test"
2875auto_update = true
2876
2877[[skills]]
2878path = "skills/my-skill"
2879"#;
2880        write_plugin(
2881            &src_v1,
2882            "update-test",
2883            manifest_v1,
2884            &[("my-skill", "v1 body")],
2885        );
2886        let archive_v1 = build_tar_gz(&src_v1);
2887        let hash_v1 = crate::integrity::sha256_hex(&archive_v1);
2888
2889        // Build v0.2.0 archive.
2890        let src_v2 = tmp.path().join("src-v2");
2891        let manifest_v2 = r#"[plugin]
2892name = "update-test"
2893version = "0.2.0"
2894description = "test"
2895auto_update = true
2896
2897[[skills]]
2898path = "skills/my-skill"
2899"#;
2900        write_plugin(
2901            &src_v2,
2902            "update-test",
2903            manifest_v2,
2904            &[("my-skill", "v2 body")],
2905        );
2906        let archive_v2 = build_tar_gz(&src_v2);
2907
2908        let mock_server = MockServer::start().await;
2909        // First call: install (serves v1).
2910        Mock::given(method("GET"))
2911            .respond_with(
2912                ResponseTemplate::new(200)
2913                    .set_body_bytes(archive_v1)
2914                    .append_header("Content-Type", "application/octet-stream"),
2915            )
2916            .up_to_n_times(1)
2917            .mount(&mock_server)
2918            .await;
2919        // Second call: auto-update check (serves v2).
2920        Mock::given(method("GET"))
2921            .respond_with(
2922                ResponseTemplate::new(200)
2923                    .set_body_bytes(archive_v2)
2924                    .append_header("Content-Type", "application/octet-stream"),
2925            )
2926            .mount(&mock_server)
2927            .await;
2928
2929        let url = format!("{}/plugin.tar.gz", mock_server.uri());
2930        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
2931        mgr.add_remote(&url, Some(&hash_v1)).await.unwrap();
2932
2933        let results = mgr.check_auto_updates().await;
2934        assert_eq!(results.len(), 1);
2935        assert!(
2936            matches!(
2937                &results[0].status,
2938                AutoUpdateStatus::Updated { old_version, new_version }
2939                if old_version == "0.1.0" && new_version == "0.2.0"
2940            ),
2941            "changed archive must yield Updated(0.1.0 → 0.2.0), got {:?}",
2942            results[0].status
2943        );
2944
2945        // Installed version must reflect v0.2.0.
2946        let installed = mgr.list_installed().unwrap();
2947        assert_eq!(installed[0].version, "0.2.0");
2948    }
2949
2950    #[tokio::test]
2951    async fn check_auto_updates_returns_failed_on_http_error() {
2952        use wiremock::matchers::method;
2953        use wiremock::{Mock, MockServer, ResponseTemplate};
2954
2955        let tmp = tempfile::tempdir().unwrap();
2956        let source = tmp.path().join("source");
2957        let manifest = r#"[plugin]
2958name = "fail-update"
2959version = "0.1.0"
2960description = "test"
2961auto_update = true
2962
2963[[skills]]
2964path = "skills/my-skill"
2965"#;
2966        write_plugin(&source, "fail-update", manifest, &[("my-skill", "body")]);
2967        let archive = build_tar_gz(&source);
2968        let hash = crate::integrity::sha256_hex(&archive);
2969
2970        let mock_server = MockServer::start().await;
2971        // Install succeeds.
2972        Mock::given(method("GET"))
2973            .respond_with(
2974                ResponseTemplate::new(200)
2975                    .set_body_bytes(archive)
2976                    .append_header("Content-Type", "application/octet-stream"),
2977            )
2978            .up_to_n_times(1)
2979            .mount(&mock_server)
2980            .await;
2981        // Auto-update check returns 404.
2982        Mock::given(method("GET"))
2983            .respond_with(ResponseTemplate::new(404))
2984            .mount(&mock_server)
2985            .await;
2986
2987        let plugins_dir = tmp.path().join("plugins");
2988        let managed_dir = tmp.path().join("managed");
2989        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
2990        let url = format!("{}/fail-update.tar.gz", mock_server.uri());
2991        mgr.add_remote(&url, Some(&hash)).await.unwrap();
2992
2993        let results = mgr.check_auto_updates().await;
2994        assert_eq!(results.len(), 1);
2995        assert!(
2996            matches!(results[0].status, AutoUpdateStatus::Failed(_)),
2997            "HTTP 404 must yield Failed, got {:?}",
2998            results[0].status
2999        );
3000
3001        // Plugin must still be installed at the old version.
3002        let installed = mgr.list_installed().unwrap();
3003        assert_eq!(installed[0].version, "0.1.0");
3004    }
3005
3006    #[tokio::test]
3007    async fn check_auto_updates_skips_plugins_with_auto_update_false() {
3008        let tmp = tempfile::tempdir().unwrap();
3009        let source = tmp.path().join("source");
3010        write_plugin(
3011            &source,
3012            "no-autoupdate",
3013            &simple_manifest("no-autoupdate", "skill-b"),
3014            &[("skill-b", "body")],
3015        );
3016
3017        let plugins_dir = tmp.path().join("plugins");
3018        let managed_dir = tmp.path().join("managed");
3019        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
3020        mgr.add(source.to_str().unwrap()).unwrap();
3021
3022        // auto_update = false (default) — check_auto_updates must return an empty list.
3023        let results = mgr.check_auto_updates().await;
3024        assert!(
3025            results.is_empty(),
3026            "auto_update=false plugin must be excluded from results"
3027        );
3028    }
3029
3030    // --- Security tests ---
3031
3032    #[test]
3033    fn validate_url_scheme_rejects_file_url() {
3034        let err = validate_url_scheme("file:///etc/passwd").unwrap_err();
3035        assert!(
3036            matches!(err, PluginError::InvalidSource { ref reason, .. } if reason.contains("file")),
3037            "file:// URL must be rejected, got {err:?}"
3038        );
3039    }
3040
3041    #[test]
3042    fn validate_url_scheme_rejects_data_url() {
3043        let err = validate_url_scheme("data:text/plain,hello").unwrap_err();
3044        assert!(
3045            matches!(err, PluginError::InvalidSource { .. }),
3046            "data: URL must be rejected, got {err:?}"
3047        );
3048    }
3049
3050    #[test]
3051    fn validate_url_scheme_accepts_https() {
3052        assert!(validate_url_scheme("https://example.com/plugin.tar.gz").is_ok());
3053    }
3054
3055    #[test]
3056    fn validate_url_scheme_accepts_http() {
3057        assert!(validate_url_scheme("http://example.com/plugin.tar.gz").is_ok());
3058    }
3059
3060    #[test]
3061    fn validate_url_scheme_rejects_invalid_url() {
3062        let err = validate_url_scheme("not a url at all").unwrap_err();
3063        assert!(
3064            matches!(err, PluginError::InvalidSource { .. }),
3065            "invalid URL must return InvalidSource, got {err:?}"
3066        );
3067    }
3068
3069    #[tokio::test]
3070    async fn add_remote_rejects_file_scheme_url() {
3071        let tmp = tempfile::tempdir().unwrap();
3072        let plugins_dir = tmp.path().join("plugins");
3073        let managed_dir = tmp.path().join("managed");
3074        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
3075        let err = mgr
3076            .add_remote("file:///etc/passwd", None)
3077            .await
3078            .unwrap_err();
3079        assert!(
3080            matches!(err, PluginError::InvalidSource { .. }),
3081            "add_remote must reject file:// URL, got {err:?}"
3082        );
3083    }
3084
3085    #[tokio::test]
3086    async fn check_auto_updates_rejects_file_scheme_in_source() {
3087        let tmp = tempfile::tempdir().unwrap();
3088        let source = tmp.path().join("source");
3089        let manifest = r#"[plugin]
3090name = "ssrf-test"
3091version = "0.1.0"
3092description = "test"
3093auto_update = true
3094
3095[[skills]]
3096path = "skills/my-skill"
3097"#;
3098        write_plugin(&source, "ssrf-test", manifest, &[("my-skill", "body")]);
3099        let plugins_dir = tmp.path().join("plugins");
3100        let managed_dir = tmp.path().join("managed");
3101        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
3102        mgr.add(source.to_str().unwrap()).unwrap();
3103
3104        // Manually write a malicious .plugin-source.toml with file:// URL.
3105        let sidecar = plugins_dir.join("ssrf-test").join(".plugin-source.toml");
3106        std::fs::write(
3107            &sidecar,
3108            r#"url = "file:///etc/passwd"
3109sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
3110"#,
3111        )
3112        .unwrap();
3113
3114        let results = mgr.check_auto_updates().await;
3115        assert_eq!(results.len(), 1);
3116        assert!(
3117            matches!(results[0].status, AutoUpdateStatus::Failed(_)),
3118            "file:// URL in source sidecar must yield Failed, got {:?}",
3119            results[0].status
3120        );
3121    }
3122
3123    #[tokio::test]
3124    async fn check_auto_updates_rejects_name_change_in_update() {
3125        use wiremock::matchers::method;
3126        use wiremock::{Mock, MockServer, ResponseTemplate};
3127
3128        let tmp = tempfile::tempdir().unwrap();
3129        let plugins_dir = tmp.path().join("plugins");
3130        let managed_dir = tmp.path().join("managed");
3131
3132        // Install v0.1.0 as "original-plugin".
3133        let src_v1 = tmp.path().join("src-v1");
3134        let manifest_v1 = r#"[plugin]
3135name = "original-plugin"
3136version = "0.1.0"
3137description = "test"
3138auto_update = true
3139
3140[[skills]]
3141path = "skills/my-skill"
3142"#;
3143        write_plugin(
3144            &src_v1,
3145            "original-plugin",
3146            manifest_v1,
3147            &[("my-skill", "v1")],
3148        );
3149        let archive_v1 = build_tar_gz(&src_v1);
3150        let hash_v1 = crate::integrity::sha256_hex(&archive_v1);
3151
3152        // Build an "update" archive that renames the plugin to "evil-plugin".
3153        let src_evil = tmp.path().join("src-evil");
3154        let manifest_evil = r#"[plugin]
3155name = "evil-plugin"
3156version = "0.2.0"
3157description = "test"
3158auto_update = true
3159
3160[[skills]]
3161path = "skills/my-skill"
3162"#;
3163        write_plugin(
3164            &src_evil,
3165            "evil-plugin",
3166            manifest_evil,
3167            &[("my-skill", "evil")],
3168        );
3169        let archive_evil = build_tar_gz(&src_evil);
3170
3171        let mock_server = MockServer::start().await;
3172        Mock::given(method("GET"))
3173            .respond_with(
3174                ResponseTemplate::new(200)
3175                    .set_body_bytes(archive_v1)
3176                    .append_header("Content-Type", "application/octet-stream"),
3177            )
3178            .up_to_n_times(1)
3179            .mount(&mock_server)
3180            .await;
3181        Mock::given(method("GET"))
3182            .respond_with(
3183                ResponseTemplate::new(200)
3184                    .set_body_bytes(archive_evil)
3185                    .append_header("Content-Type", "application/octet-stream"),
3186            )
3187            .mount(&mock_server)
3188            .await;
3189
3190        let url = format!("{}/plugin.tar.gz", mock_server.uri());
3191        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
3192        mgr.add_remote(&url, Some(&hash_v1)).await.unwrap();
3193
3194        let results = mgr.check_auto_updates().await;
3195        assert_eq!(results.len(), 1);
3196        assert!(
3197            matches!(results[0].status, AutoUpdateStatus::Failed(_)),
3198            "name change in update archive must yield Failed, got {:?}",
3199            results[0].status
3200        );
3201
3202        // Original plugin must still be installed at v0.1.0.
3203        let installed = mgr.list_installed().unwrap();
3204        assert_eq!(installed[0].version, "0.1.0");
3205    }
3206
3207    #[test]
3208    fn extract_archive_safe_path_traversal_detection() {
3209        // Verify the path-component check logic used inside extract_archive_safe.
3210        // The tar builder itself rejects `..` entries, so we test the detection logic
3211        // directly by constructing a path and running the same check.
3212        let traversal = std::path::Path::new("subdir/../../../etc/evil");
3213        let has_traversal = traversal
3214            .components()
3215            .any(|c| c == std::path::Component::ParentDir);
3216        assert!(
3217            has_traversal,
3218            "path with .. components must be detected as a traversal attempt"
3219        );
3220
3221        let safe = std::path::Path::new("plugin/skills/my-skill/SKILL.md");
3222        let safe_ok = safe
3223            .components()
3224            .all(|c| c != std::path::Component::ParentDir);
3225        assert!(safe_ok, "safe relative path must pass traversal check");
3226    }
3227
3228    // --- dependency enforcement tests ---
3229
3230    fn install_plugin_with_deps(plugins_dir: &Path, managed_dir: &Path, name: &str, deps: &[&str]) {
3231        // Use a canonicalized tmp dir so the path-prefix check in collect_skill_dirs works on
3232        // macOS where /tmp is a symlink to /private/tmp.
3233        let plugin_src_raw = tempfile::tempdir().unwrap();
3234        let plugin_src = plugin_src_raw.path().canonicalize().unwrap();
3235        let deps_toml = deps
3236            .iter()
3237            .map(|d| format!("\"{d}\""))
3238            .collect::<Vec<_>>()
3239            .join(", ");
3240        let skill_name = format!("skill-{name}");
3241        let manifest = format!(
3242            "[plugin]\nname = \"{name}\"\nversion = \"0.1.0\"\ndependencies = [{deps_toml}]\n\n[[skills]]\npath = \"skills/{skill_name}\"\n"
3243        );
3244        write_plugin(&plugin_src, name, &manifest, &[(&skill_name, "test skill")]);
3245        let mgr = PluginManager::new(
3246            plugins_dir.to_path_buf(),
3247            managed_dir.to_path_buf(),
3248            vec![],
3249            vec![],
3250        );
3251        mgr.add(plugin_src.to_str().unwrap()).unwrap();
3252    }
3253
3254    #[test]
3255    fn dependencies_field_defaults_to_empty() {
3256        let plugins_dir = tempfile::tempdir().unwrap();
3257        let managed_dir = tempfile::tempdir().unwrap();
3258        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3259        let mgr = PluginManager::new(
3260            plugins_dir.path().to_path_buf(),
3261            managed_dir.path().to_path_buf(),
3262            vec![],
3263            vec![],
3264        );
3265        let installed = mgr.list_installed().unwrap();
3266        assert_eq!(installed.len(), 1);
3267        // Manifest with no dependencies field must deserialize with empty Vec.
3268        let manifest_path = plugins_dir.path().join("base").join(".plugin.toml");
3269        let text = std::fs::read_to_string(manifest_path).unwrap();
3270        let manifest: crate::manifest::PluginManifest = toml::from_str(&text).unwrap();
3271        assert!(manifest.plugin.dependencies.is_empty());
3272    }
3273
3274    #[test]
3275    fn remove_refused_when_dependent_enabled() {
3276        let plugins_dir = tempfile::tempdir().unwrap();
3277        let managed_dir = tempfile::tempdir().unwrap();
3278        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3279        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3280        let mgr = PluginManager::new(
3281            plugins_dir.path().to_path_buf(),
3282            managed_dir.path().to_path_buf(),
3283            vec![],
3284            vec![],
3285        );
3286        let err = mgr.remove("base").unwrap_err();
3287        assert!(
3288            matches!(err, PluginError::DependencyRequired { ref name, .. } if name == "base"),
3289            "expected DependencyRequired, got {err:?}"
3290        );
3291    }
3292
3293    #[test]
3294    fn remove_succeeds_after_dependent_removed() {
3295        let plugins_dir = tempfile::tempdir().unwrap();
3296        let managed_dir = tempfile::tempdir().unwrap();
3297        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3298        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3299        let mgr = PluginManager::new(
3300            plugins_dir.path().to_path_buf(),
3301            managed_dir.path().to_path_buf(),
3302            vec![],
3303            vec![],
3304        );
3305        mgr.remove("ext").unwrap();
3306        mgr.remove("base").unwrap();
3307        assert!(mgr.list_installed().unwrap().is_empty());
3308    }
3309
3310    #[test]
3311    fn disable_refused_when_dependent_enabled() {
3312        let plugins_dir = tempfile::tempdir().unwrap();
3313        let managed_dir = tempfile::tempdir().unwrap();
3314        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3315        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3316        let mgr = PluginManager::new(
3317            plugins_dir.path().to_path_buf(),
3318            managed_dir.path().to_path_buf(),
3319            vec![],
3320            vec![],
3321        );
3322        let err = mgr.disable("base", false).unwrap_err();
3323        assert!(
3324            matches!(err, PluginError::DependencyRequired { ref name, .. } if name == "base"),
3325            "expected DependencyRequired, got {err:?}"
3326        );
3327    }
3328
3329    #[test]
3330    fn disable_and_enable_roundtrip() {
3331        let plugins_dir = tempfile::tempdir().unwrap();
3332        let managed_dir = tempfile::tempdir().unwrap();
3333        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3334        let mgr = PluginManager::new(
3335            plugins_dir.path().to_path_buf(),
3336            managed_dir.path().to_path_buf(),
3337            vec![],
3338            vec![],
3339        );
3340        mgr.disable("base", false).unwrap();
3341        assert!(plugins_dir.path().join("base").join(".disabled").exists());
3342        mgr.enable("base").unwrap();
3343        assert!(!plugins_dir.path().join("base").join(".disabled").exists());
3344    }
3345
3346    #[test]
3347    fn disable_idempotent() {
3348        let plugins_dir = tempfile::tempdir().unwrap();
3349        let managed_dir = tempfile::tempdir().unwrap();
3350        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3351        let mgr = PluginManager::new(
3352            plugins_dir.path().to_path_buf(),
3353            managed_dir.path().to_path_buf(),
3354            vec![],
3355            vec![],
3356        );
3357        mgr.disable("base", false).unwrap();
3358        // Second disable must be a no-op, not an error.
3359        mgr.disable("base", false).unwrap();
3360    }
3361
3362    #[test]
3363    fn enable_idempotent() {
3364        let plugins_dir = tempfile::tempdir().unwrap();
3365        let managed_dir = tempfile::tempdir().unwrap();
3366        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3367        let mgr = PluginManager::new(
3368            plugins_dir.path().to_path_buf(),
3369            managed_dir.path().to_path_buf(),
3370            vec![],
3371            vec![],
3372        );
3373        // Plugin is already enabled — second enable is a no-op.
3374        mgr.enable("base").unwrap();
3375        mgr.enable("base").unwrap();
3376    }
3377
3378    #[test]
3379    fn enable_transitively_enables_dependencies() {
3380        let plugins_dir = tempfile::tempdir().unwrap();
3381        let managed_dir = tempfile::tempdir().unwrap();
3382        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3383        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3384        // Disable both.
3385        std::fs::write(plugins_dir.path().join("base").join(".disabled"), b"").unwrap();
3386        std::fs::write(plugins_dir.path().join("ext").join(".disabled"), b"").unwrap();
3387        let mgr = PluginManager::new(
3388            plugins_dir.path().to_path_buf(),
3389            managed_dir.path().to_path_buf(),
3390            vec![],
3391            vec![],
3392        );
3393        // Enabling ext must also enable base.
3394        mgr.enable("ext").unwrap();
3395        assert!(
3396            !plugins_dir.path().join("base").join(".disabled").exists(),
3397            "base must be enabled"
3398        );
3399        assert!(
3400            !plugins_dir.path().join("ext").join(".disabled").exists(),
3401            "ext must be enabled"
3402        );
3403    }
3404
3405    #[test]
3406    fn enable_detects_dependency_cycle() {
3407        let plugins_dir = tempfile::tempdir().unwrap();
3408        let managed_dir = tempfile::tempdir().unwrap();
3409        // Install alpha → beta, beta → alpha (cycle).
3410        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "alpha", &["beta"]);
3411        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "beta", &["alpha"]);
3412        // Disable both to force the enable path.
3413        std::fs::write(plugins_dir.path().join("alpha").join(".disabled"), b"").unwrap();
3414        std::fs::write(plugins_dir.path().join("beta").join(".disabled"), b"").unwrap();
3415        let mgr = PluginManager::new(
3416            plugins_dir.path().to_path_buf(),
3417            managed_dir.path().to_path_buf(),
3418            vec![],
3419            vec![],
3420        );
3421        let err = mgr.enable("alpha").unwrap_err();
3422        assert!(
3423            matches!(err, PluginError::DependencyCycle { .. }),
3424            "expected DependencyCycle, got {err:?}"
3425        );
3426    }
3427
3428    #[test]
3429    fn disable_ignored_by_dependents_of() {
3430        let plugins_dir = tempfile::tempdir().unwrap();
3431        let managed_dir = tempfile::tempdir().unwrap();
3432        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3433        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3434        // Disable ext — it should no longer block removing base.
3435        std::fs::write(plugins_dir.path().join("ext").join(".disabled"), b"").unwrap();
3436        let mgr = PluginManager::new(
3437            plugins_dir.path().to_path_buf(),
3438            managed_dir.path().to_path_buf(),
3439            vec![],
3440            vec![],
3441        );
3442        // base has no enabled dependents now.
3443        mgr.remove("base").unwrap();
3444    }
3445
3446    #[test]
3447    fn enable_returns_missing_dependency_when_dep_not_installed() {
3448        let plugins_dir = tempfile::tempdir().unwrap();
3449        let managed_dir = tempfile::tempdir().unwrap();
3450        install_plugin_with_deps(
3451            plugins_dir.path(),
3452            managed_dir.path(),
3453            "needs-ghost",
3454            &["nonexistent"],
3455        );
3456        // Disable the plugin so enable() actually tries to traverse deps.
3457        std::fs::write(
3458            plugins_dir.path().join("needs-ghost").join(".disabled"),
3459            b"",
3460        )
3461        .unwrap();
3462        let mgr = PluginManager::new(
3463            plugins_dir.path().to_path_buf(),
3464            managed_dir.path().to_path_buf(),
3465            vec![],
3466            vec![],
3467        );
3468        let err = mgr.enable("needs-ghost").unwrap_err();
3469        assert!(
3470            matches!(
3471                err,
3472                PluginError::MissingDependency {
3473                    ref dependency,
3474                    ..
3475                } if dependency == "nonexistent"
3476            ),
3477            "expected MissingDependency, got {err:?}"
3478        );
3479    }
3480
3481    #[test]
3482    fn add_rejects_too_many_dependencies() {
3483        let plugins_dir = tempfile::tempdir().unwrap();
3484        let managed_dir = tempfile::tempdir().unwrap();
3485        let deps: Vec<String> = (0..=64).map(|i| format!("dep-{i:02}")).collect();
3486        let deps_toml = deps
3487            .iter()
3488            .map(|d| format!("\"{d}\""))
3489            .collect::<Vec<_>>()
3490            .join(", ");
3491        let manifest = format!(
3492            "[plugin]\nname = \"bloated\"\nversion = \"0.1.0\"\ndependencies = [{deps_toml}]\n"
3493        );
3494        let plugin_src = tempfile::tempdir().unwrap();
3495        write_plugin(
3496            plugin_src.path(),
3497            "bloated",
3498            &manifest,
3499            &[("skill-a", "test")],
3500        );
3501        let mgr = PluginManager::new(
3502            plugins_dir.path().to_path_buf(),
3503            managed_dir.path().to_path_buf(),
3504            vec![],
3505            vec![],
3506        );
3507        let err = mgr.add(plugin_src.path().to_str().unwrap()).unwrap_err();
3508        assert!(
3509            matches!(err, PluginError::InvalidManifest(_)),
3510            "expected InvalidManifest for too many dependencies, got {err:?}"
3511        );
3512    }
3513
3514    #[test]
3515    fn add_rejects_invalid_dependency_name() {
3516        let plugins_dir = tempfile::tempdir().unwrap();
3517        let managed_dir = tempfile::tempdir().unwrap();
3518        let manifest =
3519            "[plugin]\nname = \"myplugin\"\nversion = \"0.1.0\"\ndependencies = [\"../evil\"]\n";
3520        let plugin_src = tempfile::tempdir().unwrap();
3521        write_plugin(
3522            plugin_src.path(),
3523            "myplugin",
3524            manifest,
3525            &[("skill-a", "test")],
3526        );
3527        let mgr = PluginManager::new(
3528            plugins_dir.path().to_path_buf(),
3529            managed_dir.path().to_path_buf(),
3530            vec![],
3531            vec![],
3532        );
3533        let err = mgr.add(plugin_src.path().to_str().unwrap()).unwrap_err();
3534        assert!(
3535            matches!(err, PluginError::InvalidName { .. }),
3536            "expected InvalidName for malformed dep name, got {err:?}"
3537        );
3538    }
3539
3540    #[test]
3541    fn disable_force_succeeds_despite_dependent() {
3542        let plugins_dir = tempfile::tempdir().unwrap();
3543        let managed_dir = tempfile::tempdir().unwrap();
3544        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3545        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3546        let mgr = PluginManager::new(
3547            plugins_dir.path().to_path_buf(),
3548            managed_dir.path().to_path_buf(),
3549            vec![],
3550            vec![],
3551        );
3552        // Without force this would fail with DependencyRequired.
3553        let result = mgr.disable("base", true).unwrap();
3554        assert!(
3555            result.forced_over_dependents.contains(&"ext".to_owned()),
3556            "forced_over_dependents must list 'ext', got {:?}",
3557            result.forced_over_dependents
3558        );
3559        assert!(
3560            plugins_dir.path().join("base").join(".disabled").exists(),
3561            "base must be disabled after force"
3562        );
3563    }
3564
3565    #[test]
3566    fn disable_force_no_dependents_returns_empty_list() {
3567        let plugins_dir = tempfile::tempdir().unwrap();
3568        let managed_dir = tempfile::tempdir().unwrap();
3569        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "standalone", &[]);
3570        let mgr = PluginManager::new(
3571            plugins_dir.path().to_path_buf(),
3572            managed_dir.path().to_path_buf(),
3573            vec![],
3574            vec![],
3575        );
3576        let result = mgr.disable("standalone", true).unwrap();
3577        assert!(
3578            result.forced_over_dependents.is_empty(),
3579            "no dependents means forced_over_dependents must be empty"
3580        );
3581    }
3582
3583    #[test]
3584    fn disable_force_false_same_as_no_force() {
3585        let plugins_dir = tempfile::tempdir().unwrap();
3586        let managed_dir = tempfile::tempdir().unwrap();
3587        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "base", &[]);
3588        install_plugin_with_deps(plugins_dir.path(), managed_dir.path(), "ext", &["base"]);
3589        let mgr = PluginManager::new(
3590            plugins_dir.path().to_path_buf(),
3591            managed_dir.path().to_path_buf(),
3592            vec![],
3593            vec![],
3594        );
3595        // force=false with dependents must still refuse.
3596        let err = mgr.disable("base", false).unwrap_err();
3597        assert!(
3598            matches!(err, PluginError::DependencyRequired { .. }),
3599            "expected DependencyRequired with force=false, got {err:?}"
3600        );
3601    }
3602
3603    #[test]
3604    fn collect_skill_dirs_excludes_disabled_plugin() {
3605        let tmp = tempfile::tempdir().unwrap();
3606        // Canonicalize so the path-prefix check inside collect_skill_dirs works on macOS.
3607        let real = tmp.path().canonicalize().unwrap();
3608        let plugins_dir = real.join("plugins");
3609        let managed_dir = real.join("managed");
3610        std::fs::create_dir_all(&plugins_dir).unwrap();
3611        std::fs::create_dir_all(&managed_dir).unwrap();
3612        install_plugin_with_deps(&plugins_dir, &managed_dir, "active", &[]);
3613        install_plugin_with_deps(&plugins_dir, &managed_dir, "sleeping", &[]);
3614        // Disable sleeping.
3615        std::fs::write(plugins_dir.join("sleeping").join(".disabled"), b"").unwrap();
3616        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
3617        let dirs = mgr.collect_skill_dirs().unwrap();
3618        // Only the active plugin's skill dirs should appear.
3619        for dir in &dirs {
3620            assert!(
3621                !dir.to_string_lossy().contains("sleeping"),
3622                "disabled plugin skill dir must not appear: {dir:?}"
3623            );
3624        }
3625        assert!(!dirs.is_empty(), "active plugin skills must be present");
3626    }
3627}