Skip to main content

liminal_cli/
config.rs

1//! The ticket's configuration.
2//!
3//! The ticket's configuration (SDK-017 R1), read from the TOML file named on
4//! its command line: the server's address, the channel, the Lys pass from a
5//! file of its own (never printed or logged), and the limits. A missing or
6//! zero field is refused by its name before any socket or stdin is touched.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12pub use crate::mcp::listen_config::ListenConfig;
13use serde::Deserialize;
14
15/// The Lys pass, read from its file. Its Debug prints its length only.
16pub struct Token(String);
17
18impl Token {
19    /// Borrows the pass only for the authenticated connection boundary.
20    #[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/// The ticket's limits; every one is positive.
33#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
34#[serde(deny_unknown_fields)]
35pub struct Limits {
36    /// The longest JSON-RPC line read from stdin, in bytes.
37    pub frame_bytes: usize,
38    /// The largest topology record accepted from `console.topology`, in bytes.
39    pub record_bytes: usize,
40    /// How long one routed request may wait for its reply, in milliseconds.
41    pub reply_timeout_ms: u64,
42    /// The most steps one flow may carry.
43    pub flow_steps: usize,
44    /// The largest request body sent on a subject, in bytes.
45    pub request_bytes: usize,
46    /// How long a loopback server or issuer may take to accept a connection
47    /// before the seat is refused (AGT-006 R3), in milliseconds; 2000 when
48    /// unnamed: a local exchange answers in milliseconds.
49    #[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    /// The reply timeout as a duration.
63    #[must_use]
64    pub const fn reply_timeout(&self) -> Duration {
65        Duration::from_millis(self.reply_timeout_ms)
66    }
67
68    /// The loopback connect bound as a duration.
69    #[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/// Validated inputs for the ticket.
98#[derive(Debug)]
99pub struct Config {
100    /// The server address every connection dials.
101    pub server: String,
102    /// The channel routed requests travel on.
103    pub channel: String,
104    /// Where the pass comes from: a fixed pass, or Lys asked with the agent's key.
105    pub credential: Credential,
106    /// The limits validated before anything is opened.
107    pub limits: Limits,
108    /// This seat's validated agent identity, used only for its own inbox.
109    pub agent: Option<String>,
110    /// Exact subjects and positive bounds for channel input.
111    pub listen: ListenConfig,
112}
113
114/// Where the ticket's pass comes from.
115#[derive(Debug)]
116pub enum Credential {
117    /// A pass read once from `token_file`, whose Debug reveals only its length.
118    Token(Token),
119    /// `[lys]`: the agent's own pass, asked of Lys with its key and renewed.
120    Lys(crate::lys::LysTable),
121    /// Neither (ACCESS-010 R3): an open seat on a loopback exchange that
122    /// gates nothing; it presents no credential.
123    Open,
124}
125
126/// Why a configuration is refused.
127#[derive(Debug, thiserror::Error)]
128pub enum ConfigError {
129    /// Reading the named configuration failed.
130    #[error("the configuration {path} cannot be read: {message}")]
131    Unreadable {
132        /// The configuration that could not be read.
133        path: PathBuf,
134        /// The operating-system failure, without file contents.
135        message: String,
136    },
137    /// The named configuration did not satisfy its schema.
138    #[error("the configuration {path} is refused: {message}")]
139    Invalid {
140        /// The refused configuration.
141        path: PathBuf,
142        /// The schema failure, without file contents.
143        message: String,
144    },
145    /// A limit is zero.
146    #[error("the configuration {path} is refused: {field} must be positive")]
147    Zero {
148        /// The refused configuration.
149        path: PathBuf,
150        /// The field that is zero.
151        field: &'static str,
152    },
153    /// Reading or validating the pass failed, without revealing its bytes.
154    #[error("the token file {path} cannot be read: {message}")]
155    Token {
156        /// The refused token file.
157        path: PathBuf,
158        /// The failure, without token contents.
159        message: String,
160    },
161}
162
163/// The file as written; every field but the server (the central exchange
164/// when unnamed) is required, and an unknown one is refused.
165#[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}
179
180impl Config {
181    /// Reads and checks the configuration at `path`. A missing field is named
182    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
183    /// limit by its name; the pass's bytes never appear in any error.
184    ///
185    /// # Errors
186    /// Returns a named read, schema, zero-limit or token refusal.
187    pub fn load(path: &Path) -> Result<Self, ConfigError> {
188        let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
189            path: path.to_path_buf(),
190            message: e.to_string(),
191        })?;
192        let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
193            path: path.to_path_buf(),
194            message: e.message().to_owned(),
195        })?;
196        crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
197        let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
198            path: path.to_path_buf(),
199            field,
200        })?;
201        file.listen
202            .validate_bounds()
203            .map_err(|message| ConfigError::Invalid {
204                path: path.to_path_buf(),
205                message,
206            })?;
207        let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
208            path: path.to_path_buf(),
209            message,
210        })?;
211        file.listen
212            .validate(agent.as_deref())
213            .map_err(|message| ConfigError::Invalid {
214                path: path.to_path_buf(),
215                message,
216            })?;
217        if file.server.is_empty() {
218            return Err(ConfigError::Zero {
219                path: path.to_path_buf(),
220                field: "server",
221            });
222        }
223        if file.channel.is_empty() {
224            return Err(ConfigError::Zero {
225                path: path.to_path_buf(),
226                field: "channel",
227            });
228        }
229        let credential = credential(path, &file.server, file.token_file, file.lys)?;
230        Ok(Self {
231            server: file.server,
232            channel: file.channel,
233            credential,
234            limits,
235            agent,
236            listen: file.listen,
237        })
238    }
239}
240
241/// The seat's credential from its `token_file` and `[lys]` fields: exactly one
242/// of them, or neither for an open seat (ACCESS-010 R3), which reaches a
243/// loopback exchange only and is refused here, before any dial.
244fn credential(
245    path: &Path,
246    server: &str,
247    token_file: Option<PathBuf>,
248    lys: Option<crate::lys::LysTable>,
249) -> Result<Credential, ConfigError> {
250    let invalid = |message: String| ConfigError::Invalid {
251        path: path.to_path_buf(),
252        message,
253    };
254    let token_file = match (token_file, lys) {
255        (None, Some(lys)) => return Ok(Credential::Lys(lys)),
256        (None, None) if crate::seat::names_loopback(server) => return Ok(Credential::Open),
257        (None, None) => {
258            return Err(invalid(format!(
259                "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
260                 server {server}"
261            )));
262        }
263        (Some(token_file), None) if token_file.is_absolute() => token_file,
264        (Some(token_file), None) => path
265            .parent()
266            .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
267        _ => {
268            return Err(invalid(
269                "give exactly one of token_file and [lys]".to_owned(),
270            ));
271        }
272    };
273    let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
274        path: token_file.clone(),
275        message: e.to_string(),
276    })?;
277    let token = token.trim_end_matches(['\r', '\n']);
278    if token.is_empty() {
279        return Err(ConfigError::Token {
280            path: token_file,
281            message: "token file is empty".to_owned(),
282        });
283    }
284    Ok(Credential::Token(Token(token.to_owned())))
285}
286
287fn agent_name(file: &Written) -> Result<Option<String>, String> {
288    let named = file.agent.as_deref().or_else(|| {
289        file.lys
290            .as_ref()
291            .map(|lys| lys.agent.as_str())
292            .filter(|name| !name.is_empty())
293    });
294    let environment = match if named.is_some() || file.lys.is_some() {
295        std::env::var("LYS_AGENT")
296    } else {
297        Err(std::env::VarError::NotPresent)
298    } {
299        Ok(value) => Some(value),
300        Err(std::env::VarError::NotPresent) => None,
301        Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
302    };
303    for candidate in [
304        file.agent.as_deref(),
305        file.lys
306            .as_ref()
307            .map(|lys| lys.agent.as_str())
308            .filter(|name| !name.is_empty()),
309        environment.as_deref(),
310    ]
311    .into_iter()
312    .flatten()
313    {
314        if candidate.is_empty()
315            || candidate.len() > file.listen.agent_bytes
316            || candidate.split('.').any(str::is_empty)
317            || !candidate
318                .bytes()
319                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
320        {
321            return Err(format!(
322                "agent must be a plain identity of at most {} bytes",
323                file.listen.agent_bytes
324            ));
325        }
326    }
327    if let (Some(named), Some(environment)) = (named, environment.as_deref())
328        && named != environment
329    {
330        return Err(format!(
331            "seat agent {named} differs from LYS_AGENT {environment}"
332        ));
333    }
334    if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
335        && !lys.agent.is_empty()
336        && root != lys.agent
337    {
338        return Err(format!(
339            "seat agent {root} differs from lys.agent {}",
340            lys.agent
341        ));
342    }
343    let name = named.map(str::to_owned).or_else(|| {
344        if file.lys.is_some() {
345            environment
346        } else {
347            None
348        }
349    });
350    Ok(name)
351}
352
353#[cfg(test)]
354#[path = "config_tests.rs"]
355mod tests;