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
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
251pub struct Overrides {
252 pub server: Option<String>,
254 pub channel: Option<String>,
256 pub name: Option<String>,
258 pub agent: Option<String>,
260 pub key_file: Option<PathBuf>,
262}
263
264impl Overrides {
265 #[must_use]
267 pub fn is_empty(&self) -> bool {
268 self == &Self::default()
269 }
270
271 fn apply(&self, table: &mut toml::Table, written: bool) -> Result<(), String> {
277 let lys = match (&self.agent, &self.key_file) {
278 (Some(agent), Some(key_file)) => Some((agent, key_file)),
279 (Some(_), None) => return Err("--agent needs --key-file, the agent's key".to_owned()),
280 (None, Some(_)) => {
281 return Err("--key-file needs --agent, the agent it is the key of".to_owned());
282 }
283 (None, None) => None,
284 };
285 if self.name.is_some() && lys.is_some() {
286 return Err(
287 "--as names an open seat and --agent a Lys one; give one of them".to_owned(),
288 );
289 }
290 let credential = ["lys", "token_file"]
291 .into_iter()
292 .find(|key| table.contains_key(*key))
293 .map(|key| if key == "lys" { "[lys]" } else { "token_file" });
294 if let Some(server) = &self.server {
295 table.insert("server".into(), server.clone().into());
296 }
297 if let Some(channel) = &self.channel {
298 table.insert("channel".into(), channel.clone().into());
299 }
300 if let Some(name) = &self.name {
301 if let Some(credential) = credential {
302 return Err(format!(
303 "--as names an open seat; this seat has {credential}, whose key is its identity"
304 ));
305 }
306 if !is_open_name(name) {
307 return Err("--as must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned());
308 }
309 let open = table_of(table, "open")?;
310 open.insert("name".into(), name.clone().into());
311 }
312 if let Some((agent, key_file)) = lys {
313 if table.contains_key("open") || table.contains_key("token_file") {
314 let kind = if table.contains_key("open") {
315 "[open]"
316 } else {
317 "token_file"
318 };
319 return Err(format!(
320 "--agent names a Lys seat; this seat has {kind}, remove it or give --as"
321 ));
322 }
323 let key_file = std::path::absolute(key_file)
324 .map_err(|error| format!("--key-file cannot be made absolute: {error}"))?;
325 let key_file = key_file
326 .to_str()
327 .ok_or("--key-file is not a UTF-8 path")?
328 .to_owned();
329 if table.contains_key("agent") {
330 table.insert("agent".into(), agent.clone().into());
331 }
332 let lys = table_of(table, "lys")?;
333 lys.insert("agent".into(), agent.clone().into());
334 lys.insert("key_file".into(), key_file.into());
335 }
336 if !written
339 && credential.is_none()
340 && !table.contains_key("lys")
341 && table
342 .get("server")
343 .and_then(toml::Value::as_str)
344 .is_some_and(|server| !crate::seat::names_loopback(server))
345 {
346 let open = table_of(table, "open")?;
347 open.entry("reach").or_insert_with(|| "network".into());
348 }
349 Ok(())
350 }
351}
352
353fn table_of<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table, String> {
355 table
356 .entry(key)
357 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
358 .as_table_mut()
359 .ok_or_else(|| format!("{key} must be a table"))
360}
361
362const COMMAND_LINE: &str = "the command line";
364
365impl Config {
366 pub fn load(path: &Path) -> Result<Self, ConfigError> {
373 Self::load_with(Some(path), &Overrides::default())
374 }
375
376 pub fn load_with(path: Option<&Path>, overrides: &Overrides) -> Result<Self, ConfigError> {
383 let mut table = match path {
384 Some(path) => {
385 let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
386 path: path.to_path_buf(),
387 message: e.to_string(),
388 })?;
389 toml::from_str::<toml::Table>(&text).map_err(|e| ConfigError::Invalid {
390 path: path.to_path_buf(),
391 message: e.message().to_owned(),
392 })?
393 }
394 None => crate::seat::defaults(),
395 };
396 let path = path.unwrap_or_else(|| Path::new(COMMAND_LINE));
397 overrides
398 .apply(&mut table, path != Path::new(COMMAND_LINE))
399 .map_err(|message| ConfigError::Invalid {
400 path: path.to_path_buf(),
401 message,
402 })?;
403 let file: Written =
404 table
405 .try_into()
406 .map_err(|e: toml::de::Error| ConfigError::Invalid {
407 path: path.to_path_buf(),
408 message: e.message().to_owned(),
409 })?;
410 crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
411 let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
412 path: path.to_path_buf(),
413 field,
414 })?;
415 file.listen
416 .validate_bounds()
417 .map_err(|message| ConfigError::Invalid {
418 path: path.to_path_buf(),
419 message,
420 })?;
421 let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
422 path: path.to_path_buf(),
423 message,
424 })?;
425 file.listen
426 .validate(agent.as_deref())
427 .map_err(|message| ConfigError::Invalid {
428 path: path.to_path_buf(),
429 message,
430 })?;
431 if file.server.is_empty() {
432 return Err(ConfigError::Zero {
433 path: path.to_path_buf(),
434 field: "server",
435 });
436 }
437 if file.channel.is_empty() {
438 return Err(ConfigError::Zero {
439 path: path.to_path_buf(),
440 field: "channel",
441 });
442 }
443 let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
444 Ok(Self {
445 server: file.server,
446 channel: file.channel,
447 credential,
448 limits,
449 agent,
450 listen: file.listen,
451 })
452 }
453}
454
455fn credential(
460 path: &Path,
461 server: &str,
462 token_file: Option<PathBuf>,
463 lys: Option<crate::lys::LysTable>,
464 open: Option<OpenTable>,
465) -> Result<Credential, ConfigError> {
466 let invalid = |message: String| ConfigError::Invalid {
467 path: path.to_path_buf(),
468 message,
469 };
470 if open.is_some() && (token_file.is_some() || lys.is_some()) {
471 return Err(invalid(
472 "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
473 .to_owned(),
474 ));
475 }
476 let reach = open.as_ref().map_or(Reach::Loopback, |open| open.reach);
477 let name = open.and_then(|open| open.name);
478 if let Some(name) = name.as_deref()
479 && !is_open_name(name)
480 {
481 return Err(invalid(
482 "[open] name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned(),
483 ));
484 }
485 let token_file = match (token_file, lys) {
486 (None, Some(lys)) => return Ok(Credential::Lys(lys)),
487 (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
488 return Ok(Credential::Open { name });
489 }
490 (None, None) => {
491 return Err(invalid(format!(
492 "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
493 server {server}"
494 )));
495 }
496 (Some(token_file), None) if token_file.is_absolute() => token_file,
497 (Some(token_file), None) => path
498 .parent()
499 .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
500 _ => {
501 return Err(invalid(
502 "give exactly one of token_file and [lys]".to_owned(),
503 ));
504 }
505 };
506 let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
507 path: token_file.clone(),
508 message: e.to_string(),
509 })?;
510 let token = token.trim_end_matches(['\r', '\n']);
511 if token.is_empty() {
512 return Err(ConfigError::Token {
513 path: token_file,
514 message: "token file is empty".to_owned(),
515 });
516 }
517 Ok(Credential::Token(Token(token.to_owned())))
518}
519
520fn agent_name(file: &Written) -> Result<Option<String>, String> {
521 let named = file.agent.as_deref().or_else(|| {
522 file.lys
523 .as_ref()
524 .map(|lys| lys.agent.as_str())
525 .filter(|name| !name.is_empty())
526 });
527 let environment = match if named.is_some() || file.lys.is_some() {
528 std::env::var("LYS_AGENT")
529 } else {
530 Err(std::env::VarError::NotPresent)
531 } {
532 Ok(value) => Some(value),
533 Err(std::env::VarError::NotPresent) => None,
534 Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
535 };
536 for candidate in [
537 file.agent.as_deref(),
538 file.lys
539 .as_ref()
540 .map(|lys| lys.agent.as_str())
541 .filter(|name| !name.is_empty()),
542 environment.as_deref(),
543 ]
544 .into_iter()
545 .flatten()
546 {
547 if candidate.is_empty()
548 || candidate.len() > file.listen.agent_bytes
549 || candidate.split('.').any(str::is_empty)
550 || !candidate
551 .bytes()
552 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
553 {
554 return Err(format!(
555 "agent must be a plain identity of at most {} bytes",
556 file.listen.agent_bytes
557 ));
558 }
559 }
560 if let (Some(named), Some(environment)) = (named, environment.as_deref())
561 && named != environment
562 {
563 return Err(format!(
564 "seat agent {named} differs from LYS_AGENT {environment}"
565 ));
566 }
567 if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
568 && !lys.agent.is_empty()
569 && root != lys.agent
570 {
571 return Err(format!(
572 "seat agent {root} differs from lys.agent {}",
573 lys.agent
574 ));
575 }
576 let name = named.map(str::to_owned).or_else(|| {
577 if file.lys.is_some() {
578 environment
579 } else {
580 None
581 }
582 });
583 Ok(name.or_else(|| {
586 file.open
587 .as_ref()
588 .and_then(|open| open.name.clone())
589 .filter(|name| is_open_name(name))
590 }))
591}
592
593#[cfg(test)]
594#[path = "config_tests.rs"]
595mod tests;