Skip to main content

talos_plugin/
manifest.rs

1//! Plugin manifest parser (T45, ADR-027/029).
2//!
3//! Parses and validates plugin package manifests without instantiating any
4//! executable artifact. A manifest declares the plugin identity, carrier,
5//! artifact path, and optional atomic components (skills, tools).
6
7use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::event::ALL_HOOK_EVENT_KINDS;
13
14#[derive(Debug, Error)]
15pub enum ManifestError {
16    #[error("manifest parse error: {0}")]
17    Parse(#[from] toml::de::Error),
18    #[error("manifest validation failed: {0}")]
19    Validation(String),
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PluginManifest {
24    pub plugin: PluginMetadata,
25    #[serde(default)]
26    pub skills: Vec<PluginSkill>,
27    #[serde(default)]
28    pub tools: Vec<PluginTool>,
29    #[serde(default)]
30    pub hooks: Vec<PluginHook>,
31    /// Optional language-provider declaration; loading remains explicit and bounded.
32    #[serde(default)]
33    pub language_provider: Option<LanguageProviderDeclaration>,
34}
35
36/// Manifest declaration for a language provider sharing the plugin artifact boundary.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct LanguageProviderDeclaration {
40    /// Stable language identifier, for example `rust`.
41    pub language: String,
42    /// Exported provider module path relative to the package root.
43    pub artifact: String,
44}
45
46/// Versioned Bundle manifest accepted alongside the legacy Plugin shape.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct BundleManifest {
50    pub schema_version: u32,
51    pub bundle: BundleMetadata,
52    #[serde(default)]
53    pub skills: Vec<PluginSkill>,
54    #[serde(default)]
55    pub tools: Vec<PluginTool>,
56    #[serde(default)]
57    pub hooks: Vec<PluginHook>,
58    /// Optional language provider declared by this bundle; installation never activates it.
59    #[serde(default)]
60    pub language_provider: Option<LanguageProviderDeclaration>,
61}
62
63/// Stable identity and artifact metadata for a Bundle.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct BundleMetadata {
67    pub name: String,
68    pub version: String,
69    pub carrier: String,
70    pub artifact: String,
71    #[serde(default)]
72    pub digest: Option<String>,
73    #[serde(default)]
74    pub description: Option<String>,
75    #[serde(default)]
76    pub talos_protocol: Option<String>,
77}
78
79/// A manifest parsed through the compatibility boundary.
80#[derive(Debug, Clone)]
81pub enum CompatibleManifest {
82    Legacy(PluginManifest),
83    Bundle(BundleManifest),
84}
85
86/// Explicit opt-in required before a legacy manifest can be rewritten.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct MigrationOptions {
89    /// Target schema version. Only version 1 is currently supported.
90    pub schema_version: u32,
91    /// Explicitly authorizes controlled-write migration.
92    pub allow_write: bool,
93}
94
95/// Convert a legacy manifest to the versioned Bundle representation.
96///
97/// The caller must opt in explicitly; parsing alone never writes or mutates input.
98pub fn migrate_legacy_manifest(
99    input: &str,
100    options: MigrationOptions,
101) -> Result<String, ManifestError> {
102    if !options.allow_write {
103        return Err(ManifestError::Validation(
104            "migration write requires explicit opt-in".into(),
105        ));
106    }
107    if options.schema_version != 1 {
108        return Err(ManifestError::Validation(
109            "unsupported migration schema version".into(),
110        ));
111    }
112    let value: toml::Value = toml::from_str(input)?;
113    let allowed = ["plugin", "skills", "tools", "hooks"];
114    if let Some(unknown) = value
115        .as_table()
116        .and_then(|table| table.keys().find(|key| !allowed.contains(&key.as_str())))
117    {
118        return Err(ManifestError::Validation(format!(
119            "unknown legacy manifest field '{unknown}'"
120        )));
121    }
122    if let Some(plugin) = value.get("plugin").and_then(toml::Value::as_table) {
123        let allowed_plugin = [
124            "name",
125            "version",
126            "carrier",
127            "artifact",
128            "description",
129            "talos_protocol",
130        ];
131        if let Some(unknown) = plugin
132            .keys()
133            .find(|key| !allowed_plugin.contains(&key.as_str()))
134        {
135            return Err(ManifestError::Validation(format!(
136                "unknown legacy plugin field '{unknown}'"
137            )));
138        }
139    }
140    let legacy = parse_manifest(input)?;
141    let bundle = BundleManifest {
142        schema_version: 1,
143        bundle: BundleMetadata {
144            name: legacy.plugin.name,
145            version: legacy.plugin.version,
146            carrier: legacy.plugin.carrier,
147            artifact: legacy.plugin.artifact,
148            digest: None,
149            description: legacy.plugin.description,
150            talos_protocol: legacy.plugin.talos_protocol,
151        },
152        skills: legacy.skills,
153        tools: legacy.tools,
154        hooks: legacy.hooks,
155        language_provider: legacy.language_provider,
156    };
157    bundle.validate()?;
158    toml::to_string_pretty(&bundle)
159        .map_err(|error| ManifestError::Validation(format!("bundle serialization failed: {error}")))
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PluginMetadata {
164    pub name: String,
165    pub version: String,
166    pub carrier: String,
167    pub artifact: String,
168    #[serde(default)]
169    pub description: Option<String>,
170    #[serde(default)]
171    pub talos_protocol: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct PluginSkill {
177    pub name: String,
178    pub path: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct PluginTool {
184    pub name: String,
185    pub handler: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct PluginHook {
191    pub name: String,
192    pub event: String,
193    pub handler: String,
194    #[serde(default)]
195    pub priority: Option<i32>,
196}
197
198pub fn parse_manifest(toml_str: &str) -> Result<PluginManifest, ManifestError> {
199    let manifest: PluginManifest = toml::from_str(toml_str)?;
200    manifest.validate()?;
201    Ok(manifest)
202}
203
204/// Parse either the legacy Plugin manifest or an explicitly versioned Bundle manifest.
205pub fn parse_compatible_manifest(toml_str: &str) -> Result<CompatibleManifest, ManifestError> {
206    let value: toml::Value = toml::from_str(toml_str)?;
207    let has_plugin = value.get("plugin").is_some();
208    let has_bundle = value.get("bundle").is_some();
209    if has_plugin == has_bundle {
210        return Err(ManifestError::Validation(
211            "manifest must contain exactly one of [plugin] or [bundle]".into(),
212        ));
213    }
214    if has_bundle {
215        let allowed = [
216            "schema_version",
217            "bundle",
218            "skills",
219            "tools",
220            "hooks",
221            "language_provider",
222        ];
223        if let Some(unknown) = value
224            .as_table()
225            .and_then(|table| table.keys().find(|key| !allowed.contains(&key.as_str())))
226        {
227            return Err(ManifestError::Validation(format!(
228                "unknown bundle manifest field '{unknown}'"
229            )));
230        }
231        let manifest: BundleManifest = value
232            .try_into()
233            .map_err(|error| ManifestError::Validation(format!("bundle manifest: {error}")))?;
234        manifest.validate()?;
235        Ok(CompatibleManifest::Bundle(manifest))
236    } else {
237        let allowed = ["plugin", "skills", "tools", "hooks"];
238        if let Some(unknown) = value
239            .as_table()
240            .and_then(|table| table.keys().find(|key| !allowed.contains(&key.as_str())))
241        {
242            return Err(ManifestError::Validation(format!(
243                "unknown legacy manifest field '{unknown}'"
244            )));
245        }
246        if let Some(plugin) = value.get("plugin").and_then(toml::Value::as_table) {
247            let allowed_plugin = [
248                "name",
249                "version",
250                "carrier",
251                "artifact",
252                "description",
253                "talos_protocol",
254            ];
255            if let Some(unknown) = plugin
256                .keys()
257                .find(|key| !allowed_plugin.contains(&key.as_str()))
258            {
259                return Err(ManifestError::Validation(format!(
260                    "unknown legacy plugin field '{unknown}'"
261                )));
262            }
263        }
264        Ok(CompatibleManifest::Legacy(parse_manifest(toml_str)?))
265    }
266}
267
268impl PluginManifest {
269    pub fn validate(&self) -> Result<(), ManifestError> {
270        let p = &self.plugin;
271        if p.name.trim().is_empty() {
272            return Err(ManifestError::Validation("plugin.name is empty".into()));
273        }
274        if p.version.trim().is_empty() {
275            return Err(ManifestError::Validation("plugin.version is empty".into()));
276        }
277        if p.artifact.trim().is_empty() {
278            return Err(ManifestError::Validation("plugin.artifact is empty".into()));
279        }
280        if p.carrier != "wasm" {
281            return Err(ManifestError::Validation(format!(
282                "plugin.carrier must be 'wasm' (got '{}'); other carriers are not yet supported",
283                p.carrier
284            )));
285        }
286        if let Some(provider) = &self.language_provider {
287            if provider.language.trim().is_empty() {
288                return Err(ManifestError::Validation(
289                    "language_provider.language is empty".into(),
290                ));
291            }
292            if provider.artifact.trim().is_empty() {
293                return Err(ManifestError::Validation(
294                    "language_provider.artifact is empty".into(),
295                ));
296            }
297        }
298        let mut seen_tools: HashSet<&str> = HashSet::new();
299        for tool in &self.tools {
300            if tool.name.trim().is_empty() {
301                return Err(ManifestError::Validation(
302                    "tool name is empty in [[tools]]".into(),
303                ));
304            }
305            if tool.handler.trim().is_empty() {
306                return Err(ManifestError::Validation(format!(
307                    "tool '{}' has empty handler",
308                    tool.name
309                )));
310            }
311            if !seen_tools.insert(&tool.name) {
312                return Err(ManifestError::Validation(format!(
313                    "duplicate tool name '{}'",
314                    tool.name
315                )));
316            }
317        }
318        for skill in &self.skills {
319            if skill.name.trim().is_empty() {
320                return Err(ManifestError::Validation(
321                    "skill name is empty in [[skills]]".into(),
322                ));
323            }
324            if skill.path.trim().is_empty() {
325                return Err(ManifestError::Validation(format!(
326                    "skill '{}' has empty path",
327                    skill.name
328                )));
329            }
330        }
331        let mut seen_hooks: HashSet<&str> = HashSet::new();
332        for hook in &self.hooks {
333            if hook.name.trim().is_empty() {
334                return Err(ManifestError::Validation(
335                    "hook name is empty in [[hooks]]".into(),
336                ));
337            }
338            if hook.handler.trim().is_empty() {
339                return Err(ManifestError::Validation(format!(
340                    "hook '{}' has empty handler",
341                    hook.name
342                )));
343            }
344            if !is_known_hook_event(&hook.event) {
345                return Err(ManifestError::Validation(format!(
346                    "hook '{}' references unknown event '{}'",
347                    hook.name, hook.event
348                )));
349            }
350            if !seen_hooks.insert(&hook.name) {
351                return Err(ManifestError::Validation(format!(
352                    "duplicate hook name '{}'",
353                    hook.name
354                )));
355            }
356        }
357        Ok(())
358    }
359}
360
361impl BundleManifest {
362    /// Validate the versioned Bundle contract without executing or installing artifacts.
363    pub fn validate(&self) -> Result<(), ManifestError> {
364        if self.schema_version == 0 {
365            return Err(ManifestError::Validation(
366                "bundle.schema_version must be non-zero".into(),
367            ));
368        }
369        if self.schema_version != 1 {
370            return Err(ManifestError::Validation(format!(
371                "unsupported bundle schema version {}",
372                self.schema_version
373            )));
374        }
375        if self.bundle.name.trim().is_empty() || !valid_semver(&self.bundle.version) {
376            return Err(ManifestError::Validation(
377                "bundle name and version are required".into(),
378            ));
379        }
380        if self.bundle.artifact.trim().is_empty()
381            || self.bundle.artifact.starts_with('/')
382            || !safe_relative_path(&self.bundle.artifact)
383        {
384            return Err(ManifestError::Validation(
385                "bundle.artifact must be a safe relative path".into(),
386            ));
387        }
388        if self.bundle.carrier != "wasm" {
389            return Err(ManifestError::Validation(
390                "bundle.carrier must be 'wasm'".into(),
391            ));
392        }
393        if self
394            .bundle
395            .digest
396            .as_deref()
397            .is_some_and(|d| !valid_digest(d))
398        {
399            return Err(ManifestError::Validation(
400                "bundle.digest cannot be empty".into(),
401            ));
402        }
403        validate_components(&self.tools, &self.skills, &self.hooks)?;
404        if let Some(provider) = &self.language_provider
405            && (provider.language.trim().is_empty()
406                || provider.artifact.trim().is_empty()
407                || !safe_relative_path(&provider.artifact))
408        {
409            return Err(ManifestError::Validation(
410                "bundle language_provider must use a non-empty safe relative artifact path".into(),
411            ));
412        }
413        if self
414            .tools
415            .iter()
416            .any(|tool| !safe_relative_path(&tool.handler))
417            || self
418                .skills
419                .iter()
420                .any(|skill| !safe_relative_path(&skill.path))
421            || self
422                .hooks
423                .iter()
424                .any(|hook| !safe_relative_path(&hook.handler))
425        {
426            return Err(ManifestError::Validation(
427                "bundle component paths must be safe relative paths".into(),
428            ));
429        }
430        Ok(())
431    }
432}
433
434fn validate_components(
435    tools: &[PluginTool],
436    skills: &[PluginSkill],
437    hooks: &[PluginHook],
438) -> Result<(), ManifestError> {
439    let mut names = HashSet::new();
440    for tool in tools {
441        if tool.name.trim().is_empty()
442            || tool.handler.trim().is_empty()
443            || !names.insert(tool.name.as_str())
444        {
445            return Err(ManifestError::Validation(
446                "invalid or duplicate tool".into(),
447            ));
448        }
449    }
450    for skill in skills {
451        if skill.name.trim().is_empty() || !safe_relative_path(&skill.path) {
452            return Err(ManifestError::Validation("invalid skill".into()));
453        }
454    }
455    for hook in hooks {
456        if hook.name.trim().is_empty()
457            || hook.handler.trim().is_empty()
458            || !is_known_hook_event(&hook.event)
459        {
460            return Err(ManifestError::Validation("invalid hook".into()));
461        }
462    }
463    Ok(())
464}
465
466fn safe_relative_path(path: &str) -> bool {
467    let path = path.trim();
468    !path.is_empty()
469        && !path.starts_with('/')
470        && !path.starts_with('\\')
471        && !path.contains(':')
472        && !path
473            .split(['/', '\\'])
474            .any(|part| part.is_empty() || part == "..")
475}
476
477fn valid_digest(digest: &str) -> bool {
478    let Some(hex) = digest.strip_prefix("sha256:") else {
479        return false;
480    };
481    hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())
482}
483
484fn valid_semver(version: &str) -> bool {
485    let mut parts = version.trim().split('.');
486    let valid = (0..3).all(|_| {
487        parts
488            .next()
489            .is_some_and(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
490    });
491    valid && parts.next().is_none()
492}
493
494fn is_known_hook_event(event: &str) -> bool {
495    ALL_HOOK_EVENT_KINDS
496        .iter()
497        .any(|kind| kind.to_string() == event)
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    const VALID_MANIFEST: &str = r#"
505[plugin]
506name = "my-plugin"
507version = "0.1.0"
508carrier = "wasm"
509artifact = "artifacts/my-plugin.wasm"
510description = "A test plugin"
511
512[[tools]]
513name = "greet"
514handler = "tools/greet.wasm"
515
516[[skills]]
517name = "my-skill"
518path = "skills/my-skill/SKILL.md"
519
520[[hooks]]
521name = "pre-call"
522event = "BeforeProviderCall"
523handler = "hooks/pre-call.wasm"
524priority = 10
525"#;
526
527    #[test]
528    fn parse_valid_manifest() {
529        let manifest = parse_manifest(VALID_MANIFEST).expect("valid manifest");
530        assert_eq!(manifest.plugin.name, "my-plugin");
531        assert_eq!(manifest.plugin.version, "0.1.0");
532        assert_eq!(manifest.plugin.carrier, "wasm");
533        assert_eq!(manifest.plugin.artifact, "artifacts/my-plugin.wasm");
534        assert_eq!(manifest.tools.len(), 1);
535        assert_eq!(manifest.tools[0].name, "greet");
536        assert_eq!(manifest.skills.len(), 1);
537        assert_eq!(manifest.skills[0].name, "my-skill");
538        assert_eq!(manifest.hooks.len(), 1);
539        assert_eq!(manifest.hooks[0].name, "pre-call");
540        assert_eq!(manifest.hooks[0].event, "BeforeProviderCall");
541        assert_eq!(manifest.hooks[0].handler, "hooks/pre-call.wasm");
542        assert_eq!(manifest.hooks[0].priority, Some(10));
543    }
544
545    #[test]
546    fn parse_versioned_bundle_manifest() {
547        let toml = r#"
548schema_version = 1
549[bundle]
550name = "my-bundle"
551version = "1.0.0"
552carrier = "wasm"
553artifact = "artifacts/main.wasm"
554digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
555
556[language_provider]
557language = "rust"
558artifact = "artifacts/rust.wasm"
559"#;
560        let parsed = parse_compatible_manifest(toml).expect("bundle manifest");
561        assert!(matches!(
562            &parsed,
563            CompatibleManifest::Bundle(BundleManifest {
564                schema_version: 1,
565                ..
566            })
567        ));
568        let CompatibleManifest::Bundle(bundle) = parsed else {
569            unreachable!()
570        };
571        assert_eq!(bundle.language_provider.expect("provider").language, "rust");
572    }
573
574    #[test]
575    fn reject_mixed_manifest_roots_and_unknown_bundle_fields() {
576        let mixed = format!("{}\n[bundle]\nname = \"b\"", VALID_MANIFEST);
577        assert!(parse_compatible_manifest(&mixed).is_err());
578        let unknown = r#"
579schema_version = 1
580future = true
581[bundle]
582name = "b"
583version = "1.0.0"
584carrier = "wasm"
585artifact = "b.wasm"
586"#;
587        let err = parse_compatible_manifest(unknown).expect_err("unknown field must fail closed");
588        assert!(err.to_string().contains("unknown bundle manifest field"));
589        let unknown_legacy = format!("unknown = true\n{}", VALID_MANIFEST);
590        assert!(parse_compatible_manifest(&unknown_legacy).is_err());
591    }
592
593    #[test]
594    fn reject_bundle_language_provider_path_escape() {
595        let toml = r#"
596schema_version = 1
597[bundle]
598name = "bundle"
599version = "1.0.0"
600carrier = "wasm"
601artifact = "main.wasm"
602[language_provider]
603language = "rust"
604artifact = "../provider.wasm"
605"#;
606        assert!(parse_compatible_manifest(toml).is_err());
607    }
608
609    #[test]
610    fn migration_requires_explicit_opt_in_and_preserves_legacy_input() {
611        let migrated = migrate_legacy_manifest(
612            VALID_MANIFEST,
613            MigrationOptions {
614                schema_version: 1,
615                allow_write: true,
616            },
617        )
618        .expect("migration");
619        assert!(migrated.contains("schema_version = 1"));
620        assert!(migrated.contains("[bundle]"));
621        assert!(
622            migrate_legacy_manifest(
623                VALID_MANIFEST,
624                MigrationOptions {
625                    schema_version: 1,
626                    allow_write: false
627                }
628            )
629            .is_err()
630        );
631    }
632
633    #[test]
634    fn parse_minimal_manifest_no_components() {
635        let toml = r#"
636[plugin]
637name = "bare"
638version = "0.1.0"
639carrier = "wasm"
640artifact = "bare.wasm"
641"#;
642        let manifest = parse_manifest(toml).expect("minimal manifest");
643        assert!(manifest.tools.is_empty());
644        assert!(manifest.skills.is_empty());
645        assert!(manifest.hooks.is_empty());
646    }
647
648    #[test]
649    fn parse_explicit_language_provider_declaration() {
650        let manifest = parse_manifest(
651            r#"
652[plugin]
653name = "rust-provider"
654version = "1.0.0"
655carrier = "wasm"
656artifact = "provider.wasm"
657
658[language_provider]
659language = "rust"
660artifact = "provider.wasm"
661"#,
662        )
663        .expect("manifest should parse");
664        let declaration = manifest
665            .language_provider
666            .expect("provider declaration should be retained");
667        assert_eq!(declaration.language, "rust");
668        assert_eq!(declaration.artifact, "provider.wasm");
669    }
670
671    #[test]
672    fn reject_empty_name() {
673        let toml = r#"
674[plugin]
675name = ""
676version = "0.1.0"
677carrier = "wasm"
678artifact = "x.wasm"
679"#;
680        let err = parse_manifest(toml).expect_err("operation should fail");
681        assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("name is empty")));
682    }
683
684    #[test]
685    fn reject_empty_version() {
686        let toml = r#"
687[plugin]
688name = "p"
689version = ""
690carrier = "wasm"
691artifact = "x.wasm"
692"#;
693        let err = parse_manifest(toml).expect_err("operation should fail");
694        assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("version is empty")));
695    }
696
697    #[test]
698    fn reject_empty_artifact() {
699        let toml = r#"
700[plugin]
701name = "p"
702version = "0.1.0"
703carrier = "wasm"
704artifact = ""
705"#;
706        let err = parse_manifest(toml).expect_err("operation should fail");
707        assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("artifact is empty")));
708    }
709
710    #[test]
711    fn reject_non_wasm_carrier() {
712        let toml = r#"
713[plugin]
714name = "p"
715version = "0.1.0"
716carrier = "lua"
717artifact = "x.lua"
718"#;
719        let err = parse_manifest(toml).expect_err("operation should fail");
720        assert!(
721            matches!(err, ManifestError::Validation(ref m) if m.contains("carrier must be 'wasm'"))
722        );
723    }
724
725    #[test]
726    fn reject_dylib_carrier() {
727        let toml = r#"
728[plugin]
729name = "p"
730version = "0.1.0"
731carrier = "dylib"
732artifact = "x.so"
733"#;
734        let err = parse_manifest(toml).expect_err("operation should fail");
735        assert!(
736            matches!(err, ManifestError::Validation(ref m) if m.contains("carrier must be 'wasm'"))
737        );
738    }
739
740    #[test]
741    fn reject_malformed_toml() {
742        let toml = "this is not valid toml {{{";
743        let err = parse_manifest(toml).expect_err("operation should fail");
744        assert!(matches!(err, ManifestError::Parse(_)));
745    }
746
747    #[test]
748    fn reject_missing_plugin_section() {
749        let toml = r#"
750[other]
751key = "value"
752"#;
753        let err = parse_manifest(toml).expect_err("operation should fail");
754        assert!(matches!(err, ManifestError::Parse(_)));
755    }
756
757    #[test]
758    fn reject_duplicate_tool_names() {
759        let toml = r#"
760[plugin]
761name = "p"
762version = "0.1.0"
763carrier = "wasm"
764artifact = "x.wasm"
765
766[[tools]]
767name = "dup"
768handler = "a.wasm"
769
770[[tools]]
771name = "dup"
772handler = "b.wasm"
773"#;
774        let err = parse_manifest(toml).expect_err("operation should fail");
775        assert!(
776            matches!(err, ManifestError::Validation(ref m) if m.contains("duplicate tool name"))
777        );
778    }
779
780    #[test]
781    fn reject_empty_tool_name() {
782        let toml = r#"
783[plugin]
784name = "p"
785version = "0.1.0"
786carrier = "wasm"
787artifact = "x.wasm"
788
789[[tools]]
790name = ""
791handler = "a.wasm"
792"#;
793        let err = parse_manifest(toml).expect_err("operation should fail");
794        assert!(
795            matches!(err, ManifestError::Validation(ref m) if m.contains("tool name is empty"))
796        );
797    }
798
799    #[test]
800    fn reject_empty_tool_handler() {
801        let toml = r#"
802[plugin]
803name = "p"
804version = "0.1.0"
805carrier = "wasm"
806artifact = "x.wasm"
807
808[[tools]]
809name = "t"
810handler = ""
811"#;
812        let err = parse_manifest(toml).expect_err("operation should fail");
813        assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("empty handler")));
814    }
815
816    #[test]
817    fn manifest_describes_permissions_without_granting() {
818        let toml = r#"
819[plugin]
820name = "p"
821version = "0.1.0"
822carrier = "wasm"
823artifact = "x.wasm"
824
825[plugin.permissions]
826fs = ["read"]
827network = false
828"#;
829        let manifest = parse_manifest(toml).expect("manifest with permissions section");
830        assert_eq!(manifest.plugin.name, "p");
831    }
832
833    #[test]
834    fn parse_hook_declaration() {
835        let toml = r#"
836[plugin]
837name = "p"
838version = "0.1.0"
839carrier = "wasm"
840artifact = "x.wasm"
841
842[[hooks]]
843name = "turn-start"
844event = "TurnStart"
845handler = "hooks/turn-start.wasm"
846"#;
847        let manifest = parse_manifest(toml).expect("valid manifest");
848        assert_eq!(manifest.hooks.len(), 1);
849        assert_eq!(manifest.hooks[0].name, "turn-start");
850        assert_eq!(manifest.hooks[0].event, "TurnStart");
851    }
852
853    #[test]
854    fn reject_unknown_hook_event() {
855        let toml = r#"
856[plugin]
857name = "p"
858version = "0.1.0"
859carrier = "wasm"
860artifact = "x.wasm"
861
862[[hooks]]
863name = "bad"
864event = "MadeUpEvent"
865handler = "hooks/bad.wasm"
866"#;
867        let err = parse_manifest(toml).expect_err("operation should fail");
868        assert!(matches!(err, ManifestError::Validation(ref m) if m.contains("unknown event")));
869    }
870
871    #[test]
872    fn reject_duplicate_hook_names() {
873        let toml = r#"
874[plugin]
875name = "p"
876version = "0.1.0"
877carrier = "wasm"
878artifact = "x.wasm"
879
880[[hooks]]
881name = "dup"
882event = "TurnStart"
883handler = "hooks/a.wasm"
884
885[[hooks]]
886name = "dup"
887event = "TurnComplete"
888handler = "hooks/b.wasm"
889"#;
890        let err = parse_manifest(toml).expect_err("operation should fail");
891        assert!(
892            matches!(err, ManifestError::Validation(ref m) if m.contains("duplicate hook name"))
893        );
894    }
895
896    #[test]
897    fn bundle_semver_boundary_and_legacy_compatibility() {
898        let bundle = |version: &str| {
899            format!(
900                "schema_version = 1\n[bundle]\nname=\"b\"\nversion=\"{version}\"\ncarrier=\"wasm\"\nartifact=\"b.wasm\""
901            )
902        };
903        assert!(parse_compatible_manifest(&bundle("1.2.3")).is_ok());
904        assert!(parse_compatible_manifest(&bundle("garbage")).is_err());
905        let legacy = VALID_MANIFEST.replace("0.1.0", "0.1");
906        assert!(parse_compatible_manifest(&legacy).is_ok());
907        assert!(
908            migrate_legacy_manifest(
909                &legacy,
910                MigrationOptions {
911                    schema_version: 1,
912                    allow_write: true
913                }
914            )
915            .is_err()
916        );
917    }
918}