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 top-level 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 top-level SBOM info from the manifest.
189    #[must_use]
190    pub fn sbom(&self) -> Option<&crate::Sbom> {
191        self.manifest.get_sbom()
192    }
193
194    /// Get the effective build info for a platform/variant (variant-level overrides top-level).
195    #[must_use]
196    pub fn effective_build_info(&self, platform: &str, variant: &str) -> Option<&crate::BuildInfo> {
197        self.manifest.get_effective_build_info(platform, variant)
198    }
199
200    /// Get the effective SBOM for a platform/variant (variant-level overrides top-level).
201    #[must_use]
202    pub fn effective_sbom(&self, platform: &str, variant: &str) -> Option<&crate::Sbom> {
203        self.manifest.get_effective_sbom(platform, variant)
204    }
205
206    /// Extract the library for the current platform to a directory.
207    ///
208    /// Returns the path to the extracted library file.
209    pub fn extract_library_for_current_platform<P: AsRef<Path>>(
210        &mut self,
211        output_dir: P,
212    ) -> BundleResult<PathBuf> {
213        let platform = Platform::current().ok_or_else(|| {
214            BundleError::UnsupportedPlatform("Current platform is not supported".to_string())
215        })?;
216
217        self.extract_library(platform, output_dir)
218    }
219
220    /// Read a file from the bundle as bytes.
221    pub fn read_file(&mut self, path: &str) -> BundleResult<Vec<u8>> {
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 = Vec::new();
228        file.read_to_end(&mut contents)?;
229        Ok(contents)
230    }
231
232    /// Read a file from the bundle as a string.
233    pub fn read_file_string(&mut self, path: &str) -> BundleResult<String> {
234        let mut file = self
235            .archive
236            .by_name(path)
237            .map_err(|_| BundleError::MissingFile(format!("File not found in bundle: {path}")))?;
238
239        let mut contents = String::new();
240        file.read_to_string(&mut contents)?;
241        Ok(contents)
242    }
243
244    /// List all files in the bundle.
245    #[must_use]
246    pub fn list_files(&self) -> Vec<String> {
247        (0..self.archive.len())
248            .filter_map(|i| self.archive.name_for_index(i).map(String::from))
249            .collect()
250    }
251
252    /// Check if a file exists in the bundle.
253    #[must_use]
254    pub fn has_file(&self, path: &str) -> bool {
255        self.archive.index_for_name(path).is_some()
256    }
257
258    /// Check if the bundle has signature files.
259    #[must_use]
260    pub fn has_signatures(&self) -> bool {
261        self.has_file("manifest.json.minisig")
262    }
263
264    /// Get the public key from the manifest.
265    #[must_use]
266    pub fn public_key(&self) -> Option<&str> {
267        self.manifest.public_key.as_deref()
268    }
269
270    /// Verify the manifest signature.
271    ///
272    /// Returns Ok(()) if verification succeeds, or an error if it fails.
273    /// If no public key is available, returns `BundleError::NoPublicKey`.
274    pub fn verify_manifest_signature(&mut self) -> BundleResult<()> {
275        self.verify_manifest_signature_with_key(None)
276    }
277
278    /// Verify the manifest signature with an optional key override.
279    ///
280    /// # Arguments
281    ///
282    /// * `public_key_override` - If provided, use this key instead of the manifest's key
283    pub fn verify_manifest_signature_with_key(
284        &mut self,
285        public_key_override: Option<&str>,
286    ) -> BundleResult<()> {
287        // Clone the public key to avoid borrow issues
288        let public_key = public_key_override
289            .map(String::from)
290            .or_else(|| self.manifest.public_key.clone())
291            .ok_or(BundleError::NoPublicKey)?;
292
293        // Read manifest data
294        let manifest_data = self.read_file(MANIFEST_FILE)?;
295
296        // Read signature
297        let sig_path = format!("{MANIFEST_FILE}.minisig");
298        let sig_data = self.read_file(&sig_path)?;
299        let signature = String::from_utf8(sig_data).map_err(|e| {
300            BundleError::SignatureVerificationFailed(format!("Invalid signature encoding: {e}"))
301        })?;
302
303        // Verify
304        verify_minisign_signature(&public_key, &manifest_data, &signature)
305    }
306
307    /// Verify a library signature.
308    ///
309    /// # Arguments
310    ///
311    /// * `library_path` - Path to the library within the bundle
312    /// * `library_data` - The library file contents
313    pub fn verify_library_signature(
314        &mut self,
315        library_path: &str,
316        library_data: &[u8],
317    ) -> BundleResult<()> {
318        self.verify_library_signature_with_key(library_path, library_data, None)
319    }
320
321    /// Verify a library signature with an optional key override.
322    pub fn verify_library_signature_with_key(
323        &mut self,
324        library_path: &str,
325        library_data: &[u8],
326        public_key_override: Option<&str>,
327    ) -> BundleResult<()> {
328        // Clone the public key to avoid borrow issues
329        let public_key = public_key_override
330            .map(String::from)
331            .or_else(|| self.manifest.public_key.clone())
332            .ok_or(BundleError::NoPublicKey)?;
333
334        // Read signature
335        let sig_path = format!("{library_path}.minisig");
336        let sig_data = self.read_file(&sig_path)?;
337        let signature = String::from_utf8(sig_data).map_err(|e| {
338            BundleError::SignatureVerificationFailed(format!("Invalid signature encoding: {e}"))
339        })?;
340
341        // Verify
342        verify_minisign_signature(&public_key, library_data, &signature)
343    }
344
345    /// Extract and verify a library for a platform.
346    ///
347    /// This method extracts the library and verifies both the checksum and signature.
348    ///
349    /// # Arguments
350    ///
351    /// * `platform` - Target platform
352    /// * `output_dir` - Directory to extract to
353    /// * `verify_signature` - Whether to verify the signature
354    /// * `public_key_override` - Optional public key override
355    pub fn extract_library_verified<P: AsRef<Path>>(
356        &mut self,
357        platform: Platform,
358        output_dir: P,
359        verify_signature: bool,
360        public_key_override: Option<&str>,
361    ) -> BundleResult<PathBuf> {
362        // Verify manifest signature first if requested
363        if verify_signature {
364            self.verify_manifest_signature_with_key(public_key_override)?;
365        }
366
367        let output_dir = output_dir.as_ref();
368
369        // Get platform info from manifest
370        let platform_info = self.manifest.get_platform(platform).ok_or_else(|| {
371            BundleError::UnsupportedPlatform(format!(
372                "Platform {} not found in bundle",
373                platform.as_str()
374            ))
375        })?;
376
377        // Get the release variant
378        let variant_info = platform_info
379            .release()
380            .ok_or_else(|| BundleError::VariantNotFound {
381                platform: platform.as_str().to_string(),
382                variant: "release".to_string(),
383            })?;
384
385        let library_path = variant_info.library.clone();
386        let expected_checksum = variant_info.checksum.clone();
387
388        // Read the library from the archive
389        let contents = self.read_file(&library_path)?;
390
391        // Verify checksum
392        if !verify_sha256(&contents, &expected_checksum) {
393            let actual = crate::builder::compute_sha256(&contents);
394            return Err(BundleError::ChecksumMismatch {
395                path: library_path,
396                expected: expected_checksum,
397                actual: format!("sha256:{actual}"),
398            });
399        }
400
401        // Verify library signature if requested
402        if verify_signature {
403            self.verify_library_signature_with_key(&library_path, &contents, public_key_override)?;
404        }
405
406        // Create output directory
407        fs::create_dir_all(output_dir)?;
408
409        // Determine output filename
410        let file_name = Path::new(&library_path)
411            .file_name()
412            .ok_or_else(|| BundleError::InvalidManifest("Invalid library path".to_string()))?;
413
414        let output_path = output_dir.join(file_name);
415
416        // Write the library file
417        fs::write(&output_path, &contents)?;
418
419        // Set executable permissions on Unix
420        #[cfg(unix)]
421        {
422            use std::os::unix::fs::PermissionsExt;
423            let mut perms = fs::metadata(&output_path)?.permissions();
424            perms.set_mode(0o755);
425            fs::set_permissions(&output_path, perms)?;
426        }
427
428        Ok(output_path)
429    }
430}
431
432/// Verify a minisign signature.
433///
434/// Minisign format:
435/// - Public key: 2 bytes algorithm ID ("Ed") + 8 bytes key ID + 32 bytes Ed25519 key
436/// - Signature: 2 bytes algorithm ID + 8 bytes key ID + 64 bytes signature
437///   - "ED" (0x45, 0x44) = prehashed with BLAKE2b-512
438///   - "Ed" (0x45, 0x64) = legacy non-prehashed
439fn verify_minisign_signature(
440    public_key_base64: &str,
441    data: &[u8],
442    signature_content: &str,
443) -> BundleResult<()> {
444    use minisign::{PublicKey, SignatureBox};
445
446    // Parse public key
447    let public_key = PublicKey::from_base64(public_key_base64).map_err(|e| {
448        BundleError::SignatureVerificationFailed(format!("Invalid public key: {e}"))
449    })?;
450
451    // Parse signature (second line of the minisig file)
452    let signature_box = SignatureBox::from_string(signature_content).map_err(|e| {
453        BundleError::SignatureVerificationFailed(format!("Invalid signature format: {e}"))
454    })?;
455
456    // Verify - minisign::verify handles prehashing automatically
457    let mut data_reader = std::io::Cursor::new(data);
458    minisign::verify(
459        &public_key,
460        &signature_box,
461        &mut data_reader,
462        true,
463        false,
464        false,
465    )
466    .map_err(|e| {
467        BundleError::SignatureVerificationFailed(format!("Signature verification failed: {e}"))
468    })?;
469
470    Ok(())
471}
472
473#[cfg(test)]
474mod tests {
475    #![allow(non_snake_case)]
476
477    use super::*;
478    use crate::builder::{BundleBuilder, compute_sha256};
479    use std::io::Write;
480    use tempfile::TempDir;
481
482    fn create_test_bundle(temp_dir: &TempDir) -> PathBuf {
483        let bundle_path = temp_dir.path().join("test.rbp");
484
485        // Create a fake library file
486        let lib_path = temp_dir.path().join("libtest.so");
487        fs::write(&lib_path, b"fake library contents").unwrap();
488
489        // Build the bundle
490        let manifest = Manifest::new("test-plugin", "1.0.0");
491        BundleBuilder::new(manifest)
492            .add_library(Platform::LinuxX86_64, &lib_path)
493            .unwrap()
494            .add_bytes("schema/messages.h", b"// header".to_vec())
495            .write(&bundle_path)
496            .unwrap();
497
498        bundle_path
499    }
500
501    fn create_multi_platform_bundle(temp_dir: &TempDir) -> PathBuf {
502        let bundle_path = temp_dir.path().join("multi.rbp");
503
504        let linux_lib = temp_dir.path().join("libtest.so");
505        let macos_lib = temp_dir.path().join("libtest.dylib");
506        let windows_lib = temp_dir.path().join("test.dll");
507        fs::write(&linux_lib, b"linux library").unwrap();
508        fs::write(&macos_lib, b"macos library").unwrap();
509        fs::write(&windows_lib, b"windows library").unwrap();
510
511        let manifest = Manifest::new("multi-platform", "2.0.0");
512        BundleBuilder::new(manifest)
513            .add_library(Platform::LinuxX86_64, &linux_lib)
514            .unwrap()
515            .add_library(Platform::DarwinAarch64, &macos_lib)
516            .unwrap()
517            .add_library(Platform::WindowsX86_64, &windows_lib)
518            .unwrap()
519            .write(&bundle_path)
520            .unwrap();
521
522        bundle_path
523    }
524
525    #[test]
526    fn BundleLoader___open___reads_manifest() {
527        let temp_dir = TempDir::new().unwrap();
528        let bundle_path = create_test_bundle(&temp_dir);
529
530        let loader = BundleLoader::open(&bundle_path).unwrap();
531
532        assert_eq!(loader.manifest().plugin.name, "test-plugin");
533        assert_eq!(loader.manifest().plugin.version, "1.0.0");
534    }
535
536    #[test]
537    fn BundleLoader___open___nonexistent_file___returns_error() {
538        let result = BundleLoader::open("/nonexistent/bundle.rbp");
539
540        assert!(result.is_err());
541    }
542
543    #[test]
544    fn BundleLoader___open___not_a_zip___returns_error() {
545        let temp_dir = TempDir::new().unwrap();
546        let fake_bundle = temp_dir.path().join("fake.rbp");
547        fs::write(&fake_bundle, b"not a zip file").unwrap();
548
549        let result = BundleLoader::open(&fake_bundle);
550
551        assert!(result.is_err());
552    }
553
554    #[test]
555    fn BundleLoader___open___missing_manifest___returns_error() {
556        let temp_dir = TempDir::new().unwrap();
557        let bundle_path = temp_dir.path().join("no-manifest.rbp");
558
559        // Create a ZIP without manifest.json
560        let file = File::create(&bundle_path).unwrap();
561        let mut zip = zip::ZipWriter::new(file);
562        let options = zip::write::SimpleFileOptions::default();
563        zip.start_file("some-file.txt", options).unwrap();
564        zip.write_all(b"content").unwrap();
565        zip.finish().unwrap();
566
567        let result = BundleLoader::open(&bundle_path);
568
569        assert!(result.is_err());
570        let err = result.unwrap_err();
571        assert!(matches!(err, BundleError::MissingFile(_)));
572        assert!(err.to_string().contains("manifest.json"));
573    }
574
575    #[test]
576    fn BundleLoader___open___invalid_manifest_json___returns_error() {
577        let temp_dir = TempDir::new().unwrap();
578        let bundle_path = temp_dir.path().join("bad-manifest.rbp");
579
580        // Create a ZIP with invalid JSON in manifest
581        let file = File::create(&bundle_path).unwrap();
582        let mut zip = zip::ZipWriter::new(file);
583        let options = zip::write::SimpleFileOptions::default();
584        zip.start_file("manifest.json", options).unwrap();
585        zip.write_all(b"{ invalid json }").unwrap();
586        zip.finish().unwrap();
587
588        let result = BundleLoader::open(&bundle_path);
589
590        assert!(result.is_err());
591    }
592
593    #[test]
594    fn BundleLoader___list_files___returns_all_files() {
595        let temp_dir = TempDir::new().unwrap();
596        let bundle_path = create_test_bundle(&temp_dir);
597
598        let loader = BundleLoader::open(&bundle_path).unwrap();
599        let files = loader.list_files();
600
601        assert!(files.contains(&"manifest.json".to_string()));
602        assert!(files.contains(&"schema/messages.h".to_string()));
603    }
604
605    #[test]
606    fn BundleLoader___has_file___returns_true_for_existing() {
607        let temp_dir = TempDir::new().unwrap();
608        let bundle_path = create_test_bundle(&temp_dir);
609
610        let loader = BundleLoader::open(&bundle_path).unwrap();
611
612        assert!(loader.has_file("manifest.json"));
613        assert!(loader.has_file("schema/messages.h"));
614        assert!(!loader.has_file("nonexistent.txt"));
615    }
616
617    #[test]
618    fn BundleLoader___read_file___returns_contents() {
619        let temp_dir = TempDir::new().unwrap();
620        let bundle_path = create_test_bundle(&temp_dir);
621
622        let mut loader = BundleLoader::open(&bundle_path).unwrap();
623        let contents = loader.read_file_string("schema/messages.h").unwrap();
624
625        assert_eq!(contents, "// header");
626    }
627
628    #[test]
629    fn BundleLoader___read_file___missing_file___returns_error() {
630        let temp_dir = TempDir::new().unwrap();
631        let bundle_path = create_test_bundle(&temp_dir);
632
633        let mut loader = BundleLoader::open(&bundle_path).unwrap();
634        let result = loader.read_file("nonexistent.txt");
635
636        assert!(result.is_err());
637        let err = result.unwrap_err();
638        assert!(matches!(err, BundleError::MissingFile(_)));
639    }
640
641    #[test]
642    fn BundleLoader___read_file___returns_bytes() {
643        let temp_dir = TempDir::new().unwrap();
644        let bundle_path = create_test_bundle(&temp_dir);
645
646        let mut loader = BundleLoader::open(&bundle_path).unwrap();
647        let contents = loader.read_file("schema/messages.h").unwrap();
648
649        assert_eq!(contents, b"// header");
650    }
651
652    #[test]
653    fn BundleLoader___extract_library___verifies_checksum() {
654        let temp_dir = TempDir::new().unwrap();
655        let bundle_path = create_test_bundle(&temp_dir);
656        let extract_dir = temp_dir.path().join("extracted");
657
658        let mut loader = BundleLoader::open(&bundle_path).unwrap();
659        let lib_path = loader
660            .extract_library(Platform::LinuxX86_64, &extract_dir)
661            .unwrap();
662
663        assert!(lib_path.exists());
664        let contents = fs::read(&lib_path).unwrap();
665        assert_eq!(contents, b"fake library contents");
666    }
667
668    #[test]
669    fn BundleLoader___extract_library___unsupported_platform___returns_error() {
670        let temp_dir = TempDir::new().unwrap();
671        let bundle_path = create_test_bundle(&temp_dir);
672        let extract_dir = temp_dir.path().join("extracted");
673
674        let mut loader = BundleLoader::open(&bundle_path).unwrap();
675        let result = loader.extract_library(Platform::WindowsX86_64, &extract_dir);
676
677        assert!(result.is_err());
678        let err = result.unwrap_err();
679        assert!(matches!(err, BundleError::UnsupportedPlatform(_)));
680    }
681
682    #[test]
683    fn BundleLoader___extract_library___creates_output_directory() {
684        let temp_dir = TempDir::new().unwrap();
685        let bundle_path = create_test_bundle(&temp_dir);
686        let extract_dir = temp_dir.path().join("deep").join("nested").join("dir");
687
688        let mut loader = BundleLoader::open(&bundle_path).unwrap();
689        let lib_path = loader
690            .extract_library(Platform::LinuxX86_64, &extract_dir)
691            .unwrap();
692
693        assert!(extract_dir.exists());
694        assert!(lib_path.exists());
695    }
696
697    #[test]
698    fn BundleLoader___multi_platform___extract_each_platform() {
699        let temp_dir = TempDir::new().unwrap();
700        let bundle_path = create_multi_platform_bundle(&temp_dir);
701
702        let mut loader = BundleLoader::open(&bundle_path).unwrap();
703
704        // Verify all three platforms are supported
705        assert!(loader.manifest().supports_platform(Platform::LinuxX86_64));
706        assert!(loader.manifest().supports_platform(Platform::DarwinAarch64));
707        assert!(loader.manifest().supports_platform(Platform::WindowsX86_64));
708
709        // Extract Linux
710        let linux_dir = temp_dir.path().join("linux");
711        let linux_lib = loader
712            .extract_library(Platform::LinuxX86_64, &linux_dir)
713            .unwrap();
714        assert_eq!(fs::read(&linux_lib).unwrap(), b"linux library");
715
716        // Extract macOS
717        let macos_dir = temp_dir.path().join("macos");
718        let macos_lib = loader
719            .extract_library(Platform::DarwinAarch64, &macos_dir)
720            .unwrap();
721        assert_eq!(fs::read(&macos_lib).unwrap(), b"macos library");
722
723        // Extract Windows
724        let windows_dir = temp_dir.path().join("windows");
725        let windows_lib = loader
726            .extract_library(Platform::WindowsX86_64, &windows_dir)
727            .unwrap();
728        assert_eq!(fs::read(&windows_lib).unwrap(), b"windows library");
729    }
730
731    #[test]
732    fn BundleLoader___supports_current_platform___returns_correct_value() {
733        let temp_dir = TempDir::new().unwrap();
734        let bundle_path = create_test_bundle(&temp_dir);
735
736        let loader = BundleLoader::open(&bundle_path).unwrap();
737
738        // This test will pass on Linux x86_64, fail on other platforms
739        // which is expected behavior
740        if Platform::current() == Some(Platform::LinuxX86_64) {
741            assert!(loader.supports_current_platform());
742        }
743    }
744
745    #[test]
746    fn BundleLoader___current_platform_info___returns_info_when_supported() {
747        let temp_dir = TempDir::new().unwrap();
748        let bundle_path = create_test_bundle(&temp_dir);
749
750        let loader = BundleLoader::open(&bundle_path).unwrap();
751
752        if Platform::current() == Some(Platform::LinuxX86_64) {
753            let info = loader.current_platform_info();
754            assert!(info.is_some());
755            let release = info.unwrap().release().unwrap();
756            assert!(release.library.contains("libtest.so"));
757        }
758    }
759
760    #[test]
761    fn roundtrip___create_and_load___preserves_all_data() {
762        let temp_dir = TempDir::new().unwrap();
763        let bundle_path = temp_dir.path().join("roundtrip.rbp");
764
765        // Create library
766        let lib_path = temp_dir.path().join("libplugin.so");
767        let lib_contents = b"roundtrip test library";
768        fs::write(&lib_path, lib_contents).unwrap();
769
770        // Create bundle with metadata
771        let mut manifest = Manifest::new("roundtrip-plugin", "3.2.1");
772        manifest.plugin.description = Some("A test plugin for roundtrip".to_string());
773        manifest.plugin.authors = vec!["Author One".to_string(), "Author Two".to_string()];
774        manifest.plugin.license = Some("MIT".to_string());
775
776        BundleBuilder::new(manifest)
777            .add_library(Platform::LinuxX86_64, &lib_path)
778            .unwrap()
779            .add_bytes("docs/README.md", b"# Documentation".to_vec())
780            .write(&bundle_path)
781            .unwrap();
782
783        // Load and verify
784        let mut loader = BundleLoader::open(&bundle_path).unwrap();
785
786        assert_eq!(loader.manifest().plugin.name, "roundtrip-plugin");
787        assert_eq!(loader.manifest().plugin.version, "3.2.1");
788        assert_eq!(
789            loader.manifest().plugin.description,
790            Some("A test plugin for roundtrip".to_string())
791        );
792        assert_eq!(loader.manifest().plugin.authors.len(), 2);
793        assert_eq!(loader.manifest().plugin.license, Some("MIT".to_string()));
794
795        // Verify checksum in manifest matches actual content
796        let platform_info = loader
797            .manifest()
798            .get_platform(Platform::LinuxX86_64)
799            .unwrap();
800        let release = platform_info.release().unwrap();
801        let expected_checksum = format!("sha256:{}", compute_sha256(lib_contents));
802        assert_eq!(release.checksum, expected_checksum);
803
804        // Verify library extraction
805        let extract_dir = temp_dir.path().join("extract");
806        let extracted = loader
807            .extract_library(Platform::LinuxX86_64, &extract_dir)
808            .unwrap();
809        assert_eq!(fs::read(&extracted).unwrap(), lib_contents);
810    }
811
812    #[test]
813    fn roundtrip___bundle_with_schemas___preserves_schema_info() {
814        let temp_dir = TempDir::new().unwrap();
815        let bundle_path = temp_dir.path().join("schema-bundle.rbp");
816
817        let lib_path = temp_dir.path().join("libtest.so");
818        let header_path = temp_dir.path().join("messages.h");
819        let json_path = temp_dir.path().join("schema.json");
820        fs::write(&lib_path, b"lib").unwrap();
821        fs::write(&header_path, b"#pragma once").unwrap();
822        fs::write(&json_path, b"{}").unwrap();
823
824        let manifest = Manifest::new("schema-plugin", "1.0.0");
825        BundleBuilder::new(manifest)
826            .add_library(Platform::LinuxX86_64, &lib_path)
827            .unwrap()
828            .add_schema_file(&header_path, "messages.h")
829            .unwrap()
830            .add_schema_file(&json_path, "schema.json")
831            .unwrap()
832            .write(&bundle_path)
833            .unwrap();
834
835        let mut loader = BundleLoader::open(&bundle_path).unwrap();
836
837        // Verify schemas are in manifest
838        assert_eq!(loader.manifest().schemas.len(), 2);
839        assert!(loader.manifest().schemas.contains_key("messages.h"));
840        assert!(loader.manifest().schemas.contains_key("schema.json"));
841
842        // Verify schema files can be read
843        assert!(loader.has_file("schema/messages.h"));
844        assert!(loader.has_file("schema/schema.json"));
845        assert_eq!(
846            loader.read_file_string("schema/messages.h").unwrap(),
847            "#pragma once"
848        );
849    }
850}