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 an exchange that gates
122    /// nothing; it presents no credential, only the name it declares
123    /// (ACCESS-012 R2), when it has one.
124    Open {
125        /// `[open] name`, recorded by the exchange as `open:<name>`.
126        name: Option<String>,
127    },
128}
129
130impl Credential {
131    /// Who this seat is to an exchange, in one phrase: `open:<name>` or
132    /// `open:anonymous` for an open seat, the kind alone for the others.
133    #[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    /// The record an open seat presents at `Connect` (ACCESS-012 R2):
143    /// `open:<name>`, or nothing when it declares no name; empty for every
144    /// other credential, which present their pass instead.
145    #[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/// Whether `name` is a declared name: 1 to 64 of `a-z`, `0-9`, `.`, `_` or
155/// `-`, the same word as the exchange's.
156#[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/// Why a configuration is refused.
165#[derive(Debug, thiserror::Error)]
166pub enum ConfigError {
167    /// Reading the named configuration failed.
168    #[error("the configuration {path} cannot be read: {message}")]
169    Unreadable {
170        /// The configuration that could not be read.
171        path: PathBuf,
172        /// The operating-system failure, without file contents.
173        message: String,
174    },
175    /// The named configuration did not satisfy its schema.
176    #[error("the configuration {path} is refused: {message}")]
177    Invalid {
178        /// The refused configuration.
179        path: PathBuf,
180        /// The schema failure, without file contents.
181        message: String,
182    },
183    /// A limit is zero.
184    #[error("the configuration {path} is refused: {field} must be positive")]
185    Zero {
186        /// The refused configuration.
187        path: PathBuf,
188        /// The field that is zero.
189        field: &'static str,
190    },
191    /// Reading or validating the pass failed, without revealing its bytes.
192    #[error("the token file {path} cannot be read: {message}")]
193    Token {
194        /// The refused token file.
195        path: PathBuf,
196        /// The failure, without token contents.
197        message: String,
198    },
199}
200
201/// The file as written; every field but the server (the central exchange
202/// when unnamed) is required, and an unknown one is refused.
203#[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/// `[open]` (ACCESS-011 R1): how far a seat with no credential reaches. The
220/// table is refused beside a credential, so it never reads as a way to
221/// loosen a gated seat.
222#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct OpenTable {
225    /// Loopback without the table; the network when the operator writes it.
226    #[serde(default)]
227    pub reach: Reach,
228    /// The name this seat declares to an open exchange (ACCESS-012 R2):
229    /// recorded as `open:<name>` on what it creates; anonymous without one.
230    #[serde(default)]
231    pub name: Option<String>,
232}
233
234/// How far an open seat reaches.
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum Reach {
238    /// This machine only.
239    #[default]
240    Loopback,
241    /// Any exchange address the seat names; the exchange asks it nothing.
242    Network,
243}
244
245impl Config {
246    /// Reads and checks the configuration at `path`. A missing field is named
247    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
248    /// limit by its name; the pass's bytes never appear in any error.
249    ///
250    /// # Errors
251    /// Returns a named read, schema, zero-limit or token refusal.
252    pub fn load(path: &Path) -> Result<Self, ConfigError> {
253        let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
254            path: path.to_path_buf(),
255            message: e.to_string(),
256        })?;
257        let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
258            path: path.to_path_buf(),
259            message: e.message().to_owned(),
260        })?;
261        crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
262        let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
263            path: path.to_path_buf(),
264            field,
265        })?;
266        file.listen
267            .validate_bounds()
268            .map_err(|message| ConfigError::Invalid {
269                path: path.to_path_buf(),
270                message,
271            })?;
272        let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
273            path: path.to_path_buf(),
274            message,
275        })?;
276        file.listen
277            .validate(agent.as_deref())
278            .map_err(|message| ConfigError::Invalid {
279                path: path.to_path_buf(),
280                message,
281            })?;
282        if file.server.is_empty() {
283            return Err(ConfigError::Zero {
284                path: path.to_path_buf(),
285                field: "server",
286            });
287        }
288        if file.channel.is_empty() {
289            return Err(ConfigError::Zero {
290                path: path.to_path_buf(),
291                field: "channel",
292            });
293        }
294        let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
295        Ok(Self {
296            server: file.server,
297            channel: file.channel,
298            credential,
299            limits,
300            agent,
301            listen: file.listen,
302        })
303    }
304}
305
306/// The seat's credential from its `token_file` and `[lys]` fields: exactly one
307/// of them, or neither for an open seat (ACCESS-010 R3), which reaches a
308/// loopback exchange only unless `[open] reach = "network"` says otherwise
309/// (ACCESS-011 R1); the loopback refusal happens here, before any dial.
310fn credential(
311    path: &Path,
312    server: &str,
313    token_file: Option<PathBuf>,
314    lys: Option<crate::lys::LysTable>,
315    open: Option<OpenTable>,
316) -> Result<Credential, ConfigError> {
317    let invalid = |message: String| ConfigError::Invalid {
318        path: path.to_path_buf(),
319        message,
320    };
321    if open.is_some() && (token_file.is_some() || lys.is_some()) {
322        return Err(invalid(
323            "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
324                .to_owned(),
325        ));
326    }
327    let reach = open.as_ref().map_or(Reach::Loopback, |open| open.reach);
328    let name = open.and_then(|open| open.name);
329    if let Some(name) = name.as_deref()
330        && !is_open_name(name)
331    {
332        return Err(invalid(
333            "[open] name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned(),
334        ));
335    }
336    let token_file = match (token_file, lys) {
337        (None, Some(lys)) => return Ok(Credential::Lys(lys)),
338        (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
339            return Ok(Credential::Open { name });
340        }
341        (None, None) => {
342            return Err(invalid(format!(
343                "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
344                 server {server}"
345            )));
346        }
347        (Some(token_file), None) if token_file.is_absolute() => token_file,
348        (Some(token_file), None) => path
349            .parent()
350            .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
351        _ => {
352            return Err(invalid(
353                "give exactly one of token_file and [lys]".to_owned(),
354            ));
355        }
356    };
357    let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
358        path: token_file.clone(),
359        message: e.to_string(),
360    })?;
361    let token = token.trim_end_matches(['\r', '\n']);
362    if token.is_empty() {
363        return Err(ConfigError::Token {
364            path: token_file,
365            message: "token file is empty".to_owned(),
366        });
367    }
368    Ok(Credential::Token(Token(token.to_owned())))
369}
370
371fn agent_name(file: &Written) -> Result<Option<String>, String> {
372    let named = file.agent.as_deref().or_else(|| {
373        file.lys
374            .as_ref()
375            .map(|lys| lys.agent.as_str())
376            .filter(|name| !name.is_empty())
377    });
378    let environment = match if named.is_some() || file.lys.is_some() {
379        std::env::var("LYS_AGENT")
380    } else {
381        Err(std::env::VarError::NotPresent)
382    } {
383        Ok(value) => Some(value),
384        Err(std::env::VarError::NotPresent) => None,
385        Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
386    };
387    for candidate in [
388        file.agent.as_deref(),
389        file.lys
390            .as_ref()
391            .map(|lys| lys.agent.as_str())
392            .filter(|name| !name.is_empty()),
393        environment.as_deref(),
394    ]
395    .into_iter()
396    .flatten()
397    {
398        if candidate.is_empty()
399            || candidate.len() > file.listen.agent_bytes
400            || candidate.split('.').any(str::is_empty)
401            || !candidate
402                .bytes()
403                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
404        {
405            return Err(format!(
406                "agent must be a plain identity of at most {} bytes",
407                file.listen.agent_bytes
408            ));
409        }
410    }
411    if let (Some(named), Some(environment)) = (named, environment.as_deref())
412        && named != environment
413    {
414        return Err(format!(
415            "seat agent {named} differs from LYS_AGENT {environment}"
416        ));
417    }
418    if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
419        && !lys.agent.is_empty()
420        && root != lys.agent
421    {
422        return Err(format!(
423            "seat agent {root} differs from lys.agent {}",
424            lys.agent
425        ));
426    }
427    let name = named.map(str::to_owned).or_else(|| {
428        if file.lys.is_some() {
429            environment
430        } else {
431            None
432        }
433    });
434    // ACCESS-013 R2: an open seat that declares a name has that station.
435    // A name the open check refuses is left to it, so its words are the answer.
436    Ok(name.or_else(|| {
437        file.open
438            .as_ref()
439            .and_then(|open| open.name.clone())
440            .filter(|name| is_open_name(name))
441    }))
442}
443
444#[cfg(test)]
445#[path = "config_tests.rs"]
446mod tests;