1use 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#[derive(Debug, Clone)]
28pub struct RemotePluginConfig {
29 pub max_download_size: u64,
31 pub timeout: Duration,
33 pub max_retries: u32,
35 pub cache_dir: PathBuf,
37 pub verify_ssl: bool,
39 pub show_progress: bool,
41}
42
43impl Default for RemotePluginConfig {
44 fn default() -> Self {
45 Self {
46 max_download_size: 100 * 1024 * 1024, timeout: Duration::from_secs(300), 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
59pub struct RemotePluginLoader {
61 config: RemotePluginConfig,
62 client: Client,
63}
64
65impl RemotePluginLoader {
66 pub fn new(config: RemotePluginConfig) -> LoaderResult<Self> {
68 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 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 pub async fn download_from_url(&self, url: &str) -> LoaderResult<PathBuf> {
97 tracing::info!("Downloading plugin from URL: {}", url);
98
99 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 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 let temp_file = self.download_with_progress(url, file_name).await?;
119
120 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 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 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 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 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 if let Some(checksum) = expected_checksum {
174 self.verify_checksum(&plugin_dir, checksum)?;
175 }
176
177 Ok(plugin_dir)
178 }
179
180 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 let total_size = response.content_length();
195
196 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 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 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 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 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 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 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 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 let wasm_file = self.find_wasm_file(plugin_dir)?;
329
330 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 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 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 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 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 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 #[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 assert_eq!(key1, key2);
462
463 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}