khive_runtime/
mount_config.rs1use 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}