Skip to main content

mockforge_plugin_loader/
installer.rs

1//! Unified plugin installer
2//!
3//! This module provides a high-level API for installing plugins from various sources:
4//! - Local file paths
5//! - HTTP/HTTPS URLs
6//! - Git repositories
7//! - Plugin registries (future)
8//!
9//! It automatically detects the source type and uses the appropriate loader.
10
11use super::*;
12use crate::git::{GitPluginConfig, GitPluginLoader, GitPluginSource};
13use crate::loader::PluginLoader;
14use crate::metadata::{MetadataStore, PluginMetadata};
15use crate::remote::{RemotePluginConfig, RemotePluginLoader};
16use crate::signature::{has_signature_file, SignatureVerifier, SIGNATURE_FILE};
17use serde::Deserialize;
18use std::path::{Path, PathBuf};
19
20/// Plugin source specification
21#[derive(Debug, Clone)]
22pub enum PluginSource {
23    /// Local file path or directory
24    Local(PathBuf),
25    /// HTTP/HTTPS URL
26    Url {
27        /// URL to download the plugin from
28        url: String,
29        /// Optional SHA-256 checksum for verification
30        checksum: Option<String>,
31    },
32    /// Git repository
33    Git(GitPluginSource),
34    /// Plugin registry (future)
35    Registry {
36        /// Plugin name in the registry
37        name: String,
38        /// Optional version string (defaults to latest)
39        version: Option<String>,
40    },
41}
42
43impl PluginSource {
44    /// Parse a plugin source from a string
45    ///
46    /// Automatically detects the source type:
47    /// - Starts with "http://" or "https://" → URL
48    /// - Contains ".git" or starts with "git@" → Git
49    /// - Contains "/" or "\" → Local path
50    /// - Otherwise → Registry name
51    pub fn parse(input: &str) -> LoaderResult<Self> {
52        let input = input.trim();
53
54        // Check for URL
55        if input.starts_with("http://") || input.starts_with("https://") {
56            // Check if it's a Git repository URL
57            if input.contains(".git")
58                || input.contains("github.com")
59                || input.contains("gitlab.com")
60            {
61                let source = GitPluginSource::parse(input)?;
62                return Ok(PluginSource::Git(source));
63            }
64            return Ok(PluginSource::Url {
65                url: input.to_string(),
66                checksum: None,
67            });
68        }
69
70        // Check for SSH Git URL
71        if input.starts_with("git@") {
72            let source = GitPluginSource::parse(input)?;
73            return Ok(PluginSource::Git(source));
74        }
75
76        // Check for local path
77        if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
78            return Ok(PluginSource::Local(PathBuf::from(input)));
79        }
80
81        // Parse as registry reference
82        let (name, version) = if let Some((n, v)) = input.split_once('@') {
83            (n.to_string(), Some(v.to_string()))
84        } else {
85            (input.to_string(), None)
86        };
87
88        Ok(PluginSource::Registry { name, version })
89    }
90}
91
92impl std::fmt::Display for PluginSource {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            PluginSource::Local(path) => write!(f, "local:{}", path.display()),
96            PluginSource::Url { url, .. } => write!(f, "url:{}", url),
97            PluginSource::Git(source) => write!(f, "git:{}", source),
98            PluginSource::Registry { name, version } => {
99                if let Some(v) = version {
100                    write!(f, "registry:{}@{}", name, v)
101                } else {
102                    write!(f, "registry:{}", name)
103                }
104            }
105        }
106    }
107}
108
109/// Installation options. The default verifies signatures and refuses
110/// unsigned plugins.
111#[derive(Debug, Clone, Default)]
112pub struct InstallOptions {
113    /// Force reinstall even if plugin already exists
114    pub force: bool,
115    /// Skip manifest and WASM validation checks. Never skips signature
116    /// verification.
117    pub skip_validation: bool,
118    /// Install a plugin that has no `plugin.sig`. For local development
119    /// plugins only. A signature that is present but fails verification
120    /// is still rejected.
121    pub allow_unsigned: bool,
122    /// Expected checksum for verification (URL sources)
123    pub expected_checksum: Option<String>,
124}
125
126/// Unified plugin installer
127pub struct PluginInstaller {
128    loader: PluginLoader,
129    remote_loader: RemotePluginLoader,
130    git_loader: GitPluginLoader,
131    config: PluginLoaderConfig,
132    metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
133}
134
135impl PluginInstaller {
136    /// Create a new plugin installer with default configuration
137    pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
138        let loader = PluginLoader::new(loader_config.clone());
139        let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
140        let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
141
142        // Create metadata store in a standard location
143        let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
144        let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
145
146        Ok(Self {
147            loader,
148            remote_loader,
149            git_loader,
150            config: loader_config,
151            metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
152        })
153    }
154
155    /// Initialize the installer (creates directories, loads metadata)
156    pub async fn init(&self) -> LoaderResult<()> {
157        let mut store = self.metadata_store.write().await;
158        store.load().await
159    }
160
161    /// Install a plugin from a source string
162    ///
163    /// Automatically detects and handles the source type
164    pub async fn install(
165        &self,
166        source_str: &str,
167        options: InstallOptions,
168    ) -> LoaderResult<PluginId> {
169        let source = PluginSource::parse(source_str)?;
170        self.install_from_source(&source, options).await
171    }
172
173    /// Install a plugin from a specific source
174    pub async fn install_from_source(
175        &self,
176        source: &PluginSource,
177        options: InstallOptions,
178    ) -> LoaderResult<PluginId> {
179        tracing::info!("Installing plugin from source: {}", source);
180
181        // Get the plugin directory
182        let plugin_dir = match source {
183            PluginSource::Local(path) => path.clone(),
184            PluginSource::Url { url, checksum } => {
185                let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
186                self.remote_loader.download_with_checksum(url, checksum_ref).await?
187            }
188            PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
189            PluginSource::Registry { name, version } => {
190                self.install_from_registry(name, version.as_deref(), &options).await?
191            }
192        };
193
194        // Signature verification gates installation. It runs before
195        // validation and loading, and `skip_validation` does not bypass it.
196        self.enforce_plugin_signature(
197            &plugin_dir,
198            source,
199            options.allow_unsigned || self.config.allow_unsigned,
200        )?;
201
202        // Validate the plugin
203        if !options.skip_validation {
204            self.loader.validate_plugin(&plugin_dir).await?;
205        }
206
207        // Load the plugin
208        let manifest = self.loader.validate_plugin(&plugin_dir).await?;
209        let plugin_id = manifest.info.id.clone();
210
211        // Check if plugin is already loaded
212        if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
213            return Err(PluginLoaderError::already_loaded(plugin_id));
214        }
215
216        // Load the plugin
217        self.loader.load_plugin_from_path(&plugin_dir).await?;
218
219        // Save metadata for future updates
220        let version = manifest.info.version.to_string();
221        let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
222        let mut store = self.metadata_store.write().await;
223        store.save(metadata).await?;
224
225        tracing::info!("Plugin installed successfully: {}", plugin_id);
226        Ok(plugin_id)
227    }
228
229    /// Install plugin from a registry source.
230    async fn install_from_registry(
231        &self,
232        name: &str,
233        version: Option<&str>,
234        options: &InstallOptions,
235    ) -> LoaderResult<PathBuf> {
236        let base_url = std::env::var("MOCKFORGE_PLUGIN_REGISTRY_URL")
237            .unwrap_or_else(|_| "https://registry.mockforge.dev".to_string());
238        let client = reqwest::Client::new();
239
240        let (download_url, checksum) = if let Some(v) = version {
241            let version_url = format!("{}/api/v1/plugins/{}/versions/{}", base_url, name, v);
242            let response = client.get(&version_url).send().await.map_err(|e| {
243                PluginLoaderError::load(format!(
244                    "Failed to fetch registry version metadata for {}@{}: {}",
245                    name, v, e
246                ))
247            })?;
248
249            if !response.status().is_success() {
250                return Err(PluginLoaderError::load(format!(
251                    "Registry lookup failed for {}@{}: {}",
252                    name,
253                    v,
254                    response.status()
255                )));
256            }
257
258            let entry: RegistryVersionResponse = response.json().await.map_err(|e| {
259                PluginLoaderError::load(format!(
260                    "Invalid registry response for {}@{}: {}",
261                    name, v, e
262                ))
263            })?;
264            (entry.download_url, entry.checksum)
265        } else {
266            let plugin_url = format!("{}/api/v1/plugins/{}", base_url, name);
267            let response = client.get(&plugin_url).send().await.map_err(|e| {
268                PluginLoaderError::load(format!(
269                    "Failed to fetch registry plugin metadata for {}: {}",
270                    name, e
271                ))
272            })?;
273
274            if !response.status().is_success() {
275                return Err(PluginLoaderError::load(format!(
276                    "Registry lookup failed for {}: {}",
277                    name,
278                    response.status()
279                )));
280            }
281
282            let entry: RegistryPluginResponse = response.json().await.map_err(|e| {
283                PluginLoaderError::load(format!("Invalid registry response for {}: {}", name, e))
284            })?;
285
286            let selected = select_registry_version(&entry).ok_or_else(|| {
287                PluginLoaderError::load(format!(
288                    "No installable versions found for plugin '{}'",
289                    name
290                ))
291            })?;
292            (selected.download_url.clone(), selected.checksum.clone())
293        };
294
295        let checksum_ref = options.expected_checksum.as_deref().or(checksum.as_deref());
296
297        self.remote_loader.download_with_checksum(&download_url, checksum_ref).await
298    }
299
300    /// Require a valid signature from a trusted, unexpired, unrevoked key.
301    ///
302    /// With `allow_unsigned`, a plugin without `plugin.sig` installs with a
303    /// warning. A signature that is present is always verified, and a
304    /// failure aborts the install.
305    fn enforce_plugin_signature(
306        &self,
307        plugin_dir: &Path,
308        source: &PluginSource,
309        allow_unsigned: bool,
310    ) -> LoaderResult<()> {
311        if !has_signature_file(plugin_dir) {
312            if allow_unsigned {
313                tracing::warn!(
314                    source = %source,
315                    "INSTALLING UNSIGNED PLUGIN: signature verification skipped because \
316                     unsigned plugins were explicitly allowed. Only do this for local \
317                     development plugins you built yourself."
318                );
319                return Ok(());
320            }
321            return Err(PluginLoaderError::security(format!(
322                "Plugin from {} is unsigned (no {}). Refusing to install. For a local \
323                 development plugin, pass --allow-unsigned.",
324                source, SIGNATURE_FILE
325            )));
326        }
327
328        let strict = PluginLoaderConfig {
329            allow_unsigned: false,
330            ..self.config.clone()
331        };
332        SignatureVerifier::new(&strict)
333            .verify_plugin_signature(plugin_dir)
334            .map_err(|e| {
335                PluginLoaderError::security(format!(
336                    "Plugin signature verification failed for {}: {}",
337                    source, e
338                ))
339            })
340    }
341
342    /// Uninstall a plugin
343    pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
344        self.loader.unload_plugin(plugin_id).await?;
345
346        // Remove metadata
347        let mut store = self.metadata_store.write().await;
348        store.remove(plugin_id).await?;
349
350        Ok(())
351    }
352
353    /// List installed plugins
354    pub async fn list_installed(&self) -> Vec<PluginId> {
355        self.loader.list_plugins().await
356    }
357
358    /// Update a plugin to the latest version. Unsigned plugins are refused.
359    pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
360        self.update_with_options(plugin_id, false).await
361    }
362
363    /// Update a plugin, optionally allowing an unsigned replacement (see
364    /// [`InstallOptions::allow_unsigned`]).
365    pub async fn update_with_options(
366        &self,
367        plugin_id: &PluginId,
368        allow_unsigned: bool,
369    ) -> LoaderResult<()> {
370        tracing::info!("Updating plugin: {}", plugin_id);
371
372        // Get plugin metadata to find original source
373        let metadata = {
374            let store = self.metadata_store.read().await;
375            store.get(plugin_id).cloned().ok_or_else(|| {
376                PluginLoaderError::load(format!(
377                    "No installation metadata found for plugin {}. Cannot update.",
378                    plugin_id
379                ))
380            })?
381        };
382
383        tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
384
385        // Unload the plugin first
386        if self.loader.get_plugin(plugin_id).await.is_some() {
387            self.loader.unload_plugin(plugin_id).await?;
388        }
389
390        // Reinstall from original source with force flag
391        let options = InstallOptions {
392            force: true,
393            skip_validation: false,
394            allow_unsigned,
395            expected_checksum: None,
396        };
397
398        let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
399
400        // Verify it's the same plugin
401        if new_plugin_id != *plugin_id {
402            return Err(PluginLoaderError::load(format!(
403                "Plugin ID mismatch after update: expected {}, got {}",
404                plugin_id, new_plugin_id
405            )));
406        }
407
408        // Update metadata with new version
409        let new_manifest = self
410            .loader
411            .get_plugin(&new_plugin_id)
412            .await
413            .ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
414            .manifest;
415
416        let mut store = self.metadata_store.write().await;
417        if let Some(meta) = store.get(plugin_id).cloned() {
418            let mut updated_meta = meta;
419            updated_meta.mark_updated(new_manifest.info.version.to_string());
420            store.save(updated_meta).await?;
421        }
422
423        tracing::info!("Plugin {} updated successfully", plugin_id);
424        Ok(())
425    }
426
427    /// Update all plugins to their latest versions. Unsigned plugins are
428    /// refused.
429    pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
430        self.update_all_with_options(false).await
431    }
432
433    /// Update all plugins, optionally allowing unsigned replacements.
434    pub async fn update_all_with_options(
435        &self,
436        allow_unsigned: bool,
437    ) -> LoaderResult<Vec<PluginId>> {
438        tracing::info!("Updating all plugins");
439
440        // Get list of all plugins with metadata
441        let plugin_ids = {
442            let store = self.metadata_store.read().await;
443            store.list()
444        };
445
446        if plugin_ids.is_empty() {
447            tracing::info!("No plugins found with metadata to update");
448            return Ok(Vec::new());
449        }
450
451        tracing::info!("Found {} plugins to update", plugin_ids.len());
452
453        let mut updated = Vec::new();
454        let mut failed = Vec::new();
455
456        // Update each plugin
457        for plugin_id in plugin_ids {
458            match self.update_with_options(&plugin_id, allow_unsigned).await {
459                Ok(_) => {
460                    tracing::info!("Successfully updated plugin: {}", plugin_id);
461                    updated.push(plugin_id);
462                }
463                Err(e) => {
464                    tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
465                    failed.push((plugin_id, e.to_string()));
466                }
467            }
468        }
469
470        tracing::info!(
471            "Plugin update complete: {} succeeded, {} failed",
472            updated.len(),
473            failed.len()
474        );
475
476        if !failed.is_empty() {
477            let failed_list = failed
478                .iter()
479                .map(|(id, err)| format!("{}: {}", id, err))
480                .collect::<Vec<_>>()
481                .join(", ");
482            tracing::warn!("Failed updates: {}", failed_list);
483        }
484
485        Ok(updated)
486    }
487
488    /// Clear all caches (downloads and Git repositories)
489    pub async fn clear_caches(&self) -> LoaderResult<()> {
490        self.remote_loader.clear_cache().await?;
491        self.git_loader.clear_cache().await?;
492        Ok(())
493    }
494
495    /// Get cache statistics
496    pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
497        let download_cache_size = self.remote_loader.get_cache_size()?;
498        let git_cache_size = self.git_loader.get_cache_size()?;
499
500        Ok(CacheStats {
501            download_cache_size,
502            git_cache_size,
503            total_size: download_cache_size + git_cache_size,
504        })
505    }
506
507    /// Get plugin metadata
508    pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
509        let store = self.metadata_store.read().await;
510        store.get(plugin_id).cloned()
511    }
512
513    /// List all plugins with metadata
514    pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
515        let store = self.metadata_store.read().await;
516        store
517            .list()
518            .into_iter()
519            .filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
520            .collect()
521    }
522}
523
524#[derive(Debug, Deserialize)]
525#[serde(rename_all = "camelCase")]
526struct RegistryVersionResponse {
527    download_url: String,
528    #[serde(default)]
529    checksum: Option<String>,
530}
531
532#[derive(Debug, Deserialize)]
533#[serde(rename_all = "camelCase")]
534struct RegistryPluginResponse {
535    version: String,
536    versions: Vec<RegistryVersionResponseWithVersion>,
537}
538
539#[derive(Debug, Deserialize)]
540#[serde(rename_all = "camelCase")]
541struct RegistryVersionResponseWithVersion {
542    version: String,
543    download_url: String,
544    #[serde(default)]
545    checksum: Option<String>,
546    #[serde(default)]
547    yanked: bool,
548}
549
550fn select_registry_version(
551    entry: &RegistryPluginResponse,
552) -> Option<&RegistryVersionResponseWithVersion> {
553    if let Some(preferred) = entry.versions.iter().find(|v| v.version == entry.version && !v.yanked)
554    {
555        return Some(preferred);
556    }
557
558    entry.versions.iter().find(|v| !v.yanked)
559}
560
561/// Cache statistics
562#[derive(Debug, Clone)]
563pub struct CacheStats {
564    /// Download cache size in bytes
565    pub download_cache_size: u64,
566    /// Git repository cache size in bytes
567    pub git_cache_size: u64,
568    /// Total cache size in bytes
569    pub total_size: u64,
570}
571
572impl CacheStats {
573    /// Format cache size as human-readable string
574    pub fn format_size(bytes: u64) -> String {
575        const KB: u64 = 1024;
576        const MB: u64 = KB * 1024;
577        const GB: u64 = MB * 1024;
578
579        if bytes >= GB {
580            format!("{:.2} GB", bytes as f64 / GB as f64)
581        } else if bytes >= MB {
582            format!("{:.2} MB", bytes as f64 / MB as f64)
583        } else if bytes >= KB {
584            format!("{:.2} KB", bytes as f64 / KB as f64)
585        } else {
586            format!("{} bytes", bytes)
587        }
588    }
589
590    /// Get download cache size as human-readable string
591    pub fn download_cache_formatted(&self) -> String {
592        Self::format_size(self.download_cache_size)
593    }
594
595    /// Get Git cache size as human-readable string
596    pub fn git_cache_formatted(&self) -> String {
597        Self::format_size(self.git_cache_size)
598    }
599
600    /// Get total cache size as human-readable string
601    pub fn total_formatted(&self) -> String {
602        Self::format_size(self.total_size)
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    // ===== PluginSource Tests =====
611
612    #[test]
613    fn test_plugin_source_parse_url() {
614        let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
615        assert!(matches!(source, PluginSource::Url { .. }));
616    }
617
618    #[test]
619    fn test_plugin_source_parse_git_https() {
620        let source = PluginSource::parse("https://github.com/user/repo").unwrap();
621        assert!(matches!(source, PluginSource::Git(_)));
622    }
623
624    #[test]
625    fn test_plugin_source_parse_git_ssh() {
626        let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
627        assert!(matches!(source, PluginSource::Git(_)));
628    }
629
630    #[test]
631    fn test_plugin_source_parse_gitlab() {
632        let source = PluginSource::parse("https://gitlab.com/user/repo").unwrap();
633        assert!(matches!(source, PluginSource::Git(_)));
634    }
635
636    #[test]
637    fn test_plugin_source_parse_local() {
638        let source = PluginSource::parse("/path/to/plugin").unwrap();
639        assert!(matches!(source, PluginSource::Local(_)));
640
641        let source = PluginSource::parse("./relative/path").unwrap();
642        assert!(matches!(source, PluginSource::Local(_)));
643    }
644
645    #[test]
646    fn test_plugin_source_parse_registry() {
647        let source = PluginSource::parse("auth-jwt").unwrap();
648        assert!(matches!(source, PluginSource::Registry { .. }));
649
650        let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
651        if let PluginSource::Registry { name, version } = source {
652            assert_eq!(name, "auth-jwt");
653            assert_eq!(version, Some("1.0.0".to_string()));
654        } else {
655            panic!("Expected Registry source");
656        }
657    }
658
659    #[test]
660    fn test_plugin_source_parse_registry_without_version() {
661        let source = PluginSource::parse("my-plugin").unwrap();
662        if let PluginSource::Registry { name, version } = source {
663            assert_eq!(name, "my-plugin");
664            assert!(version.is_none());
665        } else {
666            panic!("Expected Registry source");
667        }
668    }
669
670    #[test]
671    fn test_plugin_source_parse_url_with_checksum() {
672        let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
673        if let PluginSource::Url { url, checksum } = source {
674            assert_eq!(url, "https://example.com/plugin.zip");
675            assert!(checksum.is_none());
676        } else {
677            panic!("Expected URL source");
678        }
679    }
680
681    #[test]
682    fn test_plugin_source_parse_empty_string() {
683        let source = PluginSource::parse("").unwrap();
684        // Empty string should be treated as registry name
685        assert!(matches!(source, PluginSource::Registry { .. }));
686    }
687
688    #[test]
689    fn test_plugin_source_parse_whitespace() {
690        let source = PluginSource::parse("  https://example.com/plugin.zip  ").unwrap();
691        assert!(matches!(source, PluginSource::Url { .. }));
692    }
693
694    #[test]
695    fn test_plugin_source_display() {
696        let source = PluginSource::Local(PathBuf::from("/tmp/plugin"));
697        assert_eq!(source.to_string(), "local:/tmp/plugin");
698
699        let source = PluginSource::Url {
700            url: "https://example.com/plugin.zip".to_string(),
701            checksum: None,
702        };
703        assert_eq!(source.to_string(), "url:https://example.com/plugin.zip");
704
705        let source = PluginSource::Registry {
706            name: "my-plugin".to_string(),
707            version: Some("1.0.0".to_string()),
708        };
709        assert_eq!(source.to_string(), "registry:my-plugin@1.0.0");
710
711        let source = PluginSource::Registry {
712            name: "my-plugin".to_string(),
713            version: None,
714        };
715        assert_eq!(source.to_string(), "registry:my-plugin");
716    }
717
718    #[test]
719    fn test_plugin_source_clone() {
720        let source = PluginSource::Local(PathBuf::from("/tmp"));
721        let cloned = source.clone();
722        assert_eq!(source.to_string(), cloned.to_string());
723    }
724
725    // ===== InstallOptions Tests =====
726
727    #[test]
728    fn test_install_options_default() {
729        let options = InstallOptions::default();
730        assert!(!options.force);
731        assert!(!options.skip_validation);
732        assert!(!options.allow_unsigned);
733        assert!(options.expected_checksum.is_none());
734    }
735
736    #[test]
737    fn test_install_options_with_force() {
738        let options = InstallOptions {
739            force: true,
740            ..Default::default()
741        };
742        assert!(options.force);
743    }
744
745    #[test]
746    fn test_install_options_with_checksum() {
747        let options = InstallOptions {
748            expected_checksum: Some("abc123".to_string()),
749            ..Default::default()
750        };
751        assert_eq!(options.expected_checksum, Some("abc123".to_string()));
752    }
753
754    #[test]
755    fn test_install_options_skip_validation() {
756        let options = InstallOptions {
757            skip_validation: true,
758            ..Default::default()
759        };
760        assert!(options.skip_validation);
761        assert!(!options.allow_unsigned);
762    }
763
764    #[test]
765    fn test_install_options_clone() {
766        let options = InstallOptions {
767            force: true,
768            skip_validation: false,
769            allow_unsigned: false,
770            expected_checksum: Some("test".to_string()),
771        };
772        let cloned = options.clone();
773        assert_eq!(options.force, cloned.force);
774        assert_eq!(options.expected_checksum, cloned.expected_checksum);
775    }
776
777    // ===== CacheStats Tests =====
778
779    #[test]
780    fn test_cache_stats_formatting() {
781        assert_eq!(CacheStats::format_size(512), "512 bytes");
782        assert_eq!(CacheStats::format_size(1024), "1.00 KB");
783        assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
784        assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
785    }
786
787    #[test]
788    fn test_cache_stats_edge_cases() {
789        assert_eq!(CacheStats::format_size(0), "0 bytes");
790        assert_eq!(CacheStats::format_size(1), "1 bytes");
791        assert_eq!(CacheStats::format_size(1023), "1023 bytes");
792        assert_eq!(CacheStats::format_size(1025), "1.00 KB");
793    }
794
795    #[test]
796    fn test_cache_stats_large_values() {
797        let tb = 1024u64 * 1024 * 1024 * 1024;
798        assert!(CacheStats::format_size(tb).contains("GB"));
799    }
800
801    #[test]
802    fn test_cache_stats_formatted_methods() {
803        let stats = CacheStats {
804            download_cache_size: 1024 * 1024,
805            git_cache_size: 2 * 1024 * 1024,
806            total_size: 3 * 1024 * 1024,
807        };
808
809        assert_eq!(stats.download_cache_formatted(), "1.00 MB");
810        assert_eq!(stats.git_cache_formatted(), "2.00 MB");
811        assert_eq!(stats.total_formatted(), "3.00 MB");
812    }
813
814    #[test]
815    fn test_cache_stats_total_calculation() {
816        let stats = CacheStats {
817            download_cache_size: 100,
818            git_cache_size: 200,
819            total_size: 300,
820        };
821
822        assert_eq!(stats.total_size, stats.download_cache_size + stats.git_cache_size);
823    }
824
825    #[test]
826    fn test_cache_stats_clone() {
827        let stats = CacheStats {
828            download_cache_size: 1024,
829            git_cache_size: 2048,
830            total_size: 3072,
831        };
832
833        let cloned = stats.clone();
834        assert_eq!(stats.download_cache_size, cloned.download_cache_size);
835        assert_eq!(stats.git_cache_size, cloned.git_cache_size);
836        assert_eq!(stats.total_size, cloned.total_size);
837    }
838
839    // ===== Edge Cases and Error Handling =====
840
841    #[test]
842    fn test_plugin_source_parse_http_url() {
843        let source = PluginSource::parse("http://example.com/plugin.zip").unwrap();
844        assert!(matches!(source, PluginSource::Url { .. }));
845    }
846
847    #[test]
848    fn test_plugin_source_parse_windows_path() {
849        let source = PluginSource::parse("C:\\Users\\plugin").unwrap();
850        assert!(matches!(source, PluginSource::Local(_)));
851    }
852
853    #[test]
854    fn test_plugin_source_parse_registry_with_special_chars() {
855        let source = PluginSource::parse("my-plugin-name_v2@2.0.0-beta").unwrap();
856        if let PluginSource::Registry { name, version } = source {
857            assert_eq!(name, "my-plugin-name_v2");
858            assert_eq!(version, Some("2.0.0-beta".to_string()));
859        } else {
860            panic!("Expected Registry source");
861        }
862    }
863
864    #[test]
865    fn test_plugin_source_parse_github_dotgit_in_url() {
866        let source = PluginSource::parse("https://github.com/user/repo.git").unwrap();
867        assert!(matches!(source, PluginSource::Git(_)));
868    }
869
870    #[test]
871    fn test_select_registry_version_prefers_current_non_yanked() {
872        let entry = RegistryPluginResponse {
873            version: "2.0.0".to_string(),
874            versions: vec![
875                RegistryVersionResponseWithVersion {
876                    version: "1.0.0".to_string(),
877                    download_url: "https://example.com/1.0.0.wasm".to_string(),
878                    checksum: None,
879                    yanked: false,
880                },
881                RegistryVersionResponseWithVersion {
882                    version: "2.0.0".to_string(),
883                    download_url: "https://example.com/2.0.0.wasm".to_string(),
884                    checksum: Some("abc".to_string()),
885                    yanked: false,
886                },
887            ],
888        };
889
890        let selected = select_registry_version(&entry).expect("expected selected version");
891        assert_eq!(selected.version, "2.0.0");
892        assert_eq!(selected.download_url, "https://example.com/2.0.0.wasm");
893    }
894
895    // ===== Signature enforcement (#1013) =====
896
897    mod signature_enforcement {
898        use super::*;
899        use crate::signature::PluginSignature;
900        use crate::signature_gen::{generate_ed25519_keypair, sign_plugin_ed25519};
901        use chrono::{Duration as ChronoDuration, Utc};
902        use tempfile::TempDir;
903
904        const KEY_ID: &str = "publisher-key";
905
906        struct Fixture {
907            _home: TempDir,
908            installer: PluginInstaller,
909            private_key: Vec<u8>,
910        }
911
912        fn installer_with(config: PluginLoaderConfig, home: &TempDir) -> PluginInstaller {
913            let remote_loader = RemotePluginLoader::new(RemotePluginConfig {
914                cache_dir: home.path().join("downloads"),
915                ..Default::default()
916            })
917            .expect("temp cache dir is writable");
918            let git_loader = GitPluginLoader::new(GitPluginConfig {
919                cache_dir: home.path().join("git"),
920                ..Default::default()
921            })
922            .expect("temp cache dir is writable");
923            PluginInstaller {
924                loader: PluginLoader::new(config.clone()),
925                remote_loader,
926                git_loader,
927                config,
928                metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(MetadataStore::new(
929                    home.path().join("metadata"),
930                ))),
931            }
932        }
933
934        /// Installer trusting one freshly generated key, plus an optional
935        /// config tweak (expiry, revocation, wrong key).
936        fn fixture(tweak: impl FnOnce(&mut PluginLoaderConfig, &[u8])) -> Fixture {
937            let home = TempDir::new().expect("tempdir");
938            let (private_key, public_key) = generate_ed25519_keypair().expect("keygen");
939            let mut config = PluginLoaderConfig {
940                trusted_keys: vec![KEY_ID.to_string()],
941                ..Default::default()
942            };
943            config.key_data.insert(KEY_ID.to_string(), public_key.clone());
944            tweak(&mut config, &public_key);
945            let installer = installer_with(config, &home);
946            Fixture {
947                _home: home,
948                installer,
949                private_key,
950            }
951        }
952
953        fn plugin_dir(wasm: &[u8]) -> TempDir {
954            let dir = TempDir::new().expect("tempdir");
955            std::fs::write(dir.path().join("plugin.yaml"), "id: sig-test\n").expect("write");
956            std::fs::write(dir.path().join("plugin.wasm"), wasm).expect("write");
957            dir
958        }
959
960        async fn install(
961            fx: &Fixture,
962            dir: &TempDir,
963            options: InstallOptions,
964        ) -> LoaderResult<PluginId> {
965            let source = PluginSource::Local(dir.path().to_path_buf());
966            fx.installer.install_from_source(&source, options).await
967        }
968
969        /// The fixture has no loadable manifest, so a plugin that clears the
970        /// signature gate fails later in manifest validation instead.
971        fn assert_passed_signature_gate(result: LoaderResult<PluginId>) {
972            let err = result.expect_err("fixture manifest is not loadable");
973            assert!(!err.is_security_error(), "blocked by signature gate: {err}");
974        }
975
976        fn assert_blocked(result: LoaderResult<PluginId>, needle: &str) {
977            let err = result.expect_err("install must be blocked");
978            assert!(err.is_security_error(), "expected security error, got: {err}");
979            assert!(err.to_string().contains(needle), "unexpected error: {err}");
980        }
981
982        #[tokio::test]
983        async fn valid_signature_loads_exact_directory_outside_search_roots() {
984            use mockforge_plugin_core::{PluginAuthor, PluginInfo, PluginManifest, PluginVersion};
985            let fx = fixture(|_, _| {});
986            let dir = TempDir::new().unwrap();
987            let manifest = PluginManifest::new(PluginInfo::new(
988                PluginId::new("exact-signed-fixture"),
989                PluginVersion::new(1, 0, 0),
990                "Exact signed fixture",
991                "Owned loadable fixture",
992                PluginAuthor::new("Fixture"),
993            ));
994            std::fs::write(
995                dir.path().join("plugin.yaml"),
996                serde_yaml::to_string(&manifest).unwrap(),
997            )
998            .unwrap();
999            std::fs::write(
1000                dir.path().join("plugin.wasm"),
1001                b"\0asm\x01\0\0\0\x05\x04\x01\x01\x01\x01\x07\x0a\x01\x06memory\x02\0",
1002            )
1003            .unwrap();
1004            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).unwrap();
1005            let id = install(&fx, &dir, InstallOptions::default()).await.unwrap();
1006            let loaded = fx.installer.loader.get_plugin(&id).await.unwrap();
1007            assert_eq!(loaded.manifest.info.name, "Exact signed fixture");
1008        }
1009
1010        #[tokio::test]
1011        async fn unsigned_plugin_is_blocked_by_default() {
1012            let fx = fixture(|_, _| {});
1013            let dir = plugin_dir(b"\0asm-good");
1014            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "unsigned");
1015        }
1016
1017        #[tokio::test]
1018        async fn skip_validation_does_not_bypass_signature() {
1019            let fx = fixture(|_, _| {});
1020            let dir = plugin_dir(b"\0asm-good");
1021            let options = InstallOptions {
1022                skip_validation: true,
1023                ..Default::default()
1024            };
1025            assert_blocked(install(&fx, &dir, options).await, "unsigned");
1026        }
1027
1028        #[tokio::test]
1029        async fn explicit_allow_unsigned_installs_unsigned_plugin() {
1030            let fx = fixture(|_, _| {});
1031            let dir = plugin_dir(b"\0asm-good");
1032            let options = InstallOptions {
1033                allow_unsigned: true,
1034                ..Default::default()
1035            };
1036            assert_passed_signature_gate(install(&fx, &dir, options).await);
1037        }
1038
1039        #[tokio::test]
1040        async fn valid_signature_passes() {
1041            let fx = fixture(|_, _| {});
1042            let dir = plugin_dir(b"\0asm-good");
1043            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1044            assert_passed_signature_gate(install(&fx, &dir, InstallOptions::default()).await);
1045        }
1046
1047        #[tokio::test]
1048        async fn tampered_wasm_is_blocked() {
1049            let fx = fixture(|_, _| {});
1050            let dir = plugin_dir(b"\0asm-good");
1051            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1052            std::fs::write(dir.path().join("plugin.wasm"), b"\0asm-evil").expect("write");
1053            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "modified");
1054        }
1055
1056        #[tokio::test]
1057        async fn injected_file_is_blocked() {
1058            let fx = fixture(|_, _| {});
1059            let dir = plugin_dir(b"\0asm-good");
1060            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1061            std::fs::create_dir(dir.path().join("assets")).expect("mkdir");
1062            std::fs::write(dir.path().join("assets/extra.js"), b"evil()").expect("write");
1063            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "modified");
1064        }
1065
1066        #[tokio::test]
1067        async fn tampered_signature_is_blocked_even_with_allow_unsigned() {
1068            let fx = fixture(|_, _| {});
1069            let dir = plugin_dir(b"\0asm-good");
1070            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1071            std::fs::write(dir.path().join("plugin.wasm"), b"\0asm-evil").expect("write");
1072            let options = InstallOptions {
1073                allow_unsigned: true,
1074                ..Default::default()
1075            };
1076            assert_blocked(install(&fx, &dir, options).await, "modified");
1077        }
1078
1079        #[tokio::test]
1080        async fn wrong_key_is_blocked() {
1081            let (_, other_public) = generate_ed25519_keypair().expect("keygen");
1082            let fx = fixture(move |config, _| {
1083                config.key_data.insert(KEY_ID.to_string(), other_public);
1084            });
1085            let dir = plugin_dir(b"\0asm-good");
1086            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1087            assert_blocked(
1088                install(&fx, &dir, InstallOptions::default()).await,
1089                "Invalid signature",
1090            );
1091        }
1092
1093        #[tokio::test]
1094        async fn untrusted_key_is_blocked() {
1095            let fx = fixture(|_, _| {});
1096            let dir = plugin_dir(b"\0asm-good");
1097            sign_plugin_ed25519(dir.path(), "someone-else", &fx.private_key).expect("sign");
1098            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "not in trusted");
1099        }
1100
1101        #[tokio::test]
1102        async fn expired_key_is_blocked() {
1103            let fx = fixture(|config, _| {
1104                config
1105                    .key_expires_at
1106                    .insert(KEY_ID.to_string(), Utc::now() - ChronoDuration::days(1));
1107            });
1108            let dir = plugin_dir(b"\0asm-good");
1109            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1110            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "expired");
1111        }
1112
1113        #[tokio::test]
1114        async fn unexpired_key_passes() {
1115            let fx = fixture(|config, _| {
1116                config
1117                    .key_expires_at
1118                    .insert(KEY_ID.to_string(), Utc::now() + ChronoDuration::days(30));
1119            });
1120            let dir = plugin_dir(b"\0asm-good");
1121            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1122            assert_passed_signature_gate(install(&fx, &dir, InstallOptions::default()).await);
1123        }
1124
1125        #[tokio::test]
1126        async fn revoked_key_is_blocked() {
1127            let fx = fixture(|config, _| {
1128                config.revoked_keys.push(KEY_ID.to_string());
1129            });
1130            let dir = plugin_dir(b"\0asm-good");
1131            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1132            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "revoked");
1133        }
1134
1135        #[tokio::test]
1136        async fn signature_for_different_artifact_is_blocked() {
1137            let fx = fixture(|_, _| {});
1138            let signed = plugin_dir(b"\0asm-good");
1139            sign_plugin_ed25519(signed.path(), KEY_ID, &fx.private_key).expect("sign");
1140            let other = plugin_dir(b"\0asm-other");
1141            std::fs::copy(signed.path().join("plugin.sig"), other.path().join("plugin.sig"))
1142                .expect("copy");
1143            assert_blocked(install(&fx, &other, InstallOptions::default()).await, "different");
1144        }
1145
1146        #[tokio::test]
1147        async fn legacy_manifest_only_signature_is_blocked() {
1148            let fx = fixture(|_, _| {});
1149            let dir = plugin_dir(b"\0asm-good");
1150            let mut sig = sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1151            sig.format_version = 1;
1152            let json = serde_json::to_string(&sig).expect("serialize");
1153            std::fs::write(dir.path().join("plugin.sig"), json).expect("write");
1154            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "format v1");
1155
1156            // A v1 file has no format_version field at all.
1157            let mut raw: serde_json::Value = serde_json::from_str(
1158                &std::fs::read_to_string(dir.path().join("plugin.sig")).expect("read"),
1159            )
1160            .expect("json");
1161            raw.as_object_mut().expect("object").remove("format_version");
1162            let parsed: PluginSignature = serde_json::from_value(raw).expect("parse");
1163            assert_eq!(parsed.format_version, 1);
1164        }
1165
1166        #[tokio::test]
1167        async fn config_allow_unsigned_still_rejects_bad_signature() {
1168            let fx = fixture(|config, _| {
1169                config.allow_unsigned = true;
1170                config.revoked_keys.push(KEY_ID.to_string());
1171            });
1172            let dir = plugin_dir(b"\0asm-good");
1173            sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
1174            assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "revoked");
1175        }
1176    }
1177
1178    #[test]
1179    fn test_select_registry_version_falls_back_to_first_non_yanked() {
1180        let entry = RegistryPluginResponse {
1181            version: "2.0.0".to_string(),
1182            versions: vec![
1183                RegistryVersionResponseWithVersion {
1184                    version: "2.0.0".to_string(),
1185                    download_url: "https://example.com/2.0.0.wasm".to_string(),
1186                    checksum: None,
1187                    yanked: true,
1188                },
1189                RegistryVersionResponseWithVersion {
1190                    version: "1.9.0".to_string(),
1191                    download_url: "https://example.com/1.9.0.wasm".to_string(),
1192                    checksum: None,
1193                    yanked: false,
1194                },
1195            ],
1196        };
1197
1198        let selected = select_registry_version(&entry).expect("expected selected version");
1199        assert_eq!(selected.version, "1.9.0");
1200    }
1201}