kyyn-core 0.1.13

Core vocabulary for kyyn: registry, links, query AST, plugin and validation contracts for typed, git-backed knowledge bases.
Documentation
//! The tap manifest — what a plugin repository ADVERTISES (`kyyn-tap.ron`
//! at its root): which plugins it serves, through which binary, with which
//! declared capabilities. The engine reads this; plugins never do. Unknown
//! fields are refused — the `tap` version field is the evolution path, and
//! an engine that cannot parse a manifest must say so, not guess (ADR 0005).

use serde::{Deserialize, Serialize};

/// The tap manifest format version this engine speaks.
pub const TAP_FORMAT: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapManifest {
    /// Manifest format version — an engine refuses versions it does not speak.
    pub tap: u32,
    /// The cargo bin target that serves every advertised plugin
    /// (`<binary> --plugin <name>`, one RON request per invocation).
    pub binary: String,
    pub plugins: Vec<TapPluginDecl>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapPluginDecl {
    /// The name sources reference (`tap:<tap>#<name>`).
    pub name: String,
    /// One line for the catalog and the consent card.
    pub summary: String,
    /// The link namespace its evidence carries (collision-checked at install).
    pub namespace: String,
    /// What accepting this plugin authorizes — shown on the consent card
    /// (declared now, enforced in stages; ADR 0005).
    #[serde(default)]
    pub capabilities: TapCapabilities,
    /// The instance-config fields this plugin understands — drives the web
    /// install form (a labeled field per entry instead of a raw RON box).
    /// Declarative on purpose: preview must never build or execute code,
    /// so config metadata lives HERE, not behind a protocol verb. Empty =
    /// the UI falls back to a raw RON input.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub config: Vec<TapConfigField>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapConfigField {
    pub name: String,
    /// One line of guidance, shown under the input.
    pub doc: String,
    /// How the web quotes the value into RON. Default `Str`.
    #[serde(default)]
    pub ty: TapConfigType,
    #[serde(default)]
    pub required: bool,
    /// Placeholder / example value (unquoted).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub example: Option<String>,
    /// The plugin's default when omitted — display only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TapConfigType {
    /// A string — the form value is RON-quoted.
    #[default]
    Str,
    Int,
    Bool,
    /// Comma-separated in the form; each entry RON-quoted into a list.
    StrList,
    /// Raw RON, inserted verbatim (escape hatch for structured values).
    Ron,
    /// A host filesystem root this instance may READ (quoted like `Str`).
    /// This is a CAPABILITY declaration: the runtime sandbox bind-mounts
    /// exactly the configured `Path` values read-only — a plugin whose
    /// manifest declares no `Path` fields sees no host filesystem beyond
    /// its own checkout. The consent card lists these.
    Path,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapCapabilities {
    /// Network hosts the plugin may reach. Empty = no network.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub network: Vec<String>,
    /// The credential realm family it signs into (`ms-graph`). None = no auth.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth: Option<String>,
}

fn bare_token(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}

impl TapManifest {
    pub fn parse(text: &str) -> Result<TapManifest, String> {
        let m: TapManifest = ron::from_str(text).map_err(|e| format!("kyyn-tap.ron: {e}"))?;
        if m.tap != TAP_FORMAT {
            return Err(format!(
                "tap manifest format v{} — this engine speaks v{TAP_FORMAT}",
                m.tap
            ));
        }
        // Identifiers become paths and trust anchors: the binary is joined
        // under target/release (a separator would escape it), plugin names
        // ride in source references, namespaces gate links. Bare tokens
        // only, refused at parse — never at some later join.
        if !bare_token(&m.binary) {
            return Err(format!(
                "binary '{}' must be a bare cargo target name (no separators or dots)",
                m.binary
            ));
        }
        let mut seen = std::collections::HashSet::new();
        for p in &m.plugins {
            if !bare_token(&p.name) {
                return Err(format!("plugin name '{}' must be a bare token", p.name));
            }
            if !bare_token(&p.namespace) {
                return Err(format!(
                    "plugin '{}': namespace '{}' must be a bare token",
                    p.name, p.namespace
                ));
            }
            if !seen.insert(p.name.clone()) {
                return Err(format!("plugin '{}' is advertised twice", p.name));
            }
        }
        Ok(m)
    }

    pub fn plugin(&self, name: &str) -> Option<&TapPluginDecl> {
        self.plugins.iter().find(|p| p.name == name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn manifest_parses_and_gates_version_and_unknown_fields() {
        let m = TapManifest::parse(
            r#"(tap: 1, binary: "kyyn-plugins", plugins: [
                (name: "sweep", summary: "glob a tree", namespace: "file"),
                (name: "graph-mail", summary: "mail", namespace: "graph",
                 capabilities: (network: ["graph.microsoft.com"], auth: Some("ms-graph"))),
            ])"#,
        )
        .expect("parses");
        assert_eq!(m.binary, "kyyn-plugins");
        assert!(m.plugin("sweep").is_some());
        assert_eq!(
            m.plugin("graph-mail").unwrap().capabilities.network,
            vec!["graph.microsoft.com"]
        );
        assert!(m.plugin("nope").is_none());

        let err = TapManifest::parse(r#"(tap: 2, binary: "x", plugins: [])"#).unwrap_err();
        assert!(err.contains("speaks v1"), "{err}");
        assert!(
            TapManifest::parse(r#"(tap: 1, binary: "x", plugins: [], sneaky: 1)"#).is_err(),
            "unknown fields refused"
        );
        // Identifiers become paths: traversal, absolute paths and dots in
        // the binary name are refused at parse.
        for evil in ["../../src/runner", "/usr/bin/env", "a/b", "a.sh", ""] {
            let m = format!(r#"(tap: 1, binary: "{evil}", plugins: [])"#);
            assert!(TapManifest::parse(&m).is_err(), "{evil}");
        }
        assert!(
            TapManifest::parse(
                r#"(tap: 1, binary: "x", plugins: [
                    (name: "a", summary: "s", namespace: "n"),
                    (name: "a", summary: "s", namespace: "n"),
                ])"#
            )
            .is_err(),
            "duplicate plugin names refused"
        );
    }
}