1use std::collections::{BTreeSet, HashSet};
2use std::fs::File;
3use std::io::{self, Read, Seek, SeekFrom};
4use std::path::{Path, PathBuf};
5
6use player_plugin::{PluginReference, PluginReferenceError, PluginTransport};
7use semver::Version;
8use serde::Deserialize;
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11use uuid::Uuid;
12use zip::{CompressionMethod, ZipArchive, result::ZipError};
13
14use crate::{NativePluginArtifact, PluginInterfaceState, PluginRegistry, PluginRegistryBuildError};
15
16pub const MAX_EMBEDDED_PLUGIN_REGISTRY_BYTES: usize = 1024 * 1024;
17pub const MAX_EMBEDDED_PLUGIN_REGISTRY_SET_BYTES: usize = 4 * 1024 * 1024;
18pub const MAX_EMBEDDED_PLUGIN_REGISTRY_FRAGMENTS: usize = 256;
19pub const MAX_EMBEDDED_PLUGIN_ARTIFACTS: usize = 256;
20pub const MAX_EMBEDDED_PLUGIN_CAPABILITIES_PER_ARTIFACT: usize = 64;
21pub const MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
22pub const MAX_ANDROID_PACKAGE_PATHS: usize = 256;
23
24#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct EmbeddedPluginRegistry {
27 schema_version: u32,
28 target: String,
29 architecture: String,
30 minimum_os: Option<String>,
31 artifacts: Vec<EmbeddedPluginArtifact>,
32}
33
34type PlatformIntegrityVerifier<'a> =
35 dyn FnMut(&Path, &EmbeddedPluginArtifact) -> Result<(), String> + 'a;
36
37#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct EmbeddedPluginArtifact {
40 plugin_id: String,
41 transport: PluginTransport,
42 locator: EmbeddedPluginLocator,
43 integrity: EmbeddedPluginIntegrity,
44 package: EmbeddedPluginPackage,
45 capabilities: Vec<EmbeddedPluginCapability>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
49#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
50pub enum EmbeddedPluginLocator {
51 AndroidNativeLibrary {
52 name: String,
53 },
54 AppleFramework {
55 name: String,
56 bundle_identifier: String,
57 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
61#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
62pub enum EmbeddedPluginIntegrity {
63 Sha256 {
64 digest: String,
65 },
66 AppleCodeSignature {
67 validation: EmbeddedAppleCodeSignatureValidation,
68 },
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum EmbeddedAppleCodeSignatureValidation {
74 SameTeamAsHostOrSimulatorAdHoc,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct EmbeddedPluginPackage {
80 version: String,
81 publisher: String,
82 descriptor_sha256: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct EmbeddedPluginCapability {
88 interface_id: String,
89 instance_id: String,
90 interface_major: u16,
91 interface_minor: u16,
92}
93
94#[derive(Debug, Error)]
95pub enum EmbeddedPluginRegistryError {
96 #[error("embedded plugin registry is {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
97 OversizedRegistry {
98 actual_bytes: usize,
99 maximum_bytes: usize,
100 },
101 #[error("invalid embedded plugin registry JSON: {0}")]
102 Json(#[from] serde_json::Error),
103 #[error("unsupported embedded plugin registry schema version {0}")]
104 SchemaVersion(u32),
105 #[error("embedded plugin registry contains {actual} fragments; maximum is {maximum}")]
106 TooManyFragments { actual: usize, maximum: usize },
107 #[error(
108 "embedded plugin registry fragments total {actual_bytes} bytes; maximum is {maximum_bytes} bytes"
109 )]
110 OversizedRegistrySet {
111 actual_bytes: usize,
112 maximum_bytes: usize,
113 },
114 #[error("embedded plugin registry target `{actual}` does not match `{expected}`")]
115 TargetMismatch { expected: String, actual: String },
116 #[error("embedded plugin registry architecture `{actual}` does not match `{expected}`")]
117 ArchitectureMismatch { expected: String, actual: String },
118 #[error("embedded plugin registry fragment minimum OS {actual:?} does not match {expected:?}")]
119 MinimumOsMismatch {
120 expected: Option<String>,
121 actual: Option<String>,
122 },
123 #[error("invalid embedded plugin registry field `{field}`: {message}")]
124 InvalidField { field: String, message: String },
125 #[error("embedded plugin registry contains {actual} artifacts; maximum is {maximum}")]
126 TooManyArtifacts { actual: usize, maximum: usize },
127 #[error("duplicate embedded plugin identity `{0}`")]
128 DuplicatePluginId(String),
129 #[error("duplicate embedded plugin locator `{0}`")]
130 DuplicateLocator(String),
131 #[error("duplicate embedded plugin capability `{plugin_id}:{interface_id}:{instance_id}`")]
132 DuplicateCapability {
133 plugin_id: String,
134 interface_id: String,
135 instance_id: String,
136 },
137 #[error("embedded plugin `{plugin_id}` uses unsupported mobile transport {transport:?}")]
138 UnsupportedTransport {
139 plugin_id: String,
140 transport: PluginTransport,
141 },
142 #[error("embedded plugin registry does not contain referenced plugin `{0}`")]
143 UnknownPluginReference(String),
144 #[error("failed to resolve embedded plugin `{plugin_id}`: {message}")]
145 ResolveArtifact { plugin_id: String, message: String },
146 #[error("embedded plugin artifact `{path}` is not a regular file")]
147 ArtifactNotFile { path: String },
148 #[error(
149 "embedded plugin artifact `{path}` is {actual_bytes} bytes; maximum is {maximum_bytes} bytes"
150 )]
151 OversizedArtifact {
152 path: String,
153 actual_bytes: u64,
154 maximum_bytes: u64,
155 },
156 #[error("Android package `{path}` contains duplicate native library entry `{entry}`")]
157 DuplicateAndroidPackageEntry { path: String, entry: String },
158 #[error("failed to read embedded plugin artifact `{path}`: {source}")]
159 ReadArtifact {
160 path: String,
161 #[source]
162 source: io::Error,
163 },
164 #[error(
165 "embedded plugin artifact `{path}` checksum mismatch: expected {expected}, actual {actual}"
166 )]
167 ChecksumMismatch {
168 path: String,
169 expected: String,
170 actual: String,
171 },
172 #[error("embedded plugin `{plugin_id}` requires platform integrity verification")]
173 PlatformIntegrityVerificationRequired { plugin_id: String },
174 #[error("platform integrity verification failed for embedded plugin `{plugin_id}`: {message}")]
175 PlatformIntegrityVerification { plugin_id: String, message: String },
176 #[error(transparent)]
177 Load(#[from] PluginRegistryBuildError),
178 #[error("embedded plugin `{plugin_id}` has unavailable interface `{instance_id}`: {message}")]
179 UnavailableInterface {
180 plugin_id: String,
181 instance_id: String,
182 message: String,
183 },
184 #[error("embedded plugin `{plugin_id}` capability metadata does not match its Root ABI")]
185 CapabilityMismatch { plugin_id: String },
186}
187
188impl EmbeddedPluginRegistry {
189 pub fn parse(
190 json: &[u8],
191 expected_target: &str,
192 expected_architecture: &str,
193 ) -> Result<Self, EmbeddedPluginRegistryError> {
194 if json.len() > MAX_EMBEDDED_PLUGIN_REGISTRY_BYTES {
195 return Err(EmbeddedPluginRegistryError::OversizedRegistry {
196 actual_bytes: json.len(),
197 maximum_bytes: MAX_EMBEDDED_PLUGIN_REGISTRY_BYTES,
198 });
199 }
200 let registry: Self = serde_json::from_slice(json)?;
201 registry.validate(expected_target, expected_architecture)?;
202 Ok(registry)
203 }
204
205 pub fn parse_fragments<'a>(
212 fragments: impl IntoIterator<Item = &'a [u8]>,
213 expected_target: &str,
214 expected_architecture: &str,
215 ) -> Result<Self, EmbeddedPluginRegistryError> {
216 let mut combined = Self {
217 schema_version: 1,
218 target: expected_target.to_owned(),
219 architecture: expected_architecture.to_owned(),
220 minimum_os: None,
221 artifacts: Vec::new(),
222 };
223 let mut fragment_count = 0_usize;
224 let mut total_bytes = 0_usize;
225 let mut expected_minimum_os: Option<Option<String>> = None;
226
227 for json in fragments {
228 fragment_count = fragment_count.saturating_add(1);
229 if fragment_count > MAX_EMBEDDED_PLUGIN_REGISTRY_FRAGMENTS {
230 return Err(EmbeddedPluginRegistryError::TooManyFragments {
231 actual: fragment_count,
232 maximum: MAX_EMBEDDED_PLUGIN_REGISTRY_FRAGMENTS,
233 });
234 }
235 total_bytes = total_bytes.saturating_add(json.len());
236 if total_bytes > MAX_EMBEDDED_PLUGIN_REGISTRY_SET_BYTES {
237 return Err(EmbeddedPluginRegistryError::OversizedRegistrySet {
238 actual_bytes: total_bytes,
239 maximum_bytes: MAX_EMBEDDED_PLUGIN_REGISTRY_SET_BYTES,
240 });
241 }
242
243 let fragment = Self::parse(json, expected_target, expected_architecture)?;
244 if !fragment.artifacts.is_empty() {
245 match expected_minimum_os.as_ref() {
246 Some(expected) if expected != &fragment.minimum_os => {
247 return Err(EmbeddedPluginRegistryError::MinimumOsMismatch {
248 expected: expected.clone(),
249 actual: fragment.minimum_os,
250 });
251 }
252 None => {
253 expected_minimum_os = Some(fragment.minimum_os.clone());
254 combined.minimum_os = fragment.minimum_os.clone();
255 }
256 Some(_) => {}
257 }
258 }
259 combined.artifacts.extend(fragment.artifacts);
260 if combined.artifacts.len() > MAX_EMBEDDED_PLUGIN_ARTIFACTS {
261 return Err(EmbeddedPluginRegistryError::TooManyArtifacts {
262 actual: combined.artifacts.len(),
263 maximum: MAX_EMBEDDED_PLUGIN_ARTIFACTS,
264 });
265 }
266 }
267
268 combined.validate(expected_target, expected_architecture)?;
269 Ok(combined)
270 }
271
272 pub fn target(&self) -> &str {
273 &self.target
274 }
275
276 pub fn architecture(&self) -> &str {
277 &self.architecture
278 }
279
280 pub fn minimum_os(&self) -> Option<&str> {
281 self.minimum_os.as_deref()
282 }
283
284 pub fn artifacts(&self) -> &[EmbeddedPluginArtifact] {
285 &self.artifacts
286 }
287
288 pub fn load_native(
289 &self,
290 mut resolve: impl FnMut(&EmbeddedPluginLocator) -> Result<PathBuf, String>,
291 ) -> Result<PluginRegistry, EmbeddedPluginRegistryError> {
292 self.load_native_artifacts(self.artifacts.iter(), &mut resolve, None)
293 }
294
295 pub fn load_native_with_platform_integrity(
297 &self,
298 mut resolve: impl FnMut(&EmbeddedPluginLocator) -> Result<PathBuf, String>,
299 mut verify_platform_integrity: impl FnMut(&Path, &EmbeddedPluginArtifact) -> Result<(), String>,
300 ) -> Result<PluginRegistry, EmbeddedPluginRegistryError> {
301 self.load_native_artifacts(
302 self.artifacts.iter(),
303 &mut resolve,
304 Some(&mut verify_platform_integrity),
305 )
306 }
307
308 pub fn load_native_selected<'a>(
314 &self,
315 references: impl IntoIterator<Item = &'a PluginReference>,
316 mut resolve: impl FnMut(&EmbeddedPluginLocator) -> Result<PathBuf, String>,
317 ) -> Result<PluginRegistry, EmbeddedPluginRegistryError> {
318 let artifacts = self.select_native_artifacts(references)?;
319 self.load_native_artifacts(artifacts, &mut resolve, None)
320 }
321
322 pub fn load_native_selected_with_platform_integrity<'a>(
325 &self,
326 references: impl IntoIterator<Item = &'a PluginReference>,
327 mut resolve: impl FnMut(&EmbeddedPluginLocator) -> Result<PathBuf, String>,
328 mut verify_platform_integrity: impl FnMut(&Path, &EmbeddedPluginArtifact) -> Result<(), String>,
329 ) -> Result<PluginRegistry, EmbeddedPluginRegistryError> {
330 let artifacts = self.select_native_artifacts(references)?;
331 self.load_native_artifacts(
332 artifacts,
333 &mut resolve,
334 Some(&mut verify_platform_integrity),
335 )
336 }
337
338 pub fn select_native_artifacts<'a, 'reference>(
345 &'a self,
346 references: impl IntoIterator<Item = &'reference PluginReference>,
347 ) -> Result<Vec<&'a EmbeddedPluginArtifact>, EmbeddedPluginRegistryError> {
348 let selected_plugin_ids = self.selected_plugin_ids(references)?;
349 Ok(self
350 .artifacts
351 .iter()
352 .filter(|artifact| selected_plugin_ids.contains(artifact.plugin_id.as_str()))
353 .collect())
354 }
355
356 fn load_native_artifacts<'a>(
357 &'a self,
358 artifacts: impl IntoIterator<Item = &'a EmbeddedPluginArtifact>,
359 resolve: &mut impl FnMut(&EmbeddedPluginLocator) -> Result<PathBuf, String>,
360 mut verify_platform_integrity: Option<&mut PlatformIntegrityVerifier<'_>>,
361 ) -> Result<PluginRegistry, EmbeddedPluginRegistryError> {
362 let artifacts = artifacts.into_iter().collect::<Vec<_>>();
363 let mut native_artifacts = Vec::with_capacity(artifacts.len());
364 for artifact in &artifacts {
365 let path = resolve(&artifact.locator).map_err(|message| {
366 EmbeddedPluginRegistryError::ResolveArtifact {
367 plugin_id: artifact.plugin_id.clone(),
368 message,
369 }
370 })?;
371 match &artifact.integrity {
372 EmbeddedPluginIntegrity::Sha256 { digest } => verify_sha256(&path, digest)?,
373 EmbeddedPluginIntegrity::AppleCodeSignature { .. } => {
374 let Some(verifier) = verify_platform_integrity.as_deref_mut() else {
375 return Err(
376 EmbeddedPluginRegistryError::PlatformIntegrityVerificationRequired {
377 plugin_id: artifact.plugin_id.clone(),
378 },
379 );
380 };
381 verifier(&path, artifact).map_err(|message| {
382 EmbeddedPluginRegistryError::PlatformIntegrityVerification {
383 plugin_id: artifact.plugin_id.clone(),
384 message,
385 }
386 })?;
387 }
388 }
389 native_artifacts.push(
390 NativePluginArtifact::new(&artifact.plugin_id, path)
391 .map_err(|error| invalid_identity("plugin_id", &artifact.plugin_id, error))?,
392 );
393 }
394 let registry = PluginRegistry::load_native_artifacts(native_artifacts)?;
395 self.verify_loaded_capabilities(®istry, artifacts.iter().copied())?;
396 Ok(registry)
397 }
398
399 fn selected_plugin_ids<'a>(
400 &self,
401 references: impl IntoIterator<Item = &'a PluginReference>,
402 ) -> Result<HashSet<String>, EmbeddedPluginRegistryError> {
403 let mut selected_plugin_ids = HashSet::new();
404 for reference in references {
405 if reference.transport() != PluginTransport::Native {
406 return Err(EmbeddedPluginRegistryError::UnsupportedTransport {
407 plugin_id: reference.plugin_id().to_owned(),
408 transport: reference.transport(),
409 });
410 }
411 selected_plugin_ids.insert(reference.plugin_id().to_owned());
412 }
413
414 for plugin_id in &selected_plugin_ids {
415 if !self
416 .artifacts
417 .iter()
418 .any(|artifact| artifact.plugin_id == *plugin_id)
419 {
420 return Err(EmbeddedPluginRegistryError::UnknownPluginReference(
421 plugin_id.clone(),
422 ));
423 }
424 }
425 Ok(selected_plugin_ids)
426 }
427
428 fn validate(
429 &self,
430 expected_target: &str,
431 expected_architecture: &str,
432 ) -> Result<(), EmbeddedPluginRegistryError> {
433 if self.schema_version != 1 {
434 return Err(EmbeddedPluginRegistryError::SchemaVersion(
435 self.schema_version,
436 ));
437 }
438 if self.target != expected_target {
439 return Err(EmbeddedPluginRegistryError::TargetMismatch {
440 expected: expected_target.to_owned(),
441 actual: self.target.clone(),
442 });
443 }
444 if self.architecture != expected_architecture {
445 return Err(EmbeddedPluginRegistryError::ArchitectureMismatch {
446 expected: expected_architecture.to_owned(),
447 actual: self.architecture.clone(),
448 });
449 }
450 validate_text("target", &self.target, 128)?;
451 validate_text("architecture", &self.architecture, 64)?;
452 if !self.artifacts.is_empty() && self.minimum_os.is_none() {
453 return Err(EmbeddedPluginRegistryError::InvalidField {
454 field: "minimum_os".to_owned(),
455 message: "is required when artifacts are present".to_owned(),
456 });
457 }
458 if let Some(minimum_os) = self.minimum_os.as_deref() {
459 validate_text("minimum_os", minimum_os, 64)?;
460 }
461 if self.artifacts.len() > MAX_EMBEDDED_PLUGIN_ARTIFACTS {
462 return Err(EmbeddedPluginRegistryError::TooManyArtifacts {
463 actual: self.artifacts.len(),
464 maximum: MAX_EMBEDDED_PLUGIN_ARTIFACTS,
465 });
466 }
467
468 let mut plugin_ids = HashSet::with_capacity(self.artifacts.len());
469 let mut locators = HashSet::with_capacity(self.artifacts.len());
470 for artifact in &self.artifacts {
471 PluginReference::new(&artifact.plugin_id, None, PluginTransport::Native)
472 .map_err(|error| invalid_identity("plugin_id", &artifact.plugin_id, error))?;
473 if artifact.transport != PluginTransport::Native {
474 return Err(EmbeddedPluginRegistryError::UnsupportedTransport {
475 plugin_id: artifact.plugin_id.clone(),
476 transport: artifact.transport,
477 });
478 }
479 validate_locator(&self.target, &artifact.locator)?;
480 validate_integrity(&self.target, &artifact.integrity)?;
481 validate_package(&artifact.package)?;
482 validate_capabilities(&artifact.plugin_id, &artifact.capabilities)?;
483
484 if !plugin_ids.insert(artifact.plugin_id.clone()) {
485 return Err(EmbeddedPluginRegistryError::DuplicatePluginId(
486 artifact.plugin_id.clone(),
487 ));
488 }
489 if !locators.insert(artifact.locator.clone()) {
490 return Err(EmbeddedPluginRegistryError::DuplicateLocator(
491 artifact.locator.label(),
492 ));
493 }
494 }
495 Ok(())
496 }
497
498 fn verify_loaded_capabilities<'a>(
499 &'a self,
500 registry: &PluginRegistry,
501 artifacts: impl IntoIterator<Item = &'a EmbeddedPluginArtifact>,
502 ) -> Result<(), EmbeddedPluginRegistryError> {
503 for artifact in artifacts {
504 let mut actual = BTreeSet::new();
505 for registered in registry
506 .registered_interfaces()
507 .iter()
508 .filter(|registered| registered.plugin_id == artifact.plugin_id)
509 {
510 if registered.interface.state != PluginInterfaceState::Available {
511 return Err(EmbeddedPluginRegistryError::UnavailableInterface {
512 plugin_id: artifact.plugin_id.clone(),
513 instance_id: registered.interface.metadata.instance_id.clone(),
514 message: "interface is unavailable for the host ABI".to_owned(),
515 });
516 }
517 actual.insert((
518 Uuid::from_bytes(registered.interface.metadata.interface_id),
519 registered.interface.metadata.instance_id.clone(),
520 registered.interface.metadata.major,
521 registered.interface.metadata.minor,
522 ));
523 }
524 let declared = artifact
525 .capabilities
526 .iter()
527 .map(EmbeddedPluginCapability::identity)
528 .collect::<Result<BTreeSet<_>, _>>()?;
529 if actual != declared {
530 return Err(EmbeddedPluginRegistryError::CapabilityMismatch {
531 plugin_id: artifact.plugin_id.clone(),
532 });
533 }
534 }
535 Ok(())
536 }
537}
538
539impl EmbeddedPluginArtifact {
540 pub fn plugin_id(&self) -> &str {
541 &self.plugin_id
542 }
543
544 pub const fn transport(&self) -> PluginTransport {
545 self.transport
546 }
547
548 pub fn locator(&self) -> &EmbeddedPluginLocator {
549 &self.locator
550 }
551
552 pub fn integrity(&self) -> &EmbeddedPluginIntegrity {
553 &self.integrity
554 }
555
556 pub fn package(&self) -> &EmbeddedPluginPackage {
557 &self.package
558 }
559
560 pub fn capabilities(&self) -> &[EmbeddedPluginCapability] {
561 &self.capabilities
562 }
563}
564
565impl EmbeddedPluginLocator {
566 pub fn name(&self) -> &str {
567 match self {
568 Self::AndroidNativeLibrary { name } | Self::AppleFramework { name, .. } => name,
569 }
570 }
571
572 pub fn apple_bundle_identifier(&self) -> Option<&str> {
573 match self {
574 Self::AppleFramework {
575 bundle_identifier, ..
576 } => Some(bundle_identifier),
577 Self::AndroidNativeLibrary { .. } => None,
578 }
579 }
580
581 fn label(&self) -> String {
582 match self {
583 Self::AndroidNativeLibrary { name } => format!("android-native-library:{name}"),
584 Self::AppleFramework {
585 name,
586 bundle_identifier,
587 } => format!("apple-framework:{name}:{bundle_identifier}"),
588 }
589 }
590}
591
592impl EmbeddedPluginIntegrity {
593 pub fn sha256_digest(&self) -> Option<&str> {
594 match self {
595 Self::Sha256 { digest } => Some(digest),
596 Self::AppleCodeSignature { .. } => None,
597 }
598 }
599
600 pub const fn apple_code_signature_validation(
601 &self,
602 ) -> Option<EmbeddedAppleCodeSignatureValidation> {
603 match self {
604 Self::AppleCodeSignature { validation } => Some(*validation),
605 Self::Sha256 { .. } => None,
606 }
607 }
608}
609
610impl EmbeddedPluginPackage {
611 pub fn version(&self) -> &str {
612 &self.version
613 }
614
615 pub fn publisher(&self) -> &str {
616 &self.publisher
617 }
618
619 pub fn descriptor_sha256(&self) -> &str {
620 &self.descriptor_sha256
621 }
622}
623
624impl EmbeddedPluginCapability {
625 pub fn interface_id(&self) -> &str {
626 &self.interface_id
627 }
628
629 pub fn instance_id(&self) -> &str {
630 &self.instance_id
631 }
632
633 pub const fn interface_major(&self) -> u16 {
634 self.interface_major
635 }
636
637 pub const fn interface_minor(&self) -> u16 {
638 self.interface_minor
639 }
640
641 fn identity(&self) -> Result<(Uuid, String, u16, u16), EmbeddedPluginRegistryError> {
642 Ok((
643 parse_canonical_uuid("interface_id", &self.interface_id)?,
644 self.instance_id.clone(),
645 self.interface_major,
646 self.interface_minor,
647 ))
648 }
649}
650
651fn validate_locator(
652 target: &str,
653 locator: &EmbeddedPluginLocator,
654) -> Result<(), EmbeddedPluginRegistryError> {
655 let target_matches = match locator {
656 EmbeddedPluginLocator::AndroidNativeLibrary { .. } => target.contains("android"),
657 EmbeddedPluginLocator::AppleFramework { .. } => target.contains("apple-ios"),
658 };
659 if !target_matches {
660 return Err(EmbeddedPluginRegistryError::InvalidField {
661 field: "locator.kind".to_owned(),
662 message: format!("{} is incompatible with target `{target}`", locator.label()),
663 });
664 }
665 let name = locator.name();
666 if name.is_empty() || name.len() > 128 || !name.is_ascii() {
667 return Err(EmbeddedPluginRegistryError::InvalidField {
668 field: "locator.name".to_owned(),
669 message: "must be 1 to 128 ASCII bytes".to_owned(),
670 });
671 }
672 let mut bytes = name.bytes();
673 if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic())
674 || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
675 {
676 return Err(EmbeddedPluginRegistryError::InvalidField {
677 field: "locator.name".to_owned(),
678 message: "contains unsupported characters".to_owned(),
679 });
680 }
681 if let EmbeddedPluginLocator::AppleFramework {
682 bundle_identifier, ..
683 } = locator
684 {
685 PluginReference::new(bundle_identifier, None, PluginTransport::Native).map_err(
686 |error| invalid_identity("locator.bundle_identifier", bundle_identifier, error),
687 )?;
688 }
689 Ok(())
690}
691
692fn validate_integrity(
693 target: &str,
694 integrity: &EmbeddedPluginIntegrity,
695) -> Result<(), EmbeddedPluginRegistryError> {
696 match integrity {
697 EmbeddedPluginIntegrity::Sha256 { digest } if target.contains("android") => {
698 validate_sha256("integrity.digest", digest)
699 }
700 EmbeddedPluginIntegrity::AppleCodeSignature {
701 validation: EmbeddedAppleCodeSignatureValidation::SameTeamAsHostOrSimulatorAdHoc,
702 } if target.contains("apple-ios") => Ok(()),
703 EmbeddedPluginIntegrity::Sha256 { .. } => Err(EmbeddedPluginRegistryError::InvalidField {
704 field: "integrity.kind".to_owned(),
705 message: format!("sha256 is incompatible with target `{target}`"),
706 }),
707 EmbeddedPluginIntegrity::AppleCodeSignature { .. } => {
708 Err(EmbeddedPluginRegistryError::InvalidField {
709 field: "integrity.kind".to_owned(),
710 message: format!("apple-code-signature is incompatible with target `{target}`"),
711 })
712 }
713 }
714}
715
716fn validate_package(package: &EmbeddedPluginPackage) -> Result<(), EmbeddedPluginRegistryError> {
717 Version::parse(&package.version).map_err(|error| {
718 EmbeddedPluginRegistryError::InvalidField {
719 field: "package.version".to_owned(),
720 message: error.to_string(),
721 }
722 })?;
723 PluginReference::new(&package.publisher, None, PluginTransport::Native)
724 .map_err(|error| invalid_identity("package.publisher", &package.publisher, error))?;
725 validate_sha256("package.descriptor_sha256", &package.descriptor_sha256)
726}
727
728fn validate_capabilities(
729 plugin_id: &str,
730 capabilities: &[EmbeddedPluginCapability],
731) -> Result<(), EmbeddedPluginRegistryError> {
732 if capabilities.is_empty() || capabilities.len() > MAX_EMBEDDED_PLUGIN_CAPABILITIES_PER_ARTIFACT
733 {
734 return Err(EmbeddedPluginRegistryError::InvalidField {
735 field: "capabilities".to_owned(),
736 message: format!(
737 "must contain 1 to {MAX_EMBEDDED_PLUGIN_CAPABILITIES_PER_ARTIFACT} entries"
738 ),
739 });
740 }
741 let mut identities = HashSet::with_capacity(capabilities.len());
742 for capability in capabilities {
743 parse_canonical_uuid("capabilities.interface_id", &capability.interface_id)?;
744 PluginReference::new(
745 plugin_id,
746 Some(capability.instance_id.clone()),
747 PluginTransport::Native,
748 )
749 .map_err(|error| {
750 invalid_identity("capabilities.instance_id", &capability.instance_id, error)
751 })?;
752 if capability.interface_major == 0 {
753 return Err(EmbeddedPluginRegistryError::InvalidField {
754 field: "capabilities.interface_major".to_owned(),
755 message: "must be greater than zero".to_owned(),
756 });
757 }
758 if !identities.insert((&capability.interface_id, &capability.instance_id)) {
759 return Err(EmbeddedPluginRegistryError::DuplicateCapability {
760 plugin_id: plugin_id.to_owned(),
761 interface_id: capability.interface_id.clone(),
762 instance_id: capability.instance_id.clone(),
763 });
764 }
765 }
766 Ok(())
767}
768
769fn validate_text(
770 field: &str,
771 value: &str,
772 maximum_bytes: usize,
773) -> Result<(), EmbeddedPluginRegistryError> {
774 if value.is_empty() || value.len() > maximum_bytes {
775 return Err(EmbeddedPluginRegistryError::InvalidField {
776 field: field.to_owned(),
777 message: format!("must be 1 to {maximum_bytes} bytes"),
778 });
779 }
780 Ok(())
781}
782
783fn validate_sha256(field: &str, value: &str) -> Result<(), EmbeddedPluginRegistryError> {
784 if value.len() != 64
785 || !value
786 .bytes()
787 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
788 {
789 return Err(EmbeddedPluginRegistryError::InvalidField {
790 field: field.to_owned(),
791 message: "must be 64 lowercase hexadecimal characters".to_owned(),
792 });
793 }
794 Ok(())
795}
796
797fn parse_canonical_uuid(field: &str, value: &str) -> Result<Uuid, EmbeddedPluginRegistryError> {
798 let parsed =
799 Uuid::parse_str(value).map_err(|error| EmbeddedPluginRegistryError::InvalidField {
800 field: field.to_owned(),
801 message: error.to_string(),
802 })?;
803 if parsed.hyphenated().to_string() != value {
804 return Err(EmbeddedPluginRegistryError::InvalidField {
805 field: field.to_owned(),
806 message: "must use canonical lowercase hyphenated UUID form".to_owned(),
807 });
808 }
809 Ok(parsed)
810}
811
812fn invalid_identity(
813 field: &str,
814 value: &str,
815 source: PluginReferenceError,
816) -> EmbeddedPluginRegistryError {
817 EmbeddedPluginRegistryError::InvalidField {
818 field: field.to_owned(),
819 message: format!("`{value}`: {source}"),
820 }
821}
822
823pub fn resolve_android_native_library(
829 native_library_dir: &Path,
830 package_paths: &[PathBuf],
831 architecture: &str,
832 library_name: &str,
833) -> Result<PathBuf, String> {
834 if package_paths.len() > MAX_ANDROID_PACKAGE_PATHS {
835 return Err(format!(
836 "Android package path count {} exceeds maximum {}",
837 package_paths.len(),
838 MAX_ANDROID_PACKAGE_PATHS,
839 ));
840 }
841 if !valid_android_path_component(architecture) {
842 return Err(format!(
843 "invalid Android plugin architecture `{architecture}`"
844 ));
845 }
846 if !valid_android_path_component(library_name) {
847 return Err(format!(
848 "invalid Android plugin library name `{library_name}`"
849 ));
850 }
851
852 let file_name = format!("lib{library_name}.so");
853 if !native_library_dir.as_os_str().is_empty() {
854 let extracted_path = native_library_dir.join(&file_name);
855 if extracted_path.is_file() {
856 return Ok(extracted_path);
857 }
858 }
859
860 let entry_name = format!("lib/{architecture}/{file_name}");
861 for package_path in package_paths {
862 if !package_path.is_file() {
863 continue;
864 }
865 let package_file = File::open(package_path).map_err(|error| {
866 format!(
867 "failed to open Android package `{}`: {error}",
868 package_path.display()
869 )
870 })?;
871 let mut archive = ZipArchive::new(package_file).map_err(|error| {
872 format!(
873 "failed to inspect Android package `{}`: {error}",
874 package_path.display()
875 )
876 })?;
877 let matching_entries = android_central_directory_entry_count(
878 package_path,
879 archive.central_directory_start(),
880 &entry_name,
881 )
882 .map_err(|error| {
883 format!(
884 "failed to inspect Android package `{}` central directory: {error}",
885 package_path.display()
886 )
887 })?;
888 if matching_entries > 1 {
889 return Err(format!(
890 "Android package `{}` contains duplicate native library entry `{entry_name}`",
891 package_path.display()
892 ));
893 }
894 if matching_entries == 0 {
895 continue;
896 }
897 match archive.by_name(&entry_name) {
898 Ok(entry) => {
899 if entry.is_dir() {
900 return Err(format!(
901 "Android package entry `{entry_name}` is not a native library"
902 ));
903 }
904 if entry.size() > MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES {
905 return Err(format!(
906 "Android package entry `{entry_name}` is {} bytes; maximum is {} bytes",
907 entry.size(),
908 MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES,
909 ));
910 }
911 if entry.compression() != CompressionMethod::Stored {
912 return Err(format!(
913 "Android package entry `{entry_name}` is compressed and cannot be loaded in place"
914 ));
915 }
916 let package_path = package_path.to_str().ok_or_else(|| {
917 "Android package path must be valid UTF-8 for linker loading".to_owned()
918 })?;
919 return Ok(PathBuf::from(format!("{package_path}!/{entry_name}")));
920 }
921 Err(ZipError::FileNotFound) => {}
922 Err(error) => {
923 return Err(format!(
924 "failed to inspect Android package entry `{entry_name}` in `{}`: {error}",
925 package_path.display(),
926 ));
927 }
928 }
929 }
930
931 Err(format!(
932 "Android plugin library `{file_name}` was not found as an extracted file or package entry"
933 ))
934}
935
936fn valid_android_path_component(value: &str) -> bool {
937 !value.is_empty()
938 && value.len() <= 128
939 && value
940 .bytes()
941 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
942}
943
944fn android_central_directory_entry_count(
945 package_path: &Path,
946 central_directory_start: u64,
947 entry_name: &str,
948) -> io::Result<usize> {
949 const CENTRAL_DIRECTORY_HEADER: u32 = 0x0201_4b50;
950 const CENTRAL_DIRECTORY_DIGITAL_SIGNATURE: u32 = 0x0505_4b50;
951 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
952 const ZIP64_END_OF_CENTRAL_DIRECTORY: u32 = 0x0606_4b50;
953 const ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR: u32 = 0x0706_4b50;
954
955 let mut reader = File::open(package_path)?;
956 reader.seek(SeekFrom::Start(central_directory_start))?;
957 let mut matches = 0_usize;
958 loop {
959 let mut signature = [0_u8; 4];
960 reader.read_exact(&mut signature)?;
961 match u32::from_le_bytes(signature) {
962 CENTRAL_DIRECTORY_HEADER => {
963 let mut fixed_fields = [0_u8; 42];
964 reader.read_exact(&mut fixed_fields)?;
965 let file_name_length =
966 u16::from_le_bytes([fixed_fields[24], fixed_fields[25]]) as usize;
967 let extra_field_length =
968 u16::from_le_bytes([fixed_fields[26], fixed_fields[27]]) as u64;
969 let comment_length =
970 u16::from_le_bytes([fixed_fields[28], fixed_fields[29]]) as u64;
971 let mut file_name = vec![0_u8; file_name_length];
972 reader.read_exact(&mut file_name)?;
973 if file_name == entry_name.as_bytes() {
974 matches = matches.saturating_add(1);
975 if matches > 1 {
976 return Ok(matches);
977 }
978 }
979 let trailing_bytes =
980 extra_field_length
981 .checked_add(comment_length)
982 .ok_or_else(|| {
983 io::Error::new(io::ErrorKind::InvalidData, "ZIP entry length overflow")
984 })?;
985 reader.seek(SeekFrom::Current(i64::try_from(trailing_bytes).map_err(
986 |_| io::Error::new(io::ErrorKind::InvalidData, "ZIP entry is too large"),
987 )?))?;
988 }
989 CENTRAL_DIRECTORY_DIGITAL_SIGNATURE
990 | END_OF_CENTRAL_DIRECTORY
991 | ZIP64_END_OF_CENTRAL_DIRECTORY
992 | ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR => return Ok(matches),
993 signature => {
994 return Err(io::Error::new(
995 io::ErrorKind::InvalidData,
996 format!("unexpected ZIP central directory signature {signature:#010x}"),
997 ));
998 }
999 }
1000 }
1001}
1002
1003fn verify_sha256(path: &Path, expected: &str) -> Result<(), EmbeddedPluginRegistryError> {
1004 if let Some((package_path, entry_name)) = android_package_entry(path) {
1005 return verify_android_package_entry_sha256(path, &package_path, entry_name, expected);
1006 }
1007 if !path.is_file() {
1008 return Err(EmbeddedPluginRegistryError::ArtifactNotFile {
1009 path: path.display().to_string(),
1010 });
1011 }
1012 let mut file =
1013 File::open(path).map_err(|source| EmbeddedPluginRegistryError::ReadArtifact {
1014 path: path.display().to_string(),
1015 source,
1016 })?;
1017 let metadata = file
1018 .metadata()
1019 .map_err(|source| EmbeddedPluginRegistryError::ReadArtifact {
1020 path: path.display().to_string(),
1021 source,
1022 })?;
1023 if metadata.len() > MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES {
1024 return Err(EmbeddedPluginRegistryError::OversizedArtifact {
1025 path: path.display().to_string(),
1026 actual_bytes: metadata.len(),
1027 maximum_bytes: MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES,
1028 });
1029 }
1030 let mut hasher = Sha256::new();
1031 let mut buffer = [0_u8; 64 * 1024];
1032 let mut total_bytes = 0_u64;
1033 loop {
1034 let read =
1035 file.read(&mut buffer)
1036 .map_err(|source| EmbeddedPluginRegistryError::ReadArtifact {
1037 path: path.display().to_string(),
1038 source,
1039 })?;
1040 if read == 0 {
1041 break;
1042 }
1043 total_bytes = total_bytes.saturating_add(read as u64);
1044 if total_bytes > MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES {
1045 return Err(EmbeddedPluginRegistryError::OversizedArtifact {
1046 path: path.display().to_string(),
1047 actual_bytes: total_bytes,
1048 maximum_bytes: MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES,
1049 });
1050 }
1051 hasher.update(&buffer[..read]);
1052 }
1053 let actual = hex::encode(hasher.finalize());
1054 if actual != expected {
1055 return Err(EmbeddedPluginRegistryError::ChecksumMismatch {
1056 path: path.display().to_string(),
1057 expected: expected.to_owned(),
1058 actual,
1059 });
1060 }
1061 Ok(())
1062}
1063
1064fn android_package_entry(path: &Path) -> Option<(PathBuf, &str)> {
1065 let value = path.to_str()?;
1066 let (package_path, entry_name) = value.split_once("!/")?;
1067 let mut components = entry_name.split('/');
1068 if package_path.is_empty()
1069 || components.next() != Some("lib")
1070 || components.any(|component| component.is_empty() || matches!(component, "." | ".."))
1071 || entry_name.contains('\\')
1072 {
1073 return None;
1074 }
1075 Some((PathBuf::from(package_path), entry_name))
1076}
1077
1078fn verify_android_package_entry_sha256(
1079 load_path: &Path,
1080 package_path: &Path,
1081 entry_name: &str,
1082 expected: &str,
1083) -> Result<(), EmbeddedPluginRegistryError> {
1084 let package_file =
1085 File::open(package_path).map_err(|source| EmbeddedPluginRegistryError::ReadArtifact {
1086 path: load_path.display().to_string(),
1087 source,
1088 })?;
1089 let mut archive = ZipArchive::new(package_file).map_err(|error| {
1090 EmbeddedPluginRegistryError::ReadArtifact {
1091 path: load_path.display().to_string(),
1092 source: io::Error::other(error),
1093 }
1094 })?;
1095 let matching_entries = android_central_directory_entry_count(
1096 package_path,
1097 archive.central_directory_start(),
1098 entry_name,
1099 )
1100 .map_err(|source| EmbeddedPluginRegistryError::ReadArtifact {
1101 path: load_path.display().to_string(),
1102 source,
1103 })?;
1104 if matching_entries > 1 {
1105 return Err(EmbeddedPluginRegistryError::DuplicateAndroidPackageEntry {
1106 path: package_path.display().to_string(),
1107 entry: entry_name.to_owned(),
1108 });
1109 }
1110 let mut entry =
1111 archive
1112 .by_name(entry_name)
1113 .map_err(|error| EmbeddedPluginRegistryError::ReadArtifact {
1114 path: load_path.display().to_string(),
1115 source: io::Error::other(error),
1116 })?;
1117 if entry.is_dir() || entry.compression() != CompressionMethod::Stored {
1118 return Err(EmbeddedPluginRegistryError::ArtifactNotFile {
1119 path: load_path.display().to_string(),
1120 });
1121 }
1122 if entry.size() > MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES {
1123 return Err(EmbeddedPluginRegistryError::OversizedArtifact {
1124 path: load_path.display().to_string(),
1125 actual_bytes: entry.size(),
1126 maximum_bytes: MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES,
1127 });
1128 }
1129
1130 let mut hasher = Sha256::new();
1131 let mut buffer = [0_u8; 64 * 1024];
1132 let mut total_bytes = 0_u64;
1133 loop {
1134 let read = entry.read(&mut buffer).map_err(|source| {
1135 EmbeddedPluginRegistryError::ReadArtifact {
1136 path: load_path.display().to_string(),
1137 source,
1138 }
1139 })?;
1140 if read == 0 {
1141 break;
1142 }
1143 total_bytes = total_bytes.saturating_add(read as u64);
1144 if total_bytes > MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES {
1145 return Err(EmbeddedPluginRegistryError::ArtifactNotFile {
1146 path: load_path.display().to_string(),
1147 });
1148 }
1149 hasher.update(&buffer[..read]);
1150 }
1151 let actual = hex::encode(hasher.finalize());
1152 if actual != expected {
1153 return Err(EmbeddedPluginRegistryError::ChecksumMismatch {
1154 path: load_path.display().to_string(),
1155 expected: expected.to_owned(),
1156 actual,
1157 });
1158 }
1159 Ok(())
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use std::io::{Cursor, Write};
1166 use zip::{ZipWriter, write::SimpleFileOptions};
1167
1168 #[test]
1169 fn android_apk_locator_hashes_the_exact_uncompressed_library_entry() {
1170 let temp = tempfile::tempdir().expect("temporary directory");
1171 let apk_path = temp.path().join("base.apk");
1172 let library_bytes = b"packaged native plugin";
1173 let mut archive = ZipWriter::new(File::create(&apk_path).expect("create package"));
1174 archive
1175 .start_file(
1176 "lib/arm64-v8a/libvesper..fixture.so",
1177 SimpleFileOptions::default().compression_method(CompressionMethod::Stored),
1178 )
1179 .expect("start library entry");
1180 archive
1181 .write_all(library_bytes)
1182 .expect("write library entry");
1183 archive.finish().expect("finish package");
1184
1185 let load_path = resolve_android_native_library(
1186 &temp.path().join("unextracted"),
1187 std::slice::from_ref(&apk_path),
1188 "arm64-v8a",
1189 "vesper..fixture",
1190 )
1191 .expect("resolve package library");
1192 assert!(
1193 load_path
1194 .to_string_lossy()
1195 .contains("base.apk!/lib/arm64-v8a/")
1196 );
1197
1198 let expected = hex::encode(Sha256::digest(library_bytes));
1199 verify_sha256(&load_path, &expected).expect("verify package entry");
1200 assert!(matches!(
1201 verify_sha256(&load_path, &"0".repeat(64)),
1202 Err(EmbeddedPluginRegistryError::ChecksumMismatch { .. })
1203 ));
1204 }
1205
1206 #[test]
1207 fn android_artifact_limits_and_duplicate_entries_apply_to_every_storage_form() {
1208 let temp = tempfile::tempdir().expect("temporary directory");
1209 let oversized_library = temp.path().join("liboversized.so");
1210 let file = File::create(&oversized_library).expect("create sparse library");
1211 file.set_len(MAX_EMBEDDED_PLUGIN_ARCHIVE_ARTIFACT_BYTES + 1)
1212 .expect("size sparse library");
1213 assert!(matches!(
1214 verify_sha256(&oversized_library, &"0".repeat(64)),
1215 Err(EmbeddedPluginRegistryError::OversizedArtifact { .. })
1216 ));
1217
1218 let apk_path = temp.path().join("duplicate.apk");
1219 let mut archive = ZipWriter::new(Cursor::new(Vec::new()));
1220 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
1221 for (name, bytes) in [
1222 ("lib/arm64-v8a/libduplicate-a.so", b"first".as_slice()),
1223 ("lib/arm64-v8a/libduplicate-b.so", b"second".as_slice()),
1224 ] {
1225 archive
1226 .start_file(name, options)
1227 .expect("start library entry");
1228 archive.write_all(bytes).expect("write duplicate entry");
1229 }
1230 let mut archive_bytes = archive
1231 .finish()
1232 .expect("finish duplicate package")
1233 .into_inner();
1234 let original_name = b"lib/arm64-v8a/libduplicate-b.so";
1235 let duplicate_name = b"lib/arm64-v8a/libduplicate-a.so";
1236 let mut replacement_count = 0;
1237 for offset in 0..=archive_bytes.len() - original_name.len() {
1238 if &archive_bytes[offset..offset + original_name.len()] == original_name {
1239 archive_bytes[offset..offset + duplicate_name.len()]
1240 .copy_from_slice(duplicate_name);
1241 replacement_count += 1;
1242 }
1243 }
1244 assert_eq!(replacement_count, 2, "local and central ZIP names");
1245 std::fs::write(&apk_path, archive_bytes).expect("write duplicate package");
1246
1247 let error = resolve_android_native_library(
1248 Path::new(""),
1249 std::slice::from_ref(&apk_path),
1250 "arm64-v8a",
1251 "duplicate-a",
1252 )
1253 .expect_err("duplicate package entry");
1254 assert!(error.contains("duplicate native library entry"));
1255 }
1256
1257 fn registry_json(artifact: &str) -> Vec<u8> {
1258 format!(
1259 r#"{{
1260 "schema_version": 1,
1261 "target": "aarch64-linux-android",
1262 "architecture": "arm64-v8a",
1263 "minimum_os": "26",
1264 "artifacts": [{artifact}]
1265 }}"#
1266 )
1267 .into_bytes()
1268 }
1269
1270 fn artifact_json(plugin_id: &str, transport: &str, capabilities: &str) -> String {
1271 format!(
1272 r#"{{
1273 "plugin_id": "{plugin_id}",
1274 "transport": "{transport}",
1275 "locator": {{
1276 "kind": "android-native-library",
1277 "name": "vesper_fixture"
1278 }},
1279 "integrity": {{
1280 "kind": "sha256",
1281 "digest": "{digest}"
1282 }},
1283 "package": {{
1284 "version": "1.2.3",
1285 "publisher": "dev.vesper.publisher",
1286 "descriptor_sha256": "{digest}"
1287 }},
1288 "capabilities": [{capabilities}]
1289 }}"#,
1290 digest = "0".repeat(64),
1291 )
1292 }
1293
1294 fn capability_json(instance_id: &str) -> String {
1295 format!(
1296 r#"{{
1297 "interface_id": "e9479dbc-42d2-575e-b39e-a24bc512fbc7",
1298 "instance_id": "{instance_id}",
1299 "interface_major": 1,
1300 "interface_minor": 0
1301 }}"#
1302 )
1303 }
1304
1305 fn apple_registry_json() -> Vec<u8> {
1306 let capability = capability_json("dev.vesper.fixture.post-download");
1307 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1308 String::from_utf8(registry_json(&artifact))
1309 .expect("fixture JSON")
1310 .replace("aarch64-linux-android", "aarch64-apple-ios")
1311 .replace("arm64-v8a", "arm64")
1312 .replace("\"minimum_os\": \"26\"", "\"minimum_os\": \"17.0\"")
1313 .replace(
1314 r#""kind": "android-native-library",
1315 "name": "vesper_fixture""#,
1316 r#""kind": "apple-framework",
1317 "name": "VesperPluginFixture",
1318 "bundle_identifier": "dev.vesper.plugin-fixture""#,
1319 )
1320 .replace(
1321 r#""kind": "sha256",
1322 "digest": "0000000000000000000000000000000000000000000000000000000000000000""#,
1323 r#""kind": "apple-code-signature",
1324 "validation": "same-team-as-host-or-simulator-ad-hoc""#,
1325 )
1326 .into_bytes()
1327 }
1328
1329 #[test]
1330 fn registry_parse_preserves_valid_identity_and_target() {
1331 let capability = capability_json("dev.vesper.fixture.post-download");
1332 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1333 let registry = EmbeddedPluginRegistry::parse(
1334 ®istry_json(&artifact),
1335 "aarch64-linux-android",
1336 "arm64-v8a",
1337 )
1338 .expect("valid registry");
1339
1340 assert_eq!(registry.minimum_os(), Some("26"));
1341 assert_eq!(registry.artifacts()[0].plugin_id(), "dev.vesper.fixture");
1342 assert_eq!(registry.artifacts()[0].locator().name(), "vesper_fixture");
1343 }
1344
1345 #[test]
1346 fn registry_requires_minimum_os_only_when_artifacts_are_present() {
1347 let capability = capability_json("dev.vesper.fixture.post-download");
1348 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1349 let without_minimum_os = String::from_utf8(registry_json(&artifact))
1350 .expect("fixture JSON")
1351 .replace("\n \"minimum_os\": \"26\",", "");
1352
1353 let error = EmbeddedPluginRegistry::parse(
1354 without_minimum_os.as_bytes(),
1355 "aarch64-linux-android",
1356 "arm64-v8a",
1357 )
1358 .expect_err("artifacts require a minimum OS");
1359 assert!(matches!(
1360 error,
1361 EmbeddedPluginRegistryError::InvalidField { ref field, .. }
1362 if field == "minimum_os"
1363 ));
1364
1365 let empty_without_minimum_os = br#"{
1366 "schema_version": 1,
1367 "target": "aarch64-linux-android",
1368 "architecture": "arm64-v8a",
1369 "artifacts": []
1370 }"#;
1371 let registry = EmbeddedPluginRegistry::parse(
1372 empty_without_minimum_os,
1373 "aarch64-linux-android",
1374 "arm64-v8a",
1375 )
1376 .expect("empty no-plugin registry may omit minimum_os");
1377 assert_eq!(registry.minimum_os(), None);
1378 }
1379
1380 #[test]
1381 fn empty_fragments_do_not_define_combined_minimum_os() {
1382 let empty = br#"{
1383 "schema_version": 1,
1384 "target": "aarch64-linux-android",
1385 "architecture": "arm64-v8a",
1386 "artifacts": []
1387 }"#;
1388 let capability = capability_json("dev.vesper.fixture.post-download");
1389 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1390 let populated = registry_json(&artifact);
1391
1392 let registry = EmbeddedPluginRegistry::parse_fragments(
1393 [empty.as_slice(), populated.as_slice()],
1394 "aarch64-linux-android",
1395 "arm64-v8a",
1396 )
1397 .expect("empty fragment must not conflict with populated platform metadata");
1398
1399 assert_eq!(registry.artifacts().len(), 1);
1400 assert_eq!(registry.minimum_os(), Some("26"));
1401 }
1402
1403 #[test]
1404 fn public_registry_schema_requires_minimum_os_for_artifacts() {
1405 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
1406 .canonicalize()
1407 .expect("canonical loader crate path");
1408 let workspace = manifest_dir.join("../../..");
1409 let Ok(workspace_member) = workspace
1412 .join("crates/plugin/player-plugin-loader")
1413 .canonicalize()
1414 else {
1415 return;
1416 };
1417 if manifest_dir != workspace_member {
1418 return;
1419 }
1420 let relative_path = "schemas/vesper-plugin/embedded-registry.schema.json";
1421 let bytes = std::fs::read(workspace.join(relative_path)).expect("read registry schema");
1422 let schema: serde_json::Value =
1423 serde_json::from_slice(&bytes).expect("parse registry schema");
1424 let conditional = &schema["allOf"][0];
1425
1426 assert_eq!(
1427 conditional["if"]["properties"]["artifacts"]["minItems"], 1,
1428 "{relative_path}"
1429 );
1430 assert!(
1431 conditional["then"]["required"]
1432 .as_array()
1433 .is_some_and(|required| required.iter().any(|field| field == "minimum_os")),
1434 "{relative_path}"
1435 );
1436 }
1437
1438 #[test]
1439 fn registry_rejects_unknown_mobile_transport_without_fallback() {
1440 let capability = capability_json("dev.vesper.fixture.post-download");
1441 let artifact = artifact_json("dev.vesper.fixture", "wasm", &capability);
1442 let error = EmbeddedPluginRegistry::parse(
1443 ®istry_json(&artifact),
1444 "aarch64-linux-android",
1445 "arm64-v8a",
1446 )
1447 .expect_err("mobile WASM must be rejected");
1448 assert!(matches!(
1449 error,
1450 EmbeddedPluginRegistryError::UnsupportedTransport {
1451 transport: PluginTransport::Wasm,
1452 ..
1453 }
1454 ));
1455 }
1456
1457 #[test]
1458 fn registry_rejects_duplicate_capability_and_lossy_identity_forms() {
1459 let capability = capability_json("dev.vesper.fixture.post-download");
1460 let duplicate_capabilities = format!("{capability},{capability}");
1461 let duplicate = artifact_json("dev.vesper.fixture", "native", &duplicate_capabilities);
1462 assert!(matches!(
1463 EmbeddedPluginRegistry::parse(
1464 ®istry_json(&duplicate),
1465 "aarch64-linux-android",
1466 "arm64-v8a",
1467 ),
1468 Err(EmbeddedPluginRegistryError::DuplicateCapability { .. })
1469 ));
1470
1471 let invalid = artifact_json(" Dev.Vesper.Fixture ", "native", &capability);
1472 assert!(matches!(
1473 EmbeddedPluginRegistry::parse(
1474 ®istry_json(&invalid),
1475 "aarch64-linux-android",
1476 "arm64-v8a",
1477 ),
1478 Err(EmbeddedPluginRegistryError::InvalidField { ref field, .. })
1479 if field == "plugin_id"
1480 ));
1481 }
1482
1483 #[test]
1484 fn registry_rejects_target_architecture_and_locator_mismatch() {
1485 let capability = capability_json("dev.vesper.fixture.post-download");
1486 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1487 let json = registry_json(&artifact);
1488 assert!(matches!(
1489 EmbeddedPluginRegistry::parse(&json, "aarch64-apple-ios", "arm64"),
1490 Err(EmbeddedPluginRegistryError::TargetMismatch { .. })
1491 ));
1492 assert!(matches!(
1493 EmbeddedPluginRegistry::parse(&json, "aarch64-linux-android", "x86_64",),
1494 Err(EmbeddedPluginRegistryError::ArchitectureMismatch { .. })
1495 ));
1496 }
1497
1498 #[test]
1499 fn registry_fragments_merge_without_implicit_identity_changes() {
1500 let first_capability = capability_json("dev.vesper.fixture.post-download");
1501 let first_artifact = artifact_json("dev.vesper.fixture", "native", &first_capability);
1502 let second_capability = capability_json("dev.vesper.other.post-download");
1503 let second_artifact = artifact_json("dev.vesper.other", "native", &second_capability)
1504 .replace("vesper_fixture", "vesper_other");
1505 let first = registry_json(&first_artifact);
1506 let second = registry_json(&second_artifact);
1507
1508 let registry = EmbeddedPluginRegistry::parse_fragments(
1509 [first.as_slice(), second.as_slice()],
1510 "aarch64-linux-android",
1511 "arm64-v8a",
1512 )
1513 .expect("valid fragments");
1514
1515 assert_eq!(registry.artifacts().len(), 2);
1516 assert_eq!(registry.minimum_os(), Some("26"));
1517 assert_eq!(registry.artifacts()[0].plugin_id(), "dev.vesper.fixture");
1518 assert_eq!(registry.artifacts()[1].plugin_id(), "dev.vesper.other");
1519 }
1520
1521 #[test]
1522 fn registry_fragments_reject_cross_package_duplicates_and_metadata_drift() {
1523 let capability = capability_json("dev.vesper.fixture.post-download");
1524 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1525 let first = registry_json(&artifact);
1526 let duplicate = registry_json(&artifact);
1527 assert!(matches!(
1528 EmbeddedPluginRegistry::parse_fragments(
1529 [first.as_slice(), duplicate.as_slice()],
1530 "aarch64-linux-android",
1531 "arm64-v8a",
1532 ),
1533 Err(EmbeddedPluginRegistryError::DuplicatePluginId(ref plugin_id))
1534 if plugin_id == "dev.vesper.fixture"
1535 ));
1536
1537 let different_minimum_os = String::from_utf8(registry_json(&artifact))
1538 .expect("fixture JSON")
1539 .replace("\"minimum_os\": \"26\"", "\"minimum_os\": \"27\"")
1540 .into_bytes();
1541 assert!(matches!(
1542 EmbeddedPluginRegistry::parse_fragments(
1543 [first.as_slice(), different_minimum_os.as_slice()],
1544 "aarch64-linux-android",
1545 "arm64-v8a",
1546 ),
1547 Err(EmbeddedPluginRegistryError::MinimumOsMismatch { .. })
1548 ));
1549 }
1550
1551 #[test]
1552 fn empty_registry_fragment_set_is_a_valid_no_plugin_baseline() {
1553 let fragments: [&[u8]; 0] = [];
1554 let registry = EmbeddedPluginRegistry::parse_fragments(
1555 fragments,
1556 "aarch64-linux-android",
1557 "arm64-v8a",
1558 )
1559 .expect("empty registry");
1560
1561 assert!(registry.artifacts().is_empty());
1562 assert_eq!(registry.minimum_os(), None);
1563 }
1564
1565 #[test]
1566 fn runtime_parser_rejects_fields_forbidden_by_the_schema() {
1567 let capability = capability_json("dev.vesper.fixture.post-download");
1568 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1569 let json = String::from_utf8(registry_json(&artifact))
1570 .expect("fixture JSON")
1571 .replace(
1572 "\"schema_version\": 1",
1573 "\"schema_version\": 1, \"unexpected\": true",
1574 );
1575
1576 let error =
1577 EmbeddedPluginRegistry::parse(json.as_bytes(), "aarch64-linux-android", "arm64-v8a")
1578 .expect_err("unknown root fields must be rejected");
1579
1580 assert!(matches!(error, EmbeddedPluginRegistryError::Json(_)));
1581 assert!(error.to_string().contains("unknown field `unexpected`"));
1582 }
1583
1584 #[test]
1585 fn apple_registry_requires_host_code_signature_verification() {
1586 let registry =
1587 EmbeddedPluginRegistry::parse(&apple_registry_json(), "aarch64-apple-ios", "arm64")
1588 .expect("valid Apple registry");
1589 let artifact = ®istry.artifacts()[0];
1590 assert_eq!(
1591 artifact.locator().apple_bundle_identifier(),
1592 Some("dev.vesper.plugin-fixture")
1593 );
1594 assert_eq!(
1595 artifact.integrity().apple_code_signature_validation(),
1596 Some(EmbeddedAppleCodeSignatureValidation::SameTeamAsHostOrSimulatorAdHoc)
1597 );
1598
1599 let executable = std::env::current_exe().expect("test executable");
1600 let error = registry
1601 .load_native(|_| Ok(executable.clone()))
1602 .expect_err("Apple integrity cannot be skipped");
1603 assert!(matches!(
1604 error,
1605 EmbeddedPluginRegistryError::PlatformIntegrityVerificationRequired {
1606 ref plugin_id
1607 } if plugin_id == "dev.vesper.fixture"
1608 ));
1609
1610 let error = registry
1611 .load_native_with_platform_integrity(
1612 |_| Ok(executable.clone()),
1613 |path, artifact| {
1614 assert_eq!(path, executable);
1615 assert_eq!(
1616 artifact.locator().apple_bundle_identifier(),
1617 Some("dev.vesper.plugin-fixture")
1618 );
1619 Err("code signature team mismatch".to_owned())
1620 },
1621 )
1622 .expect_err("failed code signature verification must stop before dlopen");
1623 assert!(matches!(
1624 error,
1625 EmbeddedPluginRegistryError::PlatformIntegrityVerification { ref message, .. }
1626 if message == "code signature team mismatch"
1627 ));
1628 }
1629
1630 #[test]
1631 fn registry_rejects_target_incompatible_integrity() {
1632 let apple_with_sha256 = String::from_utf8(apple_registry_json())
1633 .expect("fixture JSON")
1634 .replace(
1635 r#""kind": "apple-code-signature",
1636 "validation": "same-team-as-host-or-simulator-ad-hoc""#,
1637 &format!(
1638 "\"kind\": \"sha256\",\n \"digest\": \"{}\"",
1639 "0".repeat(64)
1640 ),
1641 );
1642 assert!(matches!(
1643 EmbeddedPluginRegistry::parse(
1644 apple_with_sha256.as_bytes(),
1645 "aarch64-apple-ios",
1646 "arm64",
1647 ),
1648 Err(EmbeddedPluginRegistryError::InvalidField { ref field, .. })
1649 if field == "integrity.kind"
1650 ));
1651 }
1652
1653 #[test]
1654 fn selected_loading_rejects_unlisted_and_mobile_wasm_references_before_resolution() {
1655 let fragments: [&[u8]; 0] = [];
1656 let registry = EmbeddedPluginRegistry::parse_fragments(
1657 fragments,
1658 "aarch64-linux-android",
1659 "arm64-v8a",
1660 )
1661 .expect("empty registry");
1662 let missing = PluginReference::new("dev.vesper.missing", None, PluginTransport::Native)
1663 .expect("valid reference");
1664 let mut resolver_called = false;
1665 let error = registry
1666 .load_native_selected([&missing], |_| {
1667 resolver_called = true;
1668 Err("must not resolve".to_owned())
1669 })
1670 .expect_err("missing plugin must fail");
1671 assert!(matches!(
1672 error,
1673 EmbeddedPluginRegistryError::UnknownPluginReference(ref plugin_id)
1674 if plugin_id == "dev.vesper.missing"
1675 ));
1676 assert!(!resolver_called);
1677
1678 let wasm = PluginReference::new("dev.vesper.wasm", None, PluginTransport::Wasm)
1679 .expect("valid reference");
1680 let error = registry
1681 .load_native_selected([&wasm], |_| Err("must not resolve".to_owned()))
1682 .expect_err("mobile WASM must fail");
1683 assert!(matches!(
1684 error,
1685 EmbeddedPluginRegistryError::UnsupportedTransport {
1686 transport: PluginTransport::Wasm,
1687 ..
1688 }
1689 ));
1690 }
1691
1692 #[test]
1693 fn metadata_selection_is_explicit_deduplicated_and_does_not_resolve_paths() {
1694 let capability = capability_json("dev.vesper.fixture.post-download");
1695 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1696 let registry = EmbeddedPluginRegistry::parse(
1697 ®istry_json(&artifact),
1698 "aarch64-linux-android",
1699 "arm64-v8a",
1700 )
1701 .expect("valid registry");
1702 let first = PluginReference::new(
1703 "dev.vesper.fixture",
1704 Some("dev.vesper.fixture.post-download".to_owned()),
1705 PluginTransport::Native,
1706 )
1707 .expect("first reference");
1708 let second = PluginReference::new(
1709 "dev.vesper.fixture",
1710 None::<String>,
1711 PluginTransport::Native,
1712 )
1713 .expect("second reference");
1714
1715 let selected = registry
1716 .select_native_artifacts([&first, &second])
1717 .expect("selected metadata");
1718
1719 assert_eq!(selected.len(), 1);
1720 assert_eq!(selected[0].plugin_id(), "dev.vesper.fixture");
1721 assert_eq!(selected[0].locator().name(), "vesper_fixture");
1722 }
1723
1724 #[test]
1725 fn empty_selection_does_not_resolve_or_load_packaged_artifacts() {
1726 let capability = capability_json("dev.vesper.fixture.post-download");
1727 let artifact = artifact_json("dev.vesper.fixture", "native", &capability);
1728 let registry = EmbeddedPluginRegistry::parse(
1729 ®istry_json(&artifact),
1730 "aarch64-linux-android",
1731 "arm64-v8a",
1732 )
1733 .expect("valid registry");
1734 let references: [&PluginReference; 0] = [];
1735 let mut resolver_called = false;
1736
1737 let loaded = registry
1738 .load_native_selected(references, |_| {
1739 resolver_called = true;
1740 Err("must not resolve".to_owned())
1741 })
1742 .expect("empty selection");
1743
1744 assert!(!resolver_called);
1745 assert!(loaded.registered_interfaces().is_empty());
1746 }
1747}