Skip to main content

player_plugin_loader/
embedded_registry.rs

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    /// Parses and combines independently packaged registry fragments.
206    ///
207    /// Mobile package managers merge plugin artifacts from multiple
208    /// dependencies. Each dependency owns one fragment; this method applies
209    /// the same limits and duplicate checks to the combined registry before
210    /// any dynamic library is loaded.
211    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    /// Loads every artifact after the host verifies platform-owned integrity.
296    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    /// Loads only artifacts named by explicit runtime references.
309    ///
310    /// Merely packaging a native plugin must not execute its entry point or
311    /// affect the no-plugin mobile baseline. Multiple capability references
312    /// to the same plugin load that artifact exactly once.
313    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    /// Loads explicitly selected artifacts after the host verifies
323    /// platform-owned integrity such as an Apple code signature.
324    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    /// Resolves explicit Native references to packaged artifact metadata
339    /// without loading executable code.
340    ///
341    /// Multiple capability references to one plugin produce one artifact in
342    /// registry order. Unknown plugin identities and non-Native transports
343    /// fail before a host inspects any artifact path.
344    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(&registry, 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
823/// Resolves an Android native library without assuming APK extraction.
824///
825/// Extracted libraries use a regular filesystem path. Otherwise this returns
826/// Android's supported `apk!/lib/<abi>/<name>` linker path after confirming
827/// that the package contains one uncompressed, bounded entry.
828pub 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            &registry_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        // The public schema belongs to the workspace distribution, outside the
1410        // Rust crate package. Only run this drift check from a source checkout.
1411        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            &registry_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                &registry_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                &registry_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 = &registry.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            &registry_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            &registry_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}