Skip to main content

khive_runtime/
mount_config.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::engine_config::ConfigError;
6
7#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum MountEffect {
10    Read,
11    #[default]
12    Mutating,
13}
14
15#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
16#[serde(deny_unknown_fields)]
17pub struct MountToolConfig {
18    pub name: String,
19    #[serde(default)]
20    pub effect: MountEffect,
21}
22
23#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
24#[serde(deny_unknown_fields)]
25pub struct MountConfig {
26    pub name: String,
27    pub transport: String,
28    pub command: String,
29    #[serde(default)]
30    pub args: Vec<String>,
31    #[serde(default)]
32    pub env: Vec<String>,
33    pub credential: Option<String>,
34    #[serde(default)]
35    pub tools: Vec<MountToolConfig>,
36    #[serde(default = "default_timeout_ms")]
37    pub timeout_ms: u64,
38}
39
40fn default_timeout_ms() -> u64 {
41    30_000
42}
43
44fn environment_name(name: &str) -> bool {
45    let mut chars = name.bytes();
46    matches!(chars.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_'))
47        && chars.all(|c| c.is_ascii_alphanumeric() || c == b'_')
48}
49
50pub fn valid_tool_name(name: &str) -> bool {
51    !name.is_empty()
52        && name.len() <= 128
53        && name
54            .bytes()
55            .all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
56}
57
58pub fn validate_mounts(mounts: &[MountConfig]) -> Result<(), ConfigError> {
59    let refuse = |reason: &str| ConfigError::InvalidMountConfig {
60        reason: reason.into(),
61    };
62    let mut names = BTreeSet::new();
63    for mount in mounts {
64        if mount.tools.len() > 1024 {
65            return Err(refuse("a mount may configure at most 1024 tools"));
66        }
67        if mount.name.is_empty()
68            || mount.name.len() > 128
69            || !mount
70                .name
71                .bytes()
72                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
73        {
74            return Err(refuse(
75                "name must contain lowercase ASCII letters, digits or hyphens",
76            ));
77        }
78        if !names.insert(&mount.name) {
79            return Err(refuse("duplicate mount name"));
80        }
81        if mount.transport != "stdio" {
82            return Err(refuse("only stdio transport is supported"));
83        }
84        if mount.command.is_empty()
85            || mount.command.contains('\0')
86            || mount.args.iter().any(|arg| arg.contains('\0'))
87        {
88            return Err(refuse(
89                "command must be non-empty; command and args must contain no U+0000",
90            ));
91        }
92        if mount
93            .credential
94            .as_deref()
95            .is_some_and(|name| !environment_name(name))
96        {
97            return Err(refuse(
98                "credential must be an environment-variable name, never an inline value",
99            ));
100        }
101        if mount.env.iter().any(|name| !environment_name(name)) {
102            return Err(refuse("env must contain environment-variable names only"));
103        }
104        if mount.timeout_ms == 0 {
105            return Err(refuse("timeout_ms must be positive"));
106        }
107        let mut tools = BTreeSet::new();
108        for tool in &mount.tools {
109            if !valid_tool_name(&tool.name) {
110                return Err(refuse(
111                    "tool name must contain ASCII letters, digits, underscores or hyphens",
112                ));
113            }
114            if !tools.insert(&tool.name) {
115                return Err(refuse("duplicate tool name"));
116            }
117        }
118    }
119    Ok(())
120}