1use 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#[derive(Debug)]
30pub struct BundleBuilder {
31 manifest: Manifest,
32 files: Vec<BundleFile>,
33 signing_key: Option<(String, SecretKey)>, }
35
36#[derive(Debug)]
38struct BundleFile {
39 archive_path: String,
41 contents: Vec<u8>,
43}
44
45impl BundleBuilder {
46 #[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 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 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 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 let contents = fs::read(library_path).map_err(|e| {
104 BundleError::LibraryNotFound(format!("{}: {}", library_path.display(), e))
105 })?;
106
107 let checksum = compute_sha256(&contents);
109
110 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 self.manifest
124 .add_platform_variant(platform, variant, &archive_path, &checksum, None);
125
126 self.files.push(BundleFile {
128 archive_path,
129 contents,
130 });
131
132 Ok(self)
133 }
134
135 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 let contents = fs::read(library_path).map_err(|e| {
150 BundleError::LibraryNotFound(format!("{}: {}", library_path.display(), e))
151 })?;
152
153 let checksum = compute_sha256(&contents);
155
156 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 self.manifest.add_platform_variant(
170 platform,
171 variant,
172 &archive_path,
173 &checksum,
174 Some(build),
175 );
176
177 self.files.push(BundleFile {
179 archive_path,
180 contents,
181 });
182
183 Ok(self)
184 }
185
186 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 let checksum = compute_sha256(&contents);
214
215 let format = detect_schema_format(archive_name);
217
218 let archive_path = format!("schema/{archive_name}");
219
220 self.manifest.add_schema(
222 archive_name.to_string(),
223 archive_path.clone(),
224 format,
225 checksum,
226 None, );
228
229 self.files.push(BundleFile {
230 archive_path,
231 contents,
232 });
233
234 Ok(self)
235 }
236
237 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 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 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 pub fn with_sbom(mut self, sbom: crate::Sbom) -> Self {
281 self.manifest.set_sbom(sbom);
282 self
283 }
284
285 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 pub fn add_sbom_file<P: AsRef<Path>>(
317 mut self,
318 source_path: P,
319 archive_name: &str,
320 ) -> BundleResult<Self> {
321 let source_path = source_path.as_ref();
322
323 let contents = fs::read(source_path).map_err(|e| {
324 BundleError::Io(std::io::Error::new(
325 e.kind(),
326 format!("Failed to read SBOM file {}: {}", source_path.display(), e),
327 ))
328 })?;
329
330 let archive_path = format!("sbom/{archive_name}");
331
332 self.files.push(BundleFile {
333 archive_path,
334 contents,
335 });
336
337 Ok(self)
338 }
339
340 pub fn write<P: AsRef<Path>>(self, output_path: P) -> BundleResult<()> {
342 let output_path = output_path.as_ref();
343
344 self.manifest.validate()?;
346
347 let file = File::create(output_path)?;
349 let mut zip = ZipWriter::new(file);
350 let options =
351 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
352
353 let manifest_json = self.manifest.to_json()?;
355 zip.start_file(MANIFEST_FILE, options)?;
356 zip.write_all(manifest_json.as_bytes())?;
357
358 if let Some((ref _public_key, ref secret_key)) = self.signing_key {
360 let signature = sign_data(secret_key, manifest_json.as_bytes())?;
361 zip.start_file(format!("{MANIFEST_FILE}.minisig"), options)?;
362 zip.write_all(signature.as_bytes())?;
363 }
364
365 for bundle_file in &self.files {
367 zip.start_file(&bundle_file.archive_path, options)?;
368 zip.write_all(&bundle_file.contents)?;
369
370 if let Some((ref _public_key, ref secret_key)) = self.signing_key {
372 if bundle_file.archive_path.starts_with("lib/") {
374 let signature = sign_data(secret_key, &bundle_file.contents)?;
375 let sig_path = format!("{}.minisig", bundle_file.archive_path);
376 zip.start_file(&sig_path, options)?;
377 zip.write_all(signature.as_bytes())?;
378 }
379 }
380 }
381
382 zip.finish()?;
383
384 Ok(())
385 }
386
387 #[must_use]
389 pub fn manifest(&self) -> &Manifest {
390 &self.manifest
391 }
392
393 pub fn manifest_mut(&mut self) -> &mut Manifest {
395 &mut self.manifest
396 }
397}
398
399pub fn compute_sha256(data: &[u8]) -> String {
401 let mut hasher = Sha256::new();
402 hasher.update(data);
403 let result = hasher.finalize();
404 hex::encode(result)
405}
406
407pub fn verify_sha256(data: &[u8], expected: &str) -> bool {
409 let actual = compute_sha256(data);
410
411 let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
413
414 actual == expected_hex
415}
416
417fn detect_schema_format(filename: &str) -> String {
419 if filename.ends_with(".h") || filename.ends_with(".hpp") {
420 "c-header".to_string()
421 } else if filename.ends_with(".json") {
422 "json-schema".to_string()
423 } else {
424 "unknown".to_string()
425 }
426}
427
428fn sign_data(secret_key: &SecretKey, data: &[u8]) -> BundleResult<String> {
432 let signature_box = minisign::sign(
433 None, secret_key, data, None, None, )
437 .map_err(|e| BundleError::Io(std::io::Error::other(format!("Failed to sign data: {e}"))))?;
438
439 Ok(signature_box.to_string())
440}
441
442#[cfg(test)]
443mod tests {
444 #![allow(non_snake_case)]
445
446 use super::*;
447 use tempfile::TempDir;
448
449 #[test]
450 fn compute_sha256___returns_consistent_hash() {
451 let data = b"hello world";
452 let hash1 = compute_sha256(data);
453 let hash2 = compute_sha256(data);
454
455 assert_eq!(hash1, hash2);
456 assert_eq!(hash1.len(), 64); }
458
459 #[test]
460 fn compute_sha256___different_data___different_hash() {
461 let hash1 = compute_sha256(b"hello");
462 let hash2 = compute_sha256(b"world");
463
464 assert_ne!(hash1, hash2);
465 }
466
467 #[test]
468 fn compute_sha256___empty_data___returns_valid_hash() {
469 let hash = compute_sha256(b"");
470
471 assert_eq!(hash.len(), 64);
472 assert_eq!(
474 hash,
475 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
476 );
477 }
478
479 #[test]
480 fn verify_sha256___accepts_valid_checksum() {
481 let data = b"hello world";
482 let checksum = compute_sha256(data);
483
484 assert!(verify_sha256(data, &checksum));
485 assert!(verify_sha256(data, &format!("sha256:{checksum}")));
486 }
487
488 #[test]
489 fn verify_sha256___rejects_invalid_checksum() {
490 let data = b"hello world";
491
492 assert!(!verify_sha256(data, "invalid"));
493 assert!(!verify_sha256(data, "sha256:invalid"));
494 }
495
496 #[test]
497 fn verify_sha256___case_sensitive___rejects_uppercase() {
498 let data = b"hello world";
499 let checksum = compute_sha256(data).to_uppercase();
500
501 assert!(!verify_sha256(data, &checksum));
502 }
503
504 #[test]
505 fn BundleBuilder___add_bytes___adds_file() {
506 let manifest = Manifest::new("test", "1.0.0");
507 let builder = BundleBuilder::new(manifest).add_bytes("test.txt", b"hello".to_vec());
508
509 assert_eq!(builder.files.len(), 1);
510 assert_eq!(builder.files[0].archive_path, "test.txt");
511 assert_eq!(builder.files[0].contents, b"hello");
512 }
513
514 #[test]
515 fn BundleBuilder___add_bytes___multiple_files() {
516 let manifest = Manifest::new("test", "1.0.0");
517 let builder = BundleBuilder::new(manifest)
518 .add_bytes("file1.txt", b"content1".to_vec())
519 .add_bytes("file2.txt", b"content2".to_vec())
520 .add_bytes("dir/file3.txt", b"content3".to_vec());
521
522 assert_eq!(builder.files.len(), 3);
523 }
524
525 #[test]
526 fn BundleBuilder___add_library___nonexistent_file___returns_error() {
527 let manifest = Manifest::new("test", "1.0.0");
528 let result = BundleBuilder::new(manifest)
529 .add_library(Platform::LinuxX86_64, "/nonexistent/path/libtest.so");
530
531 assert!(result.is_err());
532 let err = result.unwrap_err();
533 assert!(matches!(err, BundleError::LibraryNotFound(_)));
534 assert!(err.to_string().contains("/nonexistent/path/libtest.so"));
535 }
536
537 #[test]
538 fn BundleBuilder___add_library___valid_file___computes_checksum() {
539 let temp_dir = TempDir::new().unwrap();
540 let lib_path = temp_dir.path().join("libtest.so");
541 fs::write(&lib_path, b"fake library").unwrap();
542
543 let manifest = Manifest::new("test", "1.0.0");
544 let builder = BundleBuilder::new(manifest)
545 .add_library(Platform::LinuxX86_64, &lib_path)
546 .unwrap();
547
548 let platform_info = builder
549 .manifest
550 .get_platform(Platform::LinuxX86_64)
551 .unwrap();
552 let release = platform_info.release().unwrap();
553 assert!(release.checksum.starts_with("sha256:"));
554 assert_eq!(release.library, "lib/linux-x86_64/release/libtest.so");
555 }
556
557 #[test]
558 fn BundleBuilder___add_library_variant___adds_multiple_variants() {
559 let temp_dir = TempDir::new().unwrap();
560 let release_lib = temp_dir.path().join("libtest_release.so");
561 let debug_lib = temp_dir.path().join("libtest_debug.so");
562 fs::write(&release_lib, b"release library").unwrap();
563 fs::write(&debug_lib, b"debug library").unwrap();
564
565 let manifest = Manifest::new("test", "1.0.0");
566 let builder = BundleBuilder::new(manifest)
567 .add_library_variant(Platform::LinuxX86_64, "release", &release_lib)
568 .unwrap()
569 .add_library_variant(Platform::LinuxX86_64, "debug", &debug_lib)
570 .unwrap();
571
572 let platform_info = builder
573 .manifest
574 .get_platform(Platform::LinuxX86_64)
575 .unwrap();
576
577 assert!(platform_info.has_variant("release"));
578 assert!(platform_info.has_variant("debug"));
579
580 let release = platform_info.variant("release").unwrap();
581 let debug = platform_info.variant("debug").unwrap();
582
583 assert_eq!(
584 release.library,
585 "lib/linux-x86_64/release/libtest_release.so"
586 );
587 assert_eq!(debug.library, "lib/linux-x86_64/debug/libtest_debug.so");
588 }
589
590 #[test]
591 fn BundleBuilder___add_library___multiple_platforms() {
592 let temp_dir = TempDir::new().unwrap();
593
594 let linux_lib = temp_dir.path().join("libtest.so");
595 let macos_lib = temp_dir.path().join("libtest.dylib");
596 fs::write(&linux_lib, b"linux lib").unwrap();
597 fs::write(&macos_lib, b"macos lib").unwrap();
598
599 let manifest = Manifest::new("test", "1.0.0");
600 let builder = BundleBuilder::new(manifest)
601 .add_library(Platform::LinuxX86_64, &linux_lib)
602 .unwrap()
603 .add_library(Platform::DarwinAarch64, &macos_lib)
604 .unwrap();
605
606 assert!(builder.manifest.supports_platform(Platform::LinuxX86_64));
607 assert!(builder.manifest.supports_platform(Platform::DarwinAarch64));
608 assert!(!builder.manifest.supports_platform(Platform::WindowsX86_64));
609 }
610
611 #[test]
612 fn BundleBuilder___add_schema_file___nonexistent___returns_error() {
613 let manifest = Manifest::new("test", "1.0.0");
614 let result =
615 BundleBuilder::new(manifest).add_schema_file("/nonexistent/schema.h", "schema.h");
616
617 assert!(result.is_err());
618 }
619
620 #[test]
621 fn BundleBuilder___add_schema_file___detects_c_header_format() {
622 let temp_dir = TempDir::new().unwrap();
623 let schema_path = temp_dir.path().join("messages.h");
624 fs::write(&schema_path, b"#include <stdint.h>").unwrap();
625
626 let manifest = Manifest::new("test", "1.0.0");
627 let builder = BundleBuilder::new(manifest)
628 .add_schema_file(&schema_path, "messages.h")
629 .unwrap();
630
631 let schema_info = builder.manifest.schemas.get("messages.h").unwrap();
632 assert_eq!(schema_info.format, "c-header");
633 }
634
635 #[test]
636 fn BundleBuilder___add_schema_file___detects_json_schema_format() {
637 let temp_dir = TempDir::new().unwrap();
638 let schema_path = temp_dir.path().join("schema.json");
639 fs::write(&schema_path, b"{}").unwrap();
640
641 let manifest = Manifest::new("test", "1.0.0");
642 let builder = BundleBuilder::new(manifest)
643 .add_schema_file(&schema_path, "schema.json")
644 .unwrap();
645
646 let schema_info = builder.manifest.schemas.get("schema.json").unwrap();
647 assert_eq!(schema_info.format, "json-schema");
648 }
649
650 #[test]
651 fn BundleBuilder___add_schema_file___unknown_format() {
652 let temp_dir = TempDir::new().unwrap();
653 let schema_path = temp_dir.path().join("schema.xyz");
654 fs::write(&schema_path, b"content").unwrap();
655
656 let manifest = Manifest::new("test", "1.0.0");
657 let builder = BundleBuilder::new(manifest)
658 .add_schema_file(&schema_path, "schema.xyz")
659 .unwrap();
660
661 let schema_info = builder.manifest.schemas.get("schema.xyz").unwrap();
662 assert_eq!(schema_info.format, "unknown");
663 }
664
665 #[test]
666 fn BundleBuilder___write___invalid_manifest___returns_error() {
667 let temp_dir = TempDir::new().unwrap();
668 let output_path = temp_dir.path().join("test.rbp");
669
670 let manifest = Manifest::new("test", "1.0.0");
672 let result = BundleBuilder::new(manifest).write(&output_path);
673
674 assert!(result.is_err());
675 let err = result.unwrap_err();
676 assert!(matches!(err, BundleError::InvalidManifest(_)));
677 }
678
679 #[test]
680 fn BundleBuilder___write___creates_valid_bundle() {
681 let temp_dir = TempDir::new().unwrap();
682 let lib_path = temp_dir.path().join("libtest.so");
683 let output_path = temp_dir.path().join("test.rbp");
684 fs::write(&lib_path, b"fake library").unwrap();
685
686 let manifest = Manifest::new("test", "1.0.0");
687 BundleBuilder::new(manifest)
688 .add_library(Platform::LinuxX86_64, &lib_path)
689 .unwrap()
690 .write(&output_path)
691 .unwrap();
692
693 assert!(output_path.exists());
694
695 let file = File::open(&output_path).unwrap();
697 let archive = zip::ZipArchive::new(file).unwrap();
698 assert!(archive.len() >= 2); }
700
701 #[test]
702 fn BundleBuilder___manifest_mut___allows_modification() {
703 let manifest = Manifest::new("test", "1.0.0");
704 let mut builder = BundleBuilder::new(manifest);
705
706 builder.manifest_mut().plugin.description = Some("Modified".to_string());
707
708 assert_eq!(
709 builder.manifest().plugin.description,
710 Some("Modified".to_string())
711 );
712 }
713
714 #[test]
715 fn detect_schema_format___hpp_extension___returns_c_header() {
716 assert_eq!(detect_schema_format("types.hpp"), "c-header");
717 }
718}