use serde::{Deserialize, Serialize};
use crate::PluginError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: String,
pub version: String,
pub capabilities: Vec<PluginCapability>,
#[serde(default)]
pub permissions: PluginPermissions,
#[serde(default)]
pub resources: PluginResourceConfig,
#[serde(default)]
pub wasm_module: Option<String>,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub tools: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginCapability {
Tool,
Channel,
PipelineStage,
Skill,
MemoryBackend,
Voice,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PluginPermissions {
#[serde(default)]
pub network: Vec<String>,
#[serde(default)]
pub filesystem: Vec<String>,
#[serde(default)]
pub env_vars: Vec<String>,
#[serde(default)]
pub shell: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResourceConfig {
#[serde(default = "default_max_fuel")]
pub max_fuel: u64,
#[serde(default = "default_max_memory_mb")]
pub max_memory_mb: usize,
#[serde(default = "default_max_http_rpm")]
pub max_http_requests_per_minute: u64,
#[serde(default = "default_max_log_rpm")]
pub max_log_messages_per_minute: u64,
#[serde(default = "default_max_exec_seconds")]
pub max_execution_seconds: u64,
#[serde(default = "default_max_table_elements")]
pub max_table_elements: u32,
}
fn default_max_fuel() -> u64 {
1_000_000_000
}
fn default_max_memory_mb() -> usize {
16
}
fn default_max_http_rpm() -> u64 {
10
}
fn default_max_log_rpm() -> u64 {
100
}
fn default_max_exec_seconds() -> u64 {
30
}
fn default_max_table_elements() -> u32 {
10_000
}
impl Default for PluginResourceConfig {
fn default() -> Self {
Self {
max_fuel: default_max_fuel(),
max_memory_mb: default_max_memory_mb(),
max_http_requests_per_minute: default_max_http_rpm(),
max_log_messages_per_minute: default_max_log_rpm(),
max_execution_seconds: default_max_exec_seconds(),
max_table_elements: default_max_table_elements(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PermissionDiff {
pub new_network: Vec<String>,
pub new_filesystem: Vec<String>,
pub new_env_vars: Vec<String>,
pub shell_escalation: bool,
}
impl PermissionDiff {
pub fn is_empty(&self) -> bool {
self.new_network.is_empty()
&& self.new_filesystem.is_empty()
&& self.new_env_vars.is_empty()
&& !self.shell_escalation
}
}
impl PluginPermissions {
pub fn diff(approved: &PluginPermissions, requested: &PluginPermissions) -> PermissionDiff {
let new_network = requested
.network
.iter()
.filter(|item| !approved.network.contains(item))
.cloned()
.collect();
let new_filesystem = requested
.filesystem
.iter()
.filter(|item| !approved.filesystem.contains(item))
.cloned()
.collect();
let new_env_vars = requested
.env_vars
.iter()
.filter(|item| !approved.env_vars.contains(item))
.cloned()
.collect();
let shell_escalation = !approved.shell && requested.shell;
PermissionDiff {
new_network,
new_filesystem,
new_env_vars,
shell_escalation,
}
}
}
impl PluginManifest {
pub fn validate(&self) -> Result<(), PluginError> {
if self.id.is_empty() {
return Err(PluginError::LoadFailed(
"manifest: id is required".into(),
));
}
if self.id.len() > 128 {
return Err(PluginError::LoadFailed(
"manifest: id must be 128 characters or fewer".into(),
));
}
if !self
.id
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
{
return Err(PluginError::LoadFailed(
"manifest: id must contain only alphanumeric characters, dots, hyphens, and underscores".into(),
));
}
if self.name.is_empty() {
return Err(PluginError::LoadFailed(
"manifest: name is required".into(),
));
}
if semver::Version::parse(&self.version).is_err() {
return Err(PluginError::LoadFailed(format!(
"manifest: invalid semver version '{}'",
self.version
)));
}
if self.capabilities.is_empty() {
return Err(PluginError::LoadFailed(
"manifest: at least one capability is required".into(),
));
}
Ok(())
}
pub fn from_json(json: &str) -> Result<Self, PluginError> {
let manifest: Self = serde_json::from_str(json)?;
manifest.validate()?;
Ok(manifest)
}
pub fn from_yaml(_yaml: &str) -> Result<Self, PluginError> {
Err(PluginError::NotImplemented(
"YAML manifest parsing deferred to C3 skill loader".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_manifest_json() -> String {
serde_json::json!({
"id": "com.example.test-plugin",
"name": "Test Plugin",
"version": "1.0.0",
"capabilities": ["tool", "skill"],
"permissions": {
"network": ["api.example.com"],
"filesystem": ["/tmp/plugin"],
"env_vars": ["MY_API_KEY"],
"shell": false
},
"resources": {
"max_fuel": 500_000_000u64,
"max_memory_mb": 8,
"max_http_requests_per_minute": 5,
"max_log_messages_per_minute": 50,
"max_execution_seconds": 15,
"max_table_elements": 5000
},
"wasm_module": "plugin.wasm",
"skills": ["code-review"],
"tools": ["lint_code"]
})
.to_string()
}
#[test]
fn test_manifest_parse_json() {
let json = valid_manifest_json();
let manifest = PluginManifest::from_json(&json).unwrap();
assert_eq!(manifest.id, "com.example.test-plugin");
assert_eq!(manifest.name, "Test Plugin");
assert_eq!(manifest.version, "1.0.0");
assert_eq!(manifest.capabilities.len(), 2);
assert_eq!(manifest.capabilities[0], PluginCapability::Tool);
assert_eq!(manifest.capabilities[1], PluginCapability::Skill);
assert_eq!(manifest.permissions.network, vec!["api.example.com"]);
assert_eq!(manifest.permissions.filesystem, vec!["/tmp/plugin"]);
assert_eq!(manifest.permissions.env_vars, vec!["MY_API_KEY"]);
assert!(!manifest.permissions.shell);
assert_eq!(manifest.resources.max_fuel, 500_000_000);
assert_eq!(manifest.resources.max_memory_mb, 8);
assert_eq!(manifest.resources.max_http_requests_per_minute, 5);
assert_eq!(manifest.resources.max_log_messages_per_minute, 50);
assert_eq!(manifest.resources.max_execution_seconds, 15);
assert_eq!(manifest.resources.max_table_elements, 5000);
assert_eq!(manifest.wasm_module, Some("plugin.wasm".into()));
assert_eq!(manifest.skills, vec!["code-review"]);
assert_eq!(manifest.tools, vec!["lint_code"]);
}
#[test]
fn test_manifest_parse_yaml_returns_not_implemented() {
let result = PluginManifest::from_yaml("name: test");
assert!(result.is_err());
match result.unwrap_err() {
PluginError::NotImplemented(msg) => {
assert!(msg.contains("YAML manifest parsing deferred"));
}
other => panic!("expected NotImplemented, got: {other}"),
}
}
#[test]
fn test_manifest_missing_id_fails() {
let json = serde_json::json!({
"id": "",
"name": "Test",
"version": "1.0.0",
"capabilities": ["tool"]
})
.to_string();
let err = PluginManifest::from_json(&json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("id is required"), "got: {msg}");
}
#[test]
fn test_manifest_invalid_version_fails() {
let json = serde_json::json!({
"id": "com.test",
"name": "Test",
"version": "not-semver",
"capabilities": ["tool"]
})
.to_string();
let err = PluginManifest::from_json(&json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid semver"), "got: {msg}");
}
#[test]
fn test_manifest_empty_capabilities_fails() {
let json = serde_json::json!({
"id": "com.test",
"name": "Test",
"version": "1.0.0",
"capabilities": []
})
.to_string();
let err = PluginManifest::from_json(&json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("at least one capability"),
"got: {msg}"
);
}
#[test]
fn test_manifest_missing_name_fails() {
let json = serde_json::json!({
"id": "com.test",
"name": "",
"version": "1.0.0",
"capabilities": ["tool"]
})
.to_string();
let err = PluginManifest::from_json(&json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("name is required"), "got: {msg}");
}
#[test]
fn test_plugin_capability_serde_roundtrip() {
let capabilities = vec![
PluginCapability::Tool,
PluginCapability::Channel,
PluginCapability::PipelineStage,
PluginCapability::Skill,
PluginCapability::MemoryBackend,
PluginCapability::Voice,
];
for cap in &capabilities {
let json = serde_json::to_string(cap).unwrap();
let restored: PluginCapability = serde_json::from_str(&json).unwrap();
assert_eq!(&restored, cap);
}
}
#[test]
fn test_plugin_capability_json_values() {
assert_eq!(
serde_json::to_string(&PluginCapability::Tool).unwrap(),
"\"tool\""
);
assert_eq!(
serde_json::to_string(&PluginCapability::Channel).unwrap(),
"\"channel\""
);
assert_eq!(
serde_json::to_string(&PluginCapability::PipelineStage).unwrap(),
"\"pipeline_stage\""
);
assert_eq!(
serde_json::to_string(&PluginCapability::Skill).unwrap(),
"\"skill\""
);
assert_eq!(
serde_json::to_string(&PluginCapability::MemoryBackend).unwrap(),
"\"memory_backend\""
);
assert_eq!(
serde_json::to_string(&PluginCapability::Voice).unwrap(),
"\"voice\""
);
}
#[test]
fn test_permissions_default_is_empty() {
let perms = PluginPermissions::default();
assert!(perms.network.is_empty());
assert!(perms.filesystem.is_empty());
assert!(perms.env_vars.is_empty());
assert!(!perms.shell);
}
#[test]
fn test_resource_config_defaults() {
let config = PluginResourceConfig::default();
assert_eq!(config.max_fuel, 1_000_000_000);
assert_eq!(config.max_memory_mb, 16);
assert_eq!(config.max_http_requests_per_minute, 10);
assert_eq!(config.max_log_messages_per_minute, 100);
assert_eq!(config.max_execution_seconds, 30);
assert_eq!(config.max_table_elements, 10_000);
}
#[test]
fn test_manifest_with_defaults() {
let json = serde_json::json!({
"id": "com.test.minimal",
"name": "Minimal",
"version": "0.1.0",
"capabilities": ["tool"]
})
.to_string();
let manifest = PluginManifest::from_json(&json).unwrap();
assert!(manifest.permissions.network.is_empty());
assert!(!manifest.permissions.shell);
assert_eq!(manifest.resources.max_fuel, 1_000_000_000);
assert_eq!(manifest.resources.max_memory_mb, 16);
assert!(manifest.wasm_module.is_none());
assert!(manifest.skills.is_empty());
assert!(manifest.tools.is_empty());
}
#[test]
fn test_manifest_serde_roundtrip() {
let json = valid_manifest_json();
let manifest = PluginManifest::from_json(&json).unwrap();
let serialized = serde_json::to_string(&manifest).unwrap();
let restored = PluginManifest::from_json(&serialized).unwrap();
assert_eq!(manifest.id, restored.id);
assert_eq!(manifest.name, restored.name);
assert_eq!(manifest.version, restored.version);
assert_eq!(manifest.capabilities, restored.capabilities);
}
#[test]
fn test_permissions_serde_roundtrip() {
let perms = PluginPermissions {
network: vec!["*.example.com".into(), "api.test.com".into()],
filesystem: vec!["/tmp".into(), "/data".into()],
env_vars: vec!["MY_KEY".into()],
shell: true,
};
let json = serde_json::to_string(&perms).unwrap();
let restored: PluginPermissions = serde_json::from_str(&json).unwrap();
assert_eq!(restored.network, perms.network);
assert_eq!(restored.filesystem, perms.filesystem);
assert_eq!(restored.env_vars, perms.env_vars);
assert_eq!(restored.shell, perms.shell);
}
#[test]
fn diff_identical_permissions_is_empty() {
let perms = PluginPermissions {
network: vec!["api.example.com".into()],
filesystem: vec!["/tmp".into()],
env_vars: vec!["HOME".into()],
shell: true,
};
let diff = PluginPermissions::diff(&perms, &perms);
assert!(diff.is_empty());
assert_eq!(diff, PermissionDiff::default());
}
#[test]
fn diff_detects_new_network_hosts() {
let approved = PluginPermissions {
network: vec!["api.example.com".into()],
..Default::default()
};
let requested = PluginPermissions {
network: vec!["api.example.com".into(), "cdn.example.com".into()],
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert_eq!(diff.new_network, vec!["cdn.example.com"]);
assert!(diff.new_filesystem.is_empty());
assert!(diff.new_env_vars.is_empty());
assert!(!diff.shell_escalation);
assert!(!diff.is_empty());
}
#[test]
fn diff_detects_new_filesystem_paths() {
let approved = PluginPermissions {
filesystem: vec!["/tmp".into()],
..Default::default()
};
let requested = PluginPermissions {
filesystem: vec!["/tmp".into(), "/data".into()],
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert_eq!(diff.new_filesystem, vec!["/data"]);
}
#[test]
fn diff_detects_new_env_vars() {
let approved = PluginPermissions {
env_vars: vec!["HOME".into()],
..Default::default()
};
let requested = PluginPermissions {
env_vars: vec!["HOME".into(), "API_KEY".into()],
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert_eq!(diff.new_env_vars, vec!["API_KEY"]);
}
#[test]
fn diff_detects_shell_escalation() {
let approved = PluginPermissions {
shell: false,
..Default::default()
};
let requested = PluginPermissions {
shell: true,
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert!(diff.shell_escalation);
assert!(!diff.is_empty());
}
#[test]
fn diff_no_shell_escalation_when_already_approved() {
let approved = PluginPermissions {
shell: true,
..Default::default()
};
let requested = PluginPermissions {
shell: true,
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert!(!diff.shell_escalation);
}
#[test]
fn diff_no_shell_escalation_on_downgrade() {
let approved = PluginPermissions {
shell: true,
..Default::default()
};
let requested = PluginPermissions {
shell: false,
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert!(!diff.shell_escalation);
}
#[test]
fn diff_empty_approved_all_requested_are_new() {
let approved = PluginPermissions::default();
let requested = PluginPermissions {
network: vec!["a.com".into(), "b.com".into()],
filesystem: vec!["/data".into()],
env_vars: vec!["KEY".into()],
shell: true,
};
let diff = PluginPermissions::diff(&approved, &requested);
assert_eq!(diff.new_network, vec!["a.com", "b.com"]);
assert_eq!(diff.new_filesystem, vec!["/data"]);
assert_eq!(diff.new_env_vars, vec!["KEY"]);
assert!(diff.shell_escalation);
}
#[test]
fn diff_removed_permissions_not_reported() {
let approved = PluginPermissions {
network: vec!["old.example.com".into(), "keep.example.com".into()],
..Default::default()
};
let requested = PluginPermissions {
network: vec!["keep.example.com".into()],
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert!(diff.is_empty());
}
#[test]
fn diff_wildcard_network_is_treated_as_new_entry() {
let approved = PluginPermissions {
network: vec!["api.example.com".into()],
..Default::default()
};
let requested = PluginPermissions {
network: vec!["api.example.com".into(), "*".into()],
..Default::default()
};
let diff = PluginPermissions::diff(&approved, &requested);
assert_eq!(diff.new_network, vec!["*"]);
}
#[test]
fn permission_diff_is_empty_default() {
let diff = PermissionDiff::default();
assert!(diff.is_empty());
}
}