Files
IdeA/crates/domain/src/plugin.rs
Blomios bb35641715 feat(backend): système de plugins — domaine, application, infrastructure (#43)
Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP),
port et registre applicatif, chargement/validation en infrastructure, exposition DTO
et commandes Tauri. Tests cargo verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:37:03 +02:00

682 lines
19 KiB
Rust

//! Plugin domain model and validated manifest image.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
/// Plugin domain validation error.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PluginError {
/// A required field is empty or malformed.
#[error("invalid plugin field {field}: {reason}")]
InvalidField {
/// Field name.
field: &'static str,
/// Human-readable reason.
reason: String,
},
/// A path escaped the plugin root.
#[error("invalid plugin path {field}: {path}")]
InvalidPath {
/// Field name.
field: &'static str,
/// Offending path.
path: String,
},
/// A contribution id is duplicated within the same plugin.
#[error("duplicate contribution id: {0}")]
DuplicateContribution(String),
}
/// Stable plugin identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginId(String);
impl PluginId {
/// Validates and creates a plugin id.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
let raw = raw.into();
let valid_len = (3..=128).contains(&raw.len());
let mut chars = raw.chars();
let first = chars.next().unwrap_or('\0');
let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit();
let valid_rest =
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-');
if valid_len && valid_first && valid_rest {
Ok(Self(raw))
} else {
Err(PluginError::InvalidField {
field: "id",
reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(),
})
}
}
/// Returns the raw id.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PluginId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// SemVer-like plugin version.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginVersion(String);
impl PluginVersion {
/// Validates and creates a version.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
let raw = raw.into();
let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a);
let parts: Vec<&str> = core.split('.').collect();
let ok = parts.len() == 3
&& parts.iter().all(|p| {
!p.is_empty()
&& p.chars().all(|c| c.is_ascii_digit())
&& (p == &"0" || !p.starts_with('0'))
});
if ok {
Ok(Self(raw))
} else {
Err(PluginError::InvalidField {
field: "version",
reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(),
})
}
}
/// Returns the raw version.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Relative path inside a plugin package.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RelativePath(String);
impl RelativePath {
/// Validates a manifest path as relative, normalized, and root-confined.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
let raw = raw.into();
if is_safe_relative_path(&raw) {
Ok(Self(raw.replace('\\', "/")))
} else {
Err(PluginError::InvalidPath {
field: "path",
path: raw,
})
}
}
/// Returns the relative path.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
fn is_safe_relative_path(raw: &str) -> bool {
if raw.is_empty()
|| raw.starts_with('/')
|| raw.starts_with('\\')
|| raw.contains('\0')
|| raw.contains(':')
{
return false;
}
raw.replace('\\', "/")
.split('/')
.all(|p| !p.is_empty() && p != "." && p != "..")
}
/// Content hash of a plugin bundle/package.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContentHash(String);
impl ContentHash {
/// Creates a content hash value.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
let raw = raw.into();
if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(Self(raw))
} else {
Err(PluginError::InvalidField {
field: "contentHash",
reason: "expected non-empty hexadecimal hash".to_owned(),
})
}
}
/// Returns the hash.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// URL exposed to the frontend for a plugin bundle or asset.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginBundleUrl(String);
impl PluginBundleUrl {
/// Creates a bundle URL.
#[must_use]
pub fn new(raw: impl Into<String>) -> Self {
Self(raw.into())
}
/// Returns the URL.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Package reference managed by the store adapter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginPackageRef {
/// Plugin id if the package is already committed.
pub plugin_id: Option<PluginId>,
/// Opaque adapter-owned root path label.
pub root: String,
}
/// Staged package produced by install/review.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StagedPluginPackage {
/// Opaque staging root path.
pub root: String,
/// Original source label.
pub source: PluginInstallSource,
/// Hash of package contents at staging time.
pub content_hash: ContentHash,
}
/// Plugin install source persisted for admin display.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum PluginInstallSource {
/// Local archive source.
Archive {
/// Human-readable path label.
path_label: String,
},
/// Local directory source.
Directory {
/// Human-readable path label.
path_label: String,
},
}
impl PluginInstallSource {
/// Returns the stable DTO source kind.
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Archive { .. } => "archive",
Self::Directory { .. } => "directory",
}
}
/// Returns the optional label.
#[must_use]
pub fn label(&self) -> &str {
match self {
Self::Archive { path_label } | Self::Directory { path_label } => path_label,
}
}
}
/// Plugin lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginLifecycleState {
/// Installed and enabled.
Enabled,
/// Installed and disabled.
Disabled,
/// Enable requested.
PendingEnable,
/// Disable requested.
PendingDisable,
/// Uninstall requested.
PendingUninstall,
/// Invalid manifest/incompatible engine.
Invalid,
}
impl PluginLifecycleState {
/// Whether the plugin can expose runtime contributions.
#[must_use]
pub fn is_runtime_active(self) -> bool {
matches!(self, Self::Enabled | Self::PendingEnable)
}
}
/// Trust level supported by v1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginTrustLevel {
/// Full-trust plugin.
Full,
}
/// Declared plugin capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginCapability {
/// UI bundle/contributions.
Ui,
/// External MCP server declarations.
Mcp,
}
/// Plugin command id.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginCommandId(String);
impl PluginCommandId {
/// Validates and creates a command id.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
let raw = raw.into();
if raw.len() >= 3
&& raw
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
{
Ok(Self(raw))
} else {
Err(PluginError::InvalidField {
field: "command",
reason: "invalid command id".to_owned(),
})
}
}
/// Returns the raw id.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Plugin layout type.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginLayoutType(String);
impl PluginLayoutType {
/// Validates and creates a layout type.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
PluginCommandId::new(raw).map(|v| Self(v.0))
}
/// Returns the raw type.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Plugin MCP server id.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginMcpServerId(String);
impl PluginMcpServerId {
/// Validates and creates a server id.
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
PluginCommandId::new(raw).map(|v| Self(v.0))
}
/// Returns the raw id.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Top-level menu contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTopLevelMenuContribution {
/// Menu id.
pub id: String,
/// Display label.
pub label: String,
/// Must be true for this contribution kind.
pub top_level: bool,
/// Sort order.
#[serde(default)]
pub order: Option<i32>,
/// Optional icon path.
#[serde(default)]
pub icon: Option<RelativePath>,
}
/// Menu item contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMenuItemContribution {
/// Item id.
pub id: String,
/// Target menu id.
pub target_menu_id: String,
/// Display label.
pub label: String,
/// Command id.
pub command: PluginCommandId,
/// Sort order.
#[serde(default)]
pub order: Option<i32>,
/// Optional icon path.
#[serde(default)]
pub icon: Option<RelativePath>,
/// Optional declarative condition.
#[serde(default)]
pub when: Option<String>,
}
/// Layout contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginLayoutContribution {
/// Persisted layout type.
#[serde(rename = "type")]
pub layout_type: PluginLayoutType,
/// Display label.
pub label: String,
/// Component export name.
pub component: String,
/// Sort order.
#[serde(default)]
pub order: Option<i32>,
/// Optional icon path.
#[serde(default)]
pub icon: Option<RelativePath>,
/// Optional declarative condition.
#[serde(default)]
pub when: Option<String>,
}
/// MCP server contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpServerContribution {
/// Server id.
pub id: PluginMcpServerId,
/// Display name.
pub display_name: String,
/// Executable command path or absolute command when allowed.
pub command: String,
/// Arguments.
#[serde(default)]
pub args: Vec<String>,
/// Environment variables.
#[serde(default)]
pub env: Vec<(String, String)>,
/// Working directory.
#[serde(default)]
pub cwd: Option<String>,
/// Transport, v1 only `stdio`.
pub transport: String,
/// Auto start flag.
#[serde(default)]
pub auto_start: bool,
/// Development-only escape hatch for absolute commands.
#[serde(default)]
pub allow_absolute_command: bool,
}
/// Validated contribution set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContributionSet {
/// Top-level menus.
#[serde(default)]
pub menus: Vec<PluginTopLevelMenuContribution>,
/// Menu items.
#[serde(default)]
pub menu_items: Vec<PluginMenuItemContribution>,
/// Layout contributions.
#[serde(default)]
pub layouts: Vec<PluginLayoutContribution>,
/// MCP server contributions.
#[serde(default)]
pub mcp_servers: Vec<PluginMcpServerContribution>,
}
/// Validated plugin manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
/// Manifest schema version.
pub idea_plugin_manifest_version: u32,
/// Plugin id.
pub id: PluginId,
/// Display name.
pub display_name: String,
/// Publisher.
#[serde(default)]
pub publisher: Option<String>,
/// Version.
pub version: PluginVersion,
/// Description.
#[serde(default)]
pub description: Option<String>,
/// Engine constraint for IdeA.
#[serde(default)]
pub engine_idea: Option<String>,
/// Main ESM bundle.
pub main: RelativePath,
/// Optional icon path.
#[serde(default)]
pub icon: Option<RelativePath>,
/// Trust level.
pub trust_level: PluginTrustLevel,
/// Capabilities.
#[serde(default)]
pub capabilities: Vec<PluginCapability>,
/// Contributions.
pub contributes: PluginContributionSet,
}
/// Persisted registry entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRegistryEntry {
/// Plugin id.
pub id: PluginId,
/// Lifecycle state.
pub lifecycle_state: PluginLifecycleState,
/// Source.
pub source: PluginInstallSource,
/// Content hash.
pub content_hash: ContentHash,
/// Restart required marker.
#[serde(default)]
pub restart_required: bool,
/// Optional error.
#[serde(default)]
pub error: Option<String>,
}
/// Persisted global registry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRegistry {
/// Schema version.
pub version: u32,
/// Registry entries.
pub plugins: Vec<PluginRegistryEntry>,
}
impl Default for PluginRegistry {
fn default() -> Self {
Self {
version: 1,
plugins: Vec::new(),
}
}
}
impl PluginRegistry {
/// Finds a plugin entry.
#[must_use]
pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> {
self.plugins.iter().find(|p| &p.id == id)
}
/// Upserts a registry entry, preserving id uniqueness.
pub fn upsert(&mut self, entry: PluginRegistryEntry) {
if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) {
*slot = entry;
} else {
self.plugins.push(entry);
}
}
/// Removes an entry.
pub fn remove(&mut self, id: &PluginId) -> Option<PluginRegistryEntry> {
let index = self.plugins.iter().position(|p| &p.id == id)?;
Some(self.plugins.remove(index))
}
}
/// Admin descriptor assembled from manifest + registry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginDescriptor {
/// Manifest.
pub manifest: PluginManifest,
/// Registry entry.
pub registry: PluginRegistryEntry,
}
impl PluginDescriptor {
/// Returns true if runtime contributions are active.
#[must_use]
pub fn exposes_runtime_contributions(&self) -> bool {
self.registry.lifecycle_state.is_runtime_active()
}
}
/// Result of removing package files.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RemovalOutcome {
/// Files were removed.
Removed,
/// Files were moved to trash for later cleanup.
Tombstoned,
/// No package files existed.
NotFound,
}
/// Resolved MCP server spec passed to the supervisor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpServerSpec {
/// Plugin id.
pub plugin_id: PluginId,
/// Manifest server id.
pub server_id: PluginMcpServerId,
/// Stable external identity `plugin:<pluginId>:<serverId>`.
pub identity: String,
/// Display name.
pub display_name: String,
/// Command.
pub command: String,
/// Args.
pub args: Vec<String>,
/// Env.
pub env: Vec<(String, String)>,
/// Cwd.
pub cwd: String,
/// Transport.
pub transport: String,
}
/// MCP server status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpStatus {
/// Identity.
pub identity: String,
/// Running flag.
pub running: bool,
/// Optional error.
#[serde(default)]
pub error: Option<String>,
}
/// MCP status set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpStatusSet {
/// Statuses.
pub servers: Vec<PluginMcpStatus>,
}
/// Custom plugin layout persisted in a layout leaf's opaque content.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomPluginLayout {
/// Provider plugin id.
pub plugin_id: PluginId,
/// Provider display name if known.
#[serde(default)]
pub provider_plugin_display_name: Option<String>,
/// Layout type.
pub layout_type: PluginLayoutType,
/// Opaque plugin state.
#[serde(default)]
pub state: Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_id_pattern_is_enforced() {
assert!(PluginId::new("dev.acme.gitgraph").is_ok());
assert!(PluginId::new("De.acme").is_err());
assert!(PluginId::new("a").is_err());
}
#[test]
fn relative_paths_cannot_escape_root() {
assert_eq!(
RelativePath::new("dist/index.js").unwrap().as_str(),
"dist/index.js"
);
assert!(RelativePath::new("../dist/index.js").is_err());
assert!(RelativePath::new("/tmp/index.js").is_err());
assert!(RelativePath::new("dist/../index.js").is_err());
}
#[test]
fn disabled_and_pending_uninstall_are_not_runtime_active() {
assert!(!PluginLifecycleState::Disabled.is_runtime_active());
assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
assert!(PluginLifecycleState::Enabled.is_runtime_active());
}
}