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
245/// What `liminal mcp`'s flags say about the seat.
246///
247/// Tom, 25 Oct: "can we have a generic one and set id at runtime?" Each flag
248/// given wins over the seat file's value; with no file, `liminal seat
249/// init`'s channel and limits stand under them.
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
251pub struct Overrides {
252    /// `--server`: the exchange to dial.
253    pub server: Option<String>,
254    /// `--channel`: the channel routed requests travel on.
255    pub channel: Option<String>,
256    /// `--as`: the name an open seat declares (`[open] name`).
257    pub name: Option<String>,
258    /// `--agent`: the Lys agent id (`[lys] agent`), given with `--key-file`.
259    pub agent: Option<String>,
260    /// `--key-file`: the agent's key (`[lys] key_file`), given with `--agent`.
261    pub key_file: Option<PathBuf>,
262}
263
264impl Overrides {
265    /// Whether no flag was given.
266    #[must_use]
267    pub fn is_empty(&self) -> bool {
268        self == &Self::default()
269    }
270
271    /// Writes the flags into `table`, the seat file's or the seat-init
272    /// defaults, so the one validation below reads them as written fields.
273    /// The seat's kind is never overridden: `--as` beside `[lys]` or a
274    /// `token_file`, and `--agent` beside `[open]` or a `token_file`, are
275    /// refused, as is `--agent` or `--key-file` alone.
276    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        // With no file an open seat off loopback reaches the network, as
337        // `liminal seat init --open` writes it; a file's own reach stands.
338        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
353/// `table[key]` as a table, made empty when absent.
354fn 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
362/// Where the errors of a seat given only by flags point.
363const COMMAND_LINE: &str = "the command line";
364
365impl Config {
366    /// Reads and checks the configuration at `path`. A missing field is named
367    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
368    /// limit by its name; the pass's bytes never appear in any error.
369    ///
370    /// # Errors
371    /// Returns a named read, schema, zero-limit or token refusal.
372    pub fn load(path: &Path) -> Result<Self, ConfigError> {
373        Self::load_with(Some(path), &Overrides::default())
374    }
375
376    /// Reads the seat at `path`, or `liminal seat init`'s defaults when there
377    /// is none, with `overrides` written over it, then checks it exactly as
378    /// [`Self::load`] does.
379    ///
380    /// # Errors
381    /// As [`Self::load`], and a refused flag by its name.
382    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
455/// The seat's credential from its `token_file` and `[lys]` fields: exactly one
456/// of them, or neither for an open seat (ACCESS-010 R3), which reaches a
457/// loopback exchange only unless `[open] reach = "network"` says otherwise
458/// (ACCESS-011 R1); the loopback refusal happens here, before any dial.
459fn 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    // ACCESS-013 R2: an open seat that declares a name has that station.
584    // A name the open check refuses is left to it, so its words are the answer.
585    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;