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 {
125 name: Option<String>,
127 },
128}
129
130impl Credential {
131 #[must_use]
134 pub fn holder(&self) -> String {
135 match self {
136 Self::Open { name } => format!("open:{}", name.as_deref().unwrap_or("anonymous")),
137 Self::Token(_) => "token file".to_owned(),
138 Self::Lys(lys) => format!("lys issuer {}", lys.issuer),
139 }
140 }
141
142 #[must_use]
146 pub fn open_record(&self) -> Vec<u8> {
147 match self {
148 Self::Open { name: Some(name) } => format!("open:{name}").into_bytes(),
149 _ => Vec::new(),
150 }
151 }
152}
153
154#[must_use]
157pub fn is_open_name(name: &str) -> bool {
158 (1..=64).contains(&name.len())
159 && name.bytes().all(|b| {
160 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
161 })
162}
163
164#[derive(Debug, thiserror::Error)]
166pub enum ConfigError {
167 #[error("the configuration {path} cannot be read: {message}")]
169 Unreadable {
170 path: PathBuf,
172 message: String,
174 },
175 #[error("the configuration {path} is refused: {message}")]
177 Invalid {
178 path: PathBuf,
180 message: String,
182 },
183 #[error("the configuration {path} is refused: {field} must be positive")]
185 Zero {
186 path: PathBuf,
188 field: &'static str,
190 },
191 #[error("the token file {path} cannot be read: {message}")]
193 Token {
194 path: PathBuf,
196 message: String,
198 },
199}
200
201#[derive(Deserialize)]
204#[serde(deny_unknown_fields)]
205struct Written {
206 harness: Option<toml::Table>,
207 #[serde(default = "central_server")]
208 server: String,
209 channel: String,
210 token_file: Option<PathBuf>,
211 lys: Option<crate::lys::LysTable>,
212 limits: Limits,
213 agent: Option<String>,
214 #[serde(default)]
215 listen: ListenConfig,
216 open: Option<OpenTable>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct OpenTable {
225 #[serde(default)]
227 pub reach: Reach,
228 #[serde(default)]
231 pub name: Option<String>,
232}
233
234#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum Reach {
238 #[default]
240 Loopback,
241 Network,
243}
244
245impl Config {
246 pub fn load(path: &Path) -> Result<Self, ConfigError> {
253 let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
254 path: path.to_path_buf(),
255 message: e.to_string(),
256 })?;
257 let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
258 path: path.to_path_buf(),
259 message: e.message().to_owned(),
260 })?;
261 crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
262 let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
263 path: path.to_path_buf(),
264 field,
265 })?;
266 file.listen
267 .validate_bounds()
268 .map_err(|message| ConfigError::Invalid {
269 path: path.to_path_buf(),
270 message,
271 })?;
272 let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
273 path: path.to_path_buf(),
274 message,
275 })?;
276 file.listen
277 .validate(agent.as_deref())
278 .map_err(|message| ConfigError::Invalid {
279 path: path.to_path_buf(),
280 message,
281 })?;
282 if file.server.is_empty() {
283 return Err(ConfigError::Zero {
284 path: path.to_path_buf(),
285 field: "server",
286 });
287 }
288 if file.channel.is_empty() {
289 return Err(ConfigError::Zero {
290 path: path.to_path_buf(),
291 field: "channel",
292 });
293 }
294 let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
295 Ok(Self {
296 server: file.server,
297 channel: file.channel,
298 credential,
299 limits,
300 agent,
301 listen: file.listen,
302 })
303 }
304}
305
306fn credential(
311 path: &Path,
312 server: &str,
313 token_file: Option<PathBuf>,
314 lys: Option<crate::lys::LysTable>,
315 open: Option<OpenTable>,
316) -> Result<Credential, ConfigError> {
317 let invalid = |message: String| ConfigError::Invalid {
318 path: path.to_path_buf(),
319 message,
320 };
321 if open.is_some() && (token_file.is_some() || lys.is_some()) {
322 return Err(invalid(
323 "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
324 .to_owned(),
325 ));
326 }
327 let reach = open.as_ref().map_or(Reach::Loopback, |open| open.reach);
328 let name = open.and_then(|open| open.name);
329 if let Some(name) = name.as_deref()
330 && !is_open_name(name)
331 {
332 return Err(invalid(
333 "[open] name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned(),
334 ));
335 }
336 let token_file = match (token_file, lys) {
337 (None, Some(lys)) => return Ok(Credential::Lys(lys)),
338 (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
339 return Ok(Credential::Open { name });
340 }
341 (None, None) => {
342 return Err(invalid(format!(
343 "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
344 server {server}"
345 )));
346 }
347 (Some(token_file), None) if token_file.is_absolute() => token_file,
348 (Some(token_file), None) => path
349 .parent()
350 .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
351 _ => {
352 return Err(invalid(
353 "give exactly one of token_file and [lys]".to_owned(),
354 ));
355 }
356 };
357 let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
358 path: token_file.clone(),
359 message: e.to_string(),
360 })?;
361 let token = token.trim_end_matches(['\r', '\n']);
362 if token.is_empty() {
363 return Err(ConfigError::Token {
364 path: token_file,
365 message: "token file is empty".to_owned(),
366 });
367 }
368 Ok(Credential::Token(Token(token.to_owned())))
369}
370
371fn agent_name(file: &Written) -> Result<Option<String>, String> {
372 let named = file.agent.as_deref().or_else(|| {
373 file.lys
374 .as_ref()
375 .map(|lys| lys.agent.as_str())
376 .filter(|name| !name.is_empty())
377 });
378 let environment = match if named.is_some() || file.lys.is_some() {
379 std::env::var("LYS_AGENT")
380 } else {
381 Err(std::env::VarError::NotPresent)
382 } {
383 Ok(value) => Some(value),
384 Err(std::env::VarError::NotPresent) => None,
385 Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
386 };
387 for candidate in [
388 file.agent.as_deref(),
389 file.lys
390 .as_ref()
391 .map(|lys| lys.agent.as_str())
392 .filter(|name| !name.is_empty()),
393 environment.as_deref(),
394 ]
395 .into_iter()
396 .flatten()
397 {
398 if candidate.is_empty()
399 || candidate.len() > file.listen.agent_bytes
400 || candidate.split('.').any(str::is_empty)
401 || !candidate
402 .bytes()
403 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
404 {
405 return Err(format!(
406 "agent must be a plain identity of at most {} bytes",
407 file.listen.agent_bytes
408 ));
409 }
410 }
411 if let (Some(named), Some(environment)) = (named, environment.as_deref())
412 && named != environment
413 {
414 return Err(format!(
415 "seat agent {named} differs from LYS_AGENT {environment}"
416 ));
417 }
418 if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
419 && !lys.agent.is_empty()
420 && root != lys.agent
421 {
422 return Err(format!(
423 "seat agent {root} differs from lys.agent {}",
424 lys.agent
425 ));
426 }
427 let name = named.map(str::to_owned).or_else(|| {
428 if file.lys.is_some() {
429 environment
430 } else {
431 None
432 }
433 });
434 Ok(name.or_else(|| {
437 file.open
438 .as_ref()
439 .and_then(|open| open.name.clone())
440 .filter(|name| is_open_name(name))
441 }))
442}
443
444#[cfg(test)]
445#[path = "config_tests.rs"]
446mod tests;