Skip to main content

latexsnipper_runtime_plugin_api/
trust.rs

1//! Application-owned trust enrollment for native runtime plugins.
2
3use std::collections::BTreeMap;
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::descriptor::{validate_runtime_id, validate_sha256};
12use crate::error::{plugin_runtime_error, PluginRuntimeResult};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct TrustedRuntimePlugin {
17    pub runtime_id: String,
18    pub library_path: PathBuf,
19    pub sha256: String,
20    pub enabled: bool,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct RuntimePluginTrustStore {
26    schema_version: u32,
27    entries: BTreeMap<String, TrustedRuntimePlugin>,
28}
29
30impl Default for RuntimePluginTrustStore {
31    fn default() -> Self {
32        Self {
33            schema_version: 1,
34            entries: BTreeMap::new(),
35        }
36    }
37}
38
39impl RuntimePluginTrustStore {
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    pub fn from_json(bytes: &[u8]) -> PluginRuntimeResult<Self> {
45        let store: Self = serde_json::from_slice(bytes).map_err(|error| {
46            plugin_runtime_error(format!(
47                "invalid runtime plugin trust registry JSON: {error}"
48            ))
49        })?;
50        if store.schema_version != 1 {
51            return Err(plugin_runtime_error(format!(
52                "unsupported runtime plugin trust registry schema version {}",
53                store.schema_version
54            )));
55        }
56        for (key, entry) in &store.entries {
57            validate_runtime_id(key)?;
58            validate_runtime_id(&entry.runtime_id)?;
59            validate_sha256(&entry.sha256)?;
60            if key != &entry.runtime_id || !entry.library_path.is_absolute() {
61                return Err(plugin_runtime_error(
62                    "runtime plugin trust registry contains a mismatched id or non-absolute library path",
63                ));
64            }
65        }
66        Ok(store)
67    }
68
69    pub fn to_json_pretty(&self) -> PluginRuntimeResult<Vec<u8>> {
70        serde_json::to_vec_pretty(self).map_err(|error| plugin_runtime_error(error.to_string()))
71    }
72
73    /// Enroll an already installed library. Enrollment verifies the exact
74    /// bytes but leaves execution disabled until `set_enabled(id, true)`.
75    pub fn enroll(
76        &mut self,
77        runtime_id: impl Into<String>,
78        library_path: impl AsRef<Path>,
79        expected_sha256: impl Into<String>,
80    ) -> PluginRuntimeResult<&TrustedRuntimePlugin> {
81        let runtime_id = runtime_id.into();
82        validate_runtime_id(&runtime_id)?;
83        let expected_sha256 = expected_sha256.into().to_ascii_lowercase();
84        validate_sha256(&expected_sha256)?;
85        let library_path = canonical_regular_file(library_path.as_ref())?;
86        let actual = sha256_file(&library_path)?;
87        if actual != expected_sha256 {
88            return Err(plugin_runtime_error(format!(
89                "refusing to trust runtime plugin '{runtime_id}': expected SHA-256 {expected_sha256}, found {actual}"
90            )));
91        }
92        if self.entries.contains_key(&runtime_id) {
93            return Err(plugin_runtime_error(format!(
94                "runtime plugin '{runtime_id}' is already enrolled"
95            )));
96        }
97        self.entries.insert(
98            runtime_id.clone(),
99            TrustedRuntimePlugin {
100                runtime_id: runtime_id.clone(),
101                library_path,
102                sha256: actual,
103                enabled: false,
104            },
105        );
106        Ok(&self.entries[&runtime_id])
107    }
108
109    pub fn set_enabled(&mut self, runtime_id: &str, enabled: bool) -> PluginRuntimeResult<()> {
110        let entry = self.entries.get_mut(runtime_id).ok_or_else(|| {
111            plugin_runtime_error(format!(
112                "runtime plugin '{runtime_id}' must be enrolled before it can be enabled"
113            ))
114        })?;
115        entry.enabled = enabled;
116        Ok(())
117    }
118
119    pub fn get(&self, runtime_id: &str) -> Option<&TrustedRuntimePlugin> {
120        self.entries.get(runtime_id)
121    }
122
123    pub fn entries(&self) -> impl Iterator<Item = &TrustedRuntimePlugin> {
124        self.entries.values()
125    }
126}
127
128pub(crate) fn canonical_regular_file(path: &Path) -> PluginRuntimeResult<PathBuf> {
129    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
130        plugin_runtime_error(format!(
131            "inspect runtime plugin library '{}': {error}",
132            path.display()
133        ))
134    })?;
135    if metadata.file_type().is_symlink() || !metadata.is_file() {
136        return Err(plugin_runtime_error(format!(
137            "runtime plugin library must be a real regular file: {}",
138            path.display()
139        )));
140    }
141    path.canonicalize().map_err(|error| {
142        plugin_runtime_error(format!(
143            "canonicalize runtime plugin library '{}': {error}",
144            path.display()
145        ))
146    })
147}
148
149pub(crate) fn sha256_file(path: &Path) -> PluginRuntimeResult<String> {
150    let mut file = File::open(path).map_err(|error| {
151        plugin_runtime_error(format!(
152            "open runtime plugin library '{}': {error}",
153            path.display()
154        ))
155    })?;
156    let mut hash = Sha256::new();
157    let mut buffer = [0u8; 64 * 1024];
158    loop {
159        let read = file.read(&mut buffer).map_err(|error| {
160            plugin_runtime_error(format!(
161                "read runtime plugin library '{}': {error}",
162                path.display()
163            ))
164        })?;
165        if read == 0 {
166            return Ok(hex::encode(hash.finalize()));
167        }
168        hash.update(&buffer[..read]);
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn enrollment_is_disabled_until_explicit_enable() {
178        let root =
179            std::env::temp_dir().join(format!("latexsnipper-runtime-trust-{}", std::process::id()));
180        std::fs::create_dir_all(&root).unwrap();
181        let library = root.join("plugin.bin");
182        std::fs::write(&library, b"trusted plugin").unwrap();
183        let digest = sha256_file(&library).unwrap();
184        let mut trust = RuntimePluginTrustStore::new();
185        let entry = trust.enroll("vendor.npu", &library, digest).unwrap();
186        assert!(!entry.enabled);
187        trust.set_enabled("vendor.npu", true).unwrap();
188        assert!(trust.get("vendor.npu").unwrap().enabled);
189
190        let encoded = trust.to_json_pretty().unwrap();
191        let decoded = RuntimePluginTrustStore::from_json(&encoded).unwrap();
192        assert_eq!(decoded.get("vendor.npu"), trust.get("vendor.npu"));
193        std::fs::remove_dir_all(root).unwrap();
194    }
195}