1use std::fmt;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12pub use crate::mcp::listen_config::ListenConfig;
13use serde::Deserialize;
14
15pub struct Token(String);
17
18impl Token {
19 #[must_use]
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24}
25
26impl fmt::Debug for Token {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 write!(f, "Token({} bytes)", self.0.len())
29 }
30}
31
32#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
34#[serde(deny_unknown_fields)]
35pub struct Limits {
36 pub frame_bytes: usize,
38 pub record_bytes: usize,
40 pub reply_timeout_ms: u64,
42 pub flow_steps: usize,
44 pub request_bytes: usize,
46 #[serde(default = "default_connect_timeout_ms")]
50 pub connect_timeout_ms: u64,
51}
52
53const fn default_connect_timeout_ms() -> u64 {
54 2_000
55}
56
57fn central_server() -> String {
58 crate::seat::CENTRAL_SERVER.to_owned()
59}
60
61impl Limits {
62 #[must_use]
64 pub const fn reply_timeout(&self) -> Duration {
65 Duration::from_millis(self.reply_timeout_ms)
66 }
67
68 #[must_use]
70 pub const fn connect_timeout(&self) -> Duration {
71 Duration::from_millis(self.connect_timeout_ms)
72 }
73
74 const fn validate(self) -> Result<Self, &'static str> {
75 if self.frame_bytes == 0 {
76 return Err("frame_bytes");
77 }
78 if self.record_bytes == 0 {
79 return Err("record_bytes");
80 }
81 if self.reply_timeout_ms == 0 {
82 return Err("reply_timeout_ms");
83 }
84 if self.flow_steps == 0 {
85 return Err("flow_steps");
86 }
87 if self.request_bytes == 0 {
88 return Err("request_bytes");
89 }
90 if self.connect_timeout_ms == 0 {
91 return Err("connect_timeout_ms");
92 }
93 Ok(self)
94 }
95}
96
97#[derive(Debug)]
99pub struct Config {
100 pub server: String,
102 pub channel: String,
104 pub credential: Credential,
106 pub limits: Limits,
108 pub agent: Option<String>,
110 pub listen: ListenConfig,
112}
113
114#[derive(Debug)]
116pub enum Credential {
117 Token(Token),
119 Lys(crate::lys::LysTable),
121 Open,
124}
125
126#[derive(Debug, thiserror::Error)]
128pub enum ConfigError {
129 #[error("the configuration {path} cannot be read: {message}")]
131 Unreadable {
132 path: PathBuf,
134 message: String,
136 },
137 #[error("the configuration {path} is refused: {message}")]
139 Invalid {
140 path: PathBuf,
142 message: String,
144 },
145 #[error("the configuration {path} is refused: {field} must be positive")]
147 Zero {
148 path: PathBuf,
150 field: &'static str,
152 },
153 #[error("the token file {path} cannot be read: {message}")]
155 Token {
156 path: PathBuf,
158 message: String,
160 },
161}
162
163#[derive(Deserialize)]
166#[serde(deny_unknown_fields)]
167struct Written {
168 harness: Option<toml::Table>,
169 #[serde(default = "central_server")]
170 server: String,
171 channel: String,
172 token_file: Option<PathBuf>,
173 lys: Option<crate::lys::LysTable>,
174 limits: Limits,
175 agent: Option<String>,
176 #[serde(default)]
177 listen: ListenConfig,
178}
179
180impl Config {
181 pub fn load(path: &Path) -> Result<Self, ConfigError> {
188 let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
189 path: path.to_path_buf(),
190 message: e.to_string(),
191 })?;
192 let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
193 path: path.to_path_buf(),
194 message: e.message().to_owned(),
195 })?;
196 crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
197 let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
198 path: path.to_path_buf(),
199 field,
200 })?;
201 file.listen
202 .validate_bounds()
203 .map_err(|message| ConfigError::Invalid {
204 path: path.to_path_buf(),
205 message,
206 })?;
207 let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
208 path: path.to_path_buf(),
209 message,
210 })?;
211 file.listen
212 .validate(agent.as_deref())
213 .map_err(|message| ConfigError::Invalid {
214 path: path.to_path_buf(),
215 message,
216 })?;
217 if file.server.is_empty() {
218 return Err(ConfigError::Zero {
219 path: path.to_path_buf(),
220 field: "server",
221 });
222 }
223 if file.channel.is_empty() {
224 return Err(ConfigError::Zero {
225 path: path.to_path_buf(),
226 field: "channel",
227 });
228 }
229 let credential = credential(path, &file.server, file.token_file, file.lys)?;
230 Ok(Self {
231 server: file.server,
232 channel: file.channel,
233 credential,
234 limits,
235 agent,
236 listen: file.listen,
237 })
238 }
239}
240
241fn credential(
245 path: &Path,
246 server: &str,
247 token_file: Option<PathBuf>,
248 lys: Option<crate::lys::LysTable>,
249) -> Result<Credential, ConfigError> {
250 let invalid = |message: String| ConfigError::Invalid {
251 path: path.to_path_buf(),
252 message,
253 };
254 let token_file = match (token_file, lys) {
255 (None, Some(lys)) => return Ok(Credential::Lys(lys)),
256 (None, None) if crate::seat::names_loopback(server) => return Ok(Credential::Open),
257 (None, None) => {
258 return Err(invalid(format!(
259 "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
260 server {server}"
261 )));
262 }
263 (Some(token_file), None) if token_file.is_absolute() => token_file,
264 (Some(token_file), None) => path
265 .parent()
266 .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
267 _ => {
268 return Err(invalid(
269 "give exactly one of token_file and [lys]".to_owned(),
270 ));
271 }
272 };
273 let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
274 path: token_file.clone(),
275 message: e.to_string(),
276 })?;
277 let token = token.trim_end_matches(['\r', '\n']);
278 if token.is_empty() {
279 return Err(ConfigError::Token {
280 path: token_file,
281 message: "token file is empty".to_owned(),
282 });
283 }
284 Ok(Credential::Token(Token(token.to_owned())))
285}
286
287fn agent_name(file: &Written) -> Result<Option<String>, String> {
288 let named = file.agent.as_deref().or_else(|| {
289 file.lys
290 .as_ref()
291 .map(|lys| lys.agent.as_str())
292 .filter(|name| !name.is_empty())
293 });
294 let environment = match if named.is_some() || file.lys.is_some() {
295 std::env::var("LYS_AGENT")
296 } else {
297 Err(std::env::VarError::NotPresent)
298 } {
299 Ok(value) => Some(value),
300 Err(std::env::VarError::NotPresent) => None,
301 Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
302 };
303 for candidate in [
304 file.agent.as_deref(),
305 file.lys
306 .as_ref()
307 .map(|lys| lys.agent.as_str())
308 .filter(|name| !name.is_empty()),
309 environment.as_deref(),
310 ]
311 .into_iter()
312 .flatten()
313 {
314 if candidate.is_empty()
315 || candidate.len() > file.listen.agent_bytes
316 || candidate.split('.').any(str::is_empty)
317 || !candidate
318 .bytes()
319 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
320 {
321 return Err(format!(
322 "agent must be a plain identity of at most {} bytes",
323 file.listen.agent_bytes
324 ));
325 }
326 }
327 if let (Some(named), Some(environment)) = (named, environment.as_deref())
328 && named != environment
329 {
330 return Err(format!(
331 "seat agent {named} differs from LYS_AGENT {environment}"
332 ));
333 }
334 if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
335 && !lys.agent.is_empty()
336 && root != lys.agent
337 {
338 return Err(format!(
339 "seat agent {root} differs from lys.agent {}",
340 lys.agent
341 ));
342 }
343 let name = named.map(str::to_owned).or_else(|| {
344 if file.lys.is_some() {
345 environment
346 } else {
347 None
348 }
349 });
350 Ok(name)
351}
352
353#[cfg(test)]
354#[path = "config_tests.rs"]
355mod tests;