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