use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub use crate::mcp::listen_config::ListenConfig;
use serde::Deserialize;
pub struct Token(String);
impl Token {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Token({} bytes)", self.0.len())
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Limits {
pub frame_bytes: usize,
pub record_bytes: usize,
pub reply_timeout_ms: u64,
pub flow_steps: usize,
pub request_bytes: usize,
#[serde(default = "default_connect_timeout_ms")]
pub connect_timeout_ms: u64,
#[serde(default = "default_answer_bytes")]
pub answer_bytes: usize,
}
const fn default_connect_timeout_ms() -> u64 {
2_000
}
const fn default_answer_bytes() -> usize {
16_777_216
}
fn central_server() -> String {
crate::seat::CENTRAL_SERVER.to_owned()
}
impl Limits {
#[must_use]
pub const fn reply_timeout(&self) -> Duration {
Duration::from_millis(self.reply_timeout_ms)
}
#[must_use]
pub const fn connect_timeout(&self) -> Duration {
Duration::from_millis(self.connect_timeout_ms)
}
const fn validate(self) -> Result<Self, &'static str> {
if self.frame_bytes == 0 {
return Err("frame_bytes");
}
if self.record_bytes == 0 {
return Err("record_bytes");
}
if self.reply_timeout_ms == 0 {
return Err("reply_timeout_ms");
}
if self.flow_steps == 0 {
return Err("flow_steps");
}
if self.request_bytes == 0 {
return Err("request_bytes");
}
if self.connect_timeout_ms == 0 {
return Err("connect_timeout_ms");
}
if self.answer_bytes == 0 {
return Err("answer_bytes");
}
Ok(self)
}
}
#[derive(Debug)]
pub struct Config {
pub server: String,
pub channel: String,
pub credential: Credential,
pub limits: Limits,
pub agent: Option<String>,
pub listen: ListenConfig,
}
#[derive(Debug)]
pub enum Credential {
Token(Token),
Lys(crate::lys::LysTable),
Open {
name: Option<String>,
},
}
impl Credential {
#[must_use]
pub fn holder(&self) -> String {
match self {
Self::Open { name } => format!("open:{}", name.as_deref().unwrap_or("anonymous")),
Self::Token(_) => "token file".to_owned(),
Self::Lys(lys) => format!("lys issuer {}", lys.issuer),
}
}
#[must_use]
pub fn open_record(&self) -> Vec<u8> {
match self {
Self::Open { name: Some(name) } => format!("open:{name}").into_bytes(),
_ => Vec::new(),
}
}
}
#[must_use]
pub fn is_open_name(name: &str) -> bool {
(1..=64).contains(&name.len())
&& name.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
})
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("the configuration {path} cannot be read: {message}")]
Unreadable {
path: PathBuf,
message: String,
},
#[error("the configuration {path} is refused: {message}")]
Invalid {
path: PathBuf,
message: String,
},
#[error("the configuration {path} is refused: {field} must be positive")]
Zero {
path: PathBuf,
field: &'static str,
},
#[error("the token file {path} cannot be read: {message}")]
Token {
path: PathBuf,
message: String,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Written {
harness: Option<toml::Table>,
#[serde(default = "central_server")]
server: String,
channel: String,
token_file: Option<PathBuf>,
lys: Option<crate::lys::LysTable>,
limits: Limits,
agent: Option<String>,
#[serde(default)]
listen: ListenConfig,
open: Option<OpenTable>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OpenTable {
#[serde(default)]
pub reach: Reach,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reach {
#[default]
Loopback,
Network,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Overrides {
pub server: Option<String>,
pub channel: Option<String>,
pub name: Option<String>,
pub agent: Option<String>,
pub key_file: Option<PathBuf>,
}
impl Overrides {
#[must_use]
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
fn apply(&self, table: &mut toml::Table, written: bool) -> Result<(), String> {
let lys = match (&self.agent, &self.key_file) {
(Some(agent), Some(key_file)) => Some((agent, key_file)),
(Some(_), None) => return Err("--agent needs --key-file, the agent's key".to_owned()),
(None, Some(_)) => {
return Err("--key-file needs --agent, the agent it is the key of".to_owned());
}
(None, None) => None,
};
if self.name.is_some() && lys.is_some() {
return Err(
"--as names an open seat and --agent a Lys one; give one of them".to_owned(),
);
}
let credential = ["lys", "token_file"]
.into_iter()
.find(|key| table.contains_key(*key))
.map(|key| if key == "lys" { "[lys]" } else { "token_file" });
if let Some(server) = &self.server {
table.insert("server".into(), server.clone().into());
}
if let Some(channel) = &self.channel {
table.insert("channel".into(), channel.clone().into());
}
if let Some(name) = &self.name {
if let Some(credential) = credential {
return Err(format!(
"--as names an open seat; this seat has {credential}, whose key is its identity"
));
}
if !is_open_name(name) {
return Err("--as must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned());
}
let open = table_of(table, "open")?;
open.insert("name".into(), name.clone().into());
}
if let Some((agent, key_file)) = lys {
if table.contains_key("open") || table.contains_key("token_file") {
let kind = if table.contains_key("open") {
"[open]"
} else {
"token_file"
};
return Err(format!(
"--agent names a Lys seat; this seat has {kind}, remove it or give --as"
));
}
let key_file = std::path::absolute(key_file)
.map_err(|error| format!("--key-file cannot be made absolute: {error}"))?;
let key_file = key_file
.to_str()
.ok_or("--key-file is not a UTF-8 path")?
.to_owned();
if table.contains_key("agent") {
table.insert("agent".into(), agent.clone().into());
}
let lys = table_of(table, "lys")?;
lys.insert("agent".into(), agent.clone().into());
lys.insert("key_file".into(), key_file.into());
}
if !written
&& credential.is_none()
&& !table.contains_key("lys")
&& table
.get("server")
.and_then(toml::Value::as_str)
.is_some_and(|server| !crate::seat::names_loopback(server))
{
let open = table_of(table, "open")?;
open.entry("reach").or_insert_with(|| "network".into());
}
Ok(())
}
}
fn table_of<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table, String> {
table
.entry(key)
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| format!("{key} must be a table"))
}
const COMMAND_LINE: &str = "the command line";
impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigError> {
Self::load_with(Some(path), &Overrides::default())
}
pub fn load_with(path: Option<&Path>, overrides: &Overrides) -> Result<Self, ConfigError> {
let mut table = match path {
Some(path) => {
let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
path: path.to_path_buf(),
message: e.to_string(),
})?;
toml::from_str::<toml::Table>(&text).map_err(|e| ConfigError::Invalid {
path: path.to_path_buf(),
message: e.message().to_owned(),
})?
}
None => crate::seat::defaults(),
};
let path = path.unwrap_or_else(|| Path::new(COMMAND_LINE));
overrides
.apply(&mut table, path != Path::new(COMMAND_LINE))
.map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
let file: Written =
table
.try_into()
.map_err(|e: toml::de::Error| ConfigError::Invalid {
path: path.to_path_buf(),
message: e.message().to_owned(),
})?;
crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
path: path.to_path_buf(),
field,
})?;
file.listen
.validate_bounds()
.map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
file.listen
.validate(agent.as_deref())
.map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
if file.server.is_empty() {
return Err(ConfigError::Zero {
path: path.to_path_buf(),
field: "server",
});
}
if file.channel.is_empty() {
return Err(ConfigError::Zero {
path: path.to_path_buf(),
field: "channel",
});
}
let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
Ok(Self {
server: file.server,
channel: file.channel,
credential,
limits,
agent,
listen: file.listen,
})
}
}
fn credential(
path: &Path,
server: &str,
token_file: Option<PathBuf>,
lys: Option<crate::lys::LysTable>,
open: Option<OpenTable>,
) -> Result<Credential, ConfigError> {
let invalid = |message: String| ConfigError::Invalid {
path: path.to_path_buf(),
message,
};
if open.is_some() && (token_file.is_some() || lys.is_some()) {
return Err(invalid(
"[open] names a seat with no credential; this seat has token_file or [lys], remove one"
.to_owned(),
));
}
let reach = open.as_ref().map_or(Reach::Loopback, |open| open.reach);
let name = open.and_then(|open| open.name);
if let Some(name) = name.as_deref()
&& !is_open_name(name)
{
return Err(invalid(
"[open] name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned(),
));
}
let token_file = match (token_file, lys) {
(None, Some(lys)) => return Ok(Credential::Lys(lys)),
(None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
return Ok(Credential::Open { name });
}
(None, None) => {
return Err(invalid(format!(
"an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
server {server}"
)));
}
(Some(token_file), None) if token_file.is_absolute() => token_file,
(Some(token_file), None) => path
.parent()
.map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
_ => {
return Err(invalid(
"give exactly one of token_file and [lys]".to_owned(),
));
}
};
let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
path: token_file.clone(),
message: e.to_string(),
})?;
let token = token.trim_end_matches(['\r', '\n']);
if token.is_empty() {
return Err(ConfigError::Token {
path: token_file,
message: "token file is empty".to_owned(),
});
}
Ok(Credential::Token(Token(token.to_owned())))
}
fn agent_name(file: &Written) -> Result<Option<String>, String> {
let named = file.agent.as_deref().or_else(|| {
file.lys
.as_ref()
.map(|lys| lys.agent.as_str())
.filter(|name| !name.is_empty())
});
let environment = match if named.is_some() || file.lys.is_some() {
std::env::var("LYS_AGENT")
} else {
Err(std::env::VarError::NotPresent)
} {
Ok(value) => Some(value),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
};
for candidate in [
file.agent.as_deref(),
file.lys
.as_ref()
.map(|lys| lys.agent.as_str())
.filter(|name| !name.is_empty()),
environment.as_deref(),
]
.into_iter()
.flatten()
{
if candidate.is_empty()
|| candidate.len() > file.listen.agent_bytes
|| candidate.split('.').any(str::is_empty)
|| !candidate
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
{
return Err(format!(
"agent must be a plain identity of at most {} bytes",
file.listen.agent_bytes
));
}
}
if let (Some(named), Some(environment)) = (named, environment.as_deref())
&& named != environment
{
return Err(format!(
"seat agent {named} differs from LYS_AGENT {environment}"
));
}
if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
&& !lys.agent.is_empty()
&& root != lys.agent
{
return Err(format!(
"seat agent {root} differs from lys.agent {}",
lys.agent
));
}
let name = named.map(str::to_owned).or_else(|| {
if file.lys.is_some() {
environment
} else {
None
}
});
Ok(name.or_else(|| {
file.open
.as_ref()
.and_then(|open| open.name.clone())
.filter(|name| is_open_name(name))
}))
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;