Skip to main content

homecore_plugins/
discovery.rs

1//! Secure filesystem discovery for packaged WASM plugins.
2//!
3//! A plugin directory has exactly this shape:
4//!
5//! ```text
6//! <configured-root>/<package>/manifest.json
7//!                              <module named by wasm_module>
8//! ```
9//!
10//! Only immediate child directories of explicitly configured roots are
11//! inspected. Symlinks are rejected and the module must be a plain filename
12//! in the same package directory. This deliberately avoids recursive search,
13//! implicit current-directory loading, and path traversal.
14
15use std::collections::BTreeSet;
16use std::fs;
17use std::path::{Component, Path, PathBuf};
18
19use crate::{PluginError, PluginManifest};
20
21/// Conservative input limits applied before allocating file contents.
22#[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/// A validated package whose files are safe to read.
40#[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    /// Read the module after re-checking its type and size. Re-checking closes
50    /// the common accidental replacement window between discovery and load;
51    /// signature verification remains the authoritative tamper gate.
52    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
57/// Discover packages in deterministic root/path order.
58pub 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}