1use 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#[derive(Debug)]
27pub struct BundleLoader {
28 archive: ZipArchive<File>,
29 manifest: Manifest,
30}
31
32impl BundleLoader {
33 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 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 manifest.validate()?;
52
53 Ok(Self { archive, manifest })
54 }
55
56 #[must_use]
58 pub fn manifest(&self) -> &Manifest {
59 &self.manifest
60 }
61
62 #[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 #[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 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 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 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 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 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 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 fs::create_dir_all(output_dir)?;
144
145 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 fs::write(&output_path, &contents)?;
154
155 #[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 #[must_use]
169 pub fn list_variants(&self, platform: Platform) -> Vec<&str> {
170 self.manifest.list_variants(platform)
171 }
172
173 #[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 #[must_use]
184 pub fn build_info(&self) -> Option<&crate::BuildInfo> {
185 self.manifest.get_build_info()
186 }
187
188 #[must_use]
190 pub fn sbom(&self) -> Option<&crate::Sbom> {
191 self.manifest.get_sbom()
192 }
193
194 pub fn extract_library_for_current_platform<P: AsRef<Path>>(
198 &mut self,
199 output_dir: P,
200 ) -> BundleResult<PathBuf> {
201 let platform = Platform::current().ok_or_else(|| {
202 BundleError::UnsupportedPlatform("Current platform is not supported".to_string())
203 })?;
204
205 self.extract_library(platform, output_dir)
206 }
207
208 pub fn read_file(&mut self, path: &str) -> BundleResult<Vec<u8>> {
210 let mut file = self
211 .archive
212 .by_name(path)
213 .map_err(|_| BundleError::MissingFile(format!("File not found in bundle: {path}")))?;
214
215 let mut contents = Vec::new();
216 file.read_to_end(&mut contents)?;
217 Ok(contents)
218 }
219
220 pub fn read_file_string(&mut self, path: &str) -> BundleResult<String> {
222 let mut file = self
223 .archive
224 .by_name(path)
225 .map_err(|_| BundleError::MissingFile(format!("File not found in bundle: {path}")))?;
226
227 let mut contents = String::new();
228 file.read_to_string(&mut contents)?;
229 Ok(contents)
230 }
231
232 #[must_use]
234 pub fn list_files(&self) -> Vec<String> {
235 (0..self.archive.len())
236 .filter_map(|i| self.archive.name_for_index(i).map(String::from))
237 .collect()
238 }
239
240 #[must_use]
242 pub fn has_file(&self, path: &str) -> bool {
243 self.archive.index_for_name(path).is_some()
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 #![allow(non_snake_case)]
250
251 use super::*;
252 use crate::builder::{BundleBuilder, compute_sha256};
253 use std::io::Write;
254 use tempfile::TempDir;
255
256 fn create_test_bundle(temp_dir: &TempDir) -> PathBuf {
257 let bundle_path = temp_dir.path().join("test.rbp");
258
259 let lib_path = temp_dir.path().join("libtest.so");
261 fs::write(&lib_path, b"fake library contents").unwrap();
262
263 let manifest = Manifest::new("test-plugin", "1.0.0");
265 BundleBuilder::new(manifest)
266 .add_library(Platform::LinuxX86_64, &lib_path)
267 .unwrap()
268 .add_bytes("schema/messages.h", b"// header".to_vec())
269 .write(&bundle_path)
270 .unwrap();
271
272 bundle_path
273 }
274
275 fn create_multi_platform_bundle(temp_dir: &TempDir) -> PathBuf {
276 let bundle_path = temp_dir.path().join("multi.rbp");
277
278 let linux_lib = temp_dir.path().join("libtest.so");
279 let macos_lib = temp_dir.path().join("libtest.dylib");
280 let windows_lib = temp_dir.path().join("test.dll");
281 fs::write(&linux_lib, b"linux library").unwrap();
282 fs::write(&macos_lib, b"macos library").unwrap();
283 fs::write(&windows_lib, b"windows library").unwrap();
284
285 let manifest = Manifest::new("multi-platform", "2.0.0");
286 BundleBuilder::new(manifest)
287 .add_library(Platform::LinuxX86_64, &linux_lib)
288 .unwrap()
289 .add_library(Platform::DarwinAarch64, &macos_lib)
290 .unwrap()
291 .add_library(Platform::WindowsX86_64, &windows_lib)
292 .unwrap()
293 .write(&bundle_path)
294 .unwrap();
295
296 bundle_path
297 }
298
299 #[test]
300 fn BundleLoader___open___reads_manifest() {
301 let temp_dir = TempDir::new().unwrap();
302 let bundle_path = create_test_bundle(&temp_dir);
303
304 let loader = BundleLoader::open(&bundle_path).unwrap();
305
306 assert_eq!(loader.manifest().plugin.name, "test-plugin");
307 assert_eq!(loader.manifest().plugin.version, "1.0.0");
308 }
309
310 #[test]
311 fn BundleLoader___open___nonexistent_file___returns_error() {
312 let result = BundleLoader::open("/nonexistent/bundle.rbp");
313
314 assert!(result.is_err());
315 }
316
317 #[test]
318 fn BundleLoader___open___not_a_zip___returns_error() {
319 let temp_dir = TempDir::new().unwrap();
320 let fake_bundle = temp_dir.path().join("fake.rbp");
321 fs::write(&fake_bundle, b"not a zip file").unwrap();
322
323 let result = BundleLoader::open(&fake_bundle);
324
325 assert!(result.is_err());
326 }
327
328 #[test]
329 fn BundleLoader___open___missing_manifest___returns_error() {
330 let temp_dir = TempDir::new().unwrap();
331 let bundle_path = temp_dir.path().join("no-manifest.rbp");
332
333 let file = File::create(&bundle_path).unwrap();
335 let mut zip = zip::ZipWriter::new(file);
336 let options = zip::write::SimpleFileOptions::default();
337 zip.start_file("some-file.txt", options).unwrap();
338 zip.write_all(b"content").unwrap();
339 zip.finish().unwrap();
340
341 let result = BundleLoader::open(&bundle_path);
342
343 assert!(result.is_err());
344 let err = result.unwrap_err();
345 assert!(matches!(err, BundleError::MissingFile(_)));
346 assert!(err.to_string().contains("manifest.json"));
347 }
348
349 #[test]
350 fn BundleLoader___open___invalid_manifest_json___returns_error() {
351 let temp_dir = TempDir::new().unwrap();
352 let bundle_path = temp_dir.path().join("bad-manifest.rbp");
353
354 let file = File::create(&bundle_path).unwrap();
356 let mut zip = zip::ZipWriter::new(file);
357 let options = zip::write::SimpleFileOptions::default();
358 zip.start_file("manifest.json", options).unwrap();
359 zip.write_all(b"{ invalid json }").unwrap();
360 zip.finish().unwrap();
361
362 let result = BundleLoader::open(&bundle_path);
363
364 assert!(result.is_err());
365 }
366
367 #[test]
368 fn BundleLoader___list_files___returns_all_files() {
369 let temp_dir = TempDir::new().unwrap();
370 let bundle_path = create_test_bundle(&temp_dir);
371
372 let loader = BundleLoader::open(&bundle_path).unwrap();
373 let files = loader.list_files();
374
375 assert!(files.contains(&"manifest.json".to_string()));
376 assert!(files.contains(&"schema/messages.h".to_string()));
377 }
378
379 #[test]
380 fn BundleLoader___has_file___returns_true_for_existing() {
381 let temp_dir = TempDir::new().unwrap();
382 let bundle_path = create_test_bundle(&temp_dir);
383
384 let loader = BundleLoader::open(&bundle_path).unwrap();
385
386 assert!(loader.has_file("manifest.json"));
387 assert!(loader.has_file("schema/messages.h"));
388 assert!(!loader.has_file("nonexistent.txt"));
389 }
390
391 #[test]
392 fn BundleLoader___read_file___returns_contents() {
393 let temp_dir = TempDir::new().unwrap();
394 let bundle_path = create_test_bundle(&temp_dir);
395
396 let mut loader = BundleLoader::open(&bundle_path).unwrap();
397 let contents = loader.read_file_string("schema/messages.h").unwrap();
398
399 assert_eq!(contents, "// header");
400 }
401
402 #[test]
403 fn BundleLoader___read_file___missing_file___returns_error() {
404 let temp_dir = TempDir::new().unwrap();
405 let bundle_path = create_test_bundle(&temp_dir);
406
407 let mut loader = BundleLoader::open(&bundle_path).unwrap();
408 let result = loader.read_file("nonexistent.txt");
409
410 assert!(result.is_err());
411 let err = result.unwrap_err();
412 assert!(matches!(err, BundleError::MissingFile(_)));
413 }
414
415 #[test]
416 fn BundleLoader___read_file___returns_bytes() {
417 let temp_dir = TempDir::new().unwrap();
418 let bundle_path = create_test_bundle(&temp_dir);
419
420 let mut loader = BundleLoader::open(&bundle_path).unwrap();
421 let contents = loader.read_file("schema/messages.h").unwrap();
422
423 assert_eq!(contents, b"// header");
424 }
425
426 #[test]
427 fn BundleLoader___extract_library___verifies_checksum() {
428 let temp_dir = TempDir::new().unwrap();
429 let bundle_path = create_test_bundle(&temp_dir);
430 let extract_dir = temp_dir.path().join("extracted");
431
432 let mut loader = BundleLoader::open(&bundle_path).unwrap();
433 let lib_path = loader
434 .extract_library(Platform::LinuxX86_64, &extract_dir)
435 .unwrap();
436
437 assert!(lib_path.exists());
438 let contents = fs::read(&lib_path).unwrap();
439 assert_eq!(contents, b"fake library contents");
440 }
441
442 #[test]
443 fn BundleLoader___extract_library___unsupported_platform___returns_error() {
444 let temp_dir = TempDir::new().unwrap();
445 let bundle_path = create_test_bundle(&temp_dir);
446 let extract_dir = temp_dir.path().join("extracted");
447
448 let mut loader = BundleLoader::open(&bundle_path).unwrap();
449 let result = loader.extract_library(Platform::WindowsX86_64, &extract_dir);
450
451 assert!(result.is_err());
452 let err = result.unwrap_err();
453 assert!(matches!(err, BundleError::UnsupportedPlatform(_)));
454 }
455
456 #[test]
457 fn BundleLoader___extract_library___creates_output_directory() {
458 let temp_dir = TempDir::new().unwrap();
459 let bundle_path = create_test_bundle(&temp_dir);
460 let extract_dir = temp_dir.path().join("deep").join("nested").join("dir");
461
462 let mut loader = BundleLoader::open(&bundle_path).unwrap();
463 let lib_path = loader
464 .extract_library(Platform::LinuxX86_64, &extract_dir)
465 .unwrap();
466
467 assert!(extract_dir.exists());
468 assert!(lib_path.exists());
469 }
470
471 #[test]
472 fn BundleLoader___multi_platform___extract_each_platform() {
473 let temp_dir = TempDir::new().unwrap();
474 let bundle_path = create_multi_platform_bundle(&temp_dir);
475
476 let mut loader = BundleLoader::open(&bundle_path).unwrap();
477
478 assert!(loader.manifest().supports_platform(Platform::LinuxX86_64));
480 assert!(loader.manifest().supports_platform(Platform::DarwinAarch64));
481 assert!(loader.manifest().supports_platform(Platform::WindowsX86_64));
482
483 let linux_dir = temp_dir.path().join("linux");
485 let linux_lib = loader
486 .extract_library(Platform::LinuxX86_64, &linux_dir)
487 .unwrap();
488 assert_eq!(fs::read(&linux_lib).unwrap(), b"linux library");
489
490 let macos_dir = temp_dir.path().join("macos");
492 let macos_lib = loader
493 .extract_library(Platform::DarwinAarch64, &macos_dir)
494 .unwrap();
495 assert_eq!(fs::read(&macos_lib).unwrap(), b"macos library");
496
497 let windows_dir = temp_dir.path().join("windows");
499 let windows_lib = loader
500 .extract_library(Platform::WindowsX86_64, &windows_dir)
501 .unwrap();
502 assert_eq!(fs::read(&windows_lib).unwrap(), b"windows library");
503 }
504
505 #[test]
506 fn BundleLoader___supports_current_platform___returns_correct_value() {
507 let temp_dir = TempDir::new().unwrap();
508 let bundle_path = create_test_bundle(&temp_dir);
509
510 let loader = BundleLoader::open(&bundle_path).unwrap();
511
512 if Platform::current() == Some(Platform::LinuxX86_64) {
515 assert!(loader.supports_current_platform());
516 }
517 }
518
519 #[test]
520 fn BundleLoader___current_platform_info___returns_info_when_supported() {
521 let temp_dir = TempDir::new().unwrap();
522 let bundle_path = create_test_bundle(&temp_dir);
523
524 let loader = BundleLoader::open(&bundle_path).unwrap();
525
526 if Platform::current() == Some(Platform::LinuxX86_64) {
527 let info = loader.current_platform_info();
528 assert!(info.is_some());
529 let release = info.unwrap().release().unwrap();
530 assert!(release.library.contains("libtest.so"));
531 }
532 }
533
534 #[test]
535 fn roundtrip___create_and_load___preserves_all_data() {
536 let temp_dir = TempDir::new().unwrap();
537 let bundle_path = temp_dir.path().join("roundtrip.rbp");
538
539 let lib_path = temp_dir.path().join("libplugin.so");
541 let lib_contents = b"roundtrip test library";
542 fs::write(&lib_path, lib_contents).unwrap();
543
544 let mut manifest = Manifest::new("roundtrip-plugin", "3.2.1");
546 manifest.plugin.description = Some("A test plugin for roundtrip".to_string());
547 manifest.plugin.authors = vec!["Author One".to_string(), "Author Two".to_string()];
548 manifest.plugin.license = Some("MIT".to_string());
549
550 BundleBuilder::new(manifest)
551 .add_library(Platform::LinuxX86_64, &lib_path)
552 .unwrap()
553 .add_bytes("docs/README.md", b"# Documentation".to_vec())
554 .write(&bundle_path)
555 .unwrap();
556
557 let mut loader = BundleLoader::open(&bundle_path).unwrap();
559
560 assert_eq!(loader.manifest().plugin.name, "roundtrip-plugin");
561 assert_eq!(loader.manifest().plugin.version, "3.2.1");
562 assert_eq!(
563 loader.manifest().plugin.description,
564 Some("A test plugin for roundtrip".to_string())
565 );
566 assert_eq!(loader.manifest().plugin.authors.len(), 2);
567 assert_eq!(loader.manifest().plugin.license, Some("MIT".to_string()));
568
569 let platform_info = loader
571 .manifest()
572 .get_platform(Platform::LinuxX86_64)
573 .unwrap();
574 let release = platform_info.release().unwrap();
575 let expected_checksum = format!("sha256:{}", compute_sha256(lib_contents));
576 assert_eq!(release.checksum, expected_checksum);
577
578 let extract_dir = temp_dir.path().join("extract");
580 let extracted = loader
581 .extract_library(Platform::LinuxX86_64, &extract_dir)
582 .unwrap();
583 assert_eq!(fs::read(&extracted).unwrap(), lib_contents);
584 }
585
586 #[test]
587 fn roundtrip___bundle_with_schemas___preserves_schema_info() {
588 let temp_dir = TempDir::new().unwrap();
589 let bundle_path = temp_dir.path().join("schema-bundle.rbp");
590
591 let lib_path = temp_dir.path().join("libtest.so");
592 let header_path = temp_dir.path().join("messages.h");
593 let json_path = temp_dir.path().join("schema.json");
594 fs::write(&lib_path, b"lib").unwrap();
595 fs::write(&header_path, b"#pragma once").unwrap();
596 fs::write(&json_path, b"{}").unwrap();
597
598 let manifest = Manifest::new("schema-plugin", "1.0.0");
599 BundleBuilder::new(manifest)
600 .add_library(Platform::LinuxX86_64, &lib_path)
601 .unwrap()
602 .add_schema_file(&header_path, "messages.h")
603 .unwrap()
604 .add_schema_file(&json_path, "schema.json")
605 .unwrap()
606 .write(&bundle_path)
607 .unwrap();
608
609 let mut loader = BundleLoader::open(&bundle_path).unwrap();
610
611 assert_eq!(loader.manifest().schemas.len(), 2);
613 assert!(loader.manifest().schemas.contains_key("messages.h"));
614 assert!(loader.manifest().schemas.contains_key("schema.json"));
615
616 assert!(loader.has_file("schema/messages.h"));
618 assert!(loader.has_file("schema/schema.json"));
619 assert_eq!(
620 loader.read_file_string("schema/messages.h").unwrap(),
621 "#pragma once"
622 );
623 }
624}