Files
Oxicloud/src/infrastructure/services/plugins/manifest.rs
T
Bradley Nelson 87d68c5b6f init plugins
2026-06-16 17:57:57 -06:00

104 lines
3.6 KiB
Rust

//! `plugin.toml` parsing + load-time validation (ABI v0).
//!
//! The manifest is the host's source of truth for *what to load and when to
//! call it*. Validation fails closed: unknown sections/keys, a mismatched ABI,
//! an unknown subscribed event, or any non-empty `[permissions]` (M0 grants
//! none) all reject the plugin. A rejected plugin is skipped, never fatal.
use std::collections::BTreeMap;
use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, OXICLOUD_PLUGIN_ABI};
/// Parsed `plugin.toml`. `#[serde(deny_unknown_fields)]` on every struct turns
/// stray keys into load errors rather than silently ignored config.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
pub plugin: PluginSection,
pub events: EventsSection,
/// M0: must be empty. Any key here rejects the plugin (no grantable
/// permissions exist yet). Kept as a free map so future keys are *detected*,
/// not parsed.
#[serde(default)]
pub permissions: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSection {
/// Reverse-DNS, unique per instance.
pub id: String,
pub name: String,
/// The plugin's own semver.
pub version: String,
/// Must equal [`OXICLOUD_PLUGIN_ABI`].
pub abi: u32,
/// Path to the `.wasm`, relative to the manifest.
pub entrypoint: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventsSection {
/// Events this plugin wants. M0 accepts only `"file.uploaded"`.
pub subscribe: Vec<String>,
}
/// Why a manifest was rejected. `reason()` yields the stable, machine-readable
/// key used in audit logs.
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("failed to parse plugin.toml: {0}")]
Parse(String),
#[error("plugin declares ABI {got}, host speaks {want}")]
AbiMismatch { got: u32, want: u32 },
#[error("events.subscribe must not be empty")]
NoEvents,
#[error("unknown event '{0}' in events.subscribe")]
UnknownEvent(String),
#[error("permissions must be empty in ABI v0 (found key '{0}')")]
PermissionsNotEmpty(String),
}
impl ManifestError {
/// Stable key for `tracing` audit lines; never reworded across releases.
pub fn reason(&self) -> &'static str {
match self {
ManifestError::Parse(_) => "parse_error",
ManifestError::AbiMismatch { .. } => "abi_mismatch",
ManifestError::NoEvents => "no_events",
ManifestError::UnknownEvent(_) => "unknown_event",
ManifestError::PermissionsNotEmpty(_) => "permissions_not_empty",
}
}
}
/// Parse and validate a `plugin.toml` body. Does not touch the `.wasm`; the
/// caller probes `abi_version` separately after a successful parse.
pub fn parse_and_validate(toml_str: &str) -> Result<PluginManifest, ManifestError> {
let manifest: PluginManifest =
toml::from_str(toml_str).map_err(|e| ManifestError::Parse(e.to_string()))?;
if manifest.plugin.abi != OXICLOUD_PLUGIN_ABI {
return Err(ManifestError::AbiMismatch {
got: manifest.plugin.abi,
want: OXICLOUD_PLUGIN_ABI,
});
}
if manifest.events.subscribe.is_empty() {
return Err(ManifestError::NoEvents);
}
for event in &manifest.events.subscribe {
if event != EVENT_FILE_UPLOADED {
return Err(ManifestError::UnknownEvent(event.clone()));
}
}
if let Some((key, _)) = manifest.permissions.iter().next() {
return Err(ManifestError::PermissionsNotEmpty(key.clone()));
}
Ok(manifest)
}