diff options
Diffstat (limited to 'src/app/api/plugins/manifest.rs')
| -rw-r--r-- | src/app/api/plugins/manifest.rs | 539 |
1 files changed, 539 insertions, 0 deletions
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs new file mode 100644 index 00000000..92799aae --- /dev/null +++ b/src/app/api/plugins/manifest.rs @@ -0,0 +1,539 @@ +use crate::api::schema::{ + InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook, + PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform, + PluginSourceInfo, PluginSourceKind, +}; + +const PLUGIN_ID_MAX_CHARS: usize = 120; +const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120; + +#[derive(serde::Deserialize)] +struct RawPluginManifest { + id: String, + name: String, + version: String, + #[serde(default)] + description: Option<String>, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + #[serde(default)] + build: Vec<RawPluginManifestBuild>, + #[serde(default)] + actions: Vec<RawPluginManifestAction>, + #[serde(default)] + events: Vec<RawPluginManifestEventHook>, + #[serde(default)] + panes: Vec<RawPluginManifestPane>, + #[serde(default)] + link_handlers: Vec<RawPluginManifestLinkHandler>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestBuild { + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestAction { + id: String, + title: String, + #[serde(default)] + description: Option<String>, + #[serde(default)] + contexts: Vec<crate::api::schema::PluginActionContext>, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestEventHook { + on: String, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestPane { + id: String, + title: String, + #[serde(default)] + description: Option<String>, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + #[serde(default)] + placement: PluginPanePlacement, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestLinkHandler { + id: String, + title: String, + pattern: String, + action: String, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, +} + +/// Raw string platform value from the manifest, validated before conversion. +#[derive(serde::Deserialize)] +#[serde(try_from = "String")] +struct RawPlatform(PluginPlatform); + +impl TryFrom<String> for RawPlatform { + type Error = String; + + fn try_from(value: String) -> Result<Self, Self::Error> { + match value.as_str() { + "linux" => Ok(RawPlatform(PluginPlatform::Linux)), + "macos" => Ok(RawPlatform(PluginPlatform::Macos)), + "windows" => Ok(RawPlatform(PluginPlatform::Windows)), + other => Err(format!( + "invalid_plugin_platform: unknown platform '{other}'" + )), + } + } +} + +pub(crate) fn load_plugin_manifest( + path: &str, + enabled: bool, +) -> Result<InstalledPluginInfo, (&'static str, String)> { + let path = std::path::PathBuf::from(path); + let manifest_path = if path.is_dir() { + path.join("herdr-plugin.toml") + } else { + path + }; + let manifest_path = manifest_path + .canonicalize() + .map_err(|err| ("plugin_manifest_not_found", err.to_string()))?; + let plugin_root = manifest_path + .parent() + .ok_or_else(|| { + ( + "invalid_plugin_manifest_path", + "manifest path has no parent directory".to_string(), + ) + })? + .to_path_buf(); + let content = std::fs::read_to_string(&manifest_path) + .map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?; + let raw: RawPluginManifest = toml::from_str(&content) + .map_err(|err| ("plugin_manifest_parse_failed", err.to_string()))?; + let plugin_id = normalize_plugin_id(&raw.id) + .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?; + let name = non_empty_trimmed(&raw.name, "invalid_plugin_name", "plugin name is required")?; + let version = non_empty_trimmed( + &raw.version, + "invalid_plugin_version", + "plugin version is required", + )?; + let description = raw + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(raw.platforms)?; + let build = raw + .build + .into_iter() + .map(normalize_manifest_build) + .collect::<Result<Vec<_>, _>>()?; + let mut actions = raw + .actions + .into_iter() + .map(normalize_manifest_action) + .collect::<Result<Vec<_>, _>>()?; + reject_duplicate_action_ids(&actions)?; + actions.sort_by(|a, b| a.id.cmp(&b.id)); + let mut events = raw + .events + .into_iter() + .map(normalize_manifest_event) + .collect::<Result<Vec<_>, _>>()?; + events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command))); + let mut panes = raw + .panes + .into_iter() + .map(normalize_manifest_pane) + .collect::<Result<Vec<_>, _>>()?; + reject_duplicate_pane_ids(&panes)?; + panes.sort_by(|a, b| a.id.cmp(&b.id)); + let link_handlers = raw + .link_handlers + .into_iter() + .map(normalize_manifest_link_handler) + .collect::<Result<Vec<_>, _>>()?; + reject_duplicate_link_handler_ids(&link_handlers)?; + validate_link_handler_actions(&link_handlers, &actions)?; + + let mut warnings = validate_event_names(&events); + if platforms.is_none() { + warnings.push("manifest does not declare platforms; platform support unknown".to_string()); + } + + Ok(InstalledPluginInfo { + plugin_id, + name, + version, + description, + manifest_path: manifest_path.display().to_string(), + plugin_root: plugin_root.display().to_string(), + enabled, + platforms, + build, + actions, + events, + panes, + link_handlers, + source: Default::default(), + warnings, + }) +} + +fn normalize_manifest_build( + build: RawPluginManifestBuild, +) -> Result<PluginManifestBuild, (&'static str, String)> { + let platforms = normalize_platforms(build.platforms)?; + let command = normalize_command(build.command)?; + Ok(PluginManifestBuild { platforms, command }) +} + +pub(super) fn normalize_plugin_source( + plugin: &InstalledPluginInfo, + source: PluginSourceInfo, +) -> Result<PluginSourceInfo, (&'static str, String)> { + if source.kind == PluginSourceKind::Local { + return Ok(source); + } + let Some(managed_path) = source.managed_path.as_deref() else { + return Err(( + "invalid_plugin_source", + "GitHub plugin source requires managed_path".to_string(), + )); + }; + let managed_path = std::path::PathBuf::from(managed_path) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + let plugin_root = std::path::PathBuf::from(&plugin.plugin_root) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + let expected = crate::session::data_dir() + .join("plugins") + .join("github") + .join(crate::api::schema::plugin_managed_path_component( + &plugin.plugin_id, + )) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + if managed_path != expected { + return Err(( + "invalid_plugin_source", + "GitHub plugin managed_path does not match the plugin id".to_string(), + )); + } + if !plugin_root.starts_with(&managed_path) { + return Err(( + "invalid_plugin_source", + "plugin manifest is not inside the managed checkout".to_string(), + )); + } + Ok(source) +} + +fn reject_duplicate_action_ids( + actions: &[PluginManifestAction], +) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for action in actions { + if !seen.insert(action.id.as_str()) { + return Err(( + "duplicate_plugin_action_id", + format!("duplicate action id '{}'", action.id), + )); + } + } + Ok(()) +} + +fn validate_event_names(events: &[crate::api::schema::PluginManifestEventHook]) -> Vec<String> { + let known = crate::api::schema::plugin_hook_event_names(); + events + .iter() + .filter(|hook| !known.contains(&hook.on.as_str())) + .map(|hook| format!("unknown event '{}'", hook.on)) + .collect() +} + +fn reject_duplicate_pane_ids(panes: &[PluginManifestPane]) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for pane in panes { + if !seen.insert(pane.id.as_str()) { + return Err(( + "duplicate_plugin_pane_id", + format!("duplicate pane id '{}'", pane.id), + )); + } + } + Ok(()) +} + +fn reject_duplicate_link_handler_ids( + handlers: &[PluginManifestLinkHandler], +) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for handler in handlers { + if !seen.insert(handler.id.as_str()) { + return Err(( + "duplicate_plugin_link_handler_id", + format!("duplicate link handler id '{}'", handler.id), + )); + } + } + Ok(()) +} + +fn validate_link_handler_actions( + handlers: &[PluginManifestLinkHandler], + actions: &[PluginManifestAction], +) -> Result<(), (&'static str, String)> { + for handler in handlers { + if !actions.iter().any(|action| action.id == handler.action) { + return Err(( + "invalid_plugin_link_handler_action", + format!( + "link handler '{}' references unknown action '{}'", + handler.id, handler.action + ), + )); + } + } + Ok(()) +} + +fn normalize_manifest_action( + action: RawPluginManifestAction, +) -> Result<PluginManifestAction, (&'static str, String)> { + let id = normalize_action_id(&action.id) + .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?; + let title = non_empty_trimmed( + &action.title, + "invalid_plugin_action_title", + "action title is required", + )?; + let description = action + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(action.platforms)?; + let command = normalize_command(action.command)?; + Ok(PluginManifestAction { + id, + title, + description, + contexts: action.contexts, + platforms, + command, + }) +} + +fn normalize_manifest_pane( + pane: RawPluginManifestPane, +) -> Result<PluginManifestPane, (&'static str, String)> { + let id = normalize_action_id(&pane.id) + .ok_or_else(|| ("invalid_plugin_pane_id", "invalid pane id".to_string()))?; + let title = non_empty_trimmed( + &pane.title, + "invalid_plugin_pane_title", + "pane title is required", + )?; + let description = pane + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(pane.platforms)?; + let command = normalize_command(pane.command)?; + Ok(PluginManifestPane { + id, + title, + description, + platforms, + placement: pane.placement, + command, + }) +} + +fn normalize_manifest_event( + event: RawPluginManifestEventHook, +) -> Result<PluginManifestEventHook, (&'static str, String)> { + let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?; + let platforms = normalize_platforms(event.platforms)?; + let command = normalize_command(event.command)?; + Ok(PluginManifestEventHook { + on, + platforms, + command, + }) +} + +fn normalize_manifest_link_handler( + handler: RawPluginManifestLinkHandler, +) -> Result<PluginManifestLinkHandler, (&'static str, String)> { + let id = normalize_action_id(&handler.id).ok_or_else(|| { + ( + "invalid_plugin_link_handler_id", + "invalid link handler id".to_string(), + ) + })?; + let title = non_empty_trimmed( + &handler.title, + "invalid_plugin_link_handler_title", + "link handler title is required", + )?; + let pattern = non_empty_trimmed( + &handler.pattern, + "invalid_plugin_link_handler_pattern", + "link handler pattern is required", + )?; + regex::Regex::new(&pattern) + .map_err(|err| ("invalid_plugin_link_handler_pattern", err.to_string()))?; + let action = normalize_action_id(&handler.action).ok_or_else(|| { + ( + "invalid_plugin_link_handler_action", + "invalid link handler action".to_string(), + ) + })?; + let platforms = normalize_platforms(handler.platforms)?; + Ok(PluginManifestLinkHandler { + id, + title, + pattern, + action, + platforms, + }) +} + +fn normalize_platforms( + raw: Option<Vec<RawPlatform>>, +) -> Result<Option<Vec<PluginPlatform>>, (&'static str, String)> { + match raw { + None => Ok(None), + Some(list) if list.is_empty() => Err(( + "invalid_plugin_platform", + "platforms must not be an empty array; omit the field to leave platforms undeclared" + .to_string(), + )), + Some(list) => Ok(Some(list.into_iter().map(|p| p.0).collect())), + } +} + +/// Returns the platform the current binary was compiled for. +fn current_platform() -> PluginPlatform { + if cfg!(target_os = "linux") { + PluginPlatform::Linux + } else if cfg!(target_os = "macos") { + PluginPlatform::Macos + } else { + PluginPlatform::Windows + } +} + +/// Resolve the effective platforms for an action or event: use the item's own +/// platforms if declared, otherwise inherit from the plugin-level platforms. +/// Returns a reference to whichever `Option<Vec<PluginPlatform>>` applies. +pub(super) fn effective_platforms<'a>( + item_platforms: &'a Option<Vec<PluginPlatform>>, + plugin_platforms: &'a Option<Vec<PluginPlatform>>, +) -> &'a Option<Vec<PluginPlatform>> { + if item_platforms.is_some() { + item_platforms + } else { + plugin_platforms + } +} + +pub(super) fn ensure_platform_supported( + platforms: &Option<Vec<PluginPlatform>>, + subject: &str, +) -> Result<(), (&'static str, String)> { + if let Some(platforms) = platforms { + let host = current_platform(); + if !platforms.contains(&host) { + return Err(( + "platform_unsupported", + format!( + "{subject} does not support the current platform ({})", + platform_name(host) + ), + )); + } + } + Ok(()) +} + +fn platform_name(p: PluginPlatform) -> &'static str { + match p { + PluginPlatform::Linux => "linux", + PluginPlatform::Macos => "macos", + PluginPlatform::Windows => "windows", + } +} + +fn normalize_command(command: Vec<String>) -> Result<Vec<String>, (&'static str, String)> { + let command = command + .into_iter() + .map(|arg| arg.trim().to_string()) + .collect::<Vec<_>>(); + if command.is_empty() || command.iter().any(|arg| arg.is_empty()) { + return Err(( + "invalid_plugin_command", + "command must contain non-empty argv strings".to_string(), + )); + } + Ok(command) +} + +fn non_empty_trimmed( + value: &str, + code: &'static str, + message: &'static str, +) -> Result<String, (&'static str, String)> { + let value = value.trim().to_string(); + if value.is_empty() { + Err((code, message.to_string())) + } else { + Ok(value) + } +} + +pub(super) fn normalize_plugin_id(value: &str) -> Option<String> { + normalize_identifier(value, PLUGIN_ID_MAX_CHARS) +} + +pub(super) fn normalize_action_id(value: &str) -> Option<String> { + normalize_local_identifier(value, PLUGIN_ACTION_ID_MAX_CHARS) +} + +fn normalize_identifier(value: &str, max_chars: usize) -> Option<String> { + let value = value.trim(); + (!value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-'))) + .then(|| value.to_string()) +} + +fn normalize_local_identifier(value: &str, max_chars: usize) -> Option<String> { + let value = value.trim(); + (!value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))) + .then(|| value.to_string()) +} |
