1use serde::{Deserialize, Serialize};
8
9use crate::PluginError;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PluginManifest {
14 pub id: String,
16
17 pub name: String,
19
20 pub version: String,
22
23 pub capabilities: Vec<PluginCapability>,
25
26 #[serde(default)]
28 pub permissions: PluginPermissions,
29
30 #[serde(default)]
32 pub resources: PluginResourceConfig,
33
34 #[serde(default)]
36 pub wasm_module: Option<String>,
37
38 #[serde(default)]
40 pub skills: Vec<String>,
41
42 #[serde(default)]
44 pub tools: Vec<String>,
45}
46
47#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PluginCapability {
52 Tool,
54 Channel,
56 PipelineStage,
58 Skill,
60 MemoryBackend,
62 Voice,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
68pub struct PluginPermissions {
69 #[serde(default)]
71 pub network: Vec<String>,
72
73 #[serde(default)]
75 pub filesystem: Vec<String>,
76
77 #[serde(default)]
79 pub env_vars: Vec<String>,
80
81 #[serde(default)]
83 pub shell: bool,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PluginResourceConfig {
89 #[serde(default = "default_max_fuel")]
91 pub max_fuel: u64,
92
93 #[serde(default = "default_max_memory_mb")]
95 pub max_memory_mb: usize,
96
97 #[serde(default = "default_max_http_rpm")]
99 pub max_http_requests_per_minute: u64,
100
101 #[serde(default = "default_max_log_rpm")]
103 pub max_log_messages_per_minute: u64,
104
105 #[serde(default = "default_max_exec_seconds")]
107 pub max_execution_seconds: u64,
108
109 #[serde(default = "default_max_table_elements")]
111 pub max_table_elements: u32,
112}
113
114fn default_max_fuel() -> u64 {
115 1_000_000_000
116}
117fn default_max_memory_mb() -> usize {
118 16
119}
120fn default_max_http_rpm() -> u64 {
121 10
122}
123fn default_max_log_rpm() -> u64 {
124 100
125}
126fn default_max_exec_seconds() -> u64 {
127 30
128}
129fn default_max_table_elements() -> u32 {
130 10_000
131}
132
133impl Default for PluginResourceConfig {
134 fn default() -> Self {
135 Self {
136 max_fuel: default_max_fuel(),
137 max_memory_mb: default_max_memory_mb(),
138 max_http_requests_per_minute: default_max_http_rpm(),
139 max_log_messages_per_minute: default_max_log_rpm(),
140 max_execution_seconds: default_max_exec_seconds(),
141 max_table_elements: default_max_table_elements(),
142 }
143 }
144}
145
146#[derive(Debug, Clone, Default, PartialEq)]
152pub struct PermissionDiff {
153 pub new_network: Vec<String>,
155 pub new_filesystem: Vec<String>,
157 pub new_env_vars: Vec<String>,
159 pub shell_escalation: bool,
161}
162
163impl PermissionDiff {
164 pub fn is_empty(&self) -> bool {
166 self.new_network.is_empty()
167 && self.new_filesystem.is_empty()
168 && self.new_env_vars.is_empty()
169 && !self.shell_escalation
170 }
171}
172
173impl PluginPermissions {
174 pub fn diff(approved: &PluginPermissions, requested: &PluginPermissions) -> PermissionDiff {
181 let new_network = requested
182 .network
183 .iter()
184 .filter(|item| !approved.network.contains(item))
185 .cloned()
186 .collect();
187
188 let new_filesystem = requested
189 .filesystem
190 .iter()
191 .filter(|item| !approved.filesystem.contains(item))
192 .cloned()
193 .collect();
194
195 let new_env_vars = requested
196 .env_vars
197 .iter()
198 .filter(|item| !approved.env_vars.contains(item))
199 .cloned()
200 .collect();
201
202 let shell_escalation = !approved.shell && requested.shell;
203
204 PermissionDiff {
205 new_network,
206 new_filesystem,
207 new_env_vars,
208 shell_escalation,
209 }
210 }
211}
212
213impl PluginManifest {
214 pub fn validate(&self) -> Result<(), PluginError> {
217 if self.id.is_empty() {
218 return Err(PluginError::LoadFailed(
219 "manifest: id is required".into(),
220 ));
221 }
222 if self.id.len() > 128 {
223 return Err(PluginError::LoadFailed(
224 "manifest: id must be 128 characters or fewer".into(),
225 ));
226 }
227 if !self
228 .id
229 .chars()
230 .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
231 {
232 return Err(PluginError::LoadFailed(
233 "manifest: id must contain only alphanumeric characters, dots, hyphens, and underscores".into(),
234 ));
235 }
236 if self.name.is_empty() {
237 return Err(PluginError::LoadFailed(
238 "manifest: name is required".into(),
239 ));
240 }
241 if semver::Version::parse(&self.version).is_err() {
243 return Err(PluginError::LoadFailed(format!(
244 "manifest: invalid semver version '{}'",
245 self.version
246 )));
247 }
248 if self.capabilities.is_empty() {
249 return Err(PluginError::LoadFailed(
250 "manifest: at least one capability is required".into(),
251 ));
252 }
253 Ok(())
254 }
255
256 pub fn from_json(json: &str) -> Result<Self, PluginError> {
258 let manifest: Self = serde_json::from_str(json)?;
259 manifest.validate()?;
260 Ok(manifest)
261 }
262
263 pub fn from_yaml(_yaml: &str) -> Result<Self, PluginError> {
270 Err(PluginError::NotImplemented(
271 "YAML manifest parsing deferred to C3 skill loader".into(),
272 ))
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 fn valid_manifest_json() -> String {
281 serde_json::json!({
282 "id": "com.example.test-plugin",
283 "name": "Test Plugin",
284 "version": "1.0.0",
285 "capabilities": ["tool", "skill"],
286 "permissions": {
287 "network": ["api.example.com"],
288 "filesystem": ["/tmp/plugin"],
289 "env_vars": ["MY_API_KEY"],
290 "shell": false
291 },
292 "resources": {
293 "max_fuel": 500_000_000u64,
294 "max_memory_mb": 8,
295 "max_http_requests_per_minute": 5,
296 "max_log_messages_per_minute": 50,
297 "max_execution_seconds": 15,
298 "max_table_elements": 5000
299 },
300 "wasm_module": "plugin.wasm",
301 "skills": ["code-review"],
302 "tools": ["lint_code"]
303 })
304 .to_string()
305 }
306
307 #[test]
308 fn test_manifest_parse_json() {
309 let json = valid_manifest_json();
310 let manifest = PluginManifest::from_json(&json).unwrap();
311 assert_eq!(manifest.id, "com.example.test-plugin");
312 assert_eq!(manifest.name, "Test Plugin");
313 assert_eq!(manifest.version, "1.0.0");
314 assert_eq!(manifest.capabilities.len(), 2);
315 assert_eq!(manifest.capabilities[0], PluginCapability::Tool);
316 assert_eq!(manifest.capabilities[1], PluginCapability::Skill);
317 assert_eq!(manifest.permissions.network, vec!["api.example.com"]);
318 assert_eq!(manifest.permissions.filesystem, vec!["/tmp/plugin"]);
319 assert_eq!(manifest.permissions.env_vars, vec!["MY_API_KEY"]);
320 assert!(!manifest.permissions.shell);
321 assert_eq!(manifest.resources.max_fuel, 500_000_000);
322 assert_eq!(manifest.resources.max_memory_mb, 8);
323 assert_eq!(manifest.resources.max_http_requests_per_minute, 5);
324 assert_eq!(manifest.resources.max_log_messages_per_minute, 50);
325 assert_eq!(manifest.resources.max_execution_seconds, 15);
326 assert_eq!(manifest.resources.max_table_elements, 5000);
327 assert_eq!(manifest.wasm_module, Some("plugin.wasm".into()));
328 assert_eq!(manifest.skills, vec!["code-review"]);
329 assert_eq!(manifest.tools, vec!["lint_code"]);
330 }
331
332 #[test]
333 fn test_manifest_parse_yaml_returns_not_implemented() {
334 let result = PluginManifest::from_yaml("name: test");
335 assert!(result.is_err());
336 match result.unwrap_err() {
337 PluginError::NotImplemented(msg) => {
338 assert!(msg.contains("YAML manifest parsing deferred"));
339 }
340 other => panic!("expected NotImplemented, got: {other}"),
341 }
342 }
343
344 #[test]
345 fn test_manifest_missing_id_fails() {
346 let json = serde_json::json!({
347 "id": "",
348 "name": "Test",
349 "version": "1.0.0",
350 "capabilities": ["tool"]
351 })
352 .to_string();
353 let err = PluginManifest::from_json(&json).unwrap_err();
354 let msg = err.to_string();
355 assert!(msg.contains("id is required"), "got: {msg}");
356 }
357
358 #[test]
359 fn test_manifest_invalid_version_fails() {
360 let json = serde_json::json!({
361 "id": "com.test",
362 "name": "Test",
363 "version": "not-semver",
364 "capabilities": ["tool"]
365 })
366 .to_string();
367 let err = PluginManifest::from_json(&json).unwrap_err();
368 let msg = err.to_string();
369 assert!(msg.contains("invalid semver"), "got: {msg}");
370 }
371
372 #[test]
373 fn test_manifest_empty_capabilities_fails() {
374 let json = serde_json::json!({
375 "id": "com.test",
376 "name": "Test",
377 "version": "1.0.0",
378 "capabilities": []
379 })
380 .to_string();
381 let err = PluginManifest::from_json(&json).unwrap_err();
382 let msg = err.to_string();
383 assert!(
384 msg.contains("at least one capability"),
385 "got: {msg}"
386 );
387 }
388
389 #[test]
390 fn test_manifest_missing_name_fails() {
391 let json = serde_json::json!({
392 "id": "com.test",
393 "name": "",
394 "version": "1.0.0",
395 "capabilities": ["tool"]
396 })
397 .to_string();
398 let err = PluginManifest::from_json(&json).unwrap_err();
399 let msg = err.to_string();
400 assert!(msg.contains("name is required"), "got: {msg}");
401 }
402
403 #[test]
404 fn test_plugin_capability_serde_roundtrip() {
405 let capabilities = vec![
406 PluginCapability::Tool,
407 PluginCapability::Channel,
408 PluginCapability::PipelineStage,
409 PluginCapability::Skill,
410 PluginCapability::MemoryBackend,
411 PluginCapability::Voice,
412 ];
413 for cap in &capabilities {
414 let json = serde_json::to_string(cap).unwrap();
415 let restored: PluginCapability = serde_json::from_str(&json).unwrap();
416 assert_eq!(&restored, cap);
417 }
418 }
419
420 #[test]
421 fn test_plugin_capability_json_values() {
422 assert_eq!(
423 serde_json::to_string(&PluginCapability::Tool).unwrap(),
424 "\"tool\""
425 );
426 assert_eq!(
427 serde_json::to_string(&PluginCapability::Channel).unwrap(),
428 "\"channel\""
429 );
430 assert_eq!(
431 serde_json::to_string(&PluginCapability::PipelineStage).unwrap(),
432 "\"pipeline_stage\""
433 );
434 assert_eq!(
435 serde_json::to_string(&PluginCapability::Skill).unwrap(),
436 "\"skill\""
437 );
438 assert_eq!(
439 serde_json::to_string(&PluginCapability::MemoryBackend).unwrap(),
440 "\"memory_backend\""
441 );
442 assert_eq!(
443 serde_json::to_string(&PluginCapability::Voice).unwrap(),
444 "\"voice\""
445 );
446 }
447
448 #[test]
449 fn test_permissions_default_is_empty() {
450 let perms = PluginPermissions::default();
451 assert!(perms.network.is_empty());
452 assert!(perms.filesystem.is_empty());
453 assert!(perms.env_vars.is_empty());
454 assert!(!perms.shell);
455 }
456
457 #[test]
458 fn test_resource_config_defaults() {
459 let config = PluginResourceConfig::default();
460 assert_eq!(config.max_fuel, 1_000_000_000);
461 assert_eq!(config.max_memory_mb, 16);
462 assert_eq!(config.max_http_requests_per_minute, 10);
463 assert_eq!(config.max_log_messages_per_minute, 100);
464 assert_eq!(config.max_execution_seconds, 30);
465 assert_eq!(config.max_table_elements, 10_000);
466 }
467
468 #[test]
469 fn test_manifest_with_defaults() {
470 let json = serde_json::json!({
471 "id": "com.test.minimal",
472 "name": "Minimal",
473 "version": "0.1.0",
474 "capabilities": ["tool"]
475 })
476 .to_string();
477 let manifest = PluginManifest::from_json(&json).unwrap();
478 assert!(manifest.permissions.network.is_empty());
480 assert!(!manifest.permissions.shell);
481 assert_eq!(manifest.resources.max_fuel, 1_000_000_000);
483 assert_eq!(manifest.resources.max_memory_mb, 16);
484 assert!(manifest.wasm_module.is_none());
486 assert!(manifest.skills.is_empty());
487 assert!(manifest.tools.is_empty());
488 }
489
490 #[test]
491 fn test_manifest_serde_roundtrip() {
492 let json = valid_manifest_json();
493 let manifest = PluginManifest::from_json(&json).unwrap();
494 let serialized = serde_json::to_string(&manifest).unwrap();
495 let restored = PluginManifest::from_json(&serialized).unwrap();
496 assert_eq!(manifest.id, restored.id);
497 assert_eq!(manifest.name, restored.name);
498 assert_eq!(manifest.version, restored.version);
499 assert_eq!(manifest.capabilities, restored.capabilities);
500 }
501
502 #[test]
503 fn test_permissions_serde_roundtrip() {
504 let perms = PluginPermissions {
505 network: vec!["*.example.com".into(), "api.test.com".into()],
506 filesystem: vec!["/tmp".into(), "/data".into()],
507 env_vars: vec!["MY_KEY".into()],
508 shell: true,
509 };
510 let json = serde_json::to_string(&perms).unwrap();
511 let restored: PluginPermissions = serde_json::from_str(&json).unwrap();
512 assert_eq!(restored.network, perms.network);
513 assert_eq!(restored.filesystem, perms.filesystem);
514 assert_eq!(restored.env_vars, perms.env_vars);
515 assert_eq!(restored.shell, perms.shell);
516 }
517
518 #[test]
521 fn diff_identical_permissions_is_empty() {
522 let perms = PluginPermissions {
523 network: vec!["api.example.com".into()],
524 filesystem: vec!["/tmp".into()],
525 env_vars: vec!["HOME".into()],
526 shell: true,
527 };
528 let diff = PluginPermissions::diff(&perms, &perms);
529 assert!(diff.is_empty());
530 assert_eq!(diff, PermissionDiff::default());
531 }
532
533 #[test]
534 fn diff_detects_new_network_hosts() {
535 let approved = PluginPermissions {
536 network: vec!["api.example.com".into()],
537 ..Default::default()
538 };
539 let requested = PluginPermissions {
540 network: vec!["api.example.com".into(), "cdn.example.com".into()],
541 ..Default::default()
542 };
543 let diff = PluginPermissions::diff(&approved, &requested);
544 assert_eq!(diff.new_network, vec!["cdn.example.com"]);
545 assert!(diff.new_filesystem.is_empty());
546 assert!(diff.new_env_vars.is_empty());
547 assert!(!diff.shell_escalation);
548 assert!(!diff.is_empty());
549 }
550
551 #[test]
552 fn diff_detects_new_filesystem_paths() {
553 let approved = PluginPermissions {
554 filesystem: vec!["/tmp".into()],
555 ..Default::default()
556 };
557 let requested = PluginPermissions {
558 filesystem: vec!["/tmp".into(), "/data".into()],
559 ..Default::default()
560 };
561 let diff = PluginPermissions::diff(&approved, &requested);
562 assert_eq!(diff.new_filesystem, vec!["/data"]);
563 }
564
565 #[test]
566 fn diff_detects_new_env_vars() {
567 let approved = PluginPermissions {
568 env_vars: vec!["HOME".into()],
569 ..Default::default()
570 };
571 let requested = PluginPermissions {
572 env_vars: vec!["HOME".into(), "API_KEY".into()],
573 ..Default::default()
574 };
575 let diff = PluginPermissions::diff(&approved, &requested);
576 assert_eq!(diff.new_env_vars, vec!["API_KEY"]);
577 }
578
579 #[test]
580 fn diff_detects_shell_escalation() {
581 let approved = PluginPermissions {
582 shell: false,
583 ..Default::default()
584 };
585 let requested = PluginPermissions {
586 shell: true,
587 ..Default::default()
588 };
589 let diff = PluginPermissions::diff(&approved, &requested);
590 assert!(diff.shell_escalation);
591 assert!(!diff.is_empty());
592 }
593
594 #[test]
595 fn diff_no_shell_escalation_when_already_approved() {
596 let approved = PluginPermissions {
597 shell: true,
598 ..Default::default()
599 };
600 let requested = PluginPermissions {
601 shell: true,
602 ..Default::default()
603 };
604 let diff = PluginPermissions::diff(&approved, &requested);
605 assert!(!diff.shell_escalation);
606 }
607
608 #[test]
609 fn diff_no_shell_escalation_on_downgrade() {
610 let approved = PluginPermissions {
611 shell: true,
612 ..Default::default()
613 };
614 let requested = PluginPermissions {
615 shell: false,
616 ..Default::default()
617 };
618 let diff = PluginPermissions::diff(&approved, &requested);
619 assert!(!diff.shell_escalation);
620 }
621
622 #[test]
623 fn diff_empty_approved_all_requested_are_new() {
624 let approved = PluginPermissions::default();
625 let requested = PluginPermissions {
626 network: vec!["a.com".into(), "b.com".into()],
627 filesystem: vec!["/data".into()],
628 env_vars: vec!["KEY".into()],
629 shell: true,
630 };
631 let diff = PluginPermissions::diff(&approved, &requested);
632 assert_eq!(diff.new_network, vec!["a.com", "b.com"]);
633 assert_eq!(diff.new_filesystem, vec!["/data"]);
634 assert_eq!(diff.new_env_vars, vec!["KEY"]);
635 assert!(diff.shell_escalation);
636 }
637
638 #[test]
639 fn diff_removed_permissions_not_reported() {
640 let approved = PluginPermissions {
643 network: vec!["old.example.com".into(), "keep.example.com".into()],
644 ..Default::default()
645 };
646 let requested = PluginPermissions {
647 network: vec!["keep.example.com".into()],
648 ..Default::default()
649 };
650 let diff = PluginPermissions::diff(&approved, &requested);
651 assert!(diff.is_empty());
652 }
653
654 #[test]
655 fn diff_wildcard_network_is_treated_as_new_entry() {
656 let approved = PluginPermissions {
660 network: vec!["api.example.com".into()],
661 ..Default::default()
662 };
663 let requested = PluginPermissions {
664 network: vec!["api.example.com".into(), "*".into()],
665 ..Default::default()
666 };
667 let diff = PluginPermissions::diff(&approved, &requested);
668 assert_eq!(diff.new_network, vec!["*"]);
669 }
670
671 #[test]
672 fn permission_diff_is_empty_default() {
673 let diff = PermissionDiff::default();
674 assert!(diff.is_empty());
675 }
676}