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    open: Option<OpenTable>,
179}
180
181/// `[open]` (ACCESS-011 R1): how far a seat with no credential reaches. The
182/// table is refused beside a credential, so it never reads as a way to
183/// loosen a gated seat.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct OpenTable {
187    /// Loopback without the table; the network when the operator writes it.
188    #[serde(default)]
189    pub reach: Reach,
190}
191
192/// How far an open seat reaches.
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum Reach {
196    /// This machine only.
197    #[default]
198    Loopback,
199    /// Any exchange address the seat names; the exchange asks it nothing.
200    Network,
201}
202
203impl Config {
204    /// Reads and checks the configuration at `path`. A missing field is named
205    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
206    /// limit by its name; the pass's bytes never appear in any error.
207    ///
208    /// # Errors
209    /// Returns a named read, schema, zero-limit or token refusal.
210    pub fn load(path: &Path) -> Result<Self, ConfigError> {
211        let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
212            path: path.to_path_buf(),
213            message: e.to_string(),
214        })?;
215        let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
216            path: path.to_path_buf(),
217            message: e.message().to_owned(),
218        })?;
219        crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
220        let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
221            path: path.to_path_buf(),
222            field,
223        })?;
224        file.listen
225            .validate_bounds()
226            .map_err(|message| ConfigError::Invalid {
227                path: path.to_path_buf(),
228                message,
229            })?;
230        let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
231            path: path.to_path_buf(),
232            message,
233        })?;
234        file.listen
235            .validate(agent.as_deref())
236            .map_err(|message| ConfigError::Invalid {
237                path: path.to_path_buf(),
238                message,
239            })?;
240        if file.server.is_empty() {
241            return Err(ConfigError::Zero {
242                path: path.to_path_buf(),
243                field: "server",
244            });
245        }
246        if file.channel.is_empty() {
247            return Err(ConfigError::Zero {
248                path: path.to_path_buf(),
249                field: "channel",
250            });
251        }
252        let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
253        Ok(Self {
254            server: file.server,
255            channel: file.channel,
256            credential,
257            limits,
258            agent,
259            listen: file.listen,
260        })
261    }
262}
263
264/// The seat's credential from its `token_file` and `[lys]` fields: exactly one
265/// of them, or neither for an open seat (ACCESS-010 R3), which reaches a
266/// loopback exchange only unless `[open] reach = "network"` says otherwise
267/// (ACCESS-011 R1); the loopback refusal happens here, before any dial.
268fn credential(
269    path: &Path,
270    server: &str,
271    token_file: Option<PathBuf>,
272    lys: Option<crate::lys::LysTable>,
273    open: Option<OpenTable>,
274) -> Result<Credential, ConfigError> {
275    let invalid = |message: String| ConfigError::Invalid {
276        path: path.to_path_buf(),
277        message,
278    };
279    if open.is_some() && (token_file.is_some() || lys.is_some()) {
280        return Err(invalid(
281            "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
282                .to_owned(),
283        ));
284    }
285    let reach = open.map_or(Reach::Loopback, |open| open.reach);
286    let token_file = match (token_file, lys) {
287        (None, Some(lys)) => return Ok(Credential::Lys(lys)),
288        (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
289            return Ok(Credential::Open);
290        }
291        (None, None) => {
292            return Err(invalid(format!(
293                "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
294                 server {server}"
295            )));
296        }
297        (Some(token_file), None) if token_file.is_absolute() => token_file,
298        (Some(token_file), None) => path
299            .parent()
300            .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
301        _ => {
302            return Err(invalid(
303                "give exactly one of token_file and [lys]".to_owned(),
304            ));
305        }
306    };
307    let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
308        path: token_file.clone(),
309        message: e.to_string(),
310    })?;
311    let token = token.trim_end_matches(['\r', '\n']);
312    if token.is_empty() {
313        return Err(ConfigError::Token {
314            path: token_file,
315            message: "token file is empty".to_owned(),
316        });
317    }
318    Ok(Credential::Token(Token(token.to_owned())))
319}
320
321fn agent_name(file: &Written) -> Result<Option<String>, String> {
322    let named = file.agent.as_deref().or_else(|| {
323        file.lys
324            .as_ref()
325            .map(|lys| lys.agent.as_str())
326            .filter(|name| !name.is_empty())
327    });
328    let environment = match if named.is_some() || file.lys.is_some() {
329        std::env::var("LYS_AGENT")
330    } else {
331        Err(std::env::VarError::NotPresent)
332    } {
333        Ok(value) => Some(value),
334        Err(std::env::VarError::NotPresent) => None,
335        Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
336    };
337    for candidate in [
338        file.agent.as_deref(),
339        file.lys
340            .as_ref()
341            .map(|lys| lys.agent.as_str())
342            .filter(|name| !name.is_empty()),
343        environment.as_deref(),
344    ]
345    .into_iter()
346    .flatten()
347    {
348        if candidate.is_empty()
349            || candidate.len() > file.listen.agent_bytes
350            || candidate.split('.').any(str::is_empty)
351            || !candidate
352                .bytes()
353                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
354        {
355            return Err(format!(
356                "agent must be a plain identity of at most {} bytes",
357                file.listen.agent_bytes
358            ));
359        }
360    }
361    if let (Some(named), Some(environment)) = (named, environment.as_deref())
362        && named != environment
363    {
364        return Err(format!(
365            "seat agent {named} differs from LYS_AGENT {environment}"
366        ));
367    }
368    if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
369        && !lys.agent.is_empty()
370        && root != lys.agent
371    {
372        return Err(format!(
373            "seat agent {root} differs from lys.agent {}",
374            lys.agent
375        ));
376    }
377    let name = named.map(str::to_owned).or_else(|| {
378        if file.lys.is_some() {
379            environment
380        } else {
381            None
382        }
383    });
384    Ok(name)
385}
386
387#[cfg(test)]
388#[path = "config_tests.rs"]
389mod tests;