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 #[serde(default = "default_answer_bytes")]
56 pub answer_bytes: usize,
57}
58
59const fn default_connect_timeout_ms() -> u64 {
60 2_000
61}
62
63const fn default_answer_bytes() -> usize {
64 16_777_216
65}
66
67fn central_server() -> String {
68 crate::seat::CENTRAL_SERVER.to_owned()
69}
70
71impl Limits {
72 #[must_use]
74 pub const fn reply_timeout(&self) -> Duration {
75 Duration::from_millis(self.reply_timeout_ms)
76 }
77
78 #[must_use]
80 pub const fn connect_timeout(&self) -> Duration {
81 Duration::from_millis(self.connect_timeout_ms)
82 }
83
84 const fn validate(self) -> Result<Self, &'static str> {
85 if self.frame_bytes == 0 {
86 return Err("frame_bytes");
87 }
88 if self.record_bytes == 0 {
89 return Err("record_bytes");
90 }
91 if self.reply_timeout_ms == 0 {
92 return Err("reply_timeout_ms");
93 }
94 if self.flow_steps == 0 {
95 return Err("flow_steps");
96 }
97 if self.request_bytes == 0 {
98 return Err("request_bytes");
99 }
100 if self.connect_timeout_ms == 0 {
101 return Err("connect_timeout_ms");
102 }
103 if self.answer_bytes == 0 {
104 return Err("answer_bytes");
105 }
106 Ok(self)
107 }
108}
109
110#[derive(Debug)]
112pub struct Config {
113 pub server: String,
115 pub channel: String,
117 pub credential: Credential,
119 pub limits: Limits,
121 pub agent: Option<String>,
123 pub listen: ListenConfig,
125}
126
127#[derive(Debug)]
129pub enum Credential {
130 Token(Token),
132 Lys(crate::lys::LysTable),
134 Open {
138 name: Option<String>,
140 },
141}
142
143impl Credential {
144 #[must_use]
147 pub fn holder(&self) -> String {
148 match self {
149 Self::Open { name } => format!("open:{}", name.as_deref().unwrap_or("anonymous")),
150 Self::Token(_) => "token file".to_owned(),
151 Self::Lys(lys) => format!("lys issuer {}", lys.issuer),
152 }
153 }
154
155 #[must_use]
159 pub fn open_record(&self) -> Vec<u8> {
160 match self {
161 Self::Open { name: Some(name) } => format!("open:{name}").into_bytes(),
162 _ => Vec::new(),
163 }
164 }
165}
166
167#[must_use]
170pub fn is_open_name(name: &str) -> bool {
171 (1..=64).contains(&name.len())
172 && name.bytes().all(|b| {
173 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
174 })
175}
176
177#[derive(Debug, thiserror::Error)]
179pub enum ConfigError {
180 #[error("the configuration {path} cannot be read: {message}")]
182 Unreadable {
183 path: PathBuf,
185 message: String,
187 },
188 #[error("the configuration {path} is refused: {message}")]
190 Invalid {
191 path: PathBuf,
193 message: String,
195 },
196 #[error("the configuration {path} is refused: {field} must be positive")]
198 Zero {
199 path: PathBuf,
201 field: &'static str,
203 },
204 #[error("the token file {path} cannot be read: {message}")]
206 Token {
207 path: PathBuf,
209 message: String,
211 },
212}
213
214#[derive(Deserialize)]
217#[serde(deny_unknown_fields)]
218struct Written {
219 harness: Option<toml::Table>,
220 #[serde(default = "central_server")]
221 server: String,
222 channel: String,
223 token_file: Option<PathBuf>,
224 lys: Option<crate::lys::LysTable>,
225 limits: Limits,
226 agent: Option<String>,
227 #[serde(default)]
228 listen: ListenConfig,
229 open: Option<OpenTable>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct OpenTable {
238 #[serde(default)]
240 pub reach: Reach,
241 #[serde(default)]
244 pub name: Option<String>,
245}
246
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum Reach {
251 #[default]
253 Loopback,
254 Network,
256}
257
258#[derive(Debug, Clone, Default, PartialEq, Eq)]
264pub struct Overrides {
265 pub server: Option<String>,
267 pub channel: Option<String>,
269 pub name: Option<String>,
271 pub agent: Option<String>,
273 pub key_file: Option<PathBuf>,
275}
276
277impl Overrides {
278 #[must_use]
280 pub fn is_empty(&self) -> bool {
281 self == &Self::default()
282 }
283
284 fn apply(&self, table: &mut toml::Table, written: bool) -> Result<(), String> {
290 let lys = match (&self.agent, &self.key_file) {
291 (Some(agent), Some(key_file)) => Some((agent, key_file)),
292 (Some(_), None) => return Err("--agent needs --key-file, the agent's key".to_owned()),
293 (None, Some(_)) => {
294 return Err("--key-file needs --agent, the agent it is the key of".to_owned());
295 }
296 (None, None) => None,
297 };
298 if self.name.is_some() && lys.is_some() {
299 return Err(
300 "--as names an open seat and --agent a Lys one; give one of them".to_owned(),
301 );
302 }
303 let credential = ["lys", "token_file"]
304 .into_iter()
305 .find(|key| table.contains_key(*key))
306 .map(|key| if key == "lys" { "[lys]" } else { "token_file" });
307 if let Some(server) = &self.server {
308 table.insert("server".into(), server.clone().into());
309 }
310 if let Some(channel) = &self.channel {
311 table.insert("channel".into(), channel.clone().into());
312 }
313 if let Some(name) = &self.name {
314 if let Some(credential) = credential {
315 return Err(format!(
316 "--as names an open seat; this seat has {credential}, whose key is its identity"
317 ));
318 }
319 if !is_open_name(name) {
320 return Err("--as must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned());
321 }
322 let open = table_of(table, "open")?;
323 open.insert("name".into(), name.clone().into());
324 }
325 if let Some((agent, key_file)) = lys {
326 if table.contains_key("open") || table.contains_key("token_file") {
327 let kind = if table.contains_key("open") {
328 "[open]"
329 } else {
330 "token_file"
331 };
332 return Err(format!(
333 "--agent names a Lys seat; this seat has {kind}, remove it or give --as"
334 ));
335 }
336 let key_file = std::path::absolute(key_file)
337 .map_err(|error| format!("--key-file cannot be made absolute: {error}"))?;
338 let key_file = key_file
339 .to_str()
340 .ok_or("--key-file is not a UTF-8 path")?
341 .to_owned();
342 if table.contains_key("agent") {
343 table.insert("agent".into(), agent.clone().into());
344 }
345 let lys = table_of(table, "lys")?;
346 lys.insert("agent".into(), agent.clone().into());
347 lys.insert("key_file".into(), key_file.into());
348 }
349 if !written
352 && credential.is_none()
353 && !table.contains_key("lys")
354 && table
355 .get("server")
356 .and_then(toml::Value::as_str)
357 .is_some_and(|server| !crate::seat::names_loopback(server))
358 {
359 let open = table_of(table, "open")?;
360 open.entry("reach").or_insert_with(|| "network".into());
361 }
362 Ok(())
363 }
364}
365
366fn table_of<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table, String> {
368 table
369 .entry(key)
370 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
371 .as_table_mut()
372 .ok_or_else(|| format!("{key} must be a table"))
373}
374
375const COMMAND_LINE: &str = "the command line";
377
378impl Config {
379 pub fn load(path: &Path) -> Result<Self, ConfigError> {
386 Self::load_with(Some(path), &Overrides::default())
387 }
388
389 pub fn load_with(path: Option<&Path>, overrides: &Overrides) -> Result<Self, ConfigError> {
396 let mut table = match path {
397 Some(path) => {
398 let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
399 path: path.to_path_buf(),
400 message: e.to_string(),
401 })?;
402 toml::from_str::<toml::Table>(&text).map_err(|e| ConfigError::Invalid {
403 path: path.to_path_buf(),
404 message: e.message().to_owned(),
405 })?
406 }
407 None => crate::seat::defaults(),
408 };
409 let path = path.unwrap_or_else(|| Path::new(COMMAND_LINE));
410 overrides
411 .apply(&mut table, path != Path::new(COMMAND_LINE))
412 .map_err(|message| ConfigError::Invalid {
413 path: path.to_path_buf(),
414 message,
415 })?;
416 let file: Written =
417 table
418 .try_into()
419 .map_err(|e: toml::de::Error| ConfigError::Invalid {
420 path: path.to_path_buf(),
421 message: e.message().to_owned(),
422 })?;
423 crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
424 let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
425 path: path.to_path_buf(),
426 field,
427 })?;
428 file.listen
429 .validate_bounds()
430 .map_err(|message| ConfigError::Invalid {
431 path: path.to_path_buf(),
432 message,
433 })?;
434 let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
435 path: path.to_path_buf(),
436 message,
437 })?;
438 file.listen
439 .validate(agent.as_deref())
440 .map_err(|message| ConfigError::Invalid {
441 path: path.to_path_buf(),
442 message,
443 })?;
444 if file.server.is_empty() {
445 return Err(ConfigError::Zero {
446 path: path.to_path_buf(),
447 field: "server",
448 });
449 }
450 if file.channel.is_empty() {
451 return Err(ConfigError::Zero {
452 path: path.to_path_buf(),
453 field: "channel",
454 });
455 }
456 let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
457 Ok(Self {
458 server: file.server,
459 channel: file.channel,
460 credential,
461 limits,
462 agent,
463 listen: file.listen,
464 })
465 }
466}
467
468fn credential(
473 path: &Path,
474 server: &str,
475 token_file: Option<PathBuf>,
476 lys: Option<crate::lys::LysTable>,
477 open: Option<OpenTable>,
478) -> Result<Credential, ConfigError> {
479 let invalid = |message: String| ConfigError::Invalid {
480 path: path.to_path_buf(),
481 message,
482 };
483 if open.is_some() && (token_file.is_some() || lys.is_some()) {
484 return Err(invalid(
485 "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
486 .to_owned(),
487 ));
488 }
489 let reach = open.as_ref().map_or(Reach::Loopback, |open| open.reach);
490 let name = open.and_then(|open| open.name);
491 if let Some(name) = name.as_deref()
492 && !is_open_name(name)
493 {
494 return Err(invalid(
495 "[open] name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned(),
496 ));
497 }
498 let token_file = match (token_file, lys) {
499 (None, Some(lys)) => return Ok(Credential::Lys(lys)),
500 (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
501 return Ok(Credential::Open { name });
502 }
503 (None, None) => {
504 return Err(invalid(format!(
505 "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
506 server {server}"
507 )));
508 }
509 (Some(token_file), None) if token_file.is_absolute() => token_file,
510 (Some(token_file), None) => path
511 .parent()
512 .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
513 _ => {
514 return Err(invalid(
515 "give exactly one of token_file and [lys]".to_owned(),
516 ));
517 }
518 };
519 let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
520 path: token_file.clone(),
521 message: e.to_string(),
522 })?;
523 let token = token.trim_end_matches(['\r', '\n']);
524 if token.is_empty() {
525 return Err(ConfigError::Token {
526 path: token_file,
527 message: "token file is empty".to_owned(),
528 });
529 }
530 Ok(Credential::Token(Token(token.to_owned())))
531}
532
533fn agent_name(file: &Written) -> Result<Option<String>, String> {
534 let named = file.agent.as_deref().or_else(|| {
535 file.lys
536 .as_ref()
537 .map(|lys| lys.agent.as_str())
538 .filter(|name| !name.is_empty())
539 });
540 let environment = match if named.is_some() || file.lys.is_some() {
541 std::env::var("LYS_AGENT")
542 } else {
543 Err(std::env::VarError::NotPresent)
544 } {
545 Ok(value) => Some(value),
546 Err(std::env::VarError::NotPresent) => None,
547 Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
548 };
549 for candidate in [
550 file.agent.as_deref(),
551 file.lys
552 .as_ref()
553 .map(|lys| lys.agent.as_str())
554 .filter(|name| !name.is_empty()),
555 environment.as_deref(),
556 ]
557 .into_iter()
558 .flatten()
559 {
560 if candidate.is_empty()
561 || candidate.len() > file.listen.agent_bytes
562 || candidate.split('.').any(str::is_empty)
563 || !candidate
564 .bytes()
565 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
566 {
567 return Err(format!(
568 "agent must be a plain identity of at most {} bytes",
569 file.listen.agent_bytes
570 ));
571 }
572 }
573 if let (Some(named), Some(environment)) = (named, environment.as_deref())
574 && named != environment
575 {
576 return Err(format!(
577 "seat agent {named} differs from LYS_AGENT {environment}"
578 ));
579 }
580 if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
581 && !lys.agent.is_empty()
582 && root != lys.agent
583 {
584 return Err(format!(
585 "seat agent {root} differs from lys.agent {}",
586 lys.agent
587 ));
588 }
589 let name = named.map(str::to_owned).or_else(|| {
590 if file.lys.is_some() {
591 environment
592 } else {
593 None
594 }
595 });
596 Ok(name.or_else(|| {
599 file.open
600 .as_ref()
601 .and_then(|open| open.name.clone())
602 .filter(|name| is_open_name(name))
603 }))
604}
605
606#[cfg(test)]
607#[path = "config_tests.rs"]
608mod tests;