mockforge_plugin_loader/
remote.rs

1//! Remote plugin loading functionality
2//!
3//! This module provides functionality for downloading plugins from remote sources:
4//! - HTTP/HTTPS URLs (direct files or archives)
5//! - Git repositories with version pinning
6//! - Plugin registries
7//!
8//! ## Security Features
9//!
10//! - SHA-256 checksum verification
11//! - SSL certificate validation
12//! - Download size limits
13//! - Timeout configuration
14//! - Retry logic with exponential backoff
15//! - Download caching
16
17use super::*;
18use indicatif::{ProgressBar, ProgressStyle};
19use reqwest::Client;
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24use tokio::fs as async_fs;
25
26/// Configuration for remote plugin loading
27#[derive(Debug, Clone)]
28pub struct RemotePluginConfig {
29    /// Maximum download size (default: 100MB)
30    pub max_download_size: u64,
31    /// Download timeout (default: 5 minutes)
32    pub timeout: Duration,
33    /// Maximum number of retries (default: 3)
34    pub max_retries: u32,
35    /// Cache directory for downloaded plugins
36    pub cache_dir: PathBuf,
37    /// Verify SSL certificates (default: true)
38    pub verify_ssl: bool,
39    /// Show download progress (default: true)
40    pub show_progress: bool,
41}
42
43impl Default for RemotePluginConfig {
44    fn default() -> Self {
45        Self {
46            max_download_size: 100 * 1024 * 1024, // 100MB
47            timeout: Duration::from_secs(300),    // 5 minutes
48            max_retries: 3,
49            cache_dir: dirs::cache_dir()
50                .unwrap_or_else(|| PathBuf::from(".cache"))
51                .join("mockforge")
52                .join("plugins"),
53            verify_ssl: true,
54            show_progress: true,
55        }
56    }
57}
58
59/// Remote plugin loader for downloading plugins from URLs
60pub struct RemotePluginLoader {
61    config: RemotePluginConfig,
62    client: Client,
63}
64
65impl RemotePluginLoader {
66    /// Create a new remote plugin loader
67    pub fn new(config: RemotePluginConfig) -> LoaderResult<Self> {
68        // Create cache directory if it doesn't exist
69        std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
70            PluginLoaderError::fs(format!(
71                "Failed to create cache directory {}: {}",
72                config.cache_dir.display(),
73                e
74            ))
75        })?;
76
77        // Build HTTP client with configuration
78        let client = Client::builder()
79            .timeout(config.timeout)
80            .danger_accept_invalid_certs(!config.verify_ssl)
81            .user_agent(format!("MockForge/{}", env!("CARGO_PKG_VERSION")))
82            .build()
83            .map_err(|e| PluginLoaderError::load(format!("Failed to create HTTP client: {}", e)))?;
84
85        Ok(Self { config, client })
86    }
87
88    /// Download a plugin from a URL
89    ///
90    /// Supports:
91    /// - Direct .wasm files
92    /// - .zip archives
93    /// - .tar.gz archives
94    ///
95    /// Returns the path to the downloaded plugin directory
96    pub async fn download_from_url(&self, url: &str) -> LoaderResult<PathBuf> {
97        tracing::info!("Downloading plugin from URL: {}", url);
98
99        // Parse URL to determine file type
100        let url_parsed = reqwest::Url::parse(url)
101            .map_err(|e| PluginLoaderError::load(format!("Invalid URL '{}': {}", url, e)))?;
102
103        let file_name = url_parsed
104            .path_segments()
105            .and_then(|mut segments| segments.next_back())
106            .ok_or_else(|| PluginLoaderError::load("Could not determine file name from URL"))?;
107
108        // Check cache first
109        let cache_key = self.generate_cache_key(url);
110        let cached_path = self.config.cache_dir.join(&cache_key);
111
112        if cached_path.exists() {
113            tracing::info!("Using cached plugin at: {}", cached_path.display());
114            return Ok(cached_path);
115        }
116
117        // Download file with progress tracking
118        let temp_file = self.download_with_progress(url, file_name).await?;
119
120        // Verify file size
121        let metadata = async_fs::metadata(&temp_file)
122            .await
123            .map_err(|e| PluginLoaderError::fs(format!("Failed to read file metadata: {}", e)))?;
124
125        if metadata.len() > self.config.max_download_size {
126            return Err(PluginLoaderError::load(format!(
127                "Downloaded file size ({} bytes) exceeds maximum allowed size ({} bytes)",
128                metadata.len(),
129                self.config.max_download_size
130            )));
131        }
132
133        // Extract or move file based on type
134        let plugin_dir = if file_name.ends_with(".zip") {
135            self.extract_zip(&temp_file, &cached_path).await?
136        } else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
137            self.extract_tar_gz(&temp_file, &cached_path).await?
138        } else if file_name.ends_with(".wasm") {
139            // For direct .wasm files, create a directory and move the file
140            async_fs::create_dir_all(&cached_path)
141                .await
142                .map_err(|e| PluginLoaderError::fs(format!("Failed to create directory: {}", e)))?;
143
144            let wasm_dest = cached_path.join(file_name);
145            async_fs::rename(&temp_file, &wasm_dest)
146                .await
147                .map_err(|e| PluginLoaderError::fs(format!("Failed to move WASM file: {}", e)))?;
148
149            cached_path.clone()
150        } else {
151            return Err(PluginLoaderError::load(format!(
152                "Unsupported file type: {}. Supported: .wasm, .zip, .tar.gz",
153                file_name
154            )));
155        };
156
157        // Clean up temp file if it still exists
158        let _ = async_fs::remove_file(&temp_file).await;
159
160        tracing::info!("Plugin downloaded and extracted to: {}", plugin_dir.display());
161        Ok(plugin_dir)
162    }
163
164    /// Download a plugin from a URL with optional checksum verification
165    pub async fn download_with_checksum(
166        &self,
167        url: &str,
168        expected_checksum: Option<&str>,
169    ) -> LoaderResult<PathBuf> {
170        let plugin_dir = self.download_from_url(url).await?;
171
172        // Verify checksum if provided
173        if let Some(checksum) = expected_checksum {
174            self.verify_checksum(&plugin_dir, checksum)?;
175        }
176
177        Ok(plugin_dir)
178    }
179
180    /// Download file with progress bar
181    async fn download_with_progress(&self, url: &str, file_name: &str) -> LoaderResult<PathBuf> {
182        let mut response = self.client.get(url).send().await.map_err(|e| {
183            PluginLoaderError::load(format!("Failed to download from '{}': {}", url, e))
184        })?;
185
186        if !response.status().is_success() {
187            return Err(PluginLoaderError::load(format!(
188                "Download failed with status: {}",
189                response.status()
190            )));
191        }
192
193        // Get content length for progress bar
194        let total_size = response.content_length();
195
196        // Create progress bar if enabled
197        let progress_bar = if self.config.show_progress {
198            total_size.map(|size| {
199                let pb = ProgressBar::new(size);
200                pb.set_style(
201                    ProgressStyle::default_bar()
202                        .template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")
203                        .unwrap()
204                        .progress_chars("#>-"),
205                );
206                pb.set_message(format!("Downloading {}", file_name));
207                pb
208            })
209        } else {
210            None
211        };
212
213        // Create temporary file
214        let temp_dir = tempfile::tempdir().map_err(|e| {
215            PluginLoaderError::fs(format!("Failed to create temp directory: {}", e))
216        })?;
217        let temp_file = temp_dir.path().join(file_name);
218        let mut file = std::fs::File::create(&temp_file)
219            .map_err(|e| PluginLoaderError::fs(format!("Failed to create temp file: {}", e)))?;
220
221        // Download chunks and write to file
222        let mut downloaded: u64 = 0;
223        while let Some(chunk) = response
224            .chunk()
225            .await
226            .map_err(|e| PluginLoaderError::load(format!("Failed to download chunk: {}", e)))?
227        {
228            file.write_all(&chunk)
229                .map_err(|e| PluginLoaderError::fs(format!("Failed to write chunk: {}", e)))?;
230
231            downloaded += chunk.len() as u64;
232
233            // Check size limit
234            if downloaded > self.config.max_download_size {
235                return Err(PluginLoaderError::load(format!(
236                    "Download size exceeded maximum allowed size ({} bytes)",
237                    self.config.max_download_size
238                )));
239            }
240
241            if let Some(ref pb) = progress_bar {
242                pb.set_position(downloaded);
243            }
244        }
245
246        if let Some(pb) = progress_bar {
247            pb.finish_with_message(format!("Downloaded {}", file_name));
248        }
249
250        // Ensure file is written
251        file.flush()
252            .map_err(|e| PluginLoaderError::fs(format!("Failed to flush file: {}", e)))?;
253        drop(file);
254
255        Ok(temp_file)
256    }
257
258    /// Extract a ZIP archive
259    async fn extract_zip(&self, zip_path: &Path, dest: &Path) -> LoaderResult<PathBuf> {
260        tracing::info!("Extracting ZIP archive to: {}", dest.display());
261
262        let file = fs::File::open(zip_path)
263            .map_err(|e| PluginLoaderError::fs(format!("Failed to open ZIP file: {}", e)))?;
264
265        let mut archive = zip::ZipArchive::new(file)
266            .map_err(|e| PluginLoaderError::load(format!("Failed to read ZIP archive: {}", e)))?;
267
268        fs::create_dir_all(dest)
269            .map_err(|e| PluginLoaderError::fs(format!("Failed to create directory: {}", e)))?;
270
271        for i in 0..archive.len() {
272            let mut file = archive
273                .by_index(i)
274                .map_err(|e| PluginLoaderError::load(format!("Failed to read ZIP entry: {}", e)))?;
275
276            let outpath = match file.enclosed_name() {
277                Some(path) => dest.join(path),
278                None => continue,
279            };
280
281            if file.name().ends_with('/') {
282                fs::create_dir_all(&outpath).map_err(|e| {
283                    PluginLoaderError::fs(format!("Failed to create directory: {}", e))
284                })?;
285            } else {
286                if let Some(p) = outpath.parent() {
287                    fs::create_dir_all(p).map_err(|e| {
288                        PluginLoaderError::fs(format!("Failed to create parent directory: {}", e))
289                    })?;
290                }
291                let mut outfile = fs::File::create(&outpath)
292                    .map_err(|e| PluginLoaderError::fs(format!("Failed to create file: {}", e)))?;
293                std::io::copy(&mut file, &mut outfile)
294                    .map_err(|e| PluginLoaderError::fs(format!("Failed to extract file: {}", e)))?;
295            }
296        }
297
298        Ok(dest.to_path_buf())
299    }
300
301    /// Extract a tar.gz archive
302    async fn extract_tar_gz(&self, tar_path: &Path, dest: &Path) -> LoaderResult<PathBuf> {
303        tracing::info!("Extracting tar.gz archive to: {}", dest.display());
304
305        let file = fs::File::open(tar_path)
306            .map_err(|e| PluginLoaderError::fs(format!("Failed to open tar.gz file: {}", e)))?;
307
308        let decoder = flate2::read::GzDecoder::new(file);
309        let mut archive = tar::Archive::new(decoder);
310
311        fs::create_dir_all(dest)
312            .map_err(|e| PluginLoaderError::fs(format!("Failed to create directory: {}", e)))?;
313
314        archive.unpack(dest).map_err(|e| {
315            PluginLoaderError::load(format!("Failed to extract tar.gz archive: {}", e))
316        })?;
317
318        Ok(dest.to_path_buf())
319    }
320
321    /// Verify plugin checksum (SHA-256)
322    fn verify_checksum(&self, plugin_dir: &Path, expected_checksum: &str) -> LoaderResult<()> {
323        use ring::digest::{Context, SHA256};
324
325        tracing::info!("Verifying plugin checksum...");
326
327        // Find the main WASM file in the plugin directory
328        let wasm_file = self.find_wasm_file(plugin_dir)?;
329
330        // Calculate SHA-256 hash
331        let file_contents = fs::read(&wasm_file)
332            .map_err(|e| PluginLoaderError::fs(format!("Failed to read WASM file: {}", e)))?;
333
334        let mut context = Context::new(&SHA256);
335        context.update(&file_contents);
336        let digest = context.finish();
337        let calculated_checksum = hex::encode(digest.as_ref());
338
339        // Compare checksums
340        if calculated_checksum != expected_checksum {
341            return Err(PluginLoaderError::security(format!(
342                "Checksum verification failed! Expected: {}, Got: {}",
343                expected_checksum, calculated_checksum
344            )));
345        }
346
347        tracing::info!("Checksum verified successfully");
348        Ok(())
349    }
350
351    /// Find the main WASM file in a plugin directory
352    fn find_wasm_file(&self, plugin_dir: &Path) -> LoaderResult<PathBuf> {
353        for entry in fs::read_dir(plugin_dir)
354            .map_err(|e| PluginLoaderError::fs(format!("Failed to read directory: {}", e)))?
355        {
356            let entry =
357                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
358            let path = entry.path();
359            if path.extension().and_then(|s| s.to_str()) == Some("wasm") {
360                return Ok(path);
361            }
362        }
363        Err(PluginLoaderError::load("No .wasm file found in plugin directory"))
364    }
365
366    /// Generate a cache key from URL
367    fn generate_cache_key(&self, url: &str) -> String {
368        use ring::digest::{Context, SHA256};
369        let mut context = Context::new(&SHA256);
370        context.update(url.as_bytes());
371        let digest = context.finish();
372        hex::encode(digest.as_ref())
373    }
374
375    /// Clear the download cache
376    pub async fn clear_cache(&self) -> LoaderResult<()> {
377        if self.config.cache_dir.exists() {
378            async_fs::remove_dir_all(&self.config.cache_dir).await.map_err(|e| {
379                PluginLoaderError::fs(format!("Failed to clear cache directory: {}", e))
380            })?;
381            async_fs::create_dir_all(&self.config.cache_dir).await.map_err(|e| {
382                PluginLoaderError::fs(format!("Failed to recreate cache directory: {}", e))
383            })?;
384        }
385        Ok(())
386    }
387
388    /// Get the size of the download cache
389    pub fn get_cache_size(&self) -> LoaderResult<u64> {
390        let mut total_size = 0u64;
391
392        if !self.config.cache_dir.exists() {
393            return Ok(0);
394        }
395
396        for entry in fs::read_dir(&self.config.cache_dir)
397            .map_err(|e| PluginLoaderError::fs(format!("Failed to read cache directory: {}", e)))?
398        {
399            let entry =
400                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
401            let metadata = entry
402                .metadata()
403                .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata: {}", e)))?;
404
405            if metadata.is_file() {
406                total_size += metadata.len();
407            } else if metadata.is_dir() {
408                total_size += self.calculate_dir_size(&entry.path())?;
409            }
410        }
411
412        Ok(total_size)
413    }
414
415    /// Calculate the size of a directory recursively
416    #[allow(clippy::only_used_in_recursion)]
417    fn calculate_dir_size(&self, dir: &Path) -> LoaderResult<u64> {
418        let mut total_size = 0u64;
419
420        for entry in fs::read_dir(dir)
421            .map_err(|e| PluginLoaderError::fs(format!("Failed to read directory: {}", e)))?
422        {
423            let entry =
424                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
425            let metadata = entry
426                .metadata()
427                .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata: {}", e)))?;
428
429            if metadata.is_file() {
430                total_size += metadata.len();
431            } else if metadata.is_dir() {
432                total_size += self.calculate_dir_size(&entry.path())?;
433            }
434        }
435
436        Ok(total_size)
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[tokio::test]
445    async fn test_remote_loader_creation() {
446        let config = RemotePluginConfig::default();
447        let loader = RemotePluginLoader::new(config);
448        assert!(loader.is_ok());
449    }
450
451    #[tokio::test]
452    async fn test_cache_key_generation() {
453        let config = RemotePluginConfig::default();
454        let loader = RemotePluginLoader::new(config).unwrap();
455
456        let url = "https://example.com/plugin.zip";
457        let key1 = loader.generate_cache_key(url);
458        let key2 = loader.generate_cache_key(url);
459
460        // Same URL should generate same key
461        assert_eq!(key1, key2);
462
463        // Different URL should generate different key
464        let url2 = "https://example.com/other-plugin.zip";
465        let key3 = loader.generate_cache_key(url2);
466        assert_ne!(key1, key3);
467    }
468
469    #[tokio::test]
470    async fn test_clear_cache() {
471        let config = RemotePluginConfig::default();
472        let loader = RemotePluginLoader::new(config).unwrap();
473
474        let result = loader.clear_cache().await;
475        assert!(result.is_ok());
476    }
477
478    #[tokio::test]
479    async fn test_get_cache_size() {
480        let config = RemotePluginConfig::default();
481        let loader = RemotePluginLoader::new(config).unwrap();
482
483        let size = loader.get_cache_size();
484        assert!(size.is_ok());
485    }
486}