1use std::collections::HashMap;
55use std::path::{Path, PathBuf};
56
57use serde::{Deserialize, Serialize};
58
59use crate::error::{PluginError, PluginResult};
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Platform {
69 Macos,
70 Windows,
71 Linux,
72}
73
74impl Platform {
75 pub fn current() -> Option<Platform> {
79 Self::parse(std::env::consts::OS)
80 }
81
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Platform::Macos => "macos",
85 Platform::Windows => "windows",
86 Platform::Linux => "linux",
87 }
88 }
89
90 pub fn parse(value: &str) -> Option<Platform> {
93 match value {
94 "macos" => Some(Platform::Macos),
95 "windows" => Some(Platform::Windows),
96 "linux" => Some(Platform::Linux),
97 _ => None,
98 }
99 }
100}
101
102impl std::fmt::Display for Platform {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(self.as_str())
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct McpServerManifestEntry {
114 pub id: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub name: Option<String>,
118 #[serde(default = "default_true")]
119 pub enabled: bool,
120 pub transport: McpTransportManifest,
121 #[serde(default)]
122 pub allowed_tools: Vec<String>,
123 #[serde(default)]
124 pub denied_tools: Vec<String>,
125}
126
127fn default_true() -> bool {
128 true
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(tag = "type", rename_all = "snake_case")]
138pub enum McpTransportManifest {
139 Stdio {
140 command: String,
142 #[serde(default)]
143 args: Vec<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 cwd: Option<String>,
147 #[serde(default)]
149 env: HashMap<String, String>,
150 },
151 Sse {
152 url: String,
153 #[serde(default)]
154 headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
155 },
156 #[serde(rename = "streamable_http")]
157 StreamableHttp {
158 url: String,
159 #[serde(default)]
160 headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
161 },
162}
163
164impl McpServerManifestEntry {
165 pub fn resolve(
173 &self,
174 plugin_dir: &Path,
175 plugin_id: &str,
176 platform: Platform,
177 ) -> PluginResult<bamboo_domain::mcp_config::McpServerConfig> {
178 use bamboo_domain::mcp_config::{
179 default_connect_timeout, default_healthcheck_interval, default_request_timeout,
180 default_startup_timeout, McpServerConfig, ReconnectConfig, SseConfig, StdioConfig,
181 StreamableHttpConfig, TransportConfig,
182 };
183
184 let transport = match &self.transport {
185 McpTransportManifest::Stdio {
186 command,
187 args,
188 cwd,
189 env,
190 } => {
191 if command.trim().is_empty() {
192 return Err(PluginError::InvalidManifest(format!(
193 "mcp server '{}' has an empty stdio command",
194 self.id
195 )));
196 }
197 TransportConfig::Stdio(StdioConfig {
198 command: substitute_tokens(command, plugin_dir, plugin_id, platform),
199 args: args
200 .iter()
201 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
202 .collect(),
203 cwd: cwd
204 .as_deref()
205 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform)),
206 env: env
207 .iter()
208 .map(|(key, value)| {
209 (
210 key.clone(),
211 substitute_tokens(value, plugin_dir, plugin_id, platform),
212 )
213 })
214 .collect(),
215 env_encrypted: HashMap::new(),
216 startup_timeout_ms: default_startup_timeout(),
217 })
218 }
219 McpTransportManifest::Sse { url, headers } => TransportConfig::Sse(SseConfig {
220 url: url.clone(),
221 headers: headers.clone(),
222 connect_timeout_ms: default_connect_timeout(),
223 }),
224 McpTransportManifest::StreamableHttp { url, headers } => {
225 TransportConfig::StreamableHttp(StreamableHttpConfig {
226 url: url.clone(),
227 headers: headers.clone(),
228 connect_timeout_ms: default_connect_timeout(),
229 })
230 }
231 };
232
233 Ok(McpServerConfig {
234 id: self.id.clone(),
235 name: self.name.clone(),
236 enabled: self.enabled,
237 transport,
238 request_timeout_ms: default_request_timeout(),
239 healthcheck_interval_ms: default_healthcheck_interval(),
240 reconnect: ReconnectConfig::default(),
241 allowed_tools: self.allowed_tools.clone(),
242 denied_tools: self.denied_tools.clone(),
243 })
244 }
245}
246
247pub fn substitute_tokens(
252 template: &str,
253 plugin_dir: &Path,
254 plugin_id: &str,
255 platform: Platform,
256) -> String {
257 let plugin_dir_str = plugin_dir.to_string_lossy();
258 let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
259 .to_string_lossy()
260 .into_owned();
261 template
262 .replace("${plugin_dir}", plugin_dir_str.as_ref())
263 .replace("${platform_bin}", &platform_bin_str)
264}
265
266pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
269 let filename = if matches!(platform, Platform::Windows) {
270 format!("{plugin_id}.exe")
271 } else {
272 plugin_id.to_string()
273 };
274 plugin_dir
275 .join("bin")
276 .join(platform.as_str())
277 .join(filename)
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PluginPromptPreset {
287 pub id: String,
288 pub name: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub description: Option<String>,
291 pub content: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct PluginArtifact {
315 pub url: String,
317 pub sha256: String,
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
325pub struct PluginProvides {
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
327 pub mcp_servers: Vec<McpServerManifestEntry>,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub skills: Vec<String>,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub prompts: Vec<PluginPromptPreset>,
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub workflows: Vec<String>,
342}
343
344impl PluginProvides {
345 pub fn is_empty(&self) -> bool {
346 self.mcp_servers.is_empty()
347 && self.skills.is_empty()
348 && self.prompts.is_empty()
349 && self.workflows.is_empty()
350 }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct PluginManifest {
356 pub id: String,
359 pub name: String,
360 pub version: String,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub description: Option<String>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub bamboo_min_version: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub platforms: Option<Vec<Platform>>,
375 #[serde(default)]
376 pub provides: PluginProvides,
377 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
383 pub artifacts: HashMap<String, PluginArtifact>,
384}
385
386const MAX_PLUGIN_ID_LEN: usize = 64;
387const MAX_PRESET_ID_LEN: usize = 80;
388
389const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
397
398pub fn is_valid_plugin_id(id: &str) -> bool {
403 !id.is_empty()
404 && id.len() <= MAX_PLUGIN_ID_LEN
405 && id
406 .chars()
407 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
408}
409
410pub fn is_valid_preset_id(id: &str) -> bool {
416 !id.is_empty()
417 && id.len() <= MAX_PRESET_ID_LEN
418 && !RESERVED_PRESET_IDS.contains(&id)
419 && id
420 .chars()
421 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
422}
423
424pub fn is_plausible_semver(value: &str) -> bool {
430 let core = value.split(['-', '+']).next().unwrap_or_default();
431 let parts: Vec<&str> = core.split('.').collect();
432 parts.len() == 3
433 && parts
434 .iter()
435 .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
436}
437
438fn is_safe_relative_name(name: &str) -> bool {
442 !name.is_empty()
443 && !name.contains('/')
444 && !name.contains('\\')
445 && !name.contains("..")
446 && !name.chars().any(|ch| ch.is_control())
447}
448
449impl PluginManifest {
450 pub fn parse_str(content: &str) -> PluginResult<Self> {
455 serde_json::from_str(content).map_err(PluginError::from)
456 }
457
458 pub fn validate(&self) -> PluginResult<()> {
462 if !is_valid_plugin_id(&self.id) {
463 return Err(PluginError::InvalidManifest(format!(
464 "invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
465 self.id, MAX_PLUGIN_ID_LEN
466 )));
467 }
468 if self.name.trim().is_empty() {
469 return Err(PluginError::InvalidManifest(
470 "plugin name must not be empty".to_string(),
471 ));
472 }
473 if !is_plausible_semver(&self.version) {
474 return Err(PluginError::InvalidManifest(format!(
475 "invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
476 self.version
477 )));
478 }
479 if let Some(min_version) = &self.bamboo_min_version {
480 if !is_plausible_semver(min_version) {
481 return Err(PluginError::InvalidManifest(format!(
482 "invalid bamboo_min_version '{min_version}'"
483 )));
484 }
485 }
486 if let Some(platforms) = &self.platforms {
487 if platforms.is_empty() {
488 return Err(PluginError::InvalidManifest(
489 "platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
490 .to_string(),
491 ));
492 }
493 }
494
495 let mut seen_mcp_ids = std::collections::HashSet::new();
496 for entry in &self.provides.mcp_servers {
497 if entry.id.trim().is_empty() {
498 return Err(PluginError::InvalidManifest(
499 "mcp server entries must have a non-empty id".to_string(),
500 ));
501 }
502 if !seen_mcp_ids.insert(entry.id.clone()) {
503 return Err(PluginError::InvalidManifest(format!(
504 "duplicate mcp server id '{}' in provides.mcp_servers",
505 entry.id
506 )));
507 }
508 if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
509 if command.trim().is_empty() {
510 return Err(PluginError::InvalidManifest(format!(
511 "mcp server '{}' has an empty stdio command",
512 entry.id
513 )));
514 }
515 }
516 }
517
518 for skill_dir in &self.provides.skills {
519 if !is_safe_relative_name(skill_dir) {
520 return Err(PluginError::InvalidManifest(format!(
521 "invalid skill directory name '{skill_dir}' in provides.skills"
522 )));
523 }
524 }
525
526 let mut seen_preset_ids = std::collections::HashSet::new();
527 for preset in &self.provides.prompts {
528 if !is_valid_preset_id(&preset.id) {
529 return Err(PluginError::InvalidManifest(format!(
530 "invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
531 preset.id, MAX_PRESET_ID_LEN
532 )));
533 }
534 if !seen_preset_ids.insert(preset.id.clone()) {
535 return Err(PluginError::InvalidManifest(format!(
536 "duplicate prompt preset id '{}' in provides.prompts",
537 preset.id
538 )));
539 }
540 if preset.name.trim().is_empty() {
541 return Err(PluginError::InvalidManifest(format!(
542 "prompt preset '{}' has an empty name",
543 preset.id
544 )));
545 }
546 if preset.content.trim().is_empty() {
547 return Err(PluginError::InvalidManifest(format!(
548 "prompt preset '{}' has empty content",
549 preset.id
550 )));
551 }
552 }
553
554 for workflow_file in &self.provides.workflows {
555 if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
556 return Err(PluginError::InvalidManifest(format!(
557 "invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
558 )));
559 }
560 }
561
562 for (platform_key, artifact) in &self.artifacts {
563 let Some(artifact_platform) = Platform::parse(platform_key) else {
564 return Err(PluginError::InvalidManifest(format!(
565 "unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
566 )));
567 };
568 if let Some(gate) = &self.platforms {
572 if !gate.contains(&artifact_platform) {
573 return Err(PluginError::InvalidManifest(format!(
574 "artifacts contains platform '{platform_key}' which is not in the \
575 `platforms` gate {:?}",
576 gate.iter()
577 .map(|platform| platform.as_str())
578 .collect::<Vec<_>>()
579 )));
580 }
581 }
582 if artifact.url.trim().is_empty() {
583 return Err(PluginError::InvalidManifest(format!(
584 "artifact for platform '{platform_key}' has an empty url"
585 )));
586 }
587 let sha_is_hex64 = artifact.sha256.len() == 64
588 && artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
589 if !sha_is_hex64 {
590 return Err(PluginError::InvalidManifest(format!(
591 "artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
592 )));
593 }
594 }
595
596 if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
605 for platform in self.effective_platforms() {
606 if !self.artifacts.contains_key(platform.as_str()) {
607 return Err(PluginError::InvalidManifest(format!(
608 "plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
609 artifact for supported platform '{}' (every supported platform needs a \
610 downloadable binary bundle)",
611 platform.as_str()
612 )));
613 }
614 }
615 }
616
617 Ok(())
618 }
619
620 pub fn supports_platform(&self, platform: Platform) -> bool {
623 match &self.platforms {
624 None => true,
625 Some(platforms) => platforms.contains(&platform),
626 }
627 }
628
629 pub fn effective_platforms(&self) -> Vec<Platform> {
633 self.platforms
634 .clone()
635 .unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
636 }
637
638 pub fn uses_platform_bin_token(&self) -> bool {
643 const TOKEN: &str = "${platform_bin}";
644 self.provides.mcp_servers.iter().any(|entry| {
645 let McpTransportManifest::Stdio {
646 command,
647 args,
648 cwd,
649 env,
650 } = &entry.transport
651 else {
652 return false;
653 };
654 command.contains(TOKEN)
655 || args.iter().any(|value| value.contains(TOKEN))
656 || cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
657 || env.values().any(|value| value.contains(TOKEN))
658 })
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 fn minimal_manifest_json() -> &'static str {
667 r#"{
668 "id": "hello-plugin",
669 "name": "Hello Plugin",
670 "version": "0.1.0",
671 "provides": {
672 "skills": ["hello-world"],
673 "prompts": [
674 {"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
675 ]
676 }
677 }"#
678 }
679
680 #[test]
681 fn parses_minimal_manifest() {
682 let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
683 assert_eq!(manifest.id, "hello-plugin");
684 assert_eq!(manifest.version, "0.1.0");
685 assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
686 assert_eq!(manifest.provides.prompts.len(), 1);
687 assert!(manifest.provides.mcp_servers.is_empty());
688 assert!(manifest.artifacts.is_empty());
689 manifest.validate().expect("minimal manifest is valid");
690 }
691
692 #[test]
693 fn parses_full_manifest_with_mcp_and_artifacts() {
694 let json = r#"{
695 "id": "nova_plugin",
696 "name": "Nova",
697 "version": "1.2.3-beta+build.7",
698 "description": "Desktop control MCP server",
699 "bamboo_min_version": "2026.7.0",
700 "platforms": ["macos", "windows", "linux"],
701 "provides": {
702 "mcp_servers": [
703 {
704 "id": "nova",
705 "enabled": true,
706 "transport": {
707 "type": "stdio",
708 "command": "${platform_bin}",
709 "args": ["--serve"],
710 "cwd": "${plugin_dir}",
711 "env": {"NOVA_HOME": "${plugin_dir}/data"}
712 }
713 }
714 ],
715 "workflows": ["daily-report.md"]
716 },
717 "artifacts": {
718 "macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
719 "windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
720 "linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
721 }
722 }"#;
723
724 let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
725 manifest.validate().expect("full manifest is valid");
726 assert!(manifest.supports_platform(Platform::Macos));
727 assert!(manifest.supports_platform(Platform::Windows));
728 assert!(manifest.supports_platform(Platform::Linux));
729
730 let entry = &manifest.provides.mcp_servers[0];
731 let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
732 let resolved = entry
733 .resolve(plugin_dir, &manifest.id, Platform::Macos)
734 .expect("resolve mcp entry");
735 match resolved.transport {
736 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
737 assert_eq!(
738 stdio.command,
739 "/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
740 );
741 assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
742 assert_eq!(
743 stdio.env.get("NOVA_HOME").map(String::as_str),
744 Some("/home/user/.bamboo/plugins/nova_plugin/data")
745 );
746 }
747 _ => panic!("expected stdio transport"),
748 }
749 }
750
751 #[test]
752 fn platform_bin_path_appends_exe_on_windows_only() {
753 let dir = Path::new("/plugins/demo");
754 assert_eq!(
755 platform_bin_path(dir, "demo", Platform::Macos),
756 PathBuf::from("/plugins/demo/bin/macos/demo")
757 );
758 assert_eq!(
759 platform_bin_path(dir, "demo", Platform::Windows),
760 PathBuf::from("/plugins/demo/bin/windows/demo.exe")
761 );
762 assert_eq!(
763 platform_bin_path(dir, "demo", Platform::Linux),
764 PathBuf::from("/plugins/demo/bin/linux/demo")
765 );
766 }
767
768 #[test]
769 fn rejects_invalid_id() {
770 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
771 manifest.id = "Bad Id!".to_string();
772 let error = manifest.validate().expect_err("bad id should fail");
773 assert!(error.to_string().contains("invalid plugin id"));
774 }
775
776 #[test]
777 fn rejects_bad_semver() {
778 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
779 manifest.version = "latest".to_string();
780 let error = manifest.validate().expect_err("bad version should fail");
781 assert!(error.to_string().contains("invalid plugin version"));
782 }
783
784 #[test]
785 fn rejects_empty_platforms_list() {
786 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
787 manifest.platforms = Some(vec![]);
788 let error = manifest
789 .validate()
790 .expect_err("empty platforms should fail");
791 assert!(error.to_string().contains("platforms"));
792 }
793
794 #[test]
795 fn rejects_duplicate_mcp_server_ids() {
796 let json = r#"{
797 "id": "dup",
798 "name": "Dup",
799 "version": "1.0.0",
800 "provides": {
801 "mcp_servers": [
802 {"id": "a", "transport": {"type": "stdio", "command": "x"}},
803 {"id": "a", "transport": {"type": "stdio", "command": "y"}}
804 ]
805 }
806 }"#;
807 let manifest = PluginManifest::parse_str(json).unwrap();
808 let error = manifest
809 .validate()
810 .expect_err("duplicate mcp id should fail");
811 assert!(error.to_string().contains("duplicate mcp server id"));
812 }
813
814 #[test]
815 fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
816 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
817 manifest.provides.skills = vec!["../escape".to_string()];
818 assert!(manifest.validate().is_err());
819
820 let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
821 manifest2.provides.skills = vec![];
822 manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
823 assert!(manifest2.validate().is_err());
824 }
825
826 #[test]
827 fn rejects_invalid_artifact_sha256() {
828 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
829 manifest.artifacts.insert(
830 "macos".to_string(),
831 PluginArtifact {
832 url: "https://example.com/x.tar.gz".to_string(),
833 sha256: "not-hex".to_string(),
834 },
835 );
836 let error = manifest.validate().expect_err("bad sha256 should fail");
837 assert!(error.to_string().contains("sha256"));
838 }
839
840 #[test]
841 fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
842 let json = r#"{
846 "id": "binbacked",
847 "name": "Bin Backed",
848 "version": "1.0.0",
849 "provides": {
850 "mcp_servers": [
851 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
852 ]
853 },
854 "artifacts": {
855 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
856 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
857 }
858 }"#;
859 let manifest = PluginManifest::parse_str(json).unwrap();
860 let error = manifest
861 .validate()
862 .expect_err("missing linux artifact should fail");
863 assert!(error.to_string().contains("linux"));
864 }
865
866 #[test]
867 fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
868 let json = r#"{
871 "id": "binbacked",
872 "name": "Bin Backed",
873 "version": "1.0.0",
874 "platforms": ["macos", "windows"],
875 "provides": {
876 "mcp_servers": [
877 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
878 ]
879 },
880 "artifacts": {
881 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
882 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
883 }
884 }"#;
885 let manifest = PluginManifest::parse_str(json).unwrap();
886 manifest
887 .validate()
888 .expect("gate-narrowed binary plugin is valid");
889 assert!(manifest.uses_platform_bin_token());
890 }
891
892 #[test]
893 fn rejects_artifact_for_platform_outside_the_gate() {
894 let json = r#"{
895 "id": "gated",
896 "name": "Gated",
897 "version": "1.0.0",
898 "platforms": ["macos"],
899 "artifacts": {
900 "linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
901 }
902 }"#;
903 let manifest = PluginManifest::parse_str(json).unwrap();
904 let error = manifest
905 .validate()
906 .expect_err("artifact outside gate should fail");
907 assert!(error.to_string().contains("not in the `platforms` gate"));
908 }
909
910 #[test]
911 fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
912 let json = r#"{
915 "id": "localbin",
916 "name": "Local Bin",
917 "version": "1.0.0",
918 "provides": {
919 "mcp_servers": [
920 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
921 ]
922 }
923 }"#;
924 let manifest = PluginManifest::parse_str(json).unwrap();
925 manifest
926 .validate()
927 .expect("local binary plugin without artifacts is valid");
928 }
929
930 #[test]
931 fn rejects_reserved_preset_id() {
932 let json = r#"{
933 "id": "reserver",
934 "name": "Reserver",
935 "version": "1.0.0",
936 "provides": {
937 "prompts": [
938 {"id": "general_assistant", "name": "Nope", "content": "x"}
939 ]
940 }
941 }"#;
942 let manifest = PluginManifest::parse_str(json).unwrap();
943 let error = manifest
944 .validate()
945 .expect_err("reserved preset id should fail");
946 assert!(error.to_string().contains("prompt preset id"));
947 assert!(!is_valid_preset_id("general_assistant"));
948 }
949
950 #[test]
951 fn rejects_unknown_artifact_platform_key() {
952 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
953 manifest.artifacts.insert(
954 "solaris".to_string(),
955 PluginArtifact {
956 url: "https://example.com/x.tar.gz".to_string(),
957 sha256: "a".repeat(64),
958 },
959 );
960 let error = manifest
961 .validate()
962 .expect_err("unknown platform key should fail");
963 assert!(error.to_string().contains("unknown platform key"));
964 }
965
966 #[test]
967 fn semver_shape_check() {
968 assert!(is_plausible_semver("1.2.3"));
969 assert!(is_plausible_semver("1.2.3-beta.1"));
970 assert!(is_plausible_semver("1.2.3+build.7"));
971 assert!(is_plausible_semver("1.2.3-beta+build"));
972 assert!(!is_plausible_semver("1.2"));
973 assert!(!is_plausible_semver("latest"));
974 assert!(!is_plausible_semver(""));
975 assert!(!is_plausible_semver("v1.2.3"));
976 }
977
978 #[test]
979 fn plugin_id_rules() {
980 assert!(is_valid_plugin_id("hello-plugin"));
981 assert!(is_valid_plugin_id("nova_plugin_2"));
982 assert!(!is_valid_plugin_id(""));
983 assert!(!is_valid_plugin_id("Hello"));
984 assert!(!is_valid_plugin_id("hello plugin"));
985 assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
986 }
987}