Skip to main content

rustbridge_bundle/
loader.rs

1//! Bundle loading utilities.
2//!
3//! The [`BundleLoader`] provides functionality to load and extract plugin bundles.
4
5use crate::builder::verify_sha256;
6use crate::{BundleError, BundleResult, MANIFEST_FILE, Manifest, Platform};
7use std::fs::{self, File};
8use std::io::Read;
9use std::path::{Path, PathBuf};
10use zip::ZipArchive;
11
12/// Loader for plugin bundles.
13///
14/// # Example
15///
16/// ```no_run
17/// use rustbridge_bundle::loader::BundleLoader;
18///
19/// let mut loader = BundleLoader::open("my-plugin-1.0.0.rbp")?;
20/// let manifest = loader.manifest();
21///
22/// // Extract library for current platform to temp directory
23/// let library_path = loader.extract_library_for_current_platform("/tmp/plugins")?;
24/// # Ok::<(), rustbridge_bundle::BundleError>(())
25/// ```
26#[derive(Debug)]
27pub struct BundleLoader {
28    archive: ZipArchive<File>,
29    manifest: Manifest,
30}
31
32impl BundleLoader {
33    /// Open a bundle file for reading.
34    pub fn open<P: AsRef<Path>>(path: P) -> BundleResult<Self> {
35        let path = path.as_ref();
36        let file = File::open(path)?;
37        let mut archive = ZipArchive::new(file)?;
38
39        // Read and parse manifest
40        let manifest = {
41            let mut manifest_file = archive.by_name(MANIFEST_FILE).map_err(|_| {
42                BundleError::MissingFile(format!("{MANIFEST_FILE} not found in bundle"))
43            })?;
44
45            let mut manifest_json = String::new();
46            manifest_file.read_to_string(&mut manifest_json)?;
47            Manifest::from_json(&manifest_json)?
48        };
49
50        // Validate manifest
51        manifest.validate()?;
52
53        Ok(Self { archive, manifest })
54    }
55
56    /// Get the bundle manifest.
57    #[must_use]
58    pub fn manifest(&self) -> &Manifest {
59        &self.manifest
60    }
61
62    /// Check if the bundle supports the current platform.
63    #[must_use]
64    pub fn supports_current_platform(&self) -> bool {
65        Platform::current()
66            .map(|p| self.manifest.supports_platform(p))
67            .unwrap_or(false)
68    }
69
70    /// Get the platform info for the current platform.
71    #[must_use]
72    pub fn current_platform_info(&self) -> Option<&crate::PlatformInfo> {
73        Platform::current().and_then(|p| self.manifest.get_platform(p))
74    }
75
76    /// Extract the library for a specific platform to a directory.
77    ///
78    /// Extracts the release variant (default). For other variants,
79    /// use `extract_library_variant` instead.
80    ///
81    /// Returns the path to the extracted library file.
82    pub fn extract_library<P: AsRef<Path>>(
83        &mut self,
84        platform: Platform,
85        output_dir: P,
86    ) -> BundleResult<PathBuf> {
87        self.extract_library_variant(platform, "release", output_dir)
88    }
89
90    /// Extract a specific variant of the library for a platform.
91    ///
92    /// Returns the path to the extracted library file.
93    pub fn extract_library_variant<P: AsRef<Path>>(
94        &mut self,
95        platform: Platform,
96        variant: &str,
97        output_dir: P,
98    ) -> BundleResult<PathBuf> {
99        let output_dir = output_dir.as_ref();
100
101        // Get platform info from manifest
102        let platform_info = self.manifest.get_platform(platform).ok_or_else(|| {
103            BundleError::UnsupportedPlatform(format!(
104                "Platform {} not found in bundle",
105                platform.as_str()
106            ))
107        })?;
108
109        // Get the specific variant
110        let variant_info =
111            platform_info
112                .variant(variant)
113                .ok_or_else(|| BundleError::VariantNotFound {
114                    platform: platform.as_str().to_string(),
115                    variant: variant.to_string(),
116                })?;
117
118        let library_path = variant_info.library.clone();
119        let expected_checksum = variant_info.checksum.clone();
120
121        // Read the library from the archive
122        let contents = {
123            let mut library_file = self.archive.by_name(&library_path).map_err(|_| {
124                BundleError::MissingFile(format!("Library not found in bundle: {library_path}"))
125            })?;
126
127            let mut contents = Vec::new();
128            library_file.read_to_end(&mut contents)?;
129            contents
130        };
131
132        // Verify checksum
133        if !verify_sha256(&contents, &expected_checksum) {
134            let actual = crate::builder::compute_sha256(&contents);
135            return Err(BundleError::ChecksumMismatch {
136                path: library_path,
137                expected: expected_checksum,
138                actual: format!("sha256:{actual}"),
139            });
140        }
141
142        // Create output directory
143        fs::create_dir_all(output_dir)?;
144
145        // Determine output filename
146        let file_name = Path::new(&library_path)
147            .file_name()
148            .ok_or_else(|| BundleError::InvalidManifest("Invalid library path".to_string()))?;
149
150        let output_path = output_dir.join(file_name);
151
152        // Write the library file
153        fs::write(&output_path, &contents)?;
154
155        // Set executable permissions on Unix
156        #[cfg(unix)]
157        {
158            use std::os::unix::fs::PermissionsExt;
159            let mut perms = fs::metadata(&output_path)?.permissions();
160            perms.set_mode(0o755);
161            fs::set_permissions(&output_path, perms)?;
162        }
163
164        Ok(output_path)
165    }
166
167    /// List all available variants for a platform.
168    #[must_use]
169    pub fn list_variants(&self, platform: Platform) -> Vec<&str> {
170        self.manifest.list_variants(platform)
171    }
172
173    /// Check if a specific variant exists for a platform.
174    #[must_use]
175    pub fn has_variant(&self, platform: Platform, variant: &str) -> bool {
176        self.manifest
177            .get_platform(platform)
178            .map(|p| p.has_variant(variant))
179            .unwrap_or(false)
180    }
181
182    /// Get the build info from the manifest.
183    #[must_use]
184    pub fn build_info(&self) -> Option<&crate::BuildInfo> {
185        self.manifest.get_build_info()
186    }
187
188    /// Get the SBOM info from the manifest.
189    #[must_use]
190    pub fn sbom(&self) -> Option<&crate::Sbom> {
191        self.manifest.get_sbom()
192    }
193
194    /// Extract the library for the current platform to a directory.
195    ///
196    /// Returns the path to the extracted library file.
197    pub fn extract_library_for_current_platform<P: AsRef<Path>>(
198        &mut self,
199        output_dir: P,
200    ) -> BundleResult<PathBuf> {
201        let platform = Platform::current().ok_or_else(|| {
202            BundleError::UnsupportedPlatform("Current platform is not supported".to_string())
203        })?;
204
205        self.extract_library(platform, output_dir)
206    }
207
208    /// Read a file from the bundle as bytes.
209    pub fn read_file(&mut self, path: &str) -> BundleResult<Vec<u8>> {
210        let mut file = self
211            .archive
212            .by_name(path)
213            .map_err(|_| BundleError::MissingFile(format!("File not found in bundle: {path}")))?;
214
215        let mut contents = Vec::new();
216        file.read_to_end(&mut contents)?;
217        Ok(contents)
218    }
219
220    /// Read a file from the bundle as a string.
221    pub fn read_file_string(&mut self, path: &str) -> BundleResult<String> {
222        let mut file = self
223            .archive
224            .by_name(path)
225            .map_err(|_| BundleError::MissingFile(format!("File not found in bundle: {path}")))?;
226
227        let mut contents = String::new();
228        file.read_to_string(&mut contents)?;
229        Ok(contents)
230    }
231
232    /// List all files in the bundle.
233    #[must_use]
234    pub fn list_files(&self) -> Vec<String> {
235        (0..self.archive.len())
236            .filter_map(|i| self.archive.name_for_index(i).map(String::from))
237            .collect()
238    }
239
240    /// Check if a file exists in the bundle.
241    #[must_use]
242    pub fn has_file(&self, path: &str) -> bool {
243        self.archive.index_for_name(path).is_some()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    #![allow(non_snake_case)]
250
251    use super::*;
252    use crate::builder::{BundleBuilder, compute_sha256};
253    use std::io::Write;
254    use tempfile::TempDir;
255
256    fn create_test_bundle(temp_dir: &TempDir) -> PathBuf {
257        let bundle_path = temp_dir.path().join("test.rbp");
258
259        // Create a fake library file
260        let lib_path = temp_dir.path().join("libtest.so");
261        fs::write(&lib_path, b"fake library contents").unwrap();
262
263        // Build the bundle
264        let manifest = Manifest::new("test-plugin", "1.0.0");
265        BundleBuilder::new(manifest)
266            .add_library(Platform::LinuxX86_64, &lib_path)
267            .unwrap()
268            .add_bytes("schema/messages.h", b"// header".to_vec())
269            .write(&bundle_path)
270            .unwrap();
271
272        bundle_path
273    }
274
275    fn create_multi_platform_bundle(temp_dir: &TempDir) -> PathBuf {
276        let bundle_path = temp_dir.path().join("multi.rbp");
277
278        let linux_lib = temp_dir.path().join("libtest.so");
279        let macos_lib = temp_dir.path().join("libtest.dylib");
280        let windows_lib = temp_dir.path().join("test.dll");
281        fs::write(&linux_lib, b"linux library").unwrap();
282        fs::write(&macos_lib, b"macos library").unwrap();
283        fs::write(&windows_lib, b"windows library").unwrap();
284
285        let manifest = Manifest::new("multi-platform", "2.0.0");
286        BundleBuilder::new(manifest)
287            .add_library(Platform::LinuxX86_64, &linux_lib)
288            .unwrap()
289            .add_library(Platform::DarwinAarch64, &macos_lib)
290            .unwrap()
291            .add_library(Platform::WindowsX86_64, &windows_lib)
292            .unwrap()
293            .write(&bundle_path)
294            .unwrap();
295
296        bundle_path
297    }
298
299    #[test]
300    fn BundleLoader___open___reads_manifest() {
301        let temp_dir = TempDir::new().unwrap();
302        let bundle_path = create_test_bundle(&temp_dir);
303
304        let loader = BundleLoader::open(&bundle_path).unwrap();
305
306        assert_eq!(loader.manifest().plugin.name, "test-plugin");
307        assert_eq!(loader.manifest().plugin.version, "1.0.0");
308    }
309
310    #[test]
311    fn BundleLoader___open___nonexistent_file___returns_error() {
312        let result = BundleLoader::open("/nonexistent/bundle.rbp");
313
314        assert!(result.is_err());
315    }
316
317    #[test]
318    fn BundleLoader___open___not_a_zip___returns_error() {
319        let temp_dir = TempDir::new().unwrap();
320        let fake_bundle = temp_dir.path().join("fake.rbp");
321        fs::write(&fake_bundle, b"not a zip file").unwrap();
322
323        let result = BundleLoader::open(&fake_bundle);
324
325        assert!(result.is_err());
326    }
327
328    #[test]
329    fn BundleLoader___open___missing_manifest___returns_error() {
330        let temp_dir = TempDir::new().unwrap();
331        let bundle_path = temp_dir.path().join("no-manifest.rbp");
332
333        // Create a ZIP without manifest.json
334        let file = File::create(&bundle_path).unwrap();
335        let mut zip = zip::ZipWriter::new(file);
336        let options = zip::write::SimpleFileOptions::default();
337        zip.start_file("some-file.txt", options).unwrap();
338        zip.write_all(b"content").unwrap();
339        zip.finish().unwrap();
340
341        let result = BundleLoader::open(&bundle_path);
342
343        assert!(result.is_err());
344        let err = result.unwrap_err();
345        assert!(matches!(err, BundleError::MissingFile(_)));
346        assert!(err.to_string().contains("manifest.json"));
347    }
348
349    #[test]
350    fn BundleLoader___open___invalid_manifest_json___returns_error() {
351        let temp_dir = TempDir::new().unwrap();
352        let bundle_path = temp_dir.path().join("bad-manifest.rbp");
353
354        // Create a ZIP with invalid JSON in manifest
355        let file = File::create(&bundle_path).unwrap();
356        let mut zip = zip::ZipWriter::new(file);
357        let options = zip::write::SimpleFileOptions::default();
358        zip.start_file("manifest.json", options).unwrap();
359        zip.write_all(b"{ invalid json }").unwrap();
360        zip.finish().unwrap();
361
362        let result = BundleLoader::open(&bundle_path);
363
364        assert!(result.is_err());
365    }
366
367    #[test]
368    fn BundleLoader___list_files___returns_all_files() {
369        let temp_dir = TempDir::new().unwrap();
370        let bundle_path = create_test_bundle(&temp_dir);
371
372        let loader = BundleLoader::open(&bundle_path).unwrap();
373        let files = loader.list_files();
374
375        assert!(files.contains(&"manifest.json".to_string()));
376        assert!(files.contains(&"schema/messages.h".to_string()));
377    }
378
379    #[test]
380    fn BundleLoader___has_file___returns_true_for_existing() {
381        let temp_dir = TempDir::new().unwrap();
382        let bundle_path = create_test_bundle(&temp_dir);
383
384        let loader = BundleLoader::open(&bundle_path).unwrap();
385
386        assert!(loader.has_file("manifest.json"));
387        assert!(loader.has_file("schema/messages.h"));
388        assert!(!loader.has_file("nonexistent.txt"));
389    }
390
391    #[test]
392    fn BundleLoader___read_file___returns_contents() {
393        let temp_dir = TempDir::new().unwrap();
394        let bundle_path = create_test_bundle(&temp_dir);
395
396        let mut loader = BundleLoader::open(&bundle_path).unwrap();
397        let contents = loader.read_file_string("schema/messages.h").unwrap();
398
399        assert_eq!(contents, "// header");
400    }
401
402    #[test]
403    fn BundleLoader___read_file___missing_file___returns_error() {
404        let temp_dir = TempDir::new().unwrap();
405        let bundle_path = create_test_bundle(&temp_dir);
406
407        let mut loader = BundleLoader::open(&bundle_path).unwrap();
408        let result = loader.read_file("nonexistent.txt");
409
410        assert!(result.is_err());
411        let err = result.unwrap_err();
412        assert!(matches!(err, BundleError::MissingFile(_)));
413    }
414
415    #[test]
416    fn BundleLoader___read_file___returns_bytes() {
417        let temp_dir = TempDir::new().unwrap();
418        let bundle_path = create_test_bundle(&temp_dir);
419
420        let mut loader = BundleLoader::open(&bundle_path).unwrap();
421        let contents = loader.read_file("schema/messages.h").unwrap();
422
423        assert_eq!(contents, b"// header");
424    }
425
426    #[test]
427    fn BundleLoader___extract_library___verifies_checksum() {
428        let temp_dir = TempDir::new().unwrap();
429        let bundle_path = create_test_bundle(&temp_dir);
430        let extract_dir = temp_dir.path().join("extracted");
431
432        let mut loader = BundleLoader::open(&bundle_path).unwrap();
433        let lib_path = loader
434            .extract_library(Platform::LinuxX86_64, &extract_dir)
435            .unwrap();
436
437        assert!(lib_path.exists());
438        let contents = fs::read(&lib_path).unwrap();
439        assert_eq!(contents, b"fake library contents");
440    }
441
442    #[test]
443    fn BundleLoader___extract_library___unsupported_platform___returns_error() {
444        let temp_dir = TempDir::new().unwrap();
445        let bundle_path = create_test_bundle(&temp_dir);
446        let extract_dir = temp_dir.path().join("extracted");
447
448        let mut loader = BundleLoader::open(&bundle_path).unwrap();
449        let result = loader.extract_library(Platform::WindowsX86_64, &extract_dir);
450
451        assert!(result.is_err());
452        let err = result.unwrap_err();
453        assert!(matches!(err, BundleError::UnsupportedPlatform(_)));
454    }
455
456    #[test]
457    fn BundleLoader___extract_library___creates_output_directory() {
458        let temp_dir = TempDir::new().unwrap();
459        let bundle_path = create_test_bundle(&temp_dir);
460        let extract_dir = temp_dir.path().join("deep").join("nested").join("dir");
461
462        let mut loader = BundleLoader::open(&bundle_path).unwrap();
463        let lib_path = loader
464            .extract_library(Platform::LinuxX86_64, &extract_dir)
465            .unwrap();
466
467        assert!(extract_dir.exists());
468        assert!(lib_path.exists());
469    }
470
471    #[test]
472    fn BundleLoader___multi_platform___extract_each_platform() {
473        let temp_dir = TempDir::new().unwrap();
474        let bundle_path = create_multi_platform_bundle(&temp_dir);
475
476        let mut loader = BundleLoader::open(&bundle_path).unwrap();
477
478        // Verify all three platforms are supported
479        assert!(loader.manifest().supports_platform(Platform::LinuxX86_64));
480        assert!(loader.manifest().supports_platform(Platform::DarwinAarch64));
481        assert!(loader.manifest().supports_platform(Platform::WindowsX86_64));
482
483        // Extract Linux
484        let linux_dir = temp_dir.path().join("linux");
485        let linux_lib = loader
486            .extract_library(Platform::LinuxX86_64, &linux_dir)
487            .unwrap();
488        assert_eq!(fs::read(&linux_lib).unwrap(), b"linux library");
489
490        // Extract macOS
491        let macos_dir = temp_dir.path().join("macos");
492        let macos_lib = loader
493            .extract_library(Platform::DarwinAarch64, &macos_dir)
494            .unwrap();
495        assert_eq!(fs::read(&macos_lib).unwrap(), b"macos library");
496
497        // Extract Windows
498        let windows_dir = temp_dir.path().join("windows");
499        let windows_lib = loader
500            .extract_library(Platform::WindowsX86_64, &windows_dir)
501            .unwrap();
502        assert_eq!(fs::read(&windows_lib).unwrap(), b"windows library");
503    }
504
505    #[test]
506    fn BundleLoader___supports_current_platform___returns_correct_value() {
507        let temp_dir = TempDir::new().unwrap();
508        let bundle_path = create_test_bundle(&temp_dir);
509
510        let loader = BundleLoader::open(&bundle_path).unwrap();
511
512        // This test will pass on Linux x86_64, fail on other platforms
513        // which is expected behavior
514        if Platform::current() == Some(Platform::LinuxX86_64) {
515            assert!(loader.supports_current_platform());
516        }
517    }
518
519    #[test]
520    fn BundleLoader___current_platform_info___returns_info_when_supported() {
521        let temp_dir = TempDir::new().unwrap();
522        let bundle_path = create_test_bundle(&temp_dir);
523
524        let loader = BundleLoader::open(&bundle_path).unwrap();
525
526        if Platform::current() == Some(Platform::LinuxX86_64) {
527            let info = loader.current_platform_info();
528            assert!(info.is_some());
529            let release = info.unwrap().release().unwrap();
530            assert!(release.library.contains("libtest.so"));
531        }
532    }
533
534    #[test]
535    fn roundtrip___create_and_load___preserves_all_data() {
536        let temp_dir = TempDir::new().unwrap();
537        let bundle_path = temp_dir.path().join("roundtrip.rbp");
538
539        // Create library
540        let lib_path = temp_dir.path().join("libplugin.so");
541        let lib_contents = b"roundtrip test library";
542        fs::write(&lib_path, lib_contents).unwrap();
543
544        // Create bundle with metadata
545        let mut manifest = Manifest::new("roundtrip-plugin", "3.2.1");
546        manifest.plugin.description = Some("A test plugin for roundtrip".to_string());
547        manifest.plugin.authors = vec!["Author One".to_string(), "Author Two".to_string()];
548        manifest.plugin.license = Some("MIT".to_string());
549
550        BundleBuilder::new(manifest)
551            .add_library(Platform::LinuxX86_64, &lib_path)
552            .unwrap()
553            .add_bytes("docs/README.md", b"# Documentation".to_vec())
554            .write(&bundle_path)
555            .unwrap();
556
557        // Load and verify
558        let mut loader = BundleLoader::open(&bundle_path).unwrap();
559
560        assert_eq!(loader.manifest().plugin.name, "roundtrip-plugin");
561        assert_eq!(loader.manifest().plugin.version, "3.2.1");
562        assert_eq!(
563            loader.manifest().plugin.description,
564            Some("A test plugin for roundtrip".to_string())
565        );
566        assert_eq!(loader.manifest().plugin.authors.len(), 2);
567        assert_eq!(loader.manifest().plugin.license, Some("MIT".to_string()));
568
569        // Verify checksum in manifest matches actual content
570        let platform_info = loader
571            .manifest()
572            .get_platform(Platform::LinuxX86_64)
573            .unwrap();
574        let release = platform_info.release().unwrap();
575        let expected_checksum = format!("sha256:{}", compute_sha256(lib_contents));
576        assert_eq!(release.checksum, expected_checksum);
577
578        // Verify library extraction
579        let extract_dir = temp_dir.path().join("extract");
580        let extracted = loader
581            .extract_library(Platform::LinuxX86_64, &extract_dir)
582            .unwrap();
583        assert_eq!(fs::read(&extracted).unwrap(), lib_contents);
584    }
585
586    #[test]
587    fn roundtrip___bundle_with_schemas___preserves_schema_info() {
588        let temp_dir = TempDir::new().unwrap();
589        let bundle_path = temp_dir.path().join("schema-bundle.rbp");
590
591        let lib_path = temp_dir.path().join("libtest.so");
592        let header_path = temp_dir.path().join("messages.h");
593        let json_path = temp_dir.path().join("schema.json");
594        fs::write(&lib_path, b"lib").unwrap();
595        fs::write(&header_path, b"#pragma once").unwrap();
596        fs::write(&json_path, b"{}").unwrap();
597
598        let manifest = Manifest::new("schema-plugin", "1.0.0");
599        BundleBuilder::new(manifest)
600            .add_library(Platform::LinuxX86_64, &lib_path)
601            .unwrap()
602            .add_schema_file(&header_path, "messages.h")
603            .unwrap()
604            .add_schema_file(&json_path, "schema.json")
605            .unwrap()
606            .write(&bundle_path)
607            .unwrap();
608
609        let mut loader = BundleLoader::open(&bundle_path).unwrap();
610
611        // Verify schemas are in manifest
612        assert_eq!(loader.manifest().schemas.len(), 2);
613        assert!(loader.manifest().schemas.contains_key("messages.h"));
614        assert!(loader.manifest().schemas.contains_key("schema.json"));
615
616        // Verify schema files can be read
617        assert!(loader.has_file("schema/messages.h"));
618        assert!(loader.has_file("schema/schema.json"));
619        assert_eq!(
620            loader.read_file_string("schema/messages.h").unwrap(),
621            "#pragma once"
622        );
623    }
624}