1use serde::{Deserialize, Serialize};
8
9pub const TAP_FORMAT: u32 = 1;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct TapManifest {
15 pub tap: u32,
17 pub binary: String,
20 pub plugins: Vec<TapPluginDecl>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct TapPluginDecl {
26 pub name: String,
28 pub summary: String,
30 pub namespace: String,
32 #[serde(default)]
35 pub capabilities: TapCapabilities,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub config: Vec<TapConfigField>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct TapConfigField {
48 pub name: String,
49 pub doc: String,
51 #[serde(default)]
53 pub ty: TapConfigType,
54 #[serde(default)]
55 pub required: bool,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub example: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub default: Option<String>,
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub enum TapConfigType {
66 #[default]
68 Str,
69 Int,
70 Bool,
71 StrList,
73 Ron,
75 Path,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct TapCapabilities {
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub network: Vec<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub auth: Option<String>,
92}
93
94fn bare_token(s: &str) -> bool {
95 !s.is_empty()
96 && s.chars()
97 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
98}
99
100impl TapManifest {
101 pub fn parse(text: &str) -> Result<TapManifest, String> {
102 let m: TapManifest = ron::from_str(text).map_err(|e| format!("kyyn-tap.ron: {e}"))?;
103 if m.tap != TAP_FORMAT {
104 return Err(format!(
105 "tap manifest format v{} — this engine speaks v{TAP_FORMAT}",
106 m.tap
107 ));
108 }
109 if !bare_token(&m.binary) {
114 return Err(format!(
115 "binary '{}' must be a bare cargo target name (no separators or dots)",
116 m.binary
117 ));
118 }
119 let mut seen = std::collections::HashSet::new();
120 for p in &m.plugins {
121 if !bare_token(&p.name) {
122 return Err(format!("plugin name '{}' must be a bare token", p.name));
123 }
124 if !bare_token(&p.namespace) {
125 return Err(format!(
126 "plugin '{}': namespace '{}' must be a bare token",
127 p.name, p.namespace
128 ));
129 }
130 if !seen.insert(p.name.clone()) {
131 return Err(format!("plugin '{}' is advertised twice", p.name));
132 }
133 }
134 Ok(m)
135 }
136
137 pub fn plugin(&self, name: &str) -> Option<&TapPluginDecl> {
138 self.plugins.iter().find(|p| p.name == name)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn manifest_parses_and_gates_version_and_unknown_fields() {
148 let m = TapManifest::parse(
149 r#"(tap: 1, binary: "kyyn-plugins", plugins: [
150 (name: "sweep", summary: "glob a tree", namespace: "file"),
151 (name: "graph-mail", summary: "mail", namespace: "graph",
152 capabilities: (network: ["graph.microsoft.com"], auth: Some("ms-graph"))),
153 ])"#,
154 )
155 .expect("parses");
156 assert_eq!(m.binary, "kyyn-plugins");
157 assert!(m.plugin("sweep").is_some());
158 assert_eq!(
159 m.plugin("graph-mail").unwrap().capabilities.network,
160 vec!["graph.microsoft.com"]
161 );
162 assert!(m.plugin("nope").is_none());
163
164 let err = TapManifest::parse(r#"(tap: 2, binary: "x", plugins: [])"#).unwrap_err();
165 assert!(err.contains("speaks v1"), "{err}");
166 assert!(
167 TapManifest::parse(r#"(tap: 1, binary: "x", plugins: [], sneaky: 1)"#).is_err(),
168 "unknown fields refused"
169 );
170 for evil in ["../../src/runner", "/usr/bin/env", "a/b", "a.sh", ""] {
173 let m = format!(r#"(tap: 1, binary: "{evil}", plugins: [])"#);
174 assert!(TapManifest::parse(&m).is_err(), "{evil}");
175 }
176 assert!(
177 TapManifest::parse(
178 r#"(tap: 1, binary: "x", plugins: [
179 (name: "a", summary: "s", namespace: "n"),
180 (name: "a", summary: "s", namespace: "n"),
181 ])"#
182 )
183 .is_err(),
184 "duplicate plugin names refused"
185 );
186 }
187}