Skip to main content

agentzero_plugins/
package.rs

1use anyhow::{anyhow, Context};
2use fd_lock::RwLock;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::fs;
7use std::io::{Cursor, Read};
8use std::path::{Path, PathBuf};
9
10const MANIFEST_FILE_NAME: &str = "manifest.json";
11const CURRENT_RUNTIME_API_VERSION: u32 = 2;
12const LOCK_FILE_NAME: &str = ".agentzero-plugins.lock";
13
14/// Open a lock file for the install root directory. The caller must call
15/// `.write()` on the returned `RwLock` to acquire the exclusive lock.
16fn open_install_lock(install_root: &Path) -> anyhow::Result<RwLock<fs::File>> {
17    fs::create_dir_all(install_root)
18        .with_context(|| format!("failed to create install root {}", install_root.display()))?;
19    let lock_path = install_root.join(LOCK_FILE_NAME);
20    let lock_file = fs::OpenOptions::new()
21        .create(true)
22        .write(true)
23        .truncate(true)
24        .open(&lock_path)
25        .with_context(|| format!("failed to open lock file {}", lock_path.display()))?;
26    Ok(RwLock::new(lock_file))
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct PluginManifest {
31    pub id: String,
32    pub version: String,
33    #[serde(default)]
34    pub description: Option<String>,
35    pub entrypoint: String,
36    pub wasm_file: String,
37    pub wasm_sha256: String,
38    #[serde(default)]
39    pub capabilities: Vec<String>,
40    #[serde(default)]
41    pub hooks: Vec<String>,
42    #[serde(default = "default_runtime_api_version")]
43    pub min_runtime_api: u32,
44    #[serde(default = "default_runtime_api_version")]
45    pub max_runtime_api: u32,
46    pub allowed_host_calls: Vec<String>,
47}
48
49impl PluginManifest {
50    pub fn validate(&self) -> anyhow::Result<()> {
51        if self.id.trim().is_empty() {
52            return Err(anyhow!("plugin id cannot be empty"));
53        }
54        if self.version.trim().is_empty() {
55            return Err(anyhow!("plugin version cannot be empty"));
56        }
57        if self.entrypoint.trim().is_empty() {
58            return Err(anyhow!("plugin entrypoint cannot be empty"));
59        }
60        if self.wasm_file.trim().is_empty() {
61            return Err(anyhow!("plugin wasm_file cannot be empty"));
62        }
63        if !self.wasm_file.ends_with(".wasm") {
64            return Err(anyhow!("plugin wasm_file must end with .wasm"));
65        }
66        if self.wasm_sha256.len() != 64 || !self.wasm_sha256.chars().all(|c| c.is_ascii_hexdigit())
67        {
68            return Err(anyhow!("plugin wasm_sha256 must be a 64-char hex digest"));
69        }
70        if self.min_runtime_api == 0 {
71            return Err(anyhow!("plugin min_runtime_api must be >= 1"));
72        }
73        if self.max_runtime_api == 0 {
74            return Err(anyhow!("plugin max_runtime_api must be >= 1"));
75        }
76        if self.min_runtime_api > self.max_runtime_api {
77            return Err(anyhow!(
78                "plugin runtime API range is invalid (min_runtime_api > max_runtime_api)"
79            ));
80        }
81        self.validate_runtime_compatibility(CURRENT_RUNTIME_API_VERSION)?;
82        Ok(())
83    }
84
85    pub fn validate_runtime_compatibility(&self, current_api: u32) -> anyhow::Result<()> {
86        if current_api < self.min_runtime_api || current_api > self.max_runtime_api {
87            return Err(anyhow!(
88                "plugin runtime API compatibility failed: current={current_api}, supported={}..={}",
89                self.min_runtime_api,
90                self.max_runtime_api
91            ));
92        }
93        Ok(())
94    }
95}
96
97fn default_runtime_api_version() -> u32 {
98    CURRENT_RUNTIME_API_VERSION
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct InstalledPlugin {
103    pub install_dir: PathBuf,
104    pub manifest_path: PathBuf,
105    pub wasm_path: PathBuf,
106    pub manifest: PluginManifest,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110pub struct InstalledPluginRecord {
111    pub id: String,
112    pub version: String,
113    pub install_dir: PathBuf,
114    pub manifest_path: PathBuf,
115}
116
117pub fn package_plugin(
118    wasm_module_path: impl AsRef<Path>,
119    mut manifest: PluginManifest,
120    package_path: impl AsRef<Path>,
121) -> anyhow::Result<()> {
122    let wasm_module_path = wasm_module_path.as_ref();
123    let package_path = package_path.as_ref();
124
125    if wasm_module_path.extension().and_then(|v| v.to_str()) != Some("wasm") {
126        return Err(anyhow!("plugin module must be a .wasm file"));
127    }
128    let wasm_bytes = fs::read(wasm_module_path).with_context(|| {
129        format!(
130            "failed to read wasm module at {}",
131            wasm_module_path.display()
132        )
133    })?;
134
135    // Always regenerate the checksum at package time.
136    manifest.wasm_sha256 = sha256_hex(&wasm_bytes);
137    manifest.validate()?;
138
139    let manifest_bytes =
140        serde_json::to_vec_pretty(&manifest).context("failed to serialize plugin manifest")?;
141
142    if let Some(parent) = package_path.parent() {
143        fs::create_dir_all(parent)
144            .with_context(|| format!("failed to create package output dir {}", parent.display()))?;
145    }
146
147    let file = fs::File::create(package_path)
148        .with_context(|| format!("failed to create package file {}", package_path.display()))?;
149    let mut builder = tar::Builder::new(file);
150
151    let mut manifest_header = tar::Header::new_gnu();
152    manifest_header.set_size(manifest_bytes.len() as u64);
153    manifest_header.set_mode(0o644);
154    manifest_header.set_cksum();
155    builder
156        .append_data(
157            &mut manifest_header,
158            MANIFEST_FILE_NAME,
159            Cursor::new(manifest_bytes),
160        )
161        .context("failed to append manifest to package")?;
162
163    let mut wasm_header = tar::Header::new_gnu();
164    wasm_header.set_size(wasm_bytes.len() as u64);
165    wasm_header.set_mode(0o644);
166    wasm_header.set_cksum();
167    builder
168        .append_data(
169            &mut wasm_header,
170            &manifest.wasm_file,
171            Cursor::new(wasm_bytes),
172        )
173        .context("failed to append wasm module to package")?;
174
175    builder
176        .finish()
177        .context("failed to finalize plugin package")?;
178    Ok(())
179}
180
181pub fn install_packaged_plugin(
182    package_path: impl AsRef<Path>,
183    install_root: impl AsRef<Path>,
184) -> anyhow::Result<InstalledPlugin> {
185    let package_path = package_path.as_ref();
186    let install_root = install_root.as_ref();
187
188    // Acquire an exclusive lock so concurrent installs don't corrupt state.
189    let mut lock = open_install_lock(install_root)?;
190    let _guard = lock
191        .write()
192        .map_err(|e| anyhow!("failed to acquire install lock: {e}"))?;
193
194    let archive_file = fs::File::open(package_path)
195        .with_context(|| format!("failed to open package {}", package_path.display()))?;
196    let mut archive = tar::Archive::new(archive_file);
197
198    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
199    for entry in archive
200        .entries()
201        .context("failed to read package entries")?
202    {
203        let mut entry = entry.context("failed to parse package entry")?;
204
205        // Reject symlinks — they can be used to escape the install directory.
206        let entry_type = entry.header().entry_type();
207        if entry_type.is_symlink() || entry_type.is_hard_link() {
208            anyhow::bail!("plugin package contains a symlink entry (rejected for security)");
209        }
210
211        let entry_path = entry
212            .path()
213            .context("failed to read package entry path")?
214            .to_string_lossy()
215            .to_string();
216
217        // Reject path traversal: absolute paths or parent-directory components.
218        if entry_path.starts_with('/') || entry_path.contains("..") {
219            anyhow::bail!(
220                "path traversal in plugin package: `{entry_path}` (rejected for security)"
221            );
222        }
223
224        let mut bytes = Vec::new();
225        entry
226            .read_to_end(&mut bytes)
227            .with_context(|| format!("failed to read package entry `{entry_path}`"))?;
228        files.insert(entry_path, bytes);
229    }
230
231    let manifest_bytes = files
232        .get(MANIFEST_FILE_NAME)
233        .ok_or_else(|| anyhow!("package missing manifest.json"))?;
234    let manifest: PluginManifest =
235        serde_json::from_slice(manifest_bytes).context("failed to deserialize plugin manifest")?;
236    manifest.validate()?;
237
238    let wasm_bytes = files
239        .get(&manifest.wasm_file)
240        .ok_or_else(|| anyhow!("package missing wasm module `{}`", manifest.wasm_file))?;
241
242    let digest = sha256_hex(wasm_bytes);
243    if digest != manifest.wasm_sha256 {
244        return Err(anyhow!(
245            "integrity check failed for `{}`: checksum mismatch",
246            manifest.wasm_file
247        ));
248    }
249
250    let install_dir = install_root.join(&manifest.id).join(&manifest.version);
251    fs::create_dir_all(&install_dir)
252        .with_context(|| format!("failed to create install dir {}", install_dir.display()))?;
253
254    let manifest_path = install_dir.join(MANIFEST_FILE_NAME);
255    let wasm_path = install_dir.join(&manifest.wasm_file);
256    fs::write(&manifest_path, manifest_bytes)
257        .with_context(|| format!("failed to write manifest at {}", manifest_path.display()))?;
258    fs::write(&wasm_path, wasm_bytes)
259        .with_context(|| format!("failed to write wasm at {}", wasm_path.display()))?;
260
261    Ok(InstalledPlugin {
262        install_dir,
263        manifest_path,
264        wasm_path,
265        manifest,
266    })
267}
268
269pub fn list_installed_plugins(
270    install_root: impl AsRef<Path>,
271) -> anyhow::Result<Vec<InstalledPluginRecord>> {
272    let install_root = install_root.as_ref();
273    if !install_root.exists() {
274        return Ok(Vec::new());
275    }
276
277    let mut records = Vec::new();
278    for plugin_dir in fs::read_dir(install_root)
279        .with_context(|| format!("failed to read install root {}", install_root.display()))?
280    {
281        let plugin_dir = plugin_dir.context("failed to read plugin dir entry")?;
282        if !plugin_dir.file_type()?.is_dir() {
283            continue;
284        }
285        let plugin_id = plugin_dir.file_name().to_string_lossy().to_string();
286
287        for version_dir in fs::read_dir(plugin_dir.path()).with_context(|| {
288            format!(
289                "failed to read plugin versions for {}",
290                plugin_dir.path().display()
291            )
292        })? {
293            let version_dir = version_dir.context("failed to read plugin version entry")?;
294            if !version_dir.file_type()?.is_dir() {
295                continue;
296            }
297            let version = version_dir.file_name().to_string_lossy().to_string();
298            let manifest_path = version_dir.path().join(MANIFEST_FILE_NAME);
299            if !manifest_path.exists() {
300                continue;
301            }
302            records.push(InstalledPluginRecord {
303                id: plugin_id.clone(),
304                version,
305                install_dir: version_dir.path(),
306                manifest_path,
307            });
308        }
309    }
310
311    records.sort_by(|a, b| {
312        a.id.cmp(&b.id).then_with(|| {
313            match (
314                semver::Version::parse(&a.version),
315                semver::Version::parse(&b.version),
316            ) {
317                (Ok(va), Ok(vb)) => va.cmp(&vb),
318                _ => a.version.cmp(&b.version),
319            }
320        })
321    });
322    Ok(records)
323}
324
325pub fn remove_installed_plugin(
326    install_root: impl AsRef<Path>,
327    plugin_id: &str,
328    version: Option<&str>,
329) -> anyhow::Result<usize> {
330    let install_root = install_root.as_ref();
331    if plugin_id.trim().is_empty() {
332        return Err(anyhow!("plugin id cannot be empty"));
333    }
334
335    // Acquire an exclusive lock so concurrent removes don't corrupt state.
336    let mut lock = open_install_lock(install_root)?;
337    let _guard = lock
338        .write()
339        .map_err(|e| anyhow!("failed to acquire install lock: {e}"))?;
340
341    let plugin_root = install_root.join(plugin_id);
342    if !plugin_root.exists() {
343        return Ok(0);
344    }
345
346    if let Some(version) = version {
347        let target = plugin_root.join(version);
348        if !target.exists() {
349            return Ok(0);
350        }
351        fs::remove_dir_all(&target)
352            .with_context(|| format!("failed to remove plugin dir {}", target.display()))?;
353        if plugin_root
354            .read_dir()
355            .with_context(|| format!("failed to read plugin dir {}", plugin_root.display()))?
356            .next()
357            .is_none()
358        {
359            fs::remove_dir_all(&plugin_root).with_context(|| {
360                format!("failed to remove plugin root {}", plugin_root.display())
361            })?;
362        }
363        return Ok(1);
364    }
365
366    let mut removed = 0usize;
367    for entry in fs::read_dir(&plugin_root)
368        .with_context(|| format!("failed to read plugin dir {}", plugin_root.display()))?
369    {
370        let entry = entry.context("failed to parse plugin version entry")?;
371        if entry.file_type()?.is_dir() {
372            fs::remove_dir_all(entry.path()).with_context(|| {
373                format!(
374                    "failed to remove plugin version dir {}",
375                    entry.path().display()
376                )
377            })?;
378            removed += 1;
379        }
380    }
381    fs::remove_dir_all(&plugin_root)
382        .with_context(|| format!("failed to remove plugin root {}", plugin_root.display()))?;
383    Ok(removed)
384}
385
386/// A plugin discovered on disk, ready to be loaded as a `WasmTool`.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct DiscoveredPlugin {
389    pub manifest: PluginManifest,
390    pub wasm_path: PathBuf,
391    /// Whether this plugin was found in a development directory (CWD/plugins/).
392    pub dev_mode: bool,
393}
394
395/// Discover installed plugins by scanning up to three directory tiers:
396///
397/// 1. **Global**: `{global_plugin_dir}/` — user-installed plugins
398/// 2. **Project**: `{project_plugin_dir}/` — project-specific plugins
399/// 3. **Development**: `{cwd_plugin_dir}/` — in-development plugins (hot-reload)
400///
401/// Later tiers override earlier ones when the same plugin id is found.
402/// Each directory is expected to have the structure used by `install_packaged_plugin`:
403///   `<plugin_id>/<version>/manifest.json` + `<wasm_file>`
404///
405/// The development directory also supports a flat layout for convenience:
406///   `<plugin_id>/manifest.json` + `<wasm_file>` (no version subdir)
407///
408/// Invalid manifests are warned and skipped — they never cause a hard failure.
409pub fn discover_plugins(
410    global_plugin_dir: Option<&Path>,
411    project_plugin_dir: Option<&Path>,
412    cwd_plugin_dir: Option<&Path>,
413) -> Vec<DiscoveredPlugin> {
414    let mut plugins: std::collections::HashMap<String, DiscoveredPlugin> =
415        std::collections::HashMap::new();
416
417    // Tier 1: Global
418    if let Some(dir) = global_plugin_dir {
419        for plugin in scan_plugin_dir(dir, false) {
420            plugins.insert(plugin.manifest.id.clone(), plugin);
421        }
422    }
423
424    // Tier 2: Project
425    if let Some(dir) = project_plugin_dir {
426        for plugin in scan_plugin_dir(dir, false) {
427            plugins.insert(plugin.manifest.id.clone(), plugin);
428        }
429    }
430
431    // Tier 3: Development (CWD)
432    if let Some(dir) = cwd_plugin_dir {
433        for plugin in scan_plugin_dir(dir, true) {
434            plugins.insert(plugin.manifest.id.clone(), plugin);
435        }
436    }
437
438    let mut result: Vec<DiscoveredPlugin> = plugins.into_values().collect();
439    result.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
440    result
441}
442
443/// Scan a single plugin directory for installed plugins.
444///
445/// Supports two layouts:
446///   - Versioned: `<id>/<version>/manifest.json`
447///   - Flat (dev): `<id>/manifest.json`
448fn scan_plugin_dir(dir: &Path, dev_mode: bool) -> Vec<DiscoveredPlugin> {
449    let mut found = Vec::new();
450
451    let entries = match fs::read_dir(dir) {
452        Ok(entries) => entries,
453        Err(_) => return found, // Missing dir = zero plugins
454    };
455
456    for entry in entries {
457        let entry = match entry {
458            Ok(e) => e,
459            Err(_) => continue,
460        };
461        if !entry.path().is_dir() {
462            continue;
463        }
464
465        // Try flat layout first: <id>/manifest.json
466        let flat_manifest = entry.path().join(MANIFEST_FILE_NAME);
467        if flat_manifest.exists() {
468            if let Some(plugin) = try_load_plugin(&entry.path(), dev_mode) {
469                found.push(plugin);
470                continue;
471            }
472        }
473
474        // Try versioned layout: <id>/<version>/manifest.json
475        if let Ok(version_entries) = fs::read_dir(entry.path()) {
476            // Pick the latest version directory (lexicographic sort, last wins)
477            let mut best: Option<DiscoveredPlugin> = None;
478            for version_entry in version_entries {
479                let version_entry = match version_entry {
480                    Ok(e) => e,
481                    Err(_) => continue,
482                };
483                if !version_entry.path().is_dir() {
484                    continue;
485                }
486                if let Some(plugin) = try_load_plugin(&version_entry.path(), dev_mode) {
487                    match &best {
488                        Some(existing)
489                            if version_ge(&existing.manifest.version, &plugin.manifest.version) => {
490                        }
491                        _ => best = Some(plugin),
492                    }
493                }
494            }
495            if let Some(plugin) = best {
496                found.push(plugin);
497            }
498        }
499    }
500
501    found
502}
503
504/// Attempt to load a plugin from a directory containing `manifest.json`.
505fn try_load_plugin(dir: &Path, dev_mode: bool) -> Option<DiscoveredPlugin> {
506    let manifest_path = dir.join(MANIFEST_FILE_NAME);
507    let bytes = match fs::read(&manifest_path) {
508        Ok(b) => b,
509        Err(_) => return None,
510    };
511    let manifest: PluginManifest = match serde_json::from_slice(&bytes) {
512        Ok(m) => m,
513        Err(e) => {
514            #[cfg(feature = "wasm-runtime")]
515            tracing::warn!(
516                "skipping plugin at {}: invalid manifest: {e}",
517                dir.display()
518            );
519            let _ = e;
520            return None;
521        }
522    };
523    if let Err(e) = manifest.validate() {
524        #[cfg(feature = "wasm-runtime")]
525        tracing::warn!("skipping plugin {}: validation failed: {e}", manifest.id);
526        let _ = e;
527        return None;
528    }
529
530    let wasm_path = dir.join(&manifest.wasm_file);
531    if !wasm_path.exists() {
532        #[cfg(feature = "wasm-runtime")]
533        tracing::warn!(
534            "skipping plugin {}: wasm file not found at {}",
535            manifest.id,
536            wasm_path.display()
537        );
538        return None;
539    }
540
541    Some(DiscoveredPlugin {
542        manifest,
543        wasm_path,
544        dev_mode,
545    })
546}
547
548/// Compare two version strings using semver when possible, falling back to
549/// lexicographic comparison for non-semver strings.
550fn version_ge(a: &str, b: &str) -> bool {
551    match (semver::Version::parse(a), semver::Version::parse(b)) {
552        (Ok(va), Ok(vb)) => va >= vb,
553        _ => a >= b,
554    }
555}
556
557fn sha256_hex(bytes: &[u8]) -> String {
558    let mut hasher = Sha256::new();
559    hasher.update(bytes);
560    let digest = hasher.finalize();
561    format!("{digest:x}")
562}
563
564// ── Plugin State Management ──────────────────────────────────────────
565
566/// Persistent state for a single installed plugin.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct PluginStateEntry {
569    pub version: String,
570    pub enabled: bool,
571    pub installed_at: String,
572    /// Where this plugin was installed from.
573    pub source: String,
574}
575
576/// Top-level plugin state file, stored at `{data_dir}/plugin-state.json`.
577#[derive(Debug, Clone, Default, Serialize, Deserialize)]
578pub struct PluginState {
579    pub plugins: std::collections::HashMap<String, PluginStateEntry>,
580}
581
582const STATE_FILE_NAME: &str = "plugin-state.json";
583
584impl PluginState {
585    /// Load plugin state from a data directory. Returns default (empty) if
586    /// the file doesn't exist or can't be parsed.
587    pub fn load(data_dir: &Path) -> Self {
588        let path = data_dir.join(STATE_FILE_NAME);
589        match fs::read_to_string(&path) {
590            Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
591            Err(_) => Self::default(),
592        }
593    }
594
595    /// Save plugin state to a data directory.
596    pub fn save(&self, data_dir: &Path) -> anyhow::Result<()> {
597        let path = data_dir.join(STATE_FILE_NAME);
598        if let Some(parent) = path.parent() {
599            fs::create_dir_all(parent)
600                .with_context(|| format!("failed to create state dir: {}", parent.display()))?;
601        }
602        let json =
603            serde_json::to_string_pretty(self).context("failed to serialize plugin state")?;
604        fs::write(&path, json)
605            .with_context(|| format!("failed to write plugin state: {}", path.display()))?;
606        Ok(())
607    }
608
609    /// Check whether a plugin is enabled. Returns `true` if the plugin has
610    /// no state entry (default is enabled).
611    pub fn is_enabled(&self, id: &str) -> bool {
612        self.plugins.get(id).map(|e| e.enabled).unwrap_or(true)
613    }
614
615    /// Enable a plugin. Creates an entry if one doesn't exist.
616    pub fn enable(&mut self, id: &str) -> anyhow::Result<()> {
617        match self.plugins.get_mut(id) {
618            Some(entry) => {
619                entry.enabled = true;
620                Ok(())
621            }
622            None => Err(anyhow!(
623                "plugin '{}' has no state entry (not installed via CLI)",
624                id
625            )),
626        }
627    }
628
629    /// Disable a plugin. Creates an entry if one doesn't exist.
630    pub fn disable(&mut self, id: &str) -> anyhow::Result<()> {
631        match self.plugins.get_mut(id) {
632            Some(entry) => {
633                entry.enabled = false;
634                Ok(())
635            }
636            None => Err(anyhow!(
637                "plugin '{}' has no state entry (not installed via CLI)",
638                id
639            )),
640        }
641    }
642
643    /// Record that a plugin was installed.
644    pub fn record_install(&mut self, id: &str, version: &str, source: &str) {
645        self.plugins.insert(
646            id.to_string(),
647            PluginStateEntry {
648                version: version.to_string(),
649                enabled: true,
650                installed_at: chrono_now_iso(),
651                source: source.to_string(),
652            },
653        );
654    }
655
656    /// Remove a plugin's state entry.
657    pub fn remove(&mut self, id: &str) {
658        self.plugins.remove(id);
659    }
660}
661
662fn chrono_now_iso() -> String {
663    // Simple ISO 8601 timestamp without chrono dependency
664    let dur = std::time::SystemTime::now()
665        .duration_since(std::time::UNIX_EPOCH)
666        .unwrap_or_default();
667    let secs = dur.as_secs();
668    // Format as seconds-since-epoch (precise enough for state tracking)
669    format!("{secs}")
670}
671
672/// Filter discovered plugins by state, removing disabled ones.
673pub fn filter_by_state(
674    plugins: Vec<DiscoveredPlugin>,
675    state: &PluginState,
676) -> Vec<DiscoveredPlugin> {
677    plugins
678        .into_iter()
679        .filter(|p| state.is_enabled(&p.manifest.id))
680        .collect()
681}
682
683/// Download a plugin package from a URL, verify its SHA256 if provided,
684/// and install it.
685pub fn install_from_url(
686    url: &str,
687    install_root: &Path,
688    expected_sha256: Option<&str>,
689) -> anyhow::Result<InstalledPlugin> {
690    // Use a blocking HTTP GET — the plugin CLI commands are already async
691    // but the core package functions are sync. This uses std::net via ureq
692    // would be ideal, but we'll use a minimal approach with the existing
693    // reqwest/hyper stack or fall back to curl. For now, check if the URL
694    // is a file:// URL first.
695    let bytes = if let Some(file_path) = url.strip_prefix("file://") {
696        fs::read(file_path).with_context(|| format!("failed to read local package: {file_path}"))?
697    } else {
698        return Err(anyhow!(
699            "URL-based install requires HTTP support. Use 'file://<path>' for local archives \
700             or download the package manually and use '--package <path>'."
701        ));
702    };
703
704    // Verify SHA256 if provided
705    if let Some(expected) = expected_sha256 {
706        let actual = sha256_hex(&bytes);
707        if actual != expected {
708            return Err(anyhow!(
709                "SHA256 mismatch: expected {expected}, got {actual}"
710            ));
711        }
712    }
713
714    // Write to a temp file and install
715    let tmp_dir =
716        std::env::temp_dir().join(format!("agentzero-plugin-download-{}", std::process::id()));
717    fs::create_dir_all(&tmp_dir)?;
718    let tmp_path = tmp_dir.join("package.tar");
719    fs::write(&tmp_path, &bytes)?;
720
721    let result = install_packaged_plugin(&tmp_path, install_root);
722
723    // Clean up temp file
724    fs::remove_dir_all(&tmp_dir).ok();
725
726    result
727}
728
729// ── Plugin Registry ───────────────────────────────────────────────────────
730
731/// A single version entry in the registry index for a plugin.
732#[derive(Debug, Clone, Serialize, Deserialize)]
733pub struct RegistryVersionEntry {
734    pub version: String,
735    pub download_url: String,
736    pub sha256: String,
737    pub min_runtime_api: u32,
738    pub max_runtime_api: u32,
739}
740
741/// An entry in the registry index representing one plugin.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct RegistryEntry {
744    pub id: String,
745    pub description: String,
746    #[serde(default)]
747    pub category: String,
748    #[serde(default)]
749    pub author: String,
750    #[serde(default)]
751    pub repository: String,
752    pub latest: String,
753    pub versions: Vec<RegistryVersionEntry>,
754}
755
756/// The full registry index loaded from disk or network.
757#[derive(Debug, Clone, Default, Serialize, Deserialize)]
758pub struct RegistryIndex {
759    pub plugins: Vec<RegistryEntry>,
760}
761
762const REGISTRY_CACHE_DIR: &str = "registry-cache";
763const REGISTRY_INDEX_FILE: &str = "index.json";
764const REGISTRY_CACHE_MAX_AGE_SECS: u64 = 3600; // 1 hour
765
766impl RegistryIndex {
767    /// Load a cached registry index from the data directory.
768    /// Returns `None` if the cache is missing or expired.
769    pub fn load_cached(data_dir: &Path) -> Option<Self> {
770        let cache_path = data_dir.join(REGISTRY_CACHE_DIR).join(REGISTRY_INDEX_FILE);
771        if !cache_path.exists() {
772            return None;
773        }
774
775        // Check if cache is expired
776        if let Ok(meta) = fs::metadata(&cache_path) {
777            if let Ok(modified) = meta.modified() {
778                let age = std::time::SystemTime::now()
779                    .duration_since(modified)
780                    .unwrap_or_default();
781                if age.as_secs() > REGISTRY_CACHE_MAX_AGE_SECS {
782                    return None;
783                }
784            }
785        }
786
787        let data = fs::read_to_string(&cache_path).ok()?;
788        serde_json::from_str(&data).ok()
789    }
790
791    /// Save the registry index to the cache directory.
792    pub fn save_cache(&self, data_dir: &Path) -> anyhow::Result<()> {
793        let cache_dir = data_dir.join(REGISTRY_CACHE_DIR);
794        fs::create_dir_all(&cache_dir)?;
795        let json = serde_json::to_string_pretty(self)?;
796        fs::write(cache_dir.join(REGISTRY_INDEX_FILE), json)?;
797        Ok(())
798    }
799
800    /// Search the index by query string (case-insensitive substring match
801    /// on id, description, and category).
802    pub fn search(&self, query: &str) -> Vec<&RegistryEntry> {
803        let q = query.to_lowercase();
804        self.plugins
805            .iter()
806            .filter(|p| {
807                p.id.to_lowercase().contains(&q)
808                    || p.description.to_lowercase().contains(&q)
809                    || p.category.to_lowercase().contains(&q)
810            })
811            .collect()
812    }
813
814    /// Look up a specific plugin by id.
815    pub fn get(&self, id: &str) -> Option<&RegistryEntry> {
816        self.plugins.iter().find(|p| p.id == id)
817    }
818}
819
820impl RegistryEntry {
821    /// Get the latest version entry.
822    pub fn latest_version(&self) -> Option<&RegistryVersionEntry> {
823        self.versions.iter().find(|v| v.version == self.latest)
824    }
825
826    /// Check whether a newer version exists compared to `current`.
827    pub fn has_update(&self, current: &str) -> bool {
828        self.latest != current
829    }
830}
831
832/// Load or refresh the registry index.
833///
834/// Tries the cache first. If expired or missing, reads from a local file
835/// path (for development) or returns an error suggesting manual refresh.
836pub fn load_registry_index(
837    data_dir: &Path,
838    registry_url: Option<&str>,
839) -> anyhow::Result<RegistryIndex> {
840    // Try cache first
841    if let Some(cached) = RegistryIndex::load_cached(data_dir) {
842        return Ok(cached);
843    }
844
845    // Try fetching from URL (file:// for now)
846    if let Some(url) = registry_url {
847        if let Some(file_path) = url.strip_prefix("file://") {
848            let data = fs::read_to_string(file_path)
849                .with_context(|| format!("failed to read registry index: {file_path}"))?;
850            let index: RegistryIndex =
851                serde_json::from_str(&data).with_context(|| "failed to parse registry index")?;
852            index.save_cache(data_dir)?;
853            return Ok(index);
854        }
855        return Err(anyhow!(
856            "HTTP registry fetch not yet supported. Use 'file://<path>' for local registries \
857             or run 'plugin refresh' after manually downloading the index."
858        ));
859    }
860
861    Err(anyhow!(
862        "No registry cache found and no registry URL configured. \
863         Set 'plugins.registry_url' in config or run 'plugin refresh --url <url>'."
864    ))
865}
866
867/// Check which installed plugins have updates available in the registry.
868pub fn check_outdated(state: &PluginState, index: &RegistryIndex) -> Vec<(String, String, String)> {
869    // Returns vec of (id, installed_version, latest_version)
870    let mut outdated = Vec::new();
871    for (id, entry) in &state.plugins {
872        if let Some(reg) = index.get(id) {
873            if reg.has_update(&entry.version) {
874                outdated.push((id.clone(), entry.version.clone(), reg.latest.clone()));
875            }
876        }
877    }
878    outdated
879}
880
881/// Parameters for generating a registry index entry.
882///
883/// Use this instead of a long argument list per Rule 10 (builder pattern for >3-4 params).
884#[derive(Debug, Clone)]
885pub struct RegistryEntryParams<'a> {
886    pub manifest: &'a PluginManifest,
887    pub description: &'a str,
888    pub category: &'a str,
889    pub author: &'a str,
890    pub repository: &'a str,
891    pub download_url: &'a str,
892    pub wasm_sha256: &'a str,
893}
894
895/// Generate a registry index entry for a plugin, suitable for `plugin publish`.
896pub fn generate_registry_entry(params: &RegistryEntryParams<'_>) -> RegistryEntry {
897    RegistryEntry {
898        id: params.manifest.id.clone(),
899        description: params.description.to_string(),
900        category: params.category.to_string(),
901        author: params.author.to_string(),
902        repository: params.repository.to_string(),
903        latest: params.manifest.version.clone(),
904        versions: vec![RegistryVersionEntry {
905            version: params.manifest.version.clone(),
906            download_url: params.download_url.to_string(),
907            sha256: params.wasm_sha256.to_string(),
908            min_runtime_api: params.manifest.min_runtime_api,
909            max_runtime_api: params.manifest.max_runtime_api,
910        }],
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::{
917        filter_by_state, install_packaged_plugin, list_installed_plugins, package_plugin,
918        remove_installed_plugin, DiscoveredPlugin, PluginManifest, PluginState,
919    };
920    use anyhow::Context;
921    use std::fs;
922    use std::io::Cursor;
923    use std::path::PathBuf;
924
925    fn sample_manifest() -> PluginManifest {
926        PluginManifest {
927            id: "sample-plugin".to_string(),
928            version: "1.0.0".to_string(),
929            description: None,
930            entrypoint: "run".to_string(),
931            wasm_file: "plugin.wasm".to_string(),
932            wasm_sha256: "0".repeat(64),
933            capabilities: vec!["tool.call".to_string()],
934            hooks: vec!["before_tool_call".to_string()],
935            min_runtime_api: 1,
936            max_runtime_api: 2,
937            allowed_host_calls: vec![],
938        }
939    }
940
941    #[test]
942    fn package_and_install_round_trip_success_path() {
943        let tmp = tempfile::tempdir().expect("temp dir should be created");
944        let wasm_path = tmp.path().join("plugin.wasm");
945        let package_path = tmp.path().join("sample-plugin.tar");
946        let install_root = tmp.path().join("installed");
947
948        let wasm_bytes = wat::parse_str(
949            r#"(module
950                (func (export "run") (result i32)
951                    i32.const 7)
952            )"#,
953        )
954        .expect("wat should compile");
955        fs::write(&wasm_path, wasm_bytes).expect("wasm file should be written");
956
957        package_plugin(&wasm_path, sample_manifest(), &package_path)
958            .expect("packaging should succeed");
959        let installed =
960            install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
961
962        assert_eq!(installed.manifest.id, "sample-plugin");
963        assert!(installed.manifest_path.exists());
964        assert!(installed.wasm_path.exists());
965        assert_eq!(
966            installed.install_dir,
967            install_root.join("sample-plugin").join("1.0.0")
968        );
969    }
970
971    #[test]
972    fn install_rejects_checksum_mismatch_negative_path() {
973        let tmp = tempfile::tempdir().expect("temp dir should be created");
974        let package_path = tmp.path().join("tampered-plugin.tar");
975        let install_root = tmp.path().join("installed");
976
977        let wasm_bytes = wat::parse_str(
978            r#"(module
979                (func (export "run") (result i32)
980                    i32.const 1)
981            )"#,
982        )
983        .expect("wat should compile");
984
985        let mut manifest = sample_manifest();
986        manifest.wasm_sha256 = "f".repeat(64);
987        let manifest_bytes =
988            serde_json::to_vec_pretty(&manifest).expect("manifest should serialize");
989
990        let file = fs::File::create(&package_path).expect("package should be created");
991        let mut builder = tar::Builder::new(file);
992
993        let mut manifest_header = tar::Header::new_gnu();
994        manifest_header.set_size(manifest_bytes.len() as u64);
995        manifest_header.set_mode(0o644);
996        manifest_header.set_cksum();
997        builder
998            .append_data(
999                &mut manifest_header,
1000                "manifest.json",
1001                Cursor::new(manifest_bytes),
1002            )
1003            .expect("manifest should be added");
1004
1005        let mut wasm_header = tar::Header::new_gnu();
1006        wasm_header.set_size(wasm_bytes.len() as u64);
1007        wasm_header.set_mode(0o644);
1008        wasm_header.set_cksum();
1009        builder
1010            .append_data(&mut wasm_header, "plugin.wasm", Cursor::new(wasm_bytes))
1011            .expect("wasm should be added");
1012        builder.finish().expect("archive should finish");
1013
1014        let err = install_packaged_plugin(&package_path, &install_root)
1015            .context("tampered package should fail integrity")
1016            .expect_err("install should fail");
1017        let err_text = format!("{err:#}");
1018        assert!(
1019            err_text.contains("integrity check failed") || err_text.contains("checksum mismatch"),
1020            "unexpected tamper error: {err_text}"
1021        );
1022    }
1023
1024    #[test]
1025    fn list_and_remove_installed_plugins_success_path() {
1026        let tmp = tempfile::tempdir().expect("temp dir should be created");
1027        let wasm_path = tmp.path().join("plugin.wasm");
1028        let package_path = tmp.path().join("sample-plugin.tar");
1029        let install_root = tmp.path().join("installed");
1030
1031        let wasm_bytes = wat::parse_str(
1032            r#"(module
1033                (func (export "run") (result i32)
1034                    i32.const 9)
1035            )"#,
1036        )
1037        .expect("wat should compile");
1038        fs::write(&wasm_path, wasm_bytes).expect("wasm should be written");
1039        package_plugin(&wasm_path, sample_manifest(), &package_path)
1040            .expect("package should succeed");
1041        install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
1042
1043        let listed = list_installed_plugins(&install_root).expect("list should succeed");
1044        assert_eq!(listed.len(), 1);
1045        assert_eq!(listed[0].id, "sample-plugin");
1046        assert_eq!(listed[0].version, "1.0.0");
1047
1048        let removed = remove_installed_plugin(&install_root, "sample-plugin", Some("1.0.0"))
1049            .expect("remove should succeed");
1050        assert_eq!(removed, 1);
1051        assert!(list_installed_plugins(&install_root)
1052            .expect("list should succeed")
1053            .is_empty());
1054    }
1055
1056    #[test]
1057    fn remove_installed_plugin_rejects_empty_id_negative_path() {
1058        let tmp = tempfile::tempdir().expect("temp dir should be created");
1059        let install_root = tmp.path().join("installed");
1060        let err =
1061            remove_installed_plugin(&install_root, "", None).expect_err("empty id should fail");
1062        assert!(err.to_string().contains("plugin id cannot be empty"));
1063    }
1064
1065    #[test]
1066    fn manifest_validate_rejects_incompatible_runtime_api_negative_path() {
1067        let mut manifest = sample_manifest();
1068        manifest.min_runtime_api = 3;
1069        manifest.max_runtime_api = 4;
1070
1071        let err = manifest
1072            .validate()
1073            .expect_err("incompatible API should fail");
1074        assert!(err.to_string().contains("runtime API compatibility failed"));
1075    }
1076
1077    // --- Discovery tests ---
1078
1079    use super::discover_plugins;
1080
1081    fn write_test_plugin(dir: &std::path::Path, id: &str, version: &str) {
1082        fs::create_dir_all(dir).expect("plugin dir should be created");
1083        let wasm_bytes =
1084            wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 42))"#)
1085                .expect("wat should compile");
1086        let sha = super::sha256_hex(&wasm_bytes);
1087        let manifest = PluginManifest {
1088            id: id.to_string(),
1089            version: version.to_string(),
1090            description: None,
1091            entrypoint: "run".to_string(),
1092            wasm_file: "plugin.wasm".to_string(),
1093            wasm_sha256: sha,
1094            capabilities: vec![],
1095            hooks: vec![],
1096            min_runtime_api: 1,
1097            max_runtime_api: 2,
1098            allowed_host_calls: vec![],
1099        };
1100        fs::write(
1101            dir.join("manifest.json"),
1102            serde_json::to_vec_pretty(&manifest).expect("manifest should serialize"),
1103        )
1104        .expect("manifest should write");
1105        fs::write(dir.join("plugin.wasm"), &wasm_bytes).expect("wasm should write");
1106    }
1107
1108    #[test]
1109    fn discover_plugins_empty_dirs_returns_empty() {
1110        let tmp = tempfile::tempdir().expect("temp dir");
1111        let global = tmp.path().join("global");
1112        let project = tmp.path().join("project");
1113        let cwd = tmp.path().join("cwd");
1114        // Dirs don't exist — should return empty, not error
1115        let found = discover_plugins(Some(&global), Some(&project), Some(&cwd));
1116        assert!(found.is_empty());
1117    }
1118
1119    #[test]
1120    fn discover_plugins_finds_versioned_layout() {
1121        let tmp = tempfile::tempdir().expect("temp dir");
1122        let global = tmp.path().join("global");
1123        write_test_plugin(&global.join("my-tool").join("1.0.0"), "my-tool", "1.0.0");
1124
1125        let found = discover_plugins(Some(&global), None, None);
1126        assert_eq!(found.len(), 1);
1127        assert_eq!(found[0].manifest.id, "my-tool");
1128        assert_eq!(found[0].manifest.version, "1.0.0");
1129        assert!(!found[0].dev_mode);
1130    }
1131
1132    #[test]
1133    fn discover_plugins_finds_flat_layout() {
1134        let tmp = tempfile::tempdir().expect("temp dir");
1135        let cwd = tmp.path().join("plugins");
1136        write_test_plugin(&cwd.join("dev-tool"), "dev-tool", "0.1.0");
1137
1138        let found = discover_plugins(None, None, Some(&cwd));
1139        assert_eq!(found.len(), 1);
1140        assert_eq!(found[0].manifest.id, "dev-tool");
1141        assert!(found[0].dev_mode);
1142    }
1143
1144    #[test]
1145    fn discover_plugins_later_tier_overrides_earlier() {
1146        let tmp = tempfile::tempdir().expect("temp dir");
1147        let global = tmp.path().join("global");
1148        let project = tmp.path().join("project");
1149        write_test_plugin(&global.join("shared").join("1.0.0"), "shared", "1.0.0");
1150        write_test_plugin(&project.join("shared").join("2.0.0"), "shared", "2.0.0");
1151
1152        let found = discover_plugins(Some(&global), Some(&project), None);
1153        assert_eq!(found.len(), 1);
1154        assert_eq!(found[0].manifest.version, "2.0.0");
1155    }
1156
1157    #[test]
1158    fn discover_plugins_picks_latest_version() {
1159        let tmp = tempfile::tempdir().expect("temp dir");
1160        let global = tmp.path().join("global");
1161        write_test_plugin(&global.join("multi-v").join("1.0.0"), "multi-v", "1.0.0");
1162        write_test_plugin(&global.join("multi-v").join("2.0.0"), "multi-v", "2.0.0");
1163
1164        let found = discover_plugins(Some(&global), None, None);
1165        assert_eq!(found.len(), 1);
1166        assert_eq!(found[0].manifest.version, "2.0.0");
1167    }
1168
1169    #[test]
1170    fn discover_plugins_skips_invalid_manifest() {
1171        let tmp = tempfile::tempdir().expect("temp dir");
1172        let global = tmp.path().join("global");
1173        let bad_dir = global.join("bad-plugin").join("1.0.0");
1174        fs::create_dir_all(&bad_dir).expect("dir should be created");
1175        fs::write(bad_dir.join("manifest.json"), b"not json").expect("write bad manifest");
1176        fs::write(bad_dir.join("plugin.wasm"), b"\0asm\x01\0\0\0").expect("write wasm");
1177
1178        let found = discover_plugins(Some(&global), None, None);
1179        assert!(found.is_empty(), "invalid manifest should be skipped");
1180    }
1181
1182    #[test]
1183    fn discover_plugins_skips_missing_wasm_file() {
1184        let tmp = tempfile::tempdir().expect("temp dir");
1185        let global = tmp.path().join("global");
1186        let dir = global.join("no-wasm").join("1.0.0");
1187        fs::create_dir_all(&dir).expect("dir should be created");
1188        let manifest = PluginManifest {
1189            id: "no-wasm".to_string(),
1190            version: "1.0.0".to_string(),
1191            description: None,
1192            entrypoint: "run".to_string(),
1193            wasm_file: "plugin.wasm".to_string(),
1194            wasm_sha256: "a".repeat(64),
1195            capabilities: vec![],
1196            hooks: vec![],
1197            min_runtime_api: 1,
1198            max_runtime_api: 2,
1199            allowed_host_calls: vec![],
1200        };
1201        fs::write(
1202            dir.join("manifest.json"),
1203            serde_json::to_vec_pretty(&manifest).expect("serialize"),
1204        )
1205        .expect("write");
1206
1207        let found = discover_plugins(Some(&global), None, None);
1208        assert!(found.is_empty(), "missing wasm should be skipped");
1209    }
1210
1211    #[test]
1212    fn discover_plugins_none_dirs_returns_empty() {
1213        let found = discover_plugins(None, None, None);
1214        assert!(found.is_empty());
1215    }
1216
1217    // ── Plugin State tests ──────────────────────────────────────────
1218
1219    #[test]
1220    fn plugin_state_load_missing_returns_default() {
1221        let dir = std::env::temp_dir().join(format!("az-state-test-{}", std::process::id()));
1222        let _ = fs::create_dir_all(&dir);
1223        let state = PluginState::load(&dir);
1224        assert!(state.plugins.is_empty());
1225        fs::remove_dir_all(&dir).ok();
1226    }
1227
1228    #[test]
1229    fn plugin_state_save_and_load_round_trip() {
1230        let dir = std::env::temp_dir().join(format!("az-state-test-rt-{}", std::process::id()));
1231        let mut state = PluginState::default();
1232        state.record_install("test-plugin", "1.0.0", "local");
1233        state.save(&dir).expect("save should succeed");
1234
1235        let loaded = PluginState::load(&dir);
1236        assert_eq!(loaded.plugins.len(), 1);
1237        let entry = loaded.plugins.get("test-plugin").unwrap();
1238        assert_eq!(entry.version, "1.0.0");
1239        assert!(entry.enabled);
1240        assert_eq!(entry.source, "local");
1241
1242        fs::remove_dir_all(&dir).ok();
1243    }
1244
1245    #[test]
1246    fn plugin_state_enable_disable_toggle() {
1247        let dir = std::env::temp_dir().join(format!("az-state-test-toggle-{}", std::process::id()));
1248        let mut state = PluginState::default();
1249        state.record_install("toggle-me", "0.1.0", "local");
1250
1251        assert!(state.is_enabled("toggle-me"));
1252
1253        state.disable("toggle-me").unwrap();
1254        assert!(!state.is_enabled("toggle-me"));
1255
1256        state.enable("toggle-me").unwrap();
1257        assert!(state.is_enabled("toggle-me"));
1258
1259        state.save(&dir).expect("save should succeed");
1260        let loaded = PluginState::load(&dir);
1261        assert!(loaded.is_enabled("toggle-me"));
1262
1263        fs::remove_dir_all(&dir).ok();
1264    }
1265
1266    #[test]
1267    fn plugin_state_missing_entry_defaults_to_enabled() {
1268        let state = PluginState::default();
1269        assert!(state.is_enabled("nonexistent"));
1270    }
1271
1272    #[test]
1273    fn plugin_state_enable_missing_fails() {
1274        let mut state = PluginState::default();
1275        let err = state.enable("unknown").expect_err("should fail");
1276        assert!(err.to_string().contains("no state entry"));
1277    }
1278
1279    #[test]
1280    fn plugin_state_remove_entry() {
1281        let mut state = PluginState::default();
1282        state.record_install("removable", "1.0.0", "url");
1283        assert!(state.plugins.contains_key("removable"));
1284        state.remove("removable");
1285        assert!(!state.plugins.contains_key("removable"));
1286    }
1287
1288    #[test]
1289    fn filter_by_state_removes_disabled() {
1290        let mut state = PluginState::default();
1291        state.record_install("enabled-plugin", "1.0.0", "local");
1292        state.record_install("disabled-plugin", "1.0.0", "local");
1293        state.disable("disabled-plugin").unwrap();
1294
1295        let plugins = vec![
1296            DiscoveredPlugin {
1297                manifest: PluginManifest {
1298                    id: "enabled-plugin".to_string(),
1299                    version: "1.0.0".to_string(),
1300                    description: None,
1301                    entrypoint: "run".to_string(),
1302                    wasm_file: "plugin.wasm".to_string(),
1303                    wasm_sha256: "a".repeat(64),
1304                    capabilities: vec![],
1305                    hooks: vec![],
1306                    min_runtime_api: 1,
1307                    max_runtime_api: 2,
1308                    allowed_host_calls: vec![],
1309                },
1310                wasm_path: PathBuf::from("/tmp/a.wasm"),
1311                dev_mode: false,
1312            },
1313            DiscoveredPlugin {
1314                manifest: PluginManifest {
1315                    id: "disabled-plugin".to_string(),
1316                    version: "1.0.0".to_string(),
1317                    description: None,
1318                    entrypoint: "run".to_string(),
1319                    wasm_file: "plugin.wasm".to_string(),
1320                    wasm_sha256: "b".repeat(64),
1321                    capabilities: vec![],
1322                    hooks: vec![],
1323                    min_runtime_api: 1,
1324                    max_runtime_api: 2,
1325                    allowed_host_calls: vec![],
1326                },
1327                wasm_path: PathBuf::from("/tmp/b.wasm"),
1328                dev_mode: false,
1329            },
1330        ];
1331
1332        let filtered = filter_by_state(plugins, &state);
1333        assert_eq!(filtered.len(), 1);
1334        assert_eq!(filtered[0].manifest.id, "enabled-plugin");
1335    }
1336
1337    // ── Registry tests ───────────────────────────────────────────────────
1338
1339    use super::{
1340        check_outdated, generate_registry_entry, load_registry_index, RegistryEntry,
1341        RegistryEntryParams, RegistryIndex, RegistryVersionEntry,
1342    };
1343
1344    fn sample_registry_index() -> RegistryIndex {
1345        RegistryIndex {
1346            plugins: vec![
1347                RegistryEntry {
1348                    id: "hardware-tools".to_string(),
1349                    description: "Board info and memory tools".to_string(),
1350                    category: "hardware".to_string(),
1351                    author: "agentzero".to_string(),
1352                    repository: "https://github.com/agentzero/plugins".to_string(),
1353                    latest: "1.2.0".to_string(),
1354                    versions: vec![
1355                        RegistryVersionEntry {
1356                            version: "1.0.0".to_string(),
1357                            download_url: "https://example.com/hw-1.0.0.tar".to_string(),
1358                            sha256: "a".repeat(64),
1359                            min_runtime_api: 2,
1360                            max_runtime_api: 2,
1361                        },
1362                        RegistryVersionEntry {
1363                            version: "1.2.0".to_string(),
1364                            download_url: "https://example.com/hw-1.2.0.tar".to_string(),
1365                            sha256: "b".repeat(64),
1366                            min_runtime_api: 2,
1367                            max_runtime_api: 2,
1368                        },
1369                    ],
1370                },
1371                RegistryEntry {
1372                    id: "cron-suite".to_string(),
1373                    description: "Cron job management and scheduling".to_string(),
1374                    category: "scheduling".to_string(),
1375                    author: "agentzero".to_string(),
1376                    repository: "https://github.com/agentzero/plugins".to_string(),
1377                    latest: "0.3.0".to_string(),
1378                    versions: vec![RegistryVersionEntry {
1379                        version: "0.3.0".to_string(),
1380                        download_url: "https://example.com/cron-0.3.0.tar".to_string(),
1381                        sha256: "c".repeat(64),
1382                        min_runtime_api: 2,
1383                        max_runtime_api: 2,
1384                    }],
1385                },
1386            ],
1387        }
1388    }
1389
1390    #[test]
1391    fn registry_search_by_id() {
1392        let index = sample_registry_index();
1393        let results = index.search("hardware");
1394        assert_eq!(results.len(), 1);
1395        assert_eq!(results[0].id, "hardware-tools");
1396    }
1397
1398    #[test]
1399    fn registry_search_by_description() {
1400        let index = sample_registry_index();
1401        let results = index.search("scheduling");
1402        assert_eq!(results.len(), 1);
1403        assert_eq!(results[0].id, "cron-suite");
1404    }
1405
1406    #[test]
1407    fn registry_search_case_insensitive() {
1408        let index = sample_registry_index();
1409        let results = index.search("CRON");
1410        assert_eq!(results.len(), 1);
1411    }
1412
1413    #[test]
1414    fn registry_search_no_match() {
1415        let index = sample_registry_index();
1416        let results = index.search("nonexistent");
1417        assert!(results.is_empty());
1418    }
1419
1420    #[test]
1421    fn registry_get_by_id() {
1422        let index = sample_registry_index();
1423        let entry = index.get("hardware-tools");
1424        assert!(entry.is_some());
1425        assert_eq!(entry.unwrap().latest, "1.2.0");
1426    }
1427
1428    #[test]
1429    fn registry_get_missing_returns_none() {
1430        let index = sample_registry_index();
1431        assert!(index.get("missing").is_none());
1432    }
1433
1434    #[test]
1435    fn registry_entry_latest_version() {
1436        let index = sample_registry_index();
1437        let entry = index.get("hardware-tools").unwrap();
1438        let latest = entry.latest_version().unwrap();
1439        assert_eq!(latest.version, "1.2.0");
1440        assert!(latest.download_url.contains("1.2.0"));
1441    }
1442
1443    #[test]
1444    fn registry_entry_has_update() {
1445        let index = sample_registry_index();
1446        let entry = index.get("hardware-tools").unwrap();
1447        assert!(entry.has_update("1.0.0"));
1448        assert!(!entry.has_update("1.2.0"));
1449    }
1450
1451    #[test]
1452    fn registry_cache_save_and_load() {
1453        let dir =
1454            std::env::temp_dir().join(format!("az-registry-cache-test-{}", std::process::id()));
1455        let _ = fs::create_dir_all(&dir);
1456
1457        let index = sample_registry_index();
1458        index.save_cache(&dir).expect("save should succeed");
1459
1460        let loaded = RegistryIndex::load_cached(&dir);
1461        assert!(loaded.is_some());
1462        let loaded = loaded.unwrap();
1463        assert_eq!(loaded.plugins.len(), 2);
1464
1465        fs::remove_dir_all(&dir).ok();
1466    }
1467
1468    #[test]
1469    fn registry_cache_missing_returns_none() {
1470        let dir =
1471            std::env::temp_dir().join(format!("az-registry-cache-miss-{}", std::process::id()));
1472        assert!(RegistryIndex::load_cached(&dir).is_none());
1473    }
1474
1475    #[test]
1476    fn load_registry_from_file_url() {
1477        let dir =
1478            std::env::temp_dir().join(format!("az-registry-file-test-{}", std::process::id()));
1479        let _ = fs::create_dir_all(&dir);
1480
1481        let index = sample_registry_index();
1482        let index_path = dir.join("test-index.json");
1483        fs::write(&index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
1484
1485        let url = format!("file://{}", index_path.display());
1486        let loaded = load_registry_index(&dir, Some(&url)).expect("should load from file");
1487        assert_eq!(loaded.plugins.len(), 2);
1488
1489        // Should now be cached
1490        let cached = RegistryIndex::load_cached(&dir);
1491        assert!(cached.is_some());
1492
1493        fs::remove_dir_all(&dir).ok();
1494    }
1495
1496    #[test]
1497    fn load_registry_no_cache_no_url_fails() {
1498        let dir = std::env::temp_dir().join(format!("az-registry-no-cache-{}", std::process::id()));
1499        let err = load_registry_index(&dir, None).expect_err("should fail");
1500        assert!(err.to_string().contains("No registry cache"));
1501    }
1502
1503    #[test]
1504    fn check_outdated_finds_updates() {
1505        let index = sample_registry_index();
1506        let mut state = PluginState::default();
1507        state.record_install("hardware-tools", "1.0.0", "registry");
1508        state.record_install("cron-suite", "0.3.0", "registry");
1509
1510        let outdated = check_outdated(&state, &index);
1511        assert_eq!(outdated.len(), 1);
1512        assert_eq!(outdated[0].0, "hardware-tools");
1513        assert_eq!(outdated[0].1, "1.0.0"); // installed
1514        assert_eq!(outdated[0].2, "1.2.0"); // latest
1515    }
1516
1517    #[test]
1518    fn check_outdated_none_when_up_to_date() {
1519        let index = sample_registry_index();
1520        let mut state = PluginState::default();
1521        state.record_install("hardware-tools", "1.2.0", "registry");
1522
1523        let outdated = check_outdated(&state, &index);
1524        assert!(outdated.is_empty());
1525    }
1526
1527    #[test]
1528    fn generate_registry_entry_round_trip() {
1529        let manifest = sample_manifest();
1530        let entry = generate_registry_entry(&RegistryEntryParams {
1531            manifest: &manifest,
1532            description: "A sample plugin",
1533            category: "general",
1534            author: "test-author",
1535            repository: "https://github.com/test/repo",
1536            download_url: "https://example.com/sample-1.0.0.tar",
1537            wasm_sha256: &"f".repeat(64),
1538        });
1539        assert_eq!(entry.id, "sample-plugin");
1540        assert_eq!(entry.latest, "1.0.0");
1541        assert_eq!(entry.versions.len(), 1);
1542        assert_eq!(
1543            entry.versions[0].download_url,
1544            "https://example.com/sample-1.0.0.tar"
1545        );
1546    }
1547
1548    // ── Path traversal security tests ─────────────────────────────────
1549
1550    /// Build a tar archive with `manifest.json` and an extra entry at an
1551    /// arbitrary path. Uses raw header manipulation to bypass the tar
1552    /// crate's safety checks (which reject `..` and absolute paths).
1553    fn build_tar_with_malicious_entry(entry_name: &str) -> Vec<u8> {
1554        let wasm_bytes =
1555            wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 42))"#)
1556                .expect("wat should compile");
1557        let sha = super::sha256_hex(&wasm_bytes);
1558        let mut manifest = sample_manifest();
1559        manifest.wasm_sha256 = sha;
1560        let manifest_bytes =
1561            serde_json::to_vec_pretty(&manifest).expect("manifest should serialize");
1562
1563        let file_path = std::env::temp_dir().join(format!(
1564            "az-tar-test-{}-{}",
1565            std::process::id(),
1566            entry_name.replace(['/', '.'], "_")
1567        ));
1568        {
1569            let file = fs::File::create(&file_path).expect("create tar");
1570            let mut builder = tar::Builder::new(file);
1571
1572            let mut manifest_header = tar::Header::new_gnu();
1573            manifest_header.set_size(manifest_bytes.len() as u64);
1574            manifest_header.set_mode(0o644);
1575            manifest_header.set_cksum();
1576            builder
1577                .append_data(
1578                    &mut manifest_header,
1579                    "manifest.json",
1580                    Cursor::new(&manifest_bytes),
1581                )
1582                .expect("add manifest");
1583
1584            // Write the malicious entry by setting path bytes directly in
1585            // the header, bypassing the tar crate's path validation.
1586            let mut header = tar::Header::new_gnu();
1587            header.set_size(wasm_bytes.len() as u64);
1588            header.set_mode(0o644);
1589            header.set_entry_type(tar::EntryType::Regular);
1590            // Set path bytes directly via the raw header.
1591            {
1592                let path_bytes = entry_name.as_bytes();
1593                let header_bytes = header.as_mut_bytes();
1594                // Name field is bytes 0..100 in a tar header.
1595                let len = path_bytes.len().min(100);
1596                header_bytes[..len].copy_from_slice(&path_bytes[..len]);
1597                // Zero-fill the rest.
1598                for b in &mut header_bytes[len..100] {
1599                    *b = 0;
1600                }
1601            }
1602            header.set_cksum();
1603            builder
1604                .append(&header, Cursor::new(&wasm_bytes))
1605                .expect("add malicious entry");
1606
1607            builder.finish().expect("finish");
1608        }
1609        let bytes = fs::read(&file_path).expect("read tar");
1610        fs::remove_file(&file_path).ok();
1611        bytes
1612    }
1613
1614    #[test]
1615    fn install_rejects_path_traversal_with_dotdot() {
1616        let tmp = tempfile::tempdir().expect("temp dir");
1617        let package_path = tmp.path().join("evil.tar");
1618        let install_root = tmp.path().join("installed");
1619
1620        let tar_bytes = build_tar_with_malicious_entry("../../etc/passwd");
1621        fs::write(&package_path, tar_bytes).expect("write tar");
1622
1623        let err = install_packaged_plugin(&package_path, &install_root)
1624            .expect_err("path traversal should be rejected");
1625        let msg = err.to_string();
1626        assert!(
1627            msg.contains("path traversal"),
1628            "error should mention path traversal: {msg}"
1629        );
1630    }
1631
1632    #[test]
1633    fn install_rejects_absolute_path_entry() {
1634        let tmp = tempfile::tempdir().expect("temp dir");
1635        let package_path = tmp.path().join("evil-abs.tar");
1636        let install_root = tmp.path().join("installed");
1637
1638        let tar_bytes = build_tar_with_malicious_entry("/etc/passwd");
1639        fs::write(&package_path, tar_bytes).expect("write tar");
1640
1641        let err = install_packaged_plugin(&package_path, &install_root)
1642            .expect_err("absolute path should be rejected");
1643        let msg = err.to_string();
1644        assert!(
1645            msg.contains("path traversal"),
1646            "error should mention path traversal: {msg}"
1647        );
1648    }
1649
1650    #[test]
1651    fn install_rejects_symlink_entry() {
1652        let tmp = tempfile::tempdir().expect("temp dir");
1653        let package_path = tmp.path().join("evil-symlink.tar");
1654        let install_root = tmp.path().join("installed");
1655
1656        let wasm_bytes =
1657            wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 42))"#)
1658                .expect("wat should compile");
1659        let sha = super::sha256_hex(&wasm_bytes);
1660        let mut manifest = sample_manifest();
1661        manifest.wasm_sha256 = sha;
1662        let manifest_bytes =
1663            serde_json::to_vec_pretty(&manifest).expect("manifest should serialize");
1664
1665        let file = fs::File::create(&package_path).expect("create tar");
1666        let mut builder = tar::Builder::new(file);
1667
1668        let mut manifest_header = tar::Header::new_gnu();
1669        manifest_header.set_size(manifest_bytes.len() as u64);
1670        manifest_header.set_mode(0o644);
1671        manifest_header.set_cksum();
1672        builder
1673            .append_data(
1674                &mut manifest_header,
1675                "manifest.json",
1676                Cursor::new(&manifest_bytes),
1677            )
1678            .expect("add manifest");
1679
1680        // Add a symlink entry
1681        let mut symlink_header = tar::Header::new_gnu();
1682        symlink_header.set_entry_type(tar::EntryType::Symlink);
1683        symlink_header.set_size(0);
1684        symlink_header.set_mode(0o777);
1685        symlink_header.set_cksum();
1686        builder
1687            .append_link(&mut symlink_header, "plugin.wasm", "/etc/passwd")
1688            .expect("add symlink");
1689
1690        builder.finish().expect("finish");
1691
1692        let err = install_packaged_plugin(&package_path, &install_root)
1693            .expect_err("symlink entry should be rejected");
1694        let msg = err.to_string();
1695        assert!(
1696            msg.contains("symlink"),
1697            "error should mention symlink: {msg}"
1698        );
1699    }
1700
1701    // ── Semver version comparison tests ───────────────────────────────
1702
1703    use super::version_ge;
1704
1705    #[test]
1706    fn version_ge_semver_correct_ordering() {
1707        // These would fail with lexicographic comparison
1708        assert!(version_ge("10.0.0", "9.0.0"), "10.0.0 >= 9.0.0");
1709        assert!(version_ge("0.10.0", "0.2.0"), "0.10.0 >= 0.2.0");
1710        assert!(version_ge("1.0.0", "1.0.0"), "1.0.0 >= 1.0.0");
1711        assert!(!version_ge("0.2.0", "0.10.0"), "0.2.0 < 0.10.0");
1712        assert!(!version_ge("9.0.0", "10.0.0"), "9.0.0 < 10.0.0");
1713    }
1714
1715    #[test]
1716    fn version_ge_falls_back_to_string_for_non_semver() {
1717        // Non-semver strings fall back to lexicographic comparison
1718        assert!(version_ge("beta", "alpha"));
1719        assert!(!version_ge("alpha", "beta"));
1720    }
1721
1722    #[test]
1723    fn discover_plugins_picks_semver_latest_not_lexicographic() {
1724        let tmp = tempfile::tempdir().expect("temp dir");
1725        let global = tmp.path().join("global");
1726        // Version "0.10.0" should beat "0.2.0" with semver, but would lose lexicographically
1727        write_test_plugin(
1728            &global.join("semver-test").join("0.2.0"),
1729            "semver-test",
1730            "0.2.0",
1731        );
1732        write_test_plugin(
1733            &global.join("semver-test").join("0.10.0"),
1734            "semver-test",
1735            "0.10.0",
1736        );
1737
1738        let found = discover_plugins(Some(&global), None, None);
1739        assert_eq!(found.len(), 1);
1740        assert_eq!(
1741            found[0].manifest.version, "0.10.0",
1742            "should pick 0.10.0 over 0.2.0 with semver comparison"
1743        );
1744    }
1745
1746    // ── File locking tests ────────────────────────────────────────────
1747
1748    #[test]
1749    fn install_creates_lock_file() {
1750        let tmp = tempfile::tempdir().expect("temp dir");
1751        let wasm_path = tmp.path().join("plugin.wasm");
1752        let package_path = tmp.path().join("sample-plugin.tar");
1753        let install_root = tmp.path().join("installed");
1754
1755        let wasm_bytes =
1756            wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 7))"#)
1757                .expect("wat should compile");
1758        fs::write(&wasm_path, wasm_bytes).expect("wasm file should be written");
1759
1760        package_plugin(&wasm_path, sample_manifest(), &package_path)
1761            .expect("packaging should succeed");
1762        install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
1763
1764        // Lock file should have been created (and released after install)
1765        let lock_path = install_root.join(super::LOCK_FILE_NAME);
1766        assert!(lock_path.exists(), "lock file should exist after install");
1767    }
1768
1769    #[test]
1770    fn remove_creates_lock_file() {
1771        let tmp = tempfile::tempdir().expect("temp dir");
1772        let wasm_path = tmp.path().join("plugin.wasm");
1773        let package_path = tmp.path().join("sample-plugin.tar");
1774        let install_root = tmp.path().join("installed");
1775
1776        let wasm_bytes =
1777            wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 7))"#)
1778                .expect("wat should compile");
1779        fs::write(&wasm_path, wasm_bytes).expect("wasm file should be written");
1780
1781        package_plugin(&wasm_path, sample_manifest(), &package_path)
1782            .expect("packaging should succeed");
1783        install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
1784
1785        remove_installed_plugin(&install_root, "sample-plugin", Some("1.0.0"))
1786            .expect("remove should succeed");
1787
1788        let lock_path = install_root.join(super::LOCK_FILE_NAME);
1789        assert!(lock_path.exists(), "lock file should exist after remove");
1790    }
1791}