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 schema file to the bundle.
187    ///
188    /// Schema files are stored in the `schema/` directory within the bundle.
189    ///
190    /// The schema format is automatically detected from the file extension:
191    /// - `.h` -> "c-header"
192    /// - `.json` -> "json-schema"
193    /// - Others -> "unknown"
194    pub fn add_schema_file<P: AsRef<Path>>(
195        mut self,
196        source_path: P,
197        archive_name: &str,
198    ) -> BundleResult<Self> {
199        let source_path = source_path.as_ref();
200
201        let contents = fs::read(source_path).map_err(|e| {
202            BundleError::Io(std::io::Error::new(
203                e.kind(),
204                format!(
205                    "Failed to read schema file {}: {}",
206                    source_path.display(),
207                    e
208                ),
209            ))
210        })?;
211
212        // Compute checksum
213        let checksum = compute_sha256(&contents);
214
215        // Detect format from extension
216        let format = detect_schema_format(archive_name);
217
218        let archive_path = format!("schema/{archive_name}");
219
220        // Add to manifest
221        self.manifest.add_schema(
222            archive_name.to_string(),
223            archive_path.clone(),
224            format,
225            checksum,
226            None, // No description by default
227        );
228
229        self.files.push(BundleFile {
230            archive_path,
231            contents,
232        });
233
234        Ok(self)
235    }
236
237    /// Add a documentation file to the bundle.
238    ///
239    /// Documentation files are stored in the `docs/` directory within the bundle.
240    pub fn add_doc_file<P: AsRef<Path>>(
241        mut self,
242        source_path: P,
243        archive_name: &str,
244    ) -> BundleResult<Self> {
245        let source_path = source_path.as_ref();
246
247        let contents = fs::read(source_path).map_err(|e| {
248            BundleError::Io(std::io::Error::new(
249                e.kind(),
250                format!("Failed to read doc file {}: {}", source_path.display(), e),
251            ))
252        })?;
253
254        let archive_path = format!("docs/{archive_name}");
255
256        self.files.push(BundleFile {
257            archive_path,
258            contents,
259        });
260
261        Ok(self)
262    }
263
264    /// Add raw bytes as a file in the bundle.
265    pub fn add_bytes(mut self, archive_path: &str, contents: Vec<u8>) -> Self {
266        self.files.push(BundleFile {
267            archive_path: archive_path.to_string(),
268            contents,
269        });
270        self
271    }
272
273    /// Set the build information for the bundle.
274    pub fn with_build_info(mut self, build_info: crate::BuildInfo) -> Self {
275        self.manifest.set_build_info(build_info);
276        self
277    }
278
279    /// Set the SBOM paths.
280    pub fn with_sbom(mut self, sbom: crate::Sbom) -> Self {
281        self.manifest.set_sbom(sbom);
282        self
283    }
284
285    /// Add a notices file to the bundle.
286    ///
287    /// The file will be stored in the `docs/` directory.
288    pub fn add_notices_file<P: AsRef<Path>>(mut self, source_path: P) -> BundleResult<Self> {
289        let source_path = source_path.as_ref();
290
291        let contents = fs::read(source_path).map_err(|e| {
292            BundleError::Io(std::io::Error::new(
293                e.kind(),
294                format!(
295                    "Failed to read notices file {}: {}",
296                    source_path.display(),
297                    e
298                ),
299            ))
300        })?;
301
302        let archive_path = "docs/NOTICES.txt".to_string();
303        self.manifest.set_notices(archive_path.clone());
304
305        self.files.push(BundleFile {
306            archive_path,
307            contents,
308        });
309
310        Ok(self)
311    }
312
313    /// Add the plugin's license file to the bundle.
314    ///
315    /// The file will be stored in the `legal/` directory as `LICENSE`.
316    /// This is for the plugin's own license, not third-party notices.
317    pub fn add_license_file<P: AsRef<Path>>(mut self, source_path: P) -> BundleResult<Self> {
318        let source_path = source_path.as_ref();
319
320        let contents = fs::read(source_path).map_err(|e| {
321            BundleError::Io(std::io::Error::new(
322                e.kind(),
323                format!(
324                    "Failed to read license file {}: {}",
325                    source_path.display(),
326                    e
327                ),
328            ))
329        })?;
330
331        let archive_path = "legal/LICENSE".to_string();
332        self.manifest.set_license_file(archive_path.clone());
333
334        self.files.push(BundleFile {
335            archive_path,
336            contents,
337        });
338
339        Ok(self)
340    }
341
342    /// Add an SBOM file to the bundle.
343    ///
344    /// The file will be stored in the `sbom/` directory.
345    pub fn add_sbom_file<P: AsRef<Path>>(
346        mut self,
347        source_path: P,
348        archive_name: &str,
349    ) -> BundleResult<Self> {
350        let source_path = source_path.as_ref();
351
352        let contents = fs::read(source_path).map_err(|e| {
353            BundleError::Io(std::io::Error::new(
354                e.kind(),
355                format!("Failed to read SBOM file {}: {}", source_path.display(), e),
356            ))
357        })?;
358
359        let archive_path = format!("sbom/{archive_name}");
360
361        self.files.push(BundleFile {
362            archive_path,
363            contents,
364        });
365
366        Ok(self)
367    }
368
369    /// Write the bundle to a file.
370    pub fn write<P: AsRef<Path>>(self, output_path: P) -> BundleResult<()> {
371        let output_path = output_path.as_ref();
372
373        // Validate the manifest
374        self.manifest.validate()?;
375
376        // Create the ZIP file
377        let file = File::create(output_path)?;
378        let mut zip = ZipWriter::new(file);
379        let options =
380            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
381
382        // Write manifest.json
383        let manifest_json = self.manifest.to_json()?;
384        zip.start_file(MANIFEST_FILE, options)?;
385        zip.write_all(manifest_json.as_bytes())?;
386
387        // Sign and write manifest.json.minisig if signing is enabled
388        if let Some((ref _public_key, ref secret_key)) = self.signing_key {
389            let signature = sign_data(secret_key, manifest_json.as_bytes())?;
390            zip.start_file(format!("{MANIFEST_FILE}.minisig"), options)?;
391            zip.write_all(signature.as_bytes())?;
392        }
393
394        // Write all other files
395        for bundle_file in &self.files {
396            zip.start_file(&bundle_file.archive_path, options)?;
397            zip.write_all(&bundle_file.contents)?;
398
399            // Sign library files if signing is enabled
400            if let Some((ref _public_key, ref secret_key)) = self.signing_key {
401                // Sign library files (in lib/ or bridge/ directory)
402                if bundle_file.archive_path.starts_with("lib/")
403                    || bundle_file.archive_path.starts_with("bridge/")
404                {
405                    let signature = sign_data(secret_key, &bundle_file.contents)?;
406                    let sig_path = format!("{}.minisig", bundle_file.archive_path);
407                    zip.start_file(&sig_path, options)?;
408                    zip.write_all(signature.as_bytes())?;
409                }
410            }
411        }
412
413        zip.finish()?;
414
415        Ok(())
416    }
417
418    /// Set variant-level build info (v1.1).
419    ///
420    /// The variant must already exist for the given platform.
421    pub fn set_variant_build_info(
422        &mut self,
423        platform: Platform,
424        variant: &str,
425        build_info: crate::BuildInfo,
426    ) -> BundleResult<()> {
427        let variant_info = self.get_variant_mut(platform, variant)?;
428        variant_info.build_info = Some(build_info);
429        Ok(())
430    }
431
432    /// Set variant-level SBOM (v1.1).
433    pub fn set_variant_sbom(
434        &mut self,
435        platform: Platform,
436        variant: &str,
437        sbom: crate::Sbom,
438    ) -> BundleResult<()> {
439        let variant_info = self.get_variant_mut(platform, variant)?;
440        variant_info.sbom = Some(sbom);
441        Ok(())
442    }
443
444    /// Set variant-level schema checksum (v1.1).
445    pub fn set_variant_schema_checksum(
446        &mut self,
447        platform: Platform,
448        variant: &str,
449        checksum: String,
450    ) -> BundleResult<()> {
451        let variant_info = self.get_variant_mut(platform, variant)?;
452        variant_info.schema_checksum = Some(checksum);
453        Ok(())
454    }
455
456    /// Set variant-level schemas (v1.1).
457    pub fn set_variant_schemas(
458        &mut self,
459        platform: Platform,
460        variant: &str,
461        schemas: std::collections::HashMap<String, crate::SchemaInfo>,
462    ) -> BundleResult<()> {
463        let variant_info = self.get_variant_mut(platform, variant)?;
464        variant_info.schemas = schemas;
465        Ok(())
466    }
467
468    /// Get a mutable reference to a specific variant.
469    fn get_variant_mut(
470        &mut self,
471        platform: Platform,
472        variant: &str,
473    ) -> BundleResult<&mut crate::VariantInfo> {
474        let platform_info = self
475            .manifest
476            .platforms
477            .get_mut(platform.as_str())
478            .ok_or_else(|| {
479                BundleError::UnsupportedPlatform(format!(
480                    "Platform {} not in manifest",
481                    platform.as_str()
482                ))
483            })?;
484        platform_info
485            .variants
486            .get_mut(variant)
487            .ok_or_else(|| BundleError::VariantNotFound {
488                platform: platform.as_str().to_string(),
489                variant: variant.to_string(),
490            })
491    }
492
493    /// Get the current manifest (for inspection).
494    #[must_use]
495    pub fn manifest(&self) -> &Manifest {
496        &self.manifest
497    }
498
499    /// Get a mutable reference to the manifest (for modification).
500    pub fn manifest_mut(&mut self) -> &mut Manifest {
501        &mut self.manifest
502    }
503}
504
505/// Compute SHA256 hash of data and return as hex string.
506pub fn compute_sha256(data: &[u8]) -> String {
507    let mut hasher = Sha256::new();
508    hasher.update(data);
509    let result = hasher.finalize();
510    hex::encode(result)
511}
512
513/// Verify SHA256 checksum of data.
514pub fn verify_sha256(data: &[u8], expected: &str) -> bool {
515    let actual = compute_sha256(data);
516
517    // Handle both "sha256:xxx" and raw "xxx" formats
518    let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
519
520    actual == expected_hex
521}
522
523/// Detect schema format from file extension.
524fn detect_schema_format(filename: &str) -> String {
525    if filename.ends_with(".h") || filename.ends_with(".hpp") {
526        "c-header".to_string()
527    } else if filename.ends_with(".json") {
528        "json-schema".to_string()
529    } else {
530        "unknown".to_string()
531    }
532}
533
534/// Sign data using a minisign secret key.
535///
536/// Returns the signature in minisign format (base64-encoded).
537fn sign_data(secret_key: &SecretKey, data: &[u8]) -> BundleResult<String> {
538    let signature_box = minisign::sign(
539        None, // No public key needed for signing
540        secret_key, data, None, // No trusted comment
541        None, // No untrusted comment
542    )
543    .map_err(|e| BundleError::Io(std::io::Error::other(format!("Failed to sign data: {e}"))))?;
544
545    Ok(signature_box.to_string())
546}
547
548#[cfg(test)]
549mod tests {
550    #![allow(non_snake_case)]
551
552    use super::*;
553    use tempfile::TempDir;
554
555    #[test]
556    fn compute_sha256___returns_consistent_hash() {
557        let data = b"hello world";
558        let hash1 = compute_sha256(data);
559        let hash2 = compute_sha256(data);
560
561        assert_eq!(hash1, hash2);
562        assert_eq!(hash1.len(), 64); // SHA256 is 32 bytes = 64 hex chars
563    }
564
565    #[test]
566    fn compute_sha256___different_data___different_hash() {
567        let hash1 = compute_sha256(b"hello");
568        let hash2 = compute_sha256(b"world");
569
570        assert_ne!(hash1, hash2);
571    }
572
573    #[test]
574    fn compute_sha256___empty_data___returns_valid_hash() {
575        let hash = compute_sha256(b"");
576
577        assert_eq!(hash.len(), 64);
578        // Known SHA256 of empty string
579        assert_eq!(
580            hash,
581            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
582        );
583    }
584
585    #[test]
586    fn verify_sha256___accepts_valid_checksum() {
587        let data = b"hello world";
588        let checksum = compute_sha256(data);
589
590        assert!(verify_sha256(data, &checksum));
591        assert!(verify_sha256(data, &format!("sha256:{checksum}")));
592    }
593
594    #[test]
595    fn verify_sha256___rejects_invalid_checksum() {
596        let data = b"hello world";
597
598        assert!(!verify_sha256(data, "invalid"));
599        assert!(!verify_sha256(data, "sha256:invalid"));
600    }
601
602    #[test]
603    fn verify_sha256___case_sensitive___rejects_uppercase() {
604        let data = b"hello world";
605        let checksum = compute_sha256(data).to_uppercase();
606
607        assert!(!verify_sha256(data, &checksum));
608    }
609
610    #[test]
611    fn BundleBuilder___add_bytes___adds_file() {
612        let manifest = Manifest::new("test", "1.0.0");
613        let builder = BundleBuilder::new(manifest).add_bytes("test.txt", b"hello".to_vec());
614
615        assert_eq!(builder.files.len(), 1);
616        assert_eq!(builder.files[0].archive_path, "test.txt");
617        assert_eq!(builder.files[0].contents, b"hello");
618    }
619
620    #[test]
621    fn BundleBuilder___add_bytes___multiple_files() {
622        let manifest = Manifest::new("test", "1.0.0");
623        let builder = BundleBuilder::new(manifest)
624            .add_bytes("file1.txt", b"content1".to_vec())
625            .add_bytes("file2.txt", b"content2".to_vec())
626            .add_bytes("dir/file3.txt", b"content3".to_vec());
627
628        assert_eq!(builder.files.len(), 3);
629    }
630
631    #[test]
632    fn BundleBuilder___add_library___nonexistent_file___returns_error() {
633        let manifest = Manifest::new("test", "1.0.0");
634        let result = BundleBuilder::new(manifest)
635            .add_library(Platform::LinuxX86_64, "/nonexistent/path/libtest.so");
636
637        assert!(result.is_err());
638        let err = result.unwrap_err();
639        assert!(matches!(err, BundleError::LibraryNotFound(_)));
640        assert!(err.to_string().contains("/nonexistent/path/libtest.so"));
641    }
642
643    #[test]
644    fn BundleBuilder___add_library___valid_file___computes_checksum() {
645        let temp_dir = TempDir::new().unwrap();
646        let lib_path = temp_dir.path().join("libtest.so");
647        fs::write(&lib_path, b"fake library").unwrap();
648
649        let manifest = Manifest::new("test", "1.0.0");
650        let builder = BundleBuilder::new(manifest)
651            .add_library(Platform::LinuxX86_64, &lib_path)
652            .unwrap();
653
654        let platform_info = builder
655            .manifest
656            .get_platform(Platform::LinuxX86_64)
657            .unwrap();
658        let release = platform_info.release().unwrap();
659        assert!(release.checksum.starts_with("sha256:"));
660        assert_eq!(release.library, "lib/linux-x86_64/release/libtest.so");
661    }
662
663    #[test]
664    fn BundleBuilder___add_library_variant___adds_multiple_variants() {
665        let temp_dir = TempDir::new().unwrap();
666        let release_lib = temp_dir.path().join("libtest_release.so");
667        let debug_lib = temp_dir.path().join("libtest_debug.so");
668        fs::write(&release_lib, b"release library").unwrap();
669        fs::write(&debug_lib, b"debug library").unwrap();
670
671        let manifest = Manifest::new("test", "1.0.0");
672        let builder = BundleBuilder::new(manifest)
673            .add_library_variant(Platform::LinuxX86_64, "release", &release_lib)
674            .unwrap()
675            .add_library_variant(Platform::LinuxX86_64, "debug", &debug_lib)
676            .unwrap();
677
678        let platform_info = builder
679            .manifest
680            .get_platform(Platform::LinuxX86_64)
681            .unwrap();
682
683        assert!(platform_info.has_variant("release"));
684        assert!(platform_info.has_variant("debug"));
685
686        let release = platform_info.variant("release").unwrap();
687        let debug = platform_info.variant("debug").unwrap();
688
689        assert_eq!(
690            release.library,
691            "lib/linux-x86_64/release/libtest_release.so"
692        );
693        assert_eq!(debug.library, "lib/linux-x86_64/debug/libtest_debug.so");
694    }
695
696    #[test]
697    fn BundleBuilder___add_library___multiple_platforms() {
698        let temp_dir = TempDir::new().unwrap();
699
700        let linux_lib = temp_dir.path().join("libtest.so");
701        let macos_lib = temp_dir.path().join("libtest.dylib");
702        fs::write(&linux_lib, b"linux lib").unwrap();
703        fs::write(&macos_lib, b"macos lib").unwrap();
704
705        let manifest = Manifest::new("test", "1.0.0");
706        let builder = BundleBuilder::new(manifest)
707            .add_library(Platform::LinuxX86_64, &linux_lib)
708            .unwrap()
709            .add_library(Platform::DarwinAarch64, &macos_lib)
710            .unwrap();
711
712        assert!(builder.manifest.supports_platform(Platform::LinuxX86_64));
713        assert!(builder.manifest.supports_platform(Platform::DarwinAarch64));
714        assert!(!builder.manifest.supports_platform(Platform::WindowsX86_64));
715    }
716
717    #[test]
718    fn BundleBuilder___add_schema_file___nonexistent___returns_error() {
719        let manifest = Manifest::new("test", "1.0.0");
720        let result =
721            BundleBuilder::new(manifest).add_schema_file("/nonexistent/schema.h", "schema.h");
722
723        assert!(result.is_err());
724    }
725
726    #[test]
727    fn BundleBuilder___add_schema_file___detects_c_header_format() {
728        let temp_dir = TempDir::new().unwrap();
729        let schema_path = temp_dir.path().join("messages.h");
730        fs::write(&schema_path, b"#include <stdint.h>").unwrap();
731
732        let manifest = Manifest::new("test", "1.0.0");
733        let builder = BundleBuilder::new(manifest)
734            .add_schema_file(&schema_path, "messages.h")
735            .unwrap();
736
737        let schema_info = builder.manifest.schemas.get("messages.h").unwrap();
738        assert_eq!(schema_info.format, "c-header");
739    }
740
741    #[test]
742    fn BundleBuilder___add_schema_file___detects_json_schema_format() {
743        let temp_dir = TempDir::new().unwrap();
744        let schema_path = temp_dir.path().join("schema.json");
745        fs::write(&schema_path, b"{}").unwrap();
746
747        let manifest = Manifest::new("test", "1.0.0");
748        let builder = BundleBuilder::new(manifest)
749            .add_schema_file(&schema_path, "schema.json")
750            .unwrap();
751
752        let schema_info = builder.manifest.schemas.get("schema.json").unwrap();
753        assert_eq!(schema_info.format, "json-schema");
754    }
755
756    #[test]
757    fn BundleBuilder___add_schema_file___unknown_format() {
758        let temp_dir = TempDir::new().unwrap();
759        let schema_path = temp_dir.path().join("schema.xyz");
760        fs::write(&schema_path, b"content").unwrap();
761
762        let manifest = Manifest::new("test", "1.0.0");
763        let builder = BundleBuilder::new(manifest)
764            .add_schema_file(&schema_path, "schema.xyz")
765            .unwrap();
766
767        let schema_info = builder.manifest.schemas.get("schema.xyz").unwrap();
768        assert_eq!(schema_info.format, "unknown");
769    }
770
771    #[test]
772    fn BundleBuilder___write___invalid_manifest___returns_error() {
773        let temp_dir = TempDir::new().unwrap();
774        let output_path = temp_dir.path().join("test.rbp");
775
776        // Manifest without any platforms is invalid
777        let manifest = Manifest::new("test", "1.0.0");
778        let result = BundleBuilder::new(manifest).write(&output_path);
779
780        assert!(result.is_err());
781        let err = result.unwrap_err();
782        assert!(matches!(err, BundleError::InvalidManifest(_)));
783    }
784
785    #[test]
786    fn BundleBuilder___write___creates_valid_bundle() {
787        let temp_dir = TempDir::new().unwrap();
788        let lib_path = temp_dir.path().join("libtest.so");
789        let output_path = temp_dir.path().join("test.rbp");
790        fs::write(&lib_path, b"fake library").unwrap();
791
792        let manifest = Manifest::new("test", "1.0.0");
793        BundleBuilder::new(manifest)
794            .add_library(Platform::LinuxX86_64, &lib_path)
795            .unwrap()
796            .write(&output_path)
797            .unwrap();
798
799        assert!(output_path.exists());
800
801        // Verify it's a valid ZIP
802        let file = File::open(&output_path).unwrap();
803        let archive = zip::ZipArchive::new(file).unwrap();
804        assert!(archive.len() >= 2); // manifest + library
805    }
806
807    #[test]
808    fn BundleBuilder___manifest_mut___allows_modification() {
809        let manifest = Manifest::new("test", "1.0.0");
810        let mut builder = BundleBuilder::new(manifest);
811
812        builder.manifest_mut().plugin.description = Some("Modified".to_string());
813
814        assert_eq!(
815            builder.manifest().plugin.description,
816            Some("Modified".to_string())
817        );
818    }
819
820    #[test]
821    fn detect_schema_format___hpp_extension___returns_c_header() {
822        assert_eq!(detect_schema_format("types.hpp"), "c-header");
823    }
824
825    // ========================================================================
826    // Minisign Signature Tests
827    // ========================================================================
828    // These tests verify that minisign signature generation and verification
829    // work correctly. The test vectors are used as reference for consumer
830    // language implementations (Java, C#, Python).
831
832    #[test]
833    fn sign_data___generates_verifiable_signature() {
834        use minisign::{KeyPair, PublicKey};
835        use std::io::Cursor;
836
837        // Generate a test keypair
838        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
839
840        // Test data
841        let test_data = b"Hello, rustbridge!";
842
843        // Sign the data
844        let mut reader = Cursor::new(test_data.as_slice());
845        let signature_box = minisign::sign(
846            Some(&keypair.pk),
847            &keypair.sk,
848            &mut reader,
849            Some("trusted comment"),
850            Some("untrusted comment"),
851        )
852        .unwrap();
853
854        // Verify the signature
855        let pk = PublicKey::from_base64(&keypair.pk.to_base64()).unwrap();
856        let mut verify_reader = Cursor::new(test_data.as_slice());
857        let result = minisign::verify(&pk, &signature_box, &mut verify_reader, true, false, false);
858
859        assert!(result.is_ok(), "Signature verification should succeed");
860    }
861
862    #[test]
863    fn sign_data___wrong_data___verification_fails() {
864        use minisign::{KeyPair, PublicKey};
865        use std::io::Cursor;
866
867        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
868        let test_data = b"Hello, rustbridge!";
869        let wrong_data = b"Hello, rustbridge?"; // Changed ! to ?
870
871        // Sign the original data
872        let mut reader = Cursor::new(test_data.as_slice());
873        let signature_box =
874            minisign::sign(Some(&keypair.pk), &keypair.sk, &mut reader, None, None).unwrap();
875
876        // Try to verify with wrong data
877        let pk = PublicKey::from_base64(&keypair.pk.to_base64()).unwrap();
878        let mut verify_reader = Cursor::new(wrong_data.as_slice());
879        let result = minisign::verify(&pk, &signature_box, &mut verify_reader, true, false, false);
880
881        assert!(result.is_err(), "Verification should fail with wrong data");
882    }
883
884    #[test]
885    fn sign_data___signature_format___has_prehash_algorithm_id() {
886        use base64::Engine;
887        use minisign::KeyPair;
888        use std::io::Cursor;
889
890        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
891        let test_data = b"test";
892
893        let mut reader = Cursor::new(test_data.as_slice());
894        let signature_box = minisign::sign(
895            Some(&keypair.pk),
896            &keypair.sk,
897            &mut reader,
898            Some("trusted"),
899            Some("untrusted"),
900        )
901        .unwrap();
902
903        let sig_string = signature_box.into_string();
904        let lines: Vec<&str> = sig_string.lines().collect();
905
906        // The signature line is the second line
907        let sig_base64 = lines[1];
908        let sig_bytes = base64::engine::general_purpose::STANDARD
909            .decode(sig_base64)
910            .unwrap();
911
912        // First two bytes should be "ED" (0x45, 0x44) for prehashed signatures
913        assert_eq!(sig_bytes[0], 0x45, "First byte should be 'E'");
914        assert_eq!(
915            sig_bytes[1], 0x44,
916            "Second byte should be 'D' for prehashed"
917        );
918        assert_eq!(sig_bytes.len(), 74, "Signature should be 74 bytes");
919    }
920
921    #[test]
922    fn sign_data___public_key_format___has_ed_algorithm_id() {
923        use base64::Engine;
924        use minisign::KeyPair;
925
926        let keypair = KeyPair::generate_unencrypted_keypair().unwrap();
927        let pk_base64 = keypair.pk.to_base64();
928        let pk_bytes = base64::engine::general_purpose::STANDARD
929            .decode(&pk_base64)
930            .unwrap();
931
932        // First two bytes should be "Ed" (0x45, 0x64) for public keys
933        assert_eq!(pk_bytes[0], 0x45, "First byte should be 'E'");
934        assert_eq!(
935            pk_bytes[1], 0x64,
936            "Second byte should be 'd' for public key"
937        );
938        assert_eq!(pk_bytes.len(), 42, "Public key should be 42 bytes");
939    }
940
941    #[test]
942    fn BundleBuilder___add_license_file___adds_file_to_legal_dir() {
943        let temp_dir = TempDir::new().unwrap();
944        let license_path = temp_dir.path().join("LICENSE");
945        fs::write(&license_path, b"MIT License\n\nCopyright...").unwrap();
946
947        let manifest = Manifest::new("test", "1.0.0");
948        let builder = BundleBuilder::new(manifest)
949            .add_license_file(&license_path)
950            .unwrap();
951
952        // Check file was added
953        assert_eq!(builder.files.len(), 1);
954        assert_eq!(builder.files[0].archive_path, "legal/LICENSE");
955
956        // Check manifest was updated
957        assert_eq!(builder.manifest.get_license_file(), Some("legal/LICENSE"));
958    }
959
960    #[test]
961    fn BundleBuilder___add_license_file___nonexistent___returns_error() {
962        let manifest = Manifest::new("test", "1.0.0");
963        let result = BundleBuilder::new(manifest).add_license_file("/nonexistent/LICENSE");
964
965        assert!(result.is_err());
966    }
967}