Skip to main content

latexsnipper_runtime_plugin_api/
discovery.rs

1//! Descriptor discovery gated by an application-owned trust store.
2
3use std::collections::{BTreeSet, HashSet};
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use latexsnipper_foundation::Result;
8use latexsnipper_runtime::RuntimeRegistry;
9
10use crate::descriptor::RuntimePluginDescriptor;
11use crate::error::{plugin_runtime_error, PluginRuntimeResult};
12use crate::host::RuntimePluginFactory;
13use crate::trust::{canonical_regular_file, sha256_file, RuntimePluginTrustStore};
14
15pub const RUNTIME_PLUGIN_DESCRIPTOR: &str = "runtime-plugin.json";
16const MAX_DESCRIPTOR_BYTES: u64 = 1024 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum DiscoveryIssueKind {
20    InvalidDescriptor,
21    Untrusted,
22    Disabled,
23    IntegrityMismatch,
24    LoadFailed,
25    DuplicateRuntime,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DiscoveryIssue {
30    pub descriptor_path: PathBuf,
31    pub runtime_id: Option<String>,
32    pub kind: DiscoveryIssueKind,
33    pub reason: String,
34}
35
36#[derive(Debug, Default)]
37pub struct RuntimePluginDiscoveryReport {
38    pub factories: Vec<RuntimePluginFactory>,
39    pub issues: Vec<DiscoveryIssue>,
40}
41
42impl RuntimePluginDiscoveryReport {
43    pub fn register_all(&self, registry: &mut RuntimeRegistry) -> Result<()> {
44        for factory in &self.factories {
45            let kind = latexsnipper_runtime::RuntimeKind::Custom(factory.runtime_id().to_owned());
46            if registry.get(&kind).is_some() {
47                return Err(plugin_runtime_error(format!(
48                    "runtime factory '{kind}' is already registered; no discovered plugins were added"
49                )));
50            }
51        }
52        for factory in &self.factories {
53            registry.register(factory.clone())?;
54        }
55        Ok(())
56    }
57}
58
59#[derive(Debug)]
60pub struct RuntimePluginDiscovery<'trust> {
61    roots: Vec<PathBuf>,
62    trust: &'trust RuntimePluginTrustStore,
63}
64
65impl<'trust> RuntimePluginDiscovery<'trust> {
66    pub fn new(
67        roots: impl IntoIterator<Item = impl Into<PathBuf>>,
68        trust: &'trust RuntimePluginTrustStore,
69    ) -> Self {
70        Self {
71            roots: roots.into_iter().map(Into::into).collect(),
72            trust,
73        }
74    }
75
76    pub fn discover(&self) -> RuntimePluginDiscoveryReport {
77        let mut report = RuntimePluginDiscoveryReport::default();
78        let mut runtime_ids = HashSet::new();
79        for descriptor_path in descriptor_paths(&self.roots) {
80            match self.load_descriptor(&descriptor_path) {
81                Ok(factory) => {
82                    if runtime_ids.insert(factory.runtime_id().to_owned()) {
83                        report.factories.push(factory);
84                    } else {
85                        report.issues.push(DiscoveryIssue {
86                            descriptor_path,
87                            runtime_id: Some(factory.runtime_id().to_owned()),
88                            kind: DiscoveryIssueKind::DuplicateRuntime,
89                            reason: "more than one trusted plugin declares this runtime id"
90                                .to_owned(),
91                        });
92                    }
93                }
94                Err(issue) => report.issues.push(issue),
95            }
96        }
97        report
98            .factories
99            .sort_by(|left, right| left.runtime_id().cmp(right.runtime_id()));
100        report
101    }
102
103    fn load_descriptor(
104        &self,
105        descriptor_path: &Path,
106    ) -> std::result::Result<RuntimePluginFactory, DiscoveryIssue> {
107        let descriptor = read_descriptor(descriptor_path).map_err(|error| DiscoveryIssue {
108            descriptor_path: descriptor_path.to_path_buf(),
109            runtime_id: None,
110            kind: DiscoveryIssueKind::InvalidDescriptor,
111            reason: error.to_string(),
112        })?;
113        let issue = |kind, reason: String| DiscoveryIssue {
114            descriptor_path: descriptor_path.to_path_buf(),
115            runtime_id: Some(descriptor.runtime_id.clone()),
116            kind,
117            reason,
118        };
119        let Some(trusted) = self.trust.get(&descriptor.runtime_id) else {
120            return Err(issue(
121                DiscoveryIssueKind::Untrusted,
122                "plugin is installed but has not been enrolled in the trusted runtime registry"
123                    .to_owned(),
124            ));
125        };
126        if !trusted.enabled {
127            return Err(issue(
128                DiscoveryIssueKind::Disabled,
129                "plugin is trusted but not explicitly enabled".to_owned(),
130            ));
131        }
132        let package_root = descriptor_path
133            .parent()
134            .ok_or_else(|| {
135                issue(
136                    DiscoveryIssueKind::InvalidDescriptor,
137                    "descriptor has no package directory".to_owned(),
138                )
139            })?
140            .canonicalize()
141            .map_err(|error| {
142                issue(
143                    DiscoveryIssueKind::InvalidDescriptor,
144                    format!("canonicalize plugin package: {error}"),
145                )
146            })?;
147        let declared_library = package_root.join(&descriptor.library);
148        if !is_dynamic_library(&declared_library) {
149            return Err(issue(
150                DiscoveryIssueKind::InvalidDescriptor,
151                "descriptor library does not use the platform dynamic-library extension".to_owned(),
152            ));
153        }
154        let library = canonical_regular_file(&declared_library)
155            .map_err(|error| issue(DiscoveryIssueKind::InvalidDescriptor, error.to_string()))?;
156        if !library.starts_with(&package_root) {
157            return Err(issue(
158                DiscoveryIssueKind::InvalidDescriptor,
159                "runtime plugin library escapes its installation package".to_owned(),
160            ));
161        }
162        if library != trusted.library_path {
163            return Err(issue(
164                DiscoveryIssueKind::IntegrityMismatch,
165                format!(
166                    "descriptor resolves to '{}', but trust enrollment names '{}'",
167                    library.display(),
168                    trusted.library_path.display()
169                ),
170            ));
171        }
172        let actual = sha256_file(&library)
173            .map_err(|error| issue(DiscoveryIssueKind::IntegrityMismatch, error.to_string()))?;
174        if !actual.eq_ignore_ascii_case(&descriptor.sha256)
175            || !actual.eq_ignore_ascii_case(&trusted.sha256)
176        {
177            return Err(issue(
178                DiscoveryIssueKind::IntegrityMismatch,
179                format!(
180                    "runtime plugin digest mismatch: descriptor={}, trusted={}, actual={actual}",
181                    descriptor.sha256, trusted.sha256
182                ),
183            ));
184        }
185        // SAFETY: Canonical path, installation boundary, explicit enablement,
186        // descriptor digest, and trust digest all match immediately before load.
187        unsafe { RuntimePluginFactory::load_trusted(&library, &descriptor) }
188            .map_err(|error| issue(DiscoveryIssueKind::LoadFailed, error.to_string()))
189    }
190}
191
192fn descriptor_paths(roots: &[PathBuf]) -> Vec<PathBuf> {
193    let mut paths = BTreeSet::new();
194    for root in roots {
195        let direct = root.join(RUNTIME_PLUGIN_DESCRIPTOR);
196        if direct.is_file() {
197            paths.insert(direct);
198        }
199        let Ok(entries) = std::fs::read_dir(root) else {
200            continue;
201        };
202        for entry in entries.flatten() {
203            let path = entry.path();
204            if path.is_dir() {
205                let descriptor = path.join(RUNTIME_PLUGIN_DESCRIPTOR);
206                if descriptor.is_file() {
207                    paths.insert(descriptor);
208                }
209            }
210        }
211    }
212    paths.into_iter().collect()
213}
214
215fn read_descriptor(path: &Path) -> PluginRuntimeResult<RuntimePluginDescriptor> {
216    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
217        plugin_runtime_error(format!(
218            "inspect runtime plugin descriptor '{}': {error}",
219            path.display()
220        ))
221    })?;
222    if metadata.file_type().is_symlink()
223        || !metadata.is_file()
224        || metadata.len() > MAX_DESCRIPTOR_BYTES
225    {
226        return Err(plugin_runtime_error(format!(
227            "runtime plugin descriptor must be a real file no larger than {MAX_DESCRIPTOR_BYTES} bytes"
228        )));
229    }
230    let mut bytes = Vec::with_capacity(metadata.len() as usize);
231    std::fs::File::open(path)
232        .and_then(|file| file.take(MAX_DESCRIPTOR_BYTES + 1).read_to_end(&mut bytes))
233        .map_err(|error| {
234            plugin_runtime_error(format!(
235                "read runtime plugin descriptor '{}': {error}",
236                path.display()
237            ))
238        })?;
239    if bytes.len() as u64 > MAX_DESCRIPTOR_BYTES {
240        return Err(plugin_runtime_error(
241            "runtime plugin descriptor grew beyond its size limit",
242        ));
243    }
244    let descriptor: RuntimePluginDescriptor = serde_json::from_slice(&bytes).map_err(|error| {
245        plugin_runtime_error(format!(
246            "parse runtime plugin descriptor '{}': {error}",
247            path.display()
248        ))
249    })?;
250    descriptor.validate()?;
251    Ok(descriptor)
252}
253
254fn is_dynamic_library(path: &Path) -> bool {
255    let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else {
256        return false;
257    };
258    #[cfg(target_os = "windows")]
259    return extension.eq_ignore_ascii_case("dll");
260    #[cfg(target_os = "linux")]
261    return extension.eq_ignore_ascii_case("so");
262    #[cfg(target_os = "macos")]
263    return extension.eq_ignore_ascii_case("dylib");
264    #[allow(unreachable_code)]
265    false
266}
267
268#[cfg(test)]
269mod tests {
270    use sha2::{Digest, Sha256};
271
272    use super::*;
273
274    #[test]
275    fn raw_libraries_are_never_discovered_without_descriptors() {
276        let root = std::env::temp_dir().join(format!(
277            "latexsnipper-runtime-discovery-{}",
278            std::process::id()
279        ));
280        std::fs::create_dir_all(&root).unwrap();
281        std::fs::write(root.join("untrusted.dll"), b"native code").unwrap();
282        let trust = RuntimePluginTrustStore::new();
283        let report = RuntimePluginDiscovery::new([&root], &trust).discover();
284        assert!(report.factories.is_empty());
285        assert!(report.issues.is_empty());
286        std::fs::remove_dir_all(root).unwrap();
287    }
288
289    #[test]
290    fn installed_descriptor_stays_unloaded_without_trust() {
291        let root = std::env::temp_dir().join(format!(
292            "latexsnipper-runtime-untrusted-{}",
293            std::process::id()
294        ));
295        std::fs::create_dir_all(&root).unwrap();
296        let descriptor = RuntimePluginDescriptor {
297            schema_version: 1,
298            runtime_id: "vendor.npu".to_owned(),
299            plugin_version: "1.0.0".to_owned(),
300            library: if cfg!(target_os = "windows") {
301                "plugin.dll"
302            } else if cfg!(target_os = "macos") {
303                "libplugin.dylib"
304            } else {
305                "libplugin.so"
306            }
307            .to_owned(),
308            sha256: "a".repeat(64),
309        };
310        std::fs::write(
311            root.join(RUNTIME_PLUGIN_DESCRIPTOR),
312            serde_json::to_vec(&descriptor).unwrap(),
313        )
314        .unwrap();
315        let trust = RuntimePluginTrustStore::new();
316        let report = RuntimePluginDiscovery::new([&root], &trust).discover();
317        assert!(report.factories.is_empty());
318        assert_eq!(report.issues[0].kind, DiscoveryIssueKind::Untrusted);
319        std::fs::remove_dir_all(root).unwrap();
320    }
321
322    #[test]
323    fn mutation_after_enrollment_is_rejected_before_loading() {
324        let root = std::env::temp_dir().join(format!(
325            "latexsnipper-runtime-tampered-{}",
326            std::process::id()
327        ));
328        std::fs::create_dir_all(&root).unwrap();
329        let library_name = if cfg!(target_os = "windows") {
330            "plugin.dll"
331        } else if cfg!(target_os = "macos") {
332            "libplugin.dylib"
333        } else {
334            "libplugin.so"
335        };
336        let library = root.join(library_name);
337        std::fs::write(&library, b"trusted bytes").unwrap();
338        let digest = format!("{:x}", Sha256::digest(b"trusted bytes"));
339        let descriptor = RuntimePluginDescriptor {
340            schema_version: 1,
341            runtime_id: "vendor.tamper-test".to_owned(),
342            plugin_version: "1.0.0".to_owned(),
343            library: library_name.to_owned(),
344            sha256: digest.clone(),
345        };
346        std::fs::write(
347            root.join(RUNTIME_PLUGIN_DESCRIPTOR),
348            serde_json::to_vec(&descriptor).unwrap(),
349        )
350        .unwrap();
351        let mut trust = RuntimePluginTrustStore::new();
352        trust
353            .enroll("vendor.tamper-test", &library, &digest)
354            .unwrap();
355        trust.set_enabled("vendor.tamper-test", true).unwrap();
356        std::fs::write(&library, b"changed bytes").unwrap();
357
358        let report = RuntimePluginDiscovery::new([&root], &trust).discover();
359        assert!(report.factories.is_empty());
360        assert_eq!(report.issues[0].kind, DiscoveryIssueKind::IntegrityMismatch);
361        std::fs::remove_dir_all(root).unwrap();
362    }
363}