Skip to main content

player_cli/
plugin_registry_fragment.rs

1#![allow(
2    clippy::result_large_err,
3    reason = "the public fragment error preserves the loader registry error as its source"
4)]
5
6use std::fs::File;
7use std::io::{self, Read};
8use std::path::{Path, PathBuf};
9
10use player_plugin_loader::EmbeddedPluginRegistry;
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15use crate::{CanonicalPluginDescriptor, PluginDescriptorError};
16
17const HASH_BUFFER_BYTES: usize = 64 * 1024;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum EmbeddedRegistryTarget {
21    AndroidNativeLibrary {
22        target: String,
23        architecture: String,
24        minimum_os: String,
25        library_name: String,
26        artifact_path: PathBuf,
27    },
28    AppleFramework {
29        target: String,
30        architecture: String,
31        minimum_os: String,
32        framework_name: String,
33        bundle_identifier: String,
34    },
35}
36
37impl EmbeddedRegistryTarget {
38    fn target(&self) -> &str {
39        match self {
40            Self::AndroidNativeLibrary { target, .. } | Self::AppleFramework { target, .. } => {
41                target
42            }
43        }
44    }
45
46    fn architecture(&self) -> &str {
47        match self {
48            Self::AndroidNativeLibrary { architecture, .. }
49            | Self::AppleFramework { architecture, .. } => architecture,
50        }
51    }
52
53    fn minimum_os(&self) -> &str {
54        match self {
55            Self::AndroidNativeLibrary { minimum_os, .. }
56            | Self::AppleFramework { minimum_os, .. } => minimum_os,
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct EmbeddedRegistryFragment {
63    file_name: String,
64    json: Vec<u8>,
65}
66
67impl EmbeddedRegistryFragment {
68    pub fn generate(
69        descriptor: &CanonicalPluginDescriptor,
70        target: &EmbeddedRegistryTarget,
71    ) -> Result<Self, EmbeddedRegistryFragmentError> {
72        descriptor.descriptor().validate()?;
73        let plugin = &descriptor.descriptor().plugin;
74        let (locator, integrity) = match target {
75            EmbeddedRegistryTarget::AndroidNativeLibrary {
76                library_name,
77                artifact_path,
78                ..
79            } => (
80                FragmentArtifactLocator::AndroidNativeLibrary {
81                    name: library_name.clone(),
82                },
83                FragmentIntegrity::Sha256 {
84                    digest: sha256_file(artifact_path)?,
85                },
86            ),
87            EmbeddedRegistryTarget::AppleFramework {
88                framework_name,
89                bundle_identifier,
90                ..
91            } => (
92                FragmentArtifactLocator::AppleFramework {
93                    name: framework_name.clone(),
94                    bundle_identifier: bundle_identifier.clone(),
95                },
96                FragmentIntegrity::AppleCodeSignature {
97                    validation: AppleCodeSignatureValidation::SameTeamAsHostOrSimulatorAdHoc,
98                },
99            ),
100        };
101        let artifact = FragmentArtifact {
102            plugin_id: plugin.id.clone(),
103            transport: FragmentTransport::Native,
104            locator,
105            integrity,
106            package: FragmentPackage {
107                version: plugin.version.clone(),
108                publisher: plugin.publisher.clone(),
109                descriptor_sha256: descriptor.sha256().to_owned(),
110            },
111            capabilities: descriptor
112                .descriptor()
113                .capabilities
114                .iter()
115                .map(FragmentCapability::from)
116                .collect(),
117        };
118        let wire = FragmentRegistry {
119            schema_version: 1,
120            target: target.target().to_owned(),
121            architecture: target.architecture().to_owned(),
122            minimum_os: target.minimum_os().to_owned(),
123            artifacts: vec![artifact],
124        };
125        let json = serde_json::to_vec(&wire)?;
126        EmbeddedPluginRegistry::parse(&json, target.target(), target.architecture())?;
127        Ok(Self {
128            file_name: format!("{}.json", plugin.id),
129            json,
130        })
131    }
132
133    pub fn file_name(&self) -> &str {
134        &self.file_name
135    }
136
137    /// Returns the exact canonical JSON bytes that must be embedded in the host artifact.
138    pub fn canonical_json(&self) -> &[u8] {
139        &self.json
140    }
141}
142
143#[derive(Debug, Error)]
144pub enum EmbeddedRegistryFragmentError {
145    #[error(transparent)]
146    Descriptor(#[from] PluginDescriptorError),
147    #[error("embedded registry Android artifact `{path}` is not a regular file")]
148    ArtifactNotFile { path: String },
149    #[error("failed to read embedded registry Android artifact `{path}`: {source}")]
150    ReadArtifact {
151        path: String,
152        #[source]
153        source: io::Error,
154    },
155    #[error("failed to serialize embedded registry fragment: {0}")]
156    Json(#[from] serde_json::Error),
157    #[error("generated embedded registry fragment is invalid: {0}")]
158    Registry(#[from] player_plugin_loader::EmbeddedPluginRegistryError),
159}
160
161#[derive(Serialize)]
162struct FragmentRegistry {
163    schema_version: u32,
164    target: String,
165    architecture: String,
166    minimum_os: String,
167    artifacts: Vec<FragmentArtifact>,
168}
169
170#[derive(Serialize)]
171struct FragmentArtifact {
172    plugin_id: String,
173    transport: FragmentTransport,
174    locator: FragmentArtifactLocator,
175    integrity: FragmentIntegrity,
176    package: FragmentPackage,
177    capabilities: Vec<FragmentCapability>,
178}
179
180#[derive(Serialize)]
181#[serde(rename_all = "lowercase")]
182enum FragmentTransport {
183    Native,
184}
185
186#[derive(Serialize)]
187#[serde(tag = "kind", rename_all = "kebab-case")]
188enum FragmentArtifactLocator {
189    AndroidNativeLibrary {
190        name: String,
191    },
192    AppleFramework {
193        name: String,
194        bundle_identifier: String,
195    },
196}
197
198#[derive(Serialize)]
199#[serde(tag = "kind", rename_all = "kebab-case")]
200enum FragmentIntegrity {
201    Sha256 {
202        digest: String,
203    },
204    AppleCodeSignature {
205        validation: AppleCodeSignatureValidation,
206    },
207}
208
209#[derive(Serialize)]
210#[serde(rename_all = "kebab-case")]
211enum AppleCodeSignatureValidation {
212    SameTeamAsHostOrSimulatorAdHoc,
213}
214
215#[derive(Serialize)]
216struct FragmentPackage {
217    version: String,
218    publisher: String,
219    descriptor_sha256: String,
220}
221
222#[derive(Serialize)]
223struct FragmentCapability {
224    interface_id: String,
225    instance_id: String,
226    interface_major: u16,
227    interface_minor: u16,
228}
229
230impl From<&crate::PluginCapabilityDescriptor> for FragmentCapability {
231    fn from(capability: &crate::PluginCapabilityDescriptor) -> Self {
232        Self {
233            interface_id: capability.interface_id.clone(),
234            instance_id: capability.instance_id.clone(),
235            interface_major: capability.interface_major,
236            interface_minor: capability.interface_minor,
237        }
238    }
239}
240
241fn sha256_file(path: &Path) -> Result<String, EmbeddedRegistryFragmentError> {
242    if !path.is_file() {
243        return Err(EmbeddedRegistryFragmentError::ArtifactNotFile {
244            path: path.display().to_string(),
245        });
246    }
247    let mut file =
248        File::open(path).map_err(|source| EmbeddedRegistryFragmentError::ReadArtifact {
249            path: path.display().to_string(),
250            source,
251        })?;
252    let mut hasher = Sha256::new();
253    let mut buffer = [0_u8; HASH_BUFFER_BYTES];
254    loop {
255        let read = file.read(&mut buffer).map_err(|source| {
256            EmbeddedRegistryFragmentError::ReadArtifact {
257                path: path.display().to_string(),
258                source,
259            }
260        })?;
261        if read == 0 {
262            break;
263        }
264        hasher.update(&buffer[..read]);
265    }
266    Ok(hex::encode(hasher.finalize()))
267}
268
269#[cfg(test)]
270mod tests {
271    use std::fs;
272    use std::sync::atomic::{AtomicU64, Ordering};
273
274    use super::*;
275    use crate::PluginDescriptor;
276
277    static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
278
279    fn descriptor() -> CanonicalPluginDescriptor {
280        PluginDescriptor::from_toml(
281            r#"
282schema_version = 1
283
284[plugin]
285id = "dev.vesper.fixture"
286name = "Fixture"
287version = "1.2.3"
288description = "Fixture plugin"
289license = "Apache-2.0"
290publisher = "dev.vesper.publisher"
291
292[compatibility]
293host_sdk = ">=0.4.0, <0.5.0"
294abi_major = 1
295abi_minor_min = 0
296abi_minor_max = 0
297
298[[capabilities]]
299interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7"
300instance_id = "dev.vesper.fixture.post-download"
301interface_major = 1
302interface_minor = 0
303stability = "stable"
304"#,
305        )
306        .expect("valid descriptor")
307        .canonicalize()
308        .expect("canonical descriptor")
309    }
310
311    fn temporary_artifact() -> PathBuf {
312        let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
313        let path = std::env::temp_dir().join(format!(
314            "vesper-registry-fragment-{}-{id}.so",
315            std::process::id()
316        ));
317        fs::write(&path, b"fixture artifact bytes").expect("write artifact");
318        path
319    }
320
321    #[test]
322    fn android_fragment_hashes_the_runtime_artifact_and_round_trips() {
323        let descriptor = descriptor();
324        let artifact_path = temporary_artifact();
325        let target = EmbeddedRegistryTarget::AndroidNativeLibrary {
326            target: "aarch64-linux-android".to_owned(),
327            architecture: "arm64-v8a".to_owned(),
328            minimum_os: "26".to_owned(),
329            library_name: "vesper_fixture".to_owned(),
330            artifact_path: artifact_path.clone(),
331        };
332
333        let fragment = EmbeddedRegistryFragment::generate(&descriptor, &target)
334            .expect("Android registry fragment");
335        let value: serde_json::Value =
336            serde_json::from_slice(fragment.canonical_json()).expect("registry JSON");
337
338        assert_eq!(fragment.file_name(), "dev.vesper.fixture.json");
339        assert_eq!(
340            value["artifacts"][0]["package"]["descriptor_sha256"],
341            descriptor.sha256()
342        );
343        assert_eq!(
344            value["artifacts"][0]["integrity"]["digest"],
345            hex::encode(Sha256::digest(b"fixture artifact bytes"))
346        );
347        let _ = fs::remove_file(artifact_path);
348    }
349
350    #[test]
351    fn apple_fragment_uses_bundle_identity_and_code_signature_policy() {
352        let descriptor = descriptor();
353        let target = EmbeddedRegistryTarget::AppleFramework {
354            target: "aarch64-apple-ios".to_owned(),
355            architecture: "arm64".to_owned(),
356            minimum_os: "17.0".to_owned(),
357            framework_name: "VesperPluginFixture".to_owned(),
358            bundle_identifier: "dev.vesper.plugin-fixture".to_owned(),
359        };
360
361        let fragment = EmbeddedRegistryFragment::generate(&descriptor, &target)
362            .expect("Apple registry fragment");
363        let value: serde_json::Value =
364            serde_json::from_slice(fragment.canonical_json()).expect("registry JSON");
365
366        assert_eq!(
367            value["artifacts"][0]["locator"]["bundle_identifier"],
368            "dev.vesper.plugin-fixture"
369        );
370        assert_eq!(
371            value["artifacts"][0]["integrity"]["validation"],
372            "same-team-as-host-or-simulator-ad-hoc"
373        );
374    }
375
376    #[test]
377    fn missing_android_artifact_fails_before_writing_a_fragment() {
378        let descriptor = descriptor();
379        let target = EmbeddedRegistryTarget::AndroidNativeLibrary {
380            target: "aarch64-linux-android".to_owned(),
381            architecture: "arm64-v8a".to_owned(),
382            minimum_os: "26".to_owned(),
383            library_name: "vesper_fixture".to_owned(),
384            artifact_path: PathBuf::from("/definitely/missing/vesper_fixture.so"),
385        };
386
387        assert!(matches!(
388            EmbeddedRegistryFragment::generate(&descriptor, &target),
389            Err(EmbeddedRegistryFragmentError::ArtifactNotFile { .. })
390        ));
391    }
392}