Skip to main content

kyyn_core/
tap.rs

1//! The tap manifest — what a plugin repository ADVERTISES (`kyyn-tap.ron`
2//! at its root): which plugins it serves, through which binary, with which
3//! declared capabilities. The engine reads this; plugins never do. Unknown
4//! fields are refused — the `tap` version field is the evolution path, and
5//! an engine that cannot parse a manifest must say so, not guess (ADR 0005).
6
7use serde::{Deserialize, Serialize};
8
9/// The tap manifest format version this engine speaks.
10pub const TAP_FORMAT: u32 = 1;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct TapManifest {
15    /// Manifest format version — an engine refuses versions it does not speak.
16    pub tap: u32,
17    /// The cargo bin target that serves every advertised plugin
18    /// (`<binary> --plugin <name>`, one RON request per invocation).
19    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    /// The name sources reference (`tap:<tap>#<name>`).
27    pub name: String,
28    /// One line for the catalog and the consent card.
29    pub summary: String,
30    /// The link namespace its evidence carries (collision-checked at install).
31    pub namespace: String,
32    /// What accepting this plugin authorizes — shown on the consent card
33    /// (declared now, enforced in stages; ADR 0005).
34    #[serde(default)]
35    pub capabilities: TapCapabilities,
36    /// The instance-config fields this plugin understands — drives the web
37    /// install form (a labeled field per entry instead of a raw RON box).
38    /// Declarative on purpose: preview must never build or execute code,
39    /// so config metadata lives HERE, not behind a protocol verb. Empty =
40    /// the UI falls back to a raw RON input.
41    #[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    /// One line of guidance, shown under the input.
50    pub doc: String,
51    /// How the web quotes the value into RON. Default `Str`.
52    #[serde(default)]
53    pub ty: TapConfigType,
54    #[serde(default)]
55    pub required: bool,
56    /// Placeholder / example value (unquoted).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub example: Option<String>,
59    /// The plugin's default when omitted — display only.
60    #[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    /// A string — the form value is RON-quoted.
67    #[default]
68    Str,
69    Int,
70    Bool,
71    /// Comma-separated in the form; each entry RON-quoted into a list.
72    StrList,
73    /// Raw RON, inserted verbatim (escape hatch for structured values).
74    Ron,
75    /// A host filesystem root this instance may READ (quoted like `Str`).
76    /// This is a CAPABILITY declaration: the runtime sandbox bind-mounts
77    /// exactly the configured `Path` values read-only — a plugin whose
78    /// manifest declares no `Path` fields sees no host filesystem beyond
79    /// its own checkout. The consent card lists these.
80    Path,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct TapCapabilities {
86    /// Network hosts the plugin may reach. Empty = no network.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub network: Vec<String>,
89    /// The credential realm family it signs into (`ms-graph`). None = no auth.
90    #[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        // Identifiers become paths and trust anchors: the binary is joined
110        // under target/release (a separator would escape it), plugin names
111        // ride in source references, namespaces gate links. Bare tokens
112        // only, refused at parse — never at some later join.
113        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        // Identifiers become paths: traversal, absolute paths and dots in
171        // the binary name are refused at parse.
172        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}