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 open: Option<OpenTable>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct OpenTable {
187 #[serde(default)]
189 pub reach: Reach,
190}
191
192#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum Reach {
196 #[default]
198 Loopback,
199 Network,
201}
202
203impl Config {
204 pub fn load(path: &Path) -> Result<Self, ConfigError> {
211 let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
212 path: path.to_path_buf(),
213 message: e.to_string(),
214 })?;
215 let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
216 path: path.to_path_buf(),
217 message: e.message().to_owned(),
218 })?;
219 crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
220 let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
221 path: path.to_path_buf(),
222 field,
223 })?;
224 file.listen
225 .validate_bounds()
226 .map_err(|message| ConfigError::Invalid {
227 path: path.to_path_buf(),
228 message,
229 })?;
230 let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
231 path: path.to_path_buf(),
232 message,
233 })?;
234 file.listen
235 .validate(agent.as_deref())
236 .map_err(|message| ConfigError::Invalid {
237 path: path.to_path_buf(),
238 message,
239 })?;
240 if file.server.is_empty() {
241 return Err(ConfigError::Zero {
242 path: path.to_path_buf(),
243 field: "server",
244 });
245 }
246 if file.channel.is_empty() {
247 return Err(ConfigError::Zero {
248 path: path.to_path_buf(),
249 field: "channel",
250 });
251 }
252 let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
253 Ok(Self {
254 server: file.server,
255 channel: file.channel,
256 credential,
257 limits,
258 agent,
259 listen: file.listen,
260 })
261 }
262}
263
264fn credential(
269 path: &Path,
270 server: &str,
271 token_file: Option<PathBuf>,
272 lys: Option<crate::lys::LysTable>,
273 open: Option<OpenTable>,
274) -> Result<Credential, ConfigError> {
275 let invalid = |message: String| ConfigError::Invalid {
276 path: path.to_path_buf(),
277 message,
278 };
279 if open.is_some() && (token_file.is_some() || lys.is_some()) {
280 return Err(invalid(
281 "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
282 .to_owned(),
283 ));
284 }
285 let reach = open.map_or(Reach::Loopback, |open| open.reach);
286 let token_file = match (token_file, lys) {
287 (None, Some(lys)) => return Ok(Credential::Lys(lys)),
288 (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
289 return Ok(Credential::Open);
290 }
291 (None, None) => {
292 return Err(invalid(format!(
293 "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
294 server {server}"
295 )));
296 }
297 (Some(token_file), None) if token_file.is_absolute() => token_file,
298 (Some(token_file), None) => path
299 .parent()
300 .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
301 _ => {
302 return Err(invalid(
303 "give exactly one of token_file and [lys]".to_owned(),
304 ));
305 }
306 };
307 let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
308 path: token_file.clone(),
309 message: e.to_string(),
310 })?;
311 let token = token.trim_end_matches(['\r', '\n']);
312 if token.is_empty() {
313 return Err(ConfigError::Token {
314 path: token_file,
315 message: "token file is empty".to_owned(),
316 });
317 }
318 Ok(Credential::Token(Token(token.to_owned())))
319}
320
321fn agent_name(file: &Written) -> Result<Option<String>, String> {
322 let named = file.agent.as_deref().or_else(|| {
323 file.lys
324 .as_ref()
325 .map(|lys| lys.agent.as_str())
326 .filter(|name| !name.is_empty())
327 });
328 let environment = match if named.is_some() || file.lys.is_some() {
329 std::env::var("LYS_AGENT")
330 } else {
331 Err(std::env::VarError::NotPresent)
332 } {
333 Ok(value) => Some(value),
334 Err(std::env::VarError::NotPresent) => None,
335 Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
336 };
337 for candidate in [
338 file.agent.as_deref(),
339 file.lys
340 .as_ref()
341 .map(|lys| lys.agent.as_str())
342 .filter(|name| !name.is_empty()),
343 environment.as_deref(),
344 ]
345 .into_iter()
346 .flatten()
347 {
348 if candidate.is_empty()
349 || candidate.len() > file.listen.agent_bytes
350 || candidate.split('.').any(str::is_empty)
351 || !candidate
352 .bytes()
353 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
354 {
355 return Err(format!(
356 "agent must be a plain identity of at most {} bytes",
357 file.listen.agent_bytes
358 ));
359 }
360 }
361 if let (Some(named), Some(environment)) = (named, environment.as_deref())
362 && named != environment
363 {
364 return Err(format!(
365 "seat agent {named} differs from LYS_AGENT {environment}"
366 ));
367 }
368 if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
369 && !lys.agent.is_empty()
370 && root != lys.agent
371 {
372 return Err(format!(
373 "seat agent {root} differs from lys.agent {}",
374 lys.agent
375 ));
376 }
377 let name = named.map(str::to_owned).or_else(|| {
378 if file.lys.is_some() {
379 environment
380 } else {
381 None
382 }
383 });
384 Ok(name)
385}
386
387#[cfg(test)]
388#[path = "config_tests.rs"]
389mod tests;