Files
ruvnet--RuView/v2/crates/homecore-plugins/src/discovery.rs
T

273 lines
9.3 KiB
Rust

//! Secure filesystem discovery for packaged WASM plugins.
//!
//! A plugin directory has exactly this shape:
//!
//! ```text
//! <configured-root>/<package>/manifest.json
//! <module named by wasm_module>
//! ```
//!
//! Only immediate child directories of explicitly configured roots are
//! inspected. Symlinks are rejected and the module must be a plain filename
//! in the same package directory. This deliberately avoids recursive search,
//! implicit current-directory loading, and path traversal.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::{PluginError, PluginManifest};
/// Conservative input limits applied before allocating file contents.
#[derive(Debug, Clone, Copy)]
pub struct DiscoveryLimits {
pub max_plugins: usize,
pub max_manifest_bytes: u64,
pub max_module_bytes: u64,
}
impl Default for DiscoveryLimits {
fn default() -> Self {
Self {
max_plugins: 128,
max_manifest_bytes: 256 * 1024,
max_module_bytes: 16 * 1024 * 1024,
}
}
}
/// A validated package whose files are safe to read.
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
pub manifest: PluginManifest,
pub package_dir: PathBuf,
pub manifest_path: PathBuf,
pub module_path: PathBuf,
}
impl DiscoveredPlugin {
/// Read the module after re-checking its type and size. Re-checking closes
/// the common accidental replacement window between discovery and load;
/// signature verification remains the authoritative tamper gate.
pub fn read_module(&self, limits: DiscoveryLimits) -> Result<Vec<u8>, PluginError> {
bounded_regular_file(&self.module_path, limits.max_module_bytes, "WASM module")
}
}
/// Discover packages in deterministic root/path order.
pub fn discover_plugins(
roots: &[PathBuf],
limits: DiscoveryLimits,
) -> Result<Vec<DiscoveredPlugin>, PluginError> {
if roots.is_empty() {
return Ok(Vec::new());
}
if limits.max_plugins == 0 || limits.max_manifest_bytes == 0 || limits.max_module_bytes == 0 {
return Err(PluginError::ResourceLimit(
"discovery limits must all be greater than zero".into(),
));
}
let mut packages = BTreeSet::new();
for configured_root in roots {
let metadata = fs::symlink_metadata(configured_root).map_err(|e| {
PluginError::Discovery(format!(
"cannot inspect configured plugin root {}: {e}",
configured_root.display()
))
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(PluginError::Discovery(format!(
"configured plugin root must be a real directory, not a symlink: {}",
configured_root.display()
)));
}
let root = fs::canonicalize(configured_root)?;
let entries = fs::read_dir(&root)?;
for entry in entries {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_symlink() {
return Err(PluginError::Discovery(format!(
"symlink found in plugin root: {}",
entry.path().display()
)));
}
if ty.is_dir() && entry.path().join("manifest.json").exists() {
packages.insert(entry.path());
}
}
}
if packages.len() > limits.max_plugins {
return Err(PluginError::ResourceLimit(format!(
"discovered {} plugins, maximum is {}",
packages.len(),
limits.max_plugins
)));
}
let mut result = Vec::with_capacity(packages.len());
let mut domains = BTreeSet::new();
for package_dir in packages {
let package_dir = fs::canonicalize(package_dir)?;
let manifest_path = package_dir.join("manifest.json");
let manifest_bytes =
bounded_regular_file(&manifest_path, limits.max_manifest_bytes, "manifest")?;
let manifest_text = std::str::from_utf8(&manifest_bytes).map_err(|e| {
PluginError::InvalidManifest(format!("{} is not UTF-8: {e}", manifest_path.display()))
})?;
let manifest = PluginManifest::parse_json(manifest_text)?;
if !domains.insert(manifest.domain.clone()) {
return Err(PluginError::Discovery(format!(
"duplicate plugin domain `{}`",
manifest.domain
)));
}
let module_name = manifest.wasm_module.as_deref().ok_or_else(|| {
PluginError::InvalidManifest(format!(
"WASM package `{}` has no wasm_module",
manifest.domain
))
})?;
let relative = Path::new(module_name);
if relative.components().count() != 1
|| !matches!(relative.components().next(), Some(Component::Normal(_)))
|| relative.extension().and_then(|v| v.to_str()) != Some("wasm")
{
return Err(PluginError::InvalidManifest(format!(
"plugin `{}` wasm_module must be a .wasm filename in its package directory",
manifest.domain
)));
}
let module_path = package_dir.join(relative);
let metadata = fs::symlink_metadata(&module_path).map_err(|e| {
PluginError::Discovery(format!("cannot inspect {}: {e}", module_path.display()))
})?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(PluginError::Discovery(format!(
"plugin module must be a regular non-symlink file: {}",
module_path.display()
)));
}
if metadata.len() > limits.max_module_bytes {
return Err(PluginError::ResourceLimit(format!(
"{} is {} bytes; maximum is {}",
module_path.display(),
metadata.len(),
limits.max_module_bytes
)));
}
result.push(DiscoveredPlugin {
manifest,
package_dir,
manifest_path,
module_path,
});
}
Ok(result)
}
fn bounded_regular_file(path: &Path, maximum: u64, kind: &str) -> Result<Vec<u8>, PluginError> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(PluginError::Discovery(format!(
"{kind} must be a regular non-symlink file: {}",
path.display()
)));
}
if metadata.len() > maximum {
return Err(PluginError::ResourceLimit(format!(
"{} is {} bytes; maximum is {}",
path.display(),
metadata.len(),
maximum
)));
}
let bytes = fs::read(path)?;
if bytes.len() as u64 > maximum {
return Err(PluginError::ResourceLimit(format!(
"{} grew beyond the {maximum}-byte limit while being read",
path.display()
)));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn root() -> PathBuf {
let path = std::env::temp_dir().join(format!(
"homecore-plugin-discovery-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&path).unwrap();
path
}
fn package(root: &Path, directory: &str, domain: &str, module: &str) {
let dir = root.join(directory);
fs::create_dir(&dir).unwrap();
fs::write(
dir.join("manifest.json"),
format!(
r#"{{"domain":"{domain}","name":"Test","version":"1","wasm_module":"{module}"}}"#
),
)
.unwrap();
if !module.contains('/') && !module.contains('\\') && module.ends_with(".wasm") {
fs::write(dir.join(module), b"\0asm\x01\0\0\0").unwrap();
}
}
#[test]
fn discovery_is_sorted_and_only_reads_explicit_roots() {
let root = root();
package(&root, "z-last", "zeta", "z.wasm");
package(&root, "a-first", "alpha", "a.wasm");
let found =
discover_plugins(std::slice::from_ref(&root), DiscoveryLimits::default()).unwrap();
assert_eq!(
found
.iter()
.map(|p| p.manifest.domain.as_str())
.collect::<Vec<_>>(),
["alpha", "zeta"]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn traversal_module_path_is_rejected() {
let root = root();
package(&root, "bad", "bad", "../bad.wasm");
let error =
discover_plugins(std::slice::from_ref(&root), DiscoveryLimits::default()).unwrap_err();
assert!(matches!(error, PluginError::InvalidManifest(_)));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn size_limits_are_enforced_before_read() {
let root = root();
package(&root, "large", "large", "large.wasm");
let error = discover_plugins(
std::slice::from_ref(&root),
DiscoveryLimits {
max_module_bytes: 4,
..DiscoveryLimits::default()
},
)
.unwrap_err();
assert!(matches!(error, PluginError::ResourceLimit(_)));
fs::remove_dir_all(root).unwrap();
}
}