1use std::collections::BTreeSet;
16use std::fs;
17use std::path::{Component, Path, PathBuf};
18
19use crate::{PluginError, PluginManifest};
20
21#[derive(Debug, Clone, Copy)]
23pub struct DiscoveryLimits {
24 pub max_plugins: usize,
25 pub max_manifest_bytes: u64,
26 pub max_module_bytes: u64,
27}
28
29impl Default for DiscoveryLimits {
30 fn default() -> Self {
31 Self {
32 max_plugins: 128,
33 max_manifest_bytes: 256 * 1024,
34 max_module_bytes: 16 * 1024 * 1024,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct DiscoveredPlugin {
42 pub manifest: PluginManifest,
43 pub package_dir: PathBuf,
44 pub manifest_path: PathBuf,
45 pub module_path: PathBuf,
46}
47
48impl DiscoveredPlugin {
49 pub fn read_module(&self, limits: DiscoveryLimits) -> Result<Vec<u8>, PluginError> {
53 bounded_regular_file(&self.module_path, limits.max_module_bytes, "WASM module")
54 }
55}
56
57pub fn discover_plugins(
59 roots: &[PathBuf],
60 limits: DiscoveryLimits,
61) -> Result<Vec<DiscoveredPlugin>, PluginError> {
62 if roots.is_empty() {
63 return Ok(Vec::new());
64 }
65 if limits.max_plugins == 0 || limits.max_manifest_bytes == 0 || limits.max_module_bytes == 0 {
66 return Err(PluginError::ResourceLimit(
67 "discovery limits must all be greater than zero".into(),
68 ));
69 }
70
71 let mut packages = BTreeSet::new();
72 for configured_root in roots {
73 let metadata = fs::symlink_metadata(configured_root).map_err(|e| {
74 PluginError::Discovery(format!(
75 "cannot inspect configured plugin root {}: {e}",
76 configured_root.display()
77 ))
78 })?;
79 if metadata.file_type().is_symlink() || !metadata.is_dir() {
80 return Err(PluginError::Discovery(format!(
81 "configured plugin root must be a real directory, not a symlink: {}",
82 configured_root.display()
83 )));
84 }
85 let root = fs::canonicalize(configured_root)?;
86 let entries = fs::read_dir(&root)?;
87 for entry in entries {
88 let entry = entry?;
89 let ty = entry.file_type()?;
90 if ty.is_symlink() {
91 return Err(PluginError::Discovery(format!(
92 "symlink found in plugin root: {}",
93 entry.path().display()
94 )));
95 }
96 if ty.is_dir() && entry.path().join("manifest.json").exists() {
97 packages.insert(entry.path());
98 }
99 }
100 }
101
102 if packages.len() > limits.max_plugins {
103 return Err(PluginError::ResourceLimit(format!(
104 "discovered {} plugins, maximum is {}",
105 packages.len(),
106 limits.max_plugins
107 )));
108 }
109
110 let mut result = Vec::with_capacity(packages.len());
111 let mut domains = BTreeSet::new();
112 for package_dir in packages {
113 let package_dir = fs::canonicalize(package_dir)?;
114 let manifest_path = package_dir.join("manifest.json");
115 let manifest_bytes =
116 bounded_regular_file(&manifest_path, limits.max_manifest_bytes, "manifest")?;
117 let manifest_text = std::str::from_utf8(&manifest_bytes).map_err(|e| {
118 PluginError::InvalidManifest(format!("{} is not UTF-8: {e}", manifest_path.display()))
119 })?;
120 let manifest = PluginManifest::parse_json(manifest_text)?;
121 if !domains.insert(manifest.domain.clone()) {
122 return Err(PluginError::Discovery(format!(
123 "duplicate plugin domain `{}`",
124 manifest.domain
125 )));
126 }
127 let module_name = manifest.wasm_module.as_deref().ok_or_else(|| {
128 PluginError::InvalidManifest(format!(
129 "WASM package `{}` has no wasm_module",
130 manifest.domain
131 ))
132 })?;
133 let relative = Path::new(module_name);
134 if relative.components().count() != 1
135 || !matches!(relative.components().next(), Some(Component::Normal(_)))
136 || relative.extension().and_then(|v| v.to_str()) != Some("wasm")
137 {
138 return Err(PluginError::InvalidManifest(format!(
139 "plugin `{}` wasm_module must be a .wasm filename in its package directory",
140 manifest.domain
141 )));
142 }
143 let module_path = package_dir.join(relative);
144 let metadata = fs::symlink_metadata(&module_path).map_err(|e| {
145 PluginError::Discovery(format!("cannot inspect {}: {e}", module_path.display()))
146 })?;
147 if metadata.file_type().is_symlink() || !metadata.is_file() {
148 return Err(PluginError::Discovery(format!(
149 "plugin module must be a regular non-symlink file: {}",
150 module_path.display()
151 )));
152 }
153 if metadata.len() > limits.max_module_bytes {
154 return Err(PluginError::ResourceLimit(format!(
155 "{} is {} bytes; maximum is {}",
156 module_path.display(),
157 metadata.len(),
158 limits.max_module_bytes
159 )));
160 }
161 result.push(DiscoveredPlugin {
162 manifest,
163 package_dir,
164 manifest_path,
165 module_path,
166 });
167 }
168 Ok(result)
169}
170
171fn bounded_regular_file(path: &Path, maximum: u64, kind: &str) -> Result<Vec<u8>, PluginError> {
172 let metadata = fs::symlink_metadata(path)?;
173 if metadata.file_type().is_symlink() || !metadata.is_file() {
174 return Err(PluginError::Discovery(format!(
175 "{kind} must be a regular non-symlink file: {}",
176 path.display()
177 )));
178 }
179 if metadata.len() > maximum {
180 return Err(PluginError::ResourceLimit(format!(
181 "{} is {} bytes; maximum is {}",
182 path.display(),
183 metadata.len(),
184 maximum
185 )));
186 }
187 let bytes = fs::read(path)?;
188 if bytes.len() as u64 > maximum {
189 return Err(PluginError::ResourceLimit(format!(
190 "{} grew beyond the {maximum}-byte limit while being read",
191 path.display()
192 )));
193 }
194 Ok(bytes)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::time::{SystemTime, UNIX_EPOCH};
201
202 fn root() -> PathBuf {
203 let path = std::env::temp_dir().join(format!(
204 "homecore-plugin-discovery-{}-{}",
205 std::process::id(),
206 SystemTime::now()
207 .duration_since(UNIX_EPOCH)
208 .unwrap()
209 .as_nanos()
210 ));
211 fs::create_dir(&path).unwrap();
212 path
213 }
214
215 fn package(root: &Path, directory: &str, domain: &str, module: &str) {
216 let dir = root.join(directory);
217 fs::create_dir(&dir).unwrap();
218 fs::write(
219 dir.join("manifest.json"),
220 format!(
221 r#"{{"domain":"{domain}","name":"Test","version":"1","wasm_module":"{module}"}}"#
222 ),
223 )
224 .unwrap();
225 if !module.contains('/') && !module.contains('\\') && module.ends_with(".wasm") {
226 fs::write(dir.join(module), b"\0asm\x01\0\0\0").unwrap();
227 }
228 }
229
230 #[test]
231 fn discovery_is_sorted_and_only_reads_explicit_roots() {
232 let root = root();
233 package(&root, "z-last", "zeta", "z.wasm");
234 package(&root, "a-first", "alpha", "a.wasm");
235 let found =
236 discover_plugins(std::slice::from_ref(&root), DiscoveryLimits::default()).unwrap();
237 assert_eq!(
238 found
239 .iter()
240 .map(|p| p.manifest.domain.as_str())
241 .collect::<Vec<_>>(),
242 ["alpha", "zeta"]
243 );
244 fs::remove_dir_all(root).unwrap();
245 }
246
247 #[test]
248 fn traversal_module_path_is_rejected() {
249 let root = root();
250 package(&root, "bad", "bad", "../bad.wasm");
251 let error =
252 discover_plugins(std::slice::from_ref(&root), DiscoveryLimits::default()).unwrap_err();
253 assert!(matches!(error, PluginError::InvalidManifest(_)));
254 fs::remove_dir_all(root).unwrap();
255 }
256
257 #[test]
258 fn size_limits_are_enforced_before_read() {
259 let root = root();
260 package(&root, "large", "large", "large.wasm");
261 let error = discover_plugins(
262 std::slice::from_ref(&root),
263 DiscoveryLimits {
264 max_module_bytes: 4,
265 ..DiscoveryLimits::default()
266 },
267 )
268 .unwrap_err();
269 assert!(matches!(error, PluginError::ResourceLimit(_)));
270 fs::remove_dir_all(root).unwrap();
271 }
272}