Skip to main content

rustbridge_bundle/
builder.rs

1//! Bundle creation utilities.
2//!
3//! The [`BundleBuilder`] provides a fluent API for creating `.rbp` bundle archives.
4
5use crate::{BundleError, BundleResult, MANIFEST_FILE, Manifest, Platform};
6use minisign::SecretKey;
7use sha2::{Digest, Sha256};
8use std::fs::{self, File};
9use std::io::Write;
10use std::path::Path;
11use zip::ZipWriter;
12use zip::write::SimpleFileOptions;
13
14/// Builder for creating plugin bundles.
15///
16/// # Example
17///
18/// ```no_run
19/// use rustbridge_bundle::{BundleBuilder, Manifest, Platform};
20///
21/// let manifest = Manifest::new("my-plugin", "1.0.0");
22/// let builder = BundleBuilder::new(manifest)
23///     .add_library(Platform::LinuxX86_64, "target/release/libmyplugin.so")?
24///     .add_schema_file("schema/messages.h", "include/messages.h")?;
25///
26/// builder.write("my-plugin-1.0.0.rbp")?;
27/// # Ok::<(), rustbridge_bundle::BundleError>(())
28/// ```
29#[derive(Debug)]
30pub struct BundleBuilder {
31    manifest: Manifest,
32    files: Vec<BundleFile>,
33    signing_key: Option<(String, SecretKey)>, // (public_key_base64, secret_key)
34}
35
36/// A file to include in the bundle.
37#[derive(Debug)]
38struct BundleFile {
39    /// Path within the bundle archive.
40    archive_path: String,
41    /// File contents.
42    contents: Vec<u8>,
43}
44
45impl BundleBuilder {
46    /// Create a new bundle builder with the given manifest.
47    #[must_use]
48    pub fn new(manifest: Manifest) -> Self {
49        Self {
50            manifest,
51            files: Vec::new(),
52            signing_key: None,
53        }
54    }
55
56    /// Set the signing key for bundle signing.
57    ///
58    /// The secret key will be used to sign all library files and the manifest.
59    /// The corresponding public key will be embedded in the manifest.
60    ///
61    /// # Arguments
62    /// * `public_key_base64` - The public key in base64 format (from the .pub file)
63    /// * `secret_key` - The secret key for signing
64    pub fn with_signing_key(mut self, public_key_base64: String, secret_key: SecretKey) -> Self {
65        self.manifest.set_public_key(public_key_base64.clone());
66        self.signing_key = Some((public_key_base64, secret_key));
67        self
68    }
69
70    /// Add a platform-specific library to the bundle as the release variant.
71    ///
72    /// This reads the library file, computes its SHA256 checksum,
73    /// and updates the manifest with the platform information.
74    ///
75    /// This is a convenience method that adds the library as the `release` variant.
76    /// For other variants, use `add_library_variant` instead.
77    pub fn add_library<P: AsRef<Path>>(
78        self,
79        platform: Platform,
80        library_path: P,
81    ) -> BundleResult<Self> {
82        self.add_library_variant(platform, "release", library_path)
83    }
84
85    /// Add a variant-specific library to the bundle.
86    ///
87    /// This reads the library file, computes its SHA256 checksum,
88    /// and updates the manifest with the platform and variant information.
89    ///
90    /// # Arguments
91    /// * `platform` - Target platform
92    /// * `variant` - Variant name (e.g., "release", "debug")
93    /// * `library_path` - Path to the library file
94    pub fn add_library_variant<P: AsRef<Path>>(
95        mut self,
96        platform: Platform,
97        variant: &str,
98        library_path: P,
99    ) -> BundleResult<Self> {
100        let library_path = library_path.as_ref();
101
102        // Read the library file
103        let contents = fs::read(library_path).map_err(|e| {
104            BundleError::LibraryNotFound(format!("{}: {}", library_path.display(), e))
105        })?;
106
107        // Compute SHA256 checksum
108        let checksum = compute_sha256(&contents);
109
110        // Determine the archive path (now includes variant)
111        let file_name = library_path
112            .file_name()
113            .ok_or_else(|| {
114                BundleError::InvalidManifest(format!(
115                    "Invalid library path: {}",
116                    library_path.display()
117                ))
118            })?
119            .to_string_lossy();
120        let archive_path = format!("lib/{}/{}/{}", platform.as_str(), variant, file_name);
121
122        // Update manifest
123        self.manifest
124            .add_platform_variant(platform, variant, &archive_path, &checksum, None);
125
126        // Add to files list
127        self.files.push(BundleFile {
128            archive_path,
129            contents,
130        });
131
132        Ok(self)
133    }
134
135    /// Add a variant-specific library with build metadata.
136    ///
137    /// Similar to `add_library_variant` but also attaches build metadata
138    /// to the variant (e.g., compiler flags, features, etc.).
139    pub fn add_library_variant_with_build<P: AsRef<Path>>(
140        mut self,
141        platform: Platform,
142        variant: &str,
143        library_path: P,
144        build: serde_json::Value,
145    ) -> BundleResult<Self> {
146        let library_path = library_path.as_ref();
147
148        // Read the library file
149        let contents = fs::read(library_path).map_err(|e| {
150            BundleError::LibraryNotFound(format!("{}: {}", library_path.display(), e))
151        })?;
152
153        // Compute SHA256 checksum
154        let checksum = compute_sha256(&contents);
155
156        // Determine the archive path
157        let file_name = library_path
158            .file_name()
159            .ok_or_else(|| {
160                BundleError::InvalidManifest(format!(
161                    "Invalid library path: {}",
162                    library_path.display()
163                ))
164            })?
165            .to_string_lossy();
166        let archive_path = format!("lib/{}/{}/{}", platform.as_str(), variant, file_name);
167
168        // Update manifest with build metadata
169        self.manifest.add_platform_variant(
170            platform,
171            variant,
172            &archive_path,
173            &checksum,
174            Some(build),
175        );
176
177        // Add to files list
178        self.files.push(BundleFile {
179            archive_path,
180            contents,
181        });
182
183        Ok(self)
184    }
185
186    /// Add a JNI bridge library to the bundle as the release variant.
187    ///
188    /// This reads the library file, computes its SHA256 checksum,
189    /// and updates the manifest with the JNI bridge information.
190    ///
191    /// This is a convenience method that adds the library as the `release` variant.
192    /// For other variants, use `add_jni_library_variant` instead.
193    pub fn add_jni_library<P: AsRef<Path>>(
194        self,
195        platform: Platform,
196        library_path: P,
197    ) -> BundleResult<Self> {
198        self.add_jni_library_variant(platform, "release", library_path)
199    }
200
201    /// Add a variant-specific JNI bridge library to the bundle.
202    ///
203    /// This reads the library file, computes its SHA256 checksum,
204    /// and updates the manifest with the JNI bridge information.
205    ///
206    /// # Arguments
207    /// * `platform` - Target platform
208    /// * `variant` - Variant name (e.g., "release", "debug")
209    /// * `library_path` - Path to the library file
210    pub fn add_jni_library_variant<P: AsRef<Path>>(
211        mut self,
212        platform: Platform,
213        variant: &str,
214        library_path: P,
215    ) -> BundleResult<Self> {
216        let library_path = library_path.as_ref();
217
218        // Read the library file
219        let contents = fs::read(library_path).map_err(|e| {
220            BundleError::LibraryNotFound(format!("{}: {}", library_path.display(), e))
221        })?;
222
223        // Compute SHA256 checksum
224        let checksum = compute_sha256(&contents);
225
226        // Determine the archive path (bridge/jni/{platform}/{variant}/{filename})
227        let file_name = library_path
228            .file_name()
229            .ok_or_else(|| {
230                BundleError::InvalidManifest(format!(
231                    "Invalid library path: {}",
232                    library_path.display()
233                ))
234            })?
235            .to_string_lossy();
236        let archive_path = format!("bridge/jni/{}/{}/{}", platform.as_str(), variant, file_name);
237
238        // Update manifest
239        self.manifest
240            .add_jni_bridge(platform, variant, &archive_path, &checksum);
241
242        // Add to files list
243        self.files.push(BundleFile {
244            archive_path,
245            contents,
246        });
247
248        Ok(self)
249    }
250
251    /// Add a schema file to the bundle.
252    ///
253    /// Schema files are stored in the `schema/` directory within the bundle.
254    ///
255    /// The schema format is automatically detected from the file extension:
256    /// - `.h` -> "c-header"
257    /// - `.json` -> "json-schema"
258    /// - Others -> "unknown"
259    pub fn add_schema_file<P: AsRef<Path>>(
260        mut self,
261        source_path: P,
262        archive_name: &str,
263    ) -> BundleResult<Self> {
264        let source_path = source_path.as_ref();
265
266        let contents = fs::read(source_path).map_err(|e| {
267            BundleError::Io(std::io::Error::new(
268                e.kind(),
269                format!(
270                    "Failed to read schema file {}: {}",
271                    source_path.display(),
272                    e
273                ),
274            ))
275        })?;
276
277        // Compute checksum
278        let checksum = compute_sha256(&contents);
279
280        // Detect format from extension
281        let format = detect_schema_format(archive_name);
282
283        let archive_path = format!("schema/{archive_name}");
284
285        // Add to manifest
286        self.manifest.add_schema(
287            archive_name.to_string(),
288            archive_path.clone(),
289            format,
290            checksum,
291            None, // No description by default
292        );
293
294        self.files.push(BundleFile {
295            archive_path,
296            contents,
297        });
298
299        Ok(self)
300    }
301
302    /// Add a documentation file to the bundle.
303    ///
304    /// Documentation files are stored in the `docs/` directory within the bundle.
305    pub fn add_doc_file<P: AsRef<Path>>(
306        mut self,
307        source_path: P,
308        archive_name: &str,
309    ) -> BundleResult<Self> {
310        let source_path = source_path.as_ref();
311
312        let contents = fs::read(source_path).map_err(|e| {
313            BundleError::Io(std::io::Error::new(
314                e.kind(),
315                format!("Failed to read doc file {}: {}", source_path.display(), e),
316            ))
317        })?;
318
319        let archive_path = format!("docs/{archive_name}");
320
321        self.files.push(BundleFile {
322            archive_path,
323            contents,
324        });
325
326        Ok(self)
327    }
328
329    /// Add raw bytes as a file in the bundle.
330    pub fn add_bytes(mut self, archive_path: &str, contents: Vec<u8>) -> Self {
331        self.files.push(BundleFile {
332            archive_path: archive_path.to_string(),
333            contents,
334        });
335        self
336    }
337
338    /// Set the build information for the bundle.
339    pub fn with_build_info(mut self, build_info: crate::BuildInfo) -> Self {
340        self.manifest.set_build_info(build_info);
341        self
342    }
343
344    /// Set the SBOM paths.
345    pub fn with_sbom(mut self, sbom: crate::Sbom) -> Self {
346        self.manifest.set_sbom(sbom);
347        self
348    }
349
350    /// Add a notices file to the bundle.
351    ///
352    /// The file will be stored in the `docs/` directory.
353    pub fn add_notices_file<P: AsRef<Path>>(mut self, source_path: P) -> BundleResult<Self> {
354        let source_path = source_path.as_ref();
355
356        let contents = fs::read(source_path).map_err(|e| {
357            BundleError::Io(std::io::Error::new(
358                e.kind(),
359                format!(
360                    "Failed to read notices file {}: {}",
361                    source_path.display(),
362                    e
363                ),
364            ))
365        })?;
366
367        let archive_path = "docs/NOTICES.txt".to_string();
368        self.manifest.set_notices(archive_path.clone());
369
370        self.files.push(BundleFile {
371            archive_path,
372            contents,
373        });
374
375        Ok(self)
376    }
377
378    /// Add the plugin's license file to the bundle.
379    ///
380    /// The file will be stored in the `legal/` directory as `LICENSE`.
381    /// This is for the plugin's own license, not third-party notices.
382    pub fn add_license_file<P: AsRef<Path>>(mut self, source_path: P) -> BundleResult<Self> {
383        let source_path = source_path.as_ref();
384
385        let contents = fs::read(source_path).map_err(|e| {
386            BundleError::Io(std::io::Error::new(
387                e.kind(),
388                format!(
389                    "Failed to read license file {}: {}",
390                    source_path.display(),
391                    e
392                ),
393            ))
394        })?;
395
396        let archive_path = "legal/LICENSE".to_string();
397        self.manifest.set_license_file(archive_path.clone());
398
399        self.files.push(BundleFile {
400            archive_path,
401            contents,
402        });
403
404        Ok(self)
405    }
406
407    /// Add an SBOM file to the bundle.
408    ///
409    /// The file will be stored in the `sbom/` directory.
410    pub fn add_sbom_file<P: AsRef<Path>>(
411        mut self,
412        source_path: P,
413        archive_name: &str,
414    ) -> BundleResult<Self> {
415        let source_path = source_path.as_ref();
416
417        let contents = fs::read(source_path).map_err(|e| {
418            BundleError::Io(std::io::Error::new(
419                e.kind(),
420                format!("Failed to read SBOM file {}: {}", source_path.display(), e),
421            ))
422        })?;
423
424        let archive_path = format!("sbom/{archive_name}");
425
426        self.files.push(BundleFile {
427            archive_path,
428            contents,
429        });
430
431        Ok(self)
432    }
433
434    /// Write the bundle to a file.
435    pub fn write<P: AsRef<Path>>(self, output_path: P) -> BundleResult<()> {
436        let output_path = output_path.as_ref();
437
438        // Validate the manifest
439        self.manifest.validate()?;
440
441        // Create the ZIP file
442        let file = File::create(output_path)?;
443        let mut zip = ZipWriter::new(file);
444        let options =
445            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
446
447        // Write manifest.json
448        let manifest_json = self.manifest.to_json()?;
449        zip.start_file(MANIFEST_FILE, options)?;
450        zip.write_all(manifest_json.as_bytes())?;
451
452        // Sign and write manifest.json.minisig if signing is enabled
453        if let Some((ref _public_key, ref secret_key)) = self.signing_key {
454            let signature = sign_data(secret_key, manifest_json.as_bytes())?;
455            zip.start_file(format!("{MANIFEST_FILE}.minisig"), options)?;
456            zip.write_all(signature.as_bytes())?;
457        }
458
459        // Write all other files
460        for bundle_file in &self.files {
461            zip.start_file(&bundle_file.archive_path, options)?;
462            zip.write_all(&bundle_file.contents)?;
463
464            // Sign library files if signing is enabled
465            if let Some((ref _public_key, ref secret_key)) = self.signing_key {
466                // Sign library files (in lib/ or bridge/ directory)
467                if bundle_file.archive_path.starts_with("lib/")
468                    || bundle_file.archive_path.starts_with("bridge/")
469                {
470                    let signature = sign_data(secret_key, &bundle_file.contents)?;
471                    let sig_path = format!("{}.minisig", bundle_file.archive_path);
472                    zip.start_file(&sig_path, options)?;
473                    zip.write_all(signature.as_bytes())?;
474                }
475            }
476        }
477
478        zip.finish()?;
479
480        Ok(())
481    }
482
483    /// Get the current manifest (for inspection).
484    #[must_use]
485    pub fn manifest(&self) -> &Manifest {
486        &self.manifest
487    }
488
489    /// Get a mutable reference to the manifest (for modification).
490    pub fn manifest_mut(&mut self) -> &mut Manifest {
491        &mut self.manifest
492    }
493}
494
495/// Compute SHA256 hash of data and return as hex string.
496pub fn compute_sha256(data: &[u8]) -> String {
497    let mut hasher = Sha256::new();
498    hasher.update(data);
499    let result = hasher.finalize();
500    hex::encode(result)
501}
502
503/// Verify SHA256 checksum of data.
504pub fn verify_sha256(data: &[u8], expected: &str) -> bool {
505    let actual = compute_sha256(data);
506
507    // Handle both "sha256:xxx" and raw "xxx" formats
508    let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
509
510    actual == expected_hex
511}
512
513/// Detect schema format from file extension.
514fn detect_schema_format(filename: &str) -> String {
515    if filename.ends_with(".h") || filename.ends_with(".hpp") {
516        "c-header".to_string()
517    } else if filename.ends_with(".json") {
518        "json-schema".to_string()
519    } else {
520        "unknown".to_string()
521    }
522}
523
524/// Sign data using a minisign secret key.
525///
526/// Returns the signature in minisign format (base64-encoded).
527fn sign_data(secret_key: &SecretKey, data: &[u8]) -> BundleResult<String> {
528    let signature_box = minisign::sign(
529        None, // No public key needed for signing
530        secret_key, data, None, // No trusted comment
531        None, // No untrusted comment
532    )
533    .map_err(|e| BundleError::Io(std::io::Error::other(format!("Failed to sign data: {e}"))))?;
534
535    Ok(signature_box.to_string())
536}
537
538#[cfg(test)]
539mod tests {
540    #![allow(non_snake_case)]
541
542    use super::*;
543    use tempfile::TempDir;
544
545    #[test]
546    fn compute_sha256___returns_consistent_hash() {
547        let data = b"hello world";
548        let hash1 = compute_sha256(data);
549        let hash2 = compute_sha256(data);
550
551        assert_eq!(hash1, hash2);
552        assert_eq!(hash1.len(), 64); // SHA256 is 32 bytes = 64 hex chars
553    }
554
555    #[test]
556    fn compute_sha256___different_data___different_hash() {
557        let hash1 = compute_sha256(b"hello");
558        let hash2 = compute_sha256(b"world");
559
560        assert_ne!(hash1, hash2);
561    }
562
563    #[test]
564    fn compute_sha256___empty_data___returns_valid_hash() {
565        let hash = compute_sha256(b"");
566
567        assert_eq!(hash.len(), 64);
568        // Known SHA256 of empty string
569        assert_eq!(
570            hash,
571            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
572        );
573    }
574
575    #[test]
576    fn verify_sha256___accepts_valid_checksum() {
577        let data = b"hello world";
578        let checksum = compute_sha256(data);
579
580        assert!(verify_sha256(data, &checksum));
581        assert!(verify_sha256(data, &format!("sha256:{checksum}")));
582    }
583
584    #[test]
585    fn verify_sha256___rejects_invalid_checksum() {
586        let data = b"hello world";
587
588        assert!(!verify_sha256(data, "invalid"));
589        assert!(!verify_sha256(data, "sha256:invalid"));
590    }
591
592    #[test]
593    fn verify_sha256___case_sensitive___rejects_uppercase() {
594        let data = b"hello world";
595        let checksum = compute_sha256(data).to_uppercase();
596
597        assert!(!verify_sha256(data, &checksum));
598    }
599
600    #[test]
601    fn BundleBuilder___add_bytes___adds_file() {
602        let manifest = Manifest::new("test", "1.0.0");
603        let builder = BundleBuilder::new(manifest).add_bytes("test.txt", b"hello".to_vec());
604
605        assert_eq!(builder.files.len(), 1);
606        assert_eq!(builder.files[0].archive_path, "test.txt");
607        assert_eq!(builder.files[0].contents, b"hello");
608    }
609
610    #[test]
611    fn BundleBuilder___add_bytes___multiple_files() {
612        let manifest = Manifest::new("test", "1.0.0");
613        let builder = BundleBuilder::new(manifest)
614            .add_bytes("file1.txt", b"content1".to_vec())
615            .add_bytes("file2.txt", b"content2".to_vec())
616            .add_bytes("dir/file3.txt", b"content3".to_vec());
617
618        assert_eq!(builder.files.len(), 3);
619    }
620
621    #[test]
622    fn BundleBuilder___add_library___nonexistent_file___returns_error() {
623        let manifest = Manifest::new("test", "1.0.0");
624        let result = BundleBuilder::new(manifest)
625            .add_library(Platform::LinuxX86_64, "/nonexistent/path/libtest.so");
626
627        assert!(result.is_err());
628        let err = result.unwrap_err();
629        assert!(matches!(err, BundleError::LibraryNotFound(_)));
630        assert!(err.to_string().contains("/nonexistent/path/libtest.so"));
631    }
632
633    #[test]
634    fn BundleBuilder___add_library___valid_file___computes_checksum() {
635        let temp_dir = TempDir::new().unwrap();
636        let lib_path = temp_dir.path().join("libtest.so");
637        fs::write(&lib_path, b"fake library").unwrap();
638
639        let manifest = Manifest::new("test", "1.0.0");
640        let builder = BundleBuilder::new(manifest)
641            .add_library(Platform::LinuxX86_64, &lib_path)
642            .unwrap();
643
644        let platform_info = builder
645            .manifest
646            .get_platform(Platform::LinuxX86_64)
647            .unwrap();
648        let release = platform_info.release().unwrap();
649        assert!(release.checksum.starts_with("sha256:"));
650        assert_eq!(release.library, "lib/linux-x86_64/release/libtest.so");
651    }
652
653    #[test]
654    fn BundleBuilder___add_library_variant___adds_multiple_variants() {
655        let temp_dir = TempDir::new().unwrap();
656        let release_lib = temp_dir.path().join("libtest_release.so");
657        let debug_lib = temp_dir.path().join("libtest_debug.so");
658        fs::write(&release_lib, b"release library").unwrap();
659        fs::write(&debug_lib, b"debug library").unwrap();
660
661        let manifest = Manifest::new("test", "1.0.0");
662        let builder = BundleBuilder::new(manifest)
663            .add_library_variant(Platform::LinuxX86_64, "release", &release_lib)
664            .unwrap()
665            .add_library_variant(Platform::LinuxX86_64, "debug", &debug_lib)
666            .unwrap();
667
668        let platform_info = builder
669            .manifest
670            .get_platform(Platform::LinuxX86_64)
671            .unwrap();
672
673        assert!(platform_info.has_variant("release"));
674        assert!(platform_info.has_variant("debug"));
675
676        let release = platform_info.variant("release").unwrap();
677        let debug = platform_info.variant("debug").unwrap();
678
679        assert_eq!(
680            release.library,
681            "lib/linux-x86_64/release/libtest_release.so"
682        );
683        assert_eq!(debug.library, "lib/linux-x86_64/debug/libtest_debug.so");
684    }
685
686    #[test]
687    fn BundleBuilder___add_library___multiple_platforms() {
688        let temp_dir = TempDir::new().unwrap();
689
690        let linux_lib = temp_dir.path().join("libtest.so");
691        let macos_lib = temp_dir.path().join("libtest.dylib");
692        fs::write(&linux_lib, b"linux lib").unwrap();
693        fs::write(&macos_lib, b"macos lib").unwrap();
694
695        let manifest = Manifest::new("test", "1.0.0");
696        let builder = BundleBuilder::new(manifest)
697            .add_library(Platform::LinuxX86_64, &linux_lib)
698            .unwrap()
699            .add_library(Platform::DarwinAarch64, &macos_lib)
700            .unwrap();
701
702        assert!(builder.manifest.supports_platform(Platform::LinuxX86_64));
703        assert!(builder.manifest.supports_platform(Platform::DarwinAarch64));
704        assert!(!builder.manifest.supports_platform(Platform::WindowsX86_64));
705    }
706
707    #[test]
708    fn BundleBuilder___add_schema_file___nonexistent___returns_error() {
709        let manifest = Manifest::new("test", "1.0.0");
710        let result =
711            BundleBuilder::new(manifest).add_schema_file("/nonexistent/schema.h", "schema.h");
712
713        assert!(result.is_err());
714    }
715
716    #[test]
717    fn BundleBuilder___add_schema_file___detects_c_header_format() {
718        let temp_dir = TempDir::new().unwrap();
719        let schema_path = temp_dir.path().join("messages.h");
720        fs::write(&schema_path, b"#include <stdint.h>").unwrap();
721
722        let manifest = Manifest::new("test", "1.0.0");
723        let builder = BundleBuilder::new(manifest)
724            .add_schema_file(&schema_path, "messages.h")
725            .unwrap();
726
727        let schema_info = builder.manifest.schemas.get("messages.h").unwrap();
728        assert_eq!(schema_info.format, "c-header");
729    }
730
731    #[test]
732    fn BundleBuilder___add_schema_file___detects_json_schema_format() {
733        let temp_dir = TempDir::new().unwrap();
734        let schema_path = temp_dir.path().join("schema.json");
735        fs::write(&schema_path, b"{}").unwrap();
736
737        let manifest = Manifest::new("test", "1.0.0");
738        let builder = BundleBuilder::new(manifest)
739            .add_schema_file(&schema_path, "schema.json")
740            .unwrap();
741
742        let schema_info = builder.manifest.schemas.get("schema.json").unwrap();
743        assert_eq!(schema_info.format, "json-schema");
744    }
745
746    #[test]
747    fn BundleBuilder___add_schema_file___unknown_format() {
748        let temp_dir = TempDir::new().unwrap();
749        let schema_path = temp_dir.path().join("schema.xyz");
750        fs::write(&schema_path, b"content").unwrap();
751
752        let manifest = Manifest::new("test", "1.0.0");
753        let builder = BundleBuilder::new(manifest)
754            .add_schema_file(&schema_path, "schema.xyz")
755            .unwrap();
756
757        let schema_info = builder.manifest.schemas.get("schema.xyz").unwrap();
758        assert_eq!(schema_info.format, "unknown");
759    }
760
761    #[test]
762    fn BundleBuilder___write___invalid_manifest___returns_error() {
763        let temp_dir = TempDir::new().unwrap();
764        let output_path = temp_dir.path().join("test.rbp");
765
766        // Manifest without any platforms is invalid
767        let manifest = Manifest::new("test", "1.0.0");
768        let result = BundleBuilder::new(manifest).write(&output_path);
769
770        assert!(result.is_err());
771        let err = result.unwrap_err();
772        assert!(matches!(err, BundleError::InvalidManifest(_)));
773    }
774
775    #[test]
776    fn BundleBuilder___write___creates_valid_bundle() {
777        let temp_dir = TempDir::new().unwrap();
778        let lib_path = temp_dir.path().join("libtest.so");
779        let output_path = temp_dir.path().join("test.rbp");
780        fs::write(&lib_path, b"fake library").unwrap();
781
782        let manifest = Manifest::new("test", "1.0.0");
783        BundleBuilder::new(manifest)
784            .add_library(Platform::LinuxX86_64, &lib_path)
785            .unwrap()
786            .write(&output_path)
787            .unwrap();
788
789        assert!(output_path.exists());
790
791        // Verify it's a valid ZIP
792        let file = File::open(&output_path).unwrap();
793        let archive = zip::ZipArchive::new(file).unwrap();
794        assert!(archive.len() >= 2); // manifest + library
795    }
796
797    #[test]
798    fn BundleBuilder___manifest_mut___allows_modification() {
799        let manifest = Manifest::new("test", "1.0.0");
800        let mut builder = BundleBuilder::new(manifest);
801
802        builder.manifest_mut().plugin.description = Some("Modified".to_string());
803
804        assert_eq!(
805            builder.manifest().plugin.description,
806            Some("Modified".to_string())
807        );
808    }
809
810    #[test]
811    fn detect_schema_format___hpp_extension___returns_c_header() {
812        assert_eq!(detect_schema_format("types.hpp"), "c-header");
813    }
814
815    // ========================================================================
816    // Minisign Signature Tests
817    // ========================================================================
818    // These tests verify that minisign signature generation and verification
819    // work correctly. The test vectors are used as reference for consumer
820    // language implementations (Java, C#, Python).
821
822    #[test]
823    fn sign_data___generates_verifiable_signature() {
824        use minisign::{KeyPair, PublicKey};
825        use std::io::Cursor;
826
827        // Generate a test keypair
828        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
829
830        // Test data
831        let test_data = b"Hello, rustbridge!";
832
833        // Sign the data
834        let mut reader = Cursor::new(test_data.as_slice());
835        let signature_box = minisign::sign(
836            Some(&keypair.pk),
837            &keypair.sk,
838            &mut reader,
839            Some("trusted comment"),
840            Some("untrusted comment"),
841        )
842        .unwrap();
843
844        // Verify the signature
845        let pk = PublicKey::from_base64(&keypair.pk.to_base64()).unwrap();
846        let mut verify_reader = Cursor::new(test_data.as_slice());
847        let result = minisign::verify(&pk, &signature_box, &mut verify_reader, true, false, false);
848
849        assert!(result.is_ok(), "Signature verification should succeed");
850    }
851
852    #[test]
853    fn sign_data___wrong_data___verification_fails() {
854        use minisign::{KeyPair, PublicKey};
855        use std::io::Cursor;
856
857        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
858        let test_data = b"Hello, rustbridge!";
859        let wrong_data = b"Hello, rustbridge?"; // Changed ! to ?
860
861        // Sign the original data
862        let mut reader = Cursor::new(test_data.as_slice());
863        let signature_box =
864            minisign::sign(Some(&keypair.pk), &keypair.sk, &mut reader, None, None).unwrap();
865
866        // Try to verify with wrong data
867        let pk = PublicKey::from_base64(&keypair.pk.to_base64()).unwrap();
868        let mut verify_reader = Cursor::new(wrong_data.as_slice());
869        let result = minisign::verify(&pk, &signature_box, &mut verify_reader, true, false, false);
870
871        assert!(result.is_err(), "Verification should fail with wrong data");
872    }
873
874    #[test]
875    fn sign_data___signature_format___has_prehash_algorithm_id() {
876        use base64::Engine;
877        use minisign::KeyPair;
878        use std::io::Cursor;
879
880        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
881        let test_data = b"test";
882
883        let mut reader = Cursor::new(test_data.as_slice());
884        let signature_box = minisign::sign(
885            Some(&keypair.pk),
886            &keypair.sk,
887            &mut reader,
888            Some("trusted"),
889            Some("untrusted"),
890        )
891        .unwrap();
892
893        let sig_string = signature_box.into_string();
894        let lines: Vec<&str> = sig_string.lines().collect();
895
896        // The signature line is the second line
897        let sig_base64 = lines[1];
898        let sig_bytes = base64::engine::general_purpose::STANDARD
899            .decode(sig_base64)
900            .unwrap();
901
902        // First two bytes should be "ED" (0x45, 0x44) for prehashed signatures
903        assert_eq!(sig_bytes[0], 0x45, "First byte should be 'E'");
904        assert_eq!(
905            sig_bytes[1], 0x44,
906            "Second byte should be 'D' for prehashed"
907        );
908        assert_eq!(sig_bytes.len(), 74, "Signature should be 74 bytes");
909    }
910
911    #[test]
912    fn sign_data___public_key_format___has_ed_algorithm_id() {
913        use base64::Engine;
914        use minisign::KeyPair;
915
916        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
917        let pk_base64 = keypair.pk.to_base64();
918        let pk_bytes = base64::engine::general_purpose::STANDARD
919            .decode(&pk_base64)
920            .unwrap();
921
922        // First two bytes should be "Ed" (0x45, 0x64) for public keys
923        assert_eq!(pk_bytes[0], 0x45, "First byte should be 'E'");
924        assert_eq!(
925            pk_bytes[1], 0x64,
926            "Second byte should be 'd' for public key"
927        );
928        assert_eq!(pk_bytes.len(), 42, "Public key should be 42 bytes");
929    }
930
931    #[test]
932    fn BundleBuilder___add_license_file___adds_file_to_legal_dir() {
933        let temp_dir = TempDir::new().unwrap();
934        let license_path = temp_dir.path().join("LICENSE");
935        fs::write(&license_path, b"MIT License\n\nCopyright...").unwrap();
936
937        let manifest = Manifest::new("test", "1.0.0");
938        let builder = BundleBuilder::new(manifest)
939            .add_license_file(&license_path)
940            .unwrap();
941
942        // Check file was added
943        assert_eq!(builder.files.len(), 1);
944        assert_eq!(builder.files[0].archive_path, "legal/LICENSE");
945
946        // Check manifest was updated
947        assert_eq!(builder.manifest.get_license_file(), Some("legal/LICENSE"));
948    }
949
950    #[test]
951    fn BundleBuilder___add_license_file___nonexistent___returns_error() {
952        let manifest = Manifest::new("test", "1.0.0");
953        let result = BundleBuilder::new(manifest).add_license_file("/nonexistent/LICENSE");
954
955        assert!(result.is_err());
956    }
957
958    #[test]
959    fn BundleBuilder___add_jni_library___adds_file_to_bridge_dir() {
960        let temp_dir = TempDir::new().unwrap();
961        let jni_lib = temp_dir.path().join("librustbridge_jni.so");
962        fs::write(&jni_lib, b"fake jni library").unwrap();
963
964        let manifest = Manifest::new("test", "1.0.0");
965        let builder = BundleBuilder::new(manifest)
966            .add_jni_library(Platform::LinuxX86_64, &jni_lib)
967            .unwrap();
968
969        // Check file was added
970        assert_eq!(builder.files.len(), 1);
971        assert_eq!(
972            builder.files[0].archive_path,
973            "bridge/jni/linux-x86_64/release/librustbridge_jni.so"
974        );
975
976        // Check manifest was updated
977        assert!(builder.manifest.has_jni_bridge());
978        let bridge = builder
979            .manifest
980            .get_jni_bridge(Platform::LinuxX86_64)
981            .unwrap();
982        let release = bridge.release().unwrap();
983        assert_eq!(
984            release.library,
985            "bridge/jni/linux-x86_64/release/librustbridge_jni.so"
986        );
987        assert!(release.checksum.starts_with("sha256:"));
988    }
989
990    #[test]
991    fn BundleBuilder___add_jni_library_variant___adds_multiple_variants() {
992        let temp_dir = TempDir::new().unwrap();
993        let release_lib = temp_dir.path().join("librustbridge_jni_release.so");
994        let debug_lib = temp_dir.path().join("librustbridge_jni_debug.so");
995        fs::write(&release_lib, b"release jni library").unwrap();
996        fs::write(&debug_lib, b"debug jni library").unwrap();
997
998        let manifest = Manifest::new("test", "1.0.0");
999        let builder = BundleBuilder::new(manifest)
1000            .add_jni_library_variant(Platform::LinuxX86_64, "release", &release_lib)
1001            .unwrap()
1002            .add_jni_library_variant(Platform::LinuxX86_64, "debug", &debug_lib)
1003            .unwrap();
1004
1005        assert_eq!(builder.files.len(), 2);
1006
1007        let bridge = builder
1008            .manifest
1009            .get_jni_bridge(Platform::LinuxX86_64)
1010            .unwrap();
1011        assert!(bridge.has_variant("release"));
1012        assert!(bridge.has_variant("debug"));
1013    }
1014
1015    #[test]
1016    fn BundleBuilder___add_jni_library___nonexistent___returns_error() {
1017        let manifest = Manifest::new("test", "1.0.0");
1018        let result = BundleBuilder::new(manifest)
1019            .add_jni_library(Platform::LinuxX86_64, "/nonexistent/librustbridge_jni.so");
1020
1021        assert!(result.is_err());
1022        let err = result.unwrap_err();
1023        assert!(matches!(err, BundleError::LibraryNotFound(_)));
1024    }
1025
1026    #[test]
1027    fn BundleBuilder___write___with_jni_library___creates_valid_bundle() {
1028        let temp_dir = TempDir::new().unwrap();
1029
1030        // Create fake libraries
1031        let plugin_lib = temp_dir.path().join("libtest.so");
1032        let jni_lib = temp_dir.path().join("librustbridge_jni.so");
1033        let output_path = temp_dir.path().join("test.rbp");
1034        fs::write(&plugin_lib, b"fake plugin library").unwrap();
1035        fs::write(&jni_lib, b"fake jni library").unwrap();
1036
1037        let manifest = Manifest::new("test", "1.0.0");
1038        BundleBuilder::new(manifest)
1039            .add_library(Platform::LinuxX86_64, &plugin_lib)
1040            .unwrap()
1041            .add_jni_library(Platform::LinuxX86_64, &jni_lib)
1042            .unwrap()
1043            .write(&output_path)
1044            .unwrap();
1045
1046        assert!(output_path.exists());
1047
1048        // Verify it's a valid ZIP with expected files
1049        let file = File::open(&output_path).unwrap();
1050        let archive = zip::ZipArchive::new(file).unwrap();
1051
1052        let names: Vec<&str> = archive.file_names().collect();
1053        assert!(names.contains(&"manifest.json"));
1054        assert!(names.contains(&"lib/linux-x86_64/release/libtest.so"));
1055        assert!(names.contains(&"bridge/jni/linux-x86_64/release/librustbridge_jni.so"));
1056    }
1057}