latexsnipper_runtime_plugin_api/
discovery.rs1use 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 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}