1use 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#[derive(Debug, Clone)]
22pub enum PluginSource {
23 Local(PathBuf),
25 Url {
27 url: String,
29 checksum: Option<String>,
31 },
32 Git(GitPluginSource),
34 Registry {
36 name: String,
38 version: Option<String>,
40 },
41}
42
43impl PluginSource {
44 pub fn parse(input: &str) -> LoaderResult<Self> {
52 let input = input.trim();
53
54 if input.starts_with("http://") || input.starts_with("https://") {
56 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 if input.starts_with("git@") {
72 let source = GitPluginSource::parse(input)?;
73 return Ok(PluginSource::Git(source));
74 }
75
76 if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
78 return Ok(PluginSource::Local(PathBuf::from(input)));
79 }
80
81 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#[derive(Debug, Clone, Default)]
112pub struct InstallOptions {
113 pub force: bool,
115 pub skip_validation: bool,
118 pub allow_unsigned: bool,
122 pub expected_checksum: Option<String>,
124}
125
126pub 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 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 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 pub async fn init(&self) -> LoaderResult<()> {
157 let mut store = self.metadata_store.write().await;
158 store.load().await
159 }
160
161 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 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 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 self.enforce_plugin_signature(
197 &plugin_dir,
198 source,
199 options.allow_unsigned || self.config.allow_unsigned,
200 )?;
201
202 if !options.skip_validation {
204 self.loader.validate_plugin(&plugin_dir).await?;
205 }
206
207 let manifest = self.loader.validate_plugin(&plugin_dir).await?;
209 let plugin_id = manifest.info.id.clone();
210
211 if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
213 return Err(PluginLoaderError::already_loaded(plugin_id));
214 }
215
216 self.loader.load_plugin_from_path(&plugin_dir).await?;
218
219 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 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 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 pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
344 self.loader.unload_plugin(plugin_id).await?;
345
346 let mut store = self.metadata_store.write().await;
348 store.remove(plugin_id).await?;
349
350 Ok(())
351 }
352
353 pub async fn list_installed(&self) -> Vec<PluginId> {
355 self.loader.list_plugins().await
356 }
357
358 pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
360 self.update_with_options(plugin_id, false).await
361 }
362
363 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 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 if self.loader.get_plugin(plugin_id).await.is_some() {
387 self.loader.unload_plugin(plugin_id).await?;
388 }
389
390 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 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 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 pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
430 self.update_all_with_options(false).await
431 }
432
433 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 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 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 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 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 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 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#[derive(Debug, Clone)]
563pub struct CacheStats {
564 pub download_cache_size: u64,
566 pub git_cache_size: u64,
568 pub total_size: u64,
570}
571
572impl CacheStats {
573 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 pub fn download_cache_formatted(&self) -> String {
592 Self::format_size(self.download_cache_size)
593 }
594
595 pub fn git_cache_formatted(&self) -> String {
597 Self::format_size(self.git_cache_size)
598 }
599
600 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 #[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 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 #[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 #[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 #[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 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 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 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 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}