liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The ticket's configuration.
//!
//! The ticket's configuration (SDK-017 R1), read from the TOML file named on
//! its command line: the server's address, the channel, the Lys pass from a
//! file of its own (never printed or logged), and the limits. A missing or
//! zero field is refused by its name before any socket or stdin is touched.

use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;

pub use crate::mcp::listen_config::ListenConfig;
use serde::Deserialize;

/// The Lys pass, read from its file. Its Debug prints its length only.
pub struct Token(String);

impl Token {
    /// Borrows the pass only for the authenticated connection boundary.
    #[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())
    }
}

/// The ticket's limits; every one is positive.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Limits {
    /// The longest JSON-RPC line read from stdin, in bytes.
    pub frame_bytes: usize,
    /// The largest topology record accepted from `console.topology`, in bytes.
    pub record_bytes: usize,
    /// How long one routed request may wait for its reply, in milliseconds.
    pub reply_timeout_ms: u64,
    /// The most steps one flow may carry.
    pub flow_steps: usize,
    /// The largest request body sent on a subject, in bytes.
    pub request_bytes: usize,
    /// How long a loopback server or issuer may take to accept a connection
    /// before the seat is refused (AGT-006 R3), in milliseconds; 2000 when
    /// unnamed: a local exchange answers in milliseconds.
    #[serde(default = "default_connect_timeout_ms")]
    pub connect_timeout_ms: u64,
}

const fn default_connect_timeout_ms() -> u64 {
    2_000
}

fn central_server() -> String {
    crate::seat::CENTRAL_SERVER.to_owned()
}

impl Limits {
    /// The reply timeout as a duration.
    #[must_use]
    pub const fn reply_timeout(&self) -> Duration {
        Duration::from_millis(self.reply_timeout_ms)
    }

    /// The loopback connect bound as a duration.
    #[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");
        }
        Ok(self)
    }
}

/// Validated inputs for the ticket.
#[derive(Debug)]
pub struct Config {
    /// The server address every connection dials.
    pub server: String,
    /// The channel routed requests travel on.
    pub channel: String,
    /// Where the pass comes from: a fixed pass, or Lys asked with the agent's key.
    pub credential: Credential,
    /// The limits validated before anything is opened.
    pub limits: Limits,
    /// This seat's validated agent identity, used only for its own inbox.
    pub agent: Option<String>,
    /// Exact subjects and positive bounds for channel input.
    pub listen: ListenConfig,
}

/// Where the ticket's pass comes from.
#[derive(Debug)]
pub enum Credential {
    /// A pass read once from `token_file`, whose Debug reveals only its length.
    Token(Token),
    /// `[lys]`: the agent's own pass, asked of Lys with its key and renewed.
    Lys(crate::lys::LysTable),
    /// Neither (ACCESS-010 R3): an open seat on a loopback exchange that
    /// gates nothing; it presents no credential.
    Open,
}

/// Why a configuration is refused.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// Reading the named configuration failed.
    #[error("the configuration {path} cannot be read: {message}")]
    Unreadable {
        /// The configuration that could not be read.
        path: PathBuf,
        /// The operating-system failure, without file contents.
        message: String,
    },
    /// The named configuration did not satisfy its schema.
    #[error("the configuration {path} is refused: {message}")]
    Invalid {
        /// The refused configuration.
        path: PathBuf,
        /// The schema failure, without file contents.
        message: String,
    },
    /// A limit is zero.
    #[error("the configuration {path} is refused: {field} must be positive")]
    Zero {
        /// The refused configuration.
        path: PathBuf,
        /// The field that is zero.
        field: &'static str,
    },
    /// Reading or validating the pass failed, without revealing its bytes.
    #[error("the token file {path} cannot be read: {message}")]
    Token {
        /// The refused token file.
        path: PathBuf,
        /// The failure, without token contents.
        message: String,
    },
}

/// The file as written; every field but the server (the central exchange
/// when unnamed) is required, and an unknown one is refused.
#[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,
}

impl Config {
    /// Reads and checks the configuration at `path`. A missing field is named
    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
    /// limit by its name; the pass's bytes never appear in any error.
    ///
    /// # Errors
    /// Returns a named read, schema, zero-limit or token refusal.
    pub fn load(path: &Path) -> Result<Self, ConfigError> {
        let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
            path: path.to_path_buf(),
            message: e.to_string(),
        })?;
        let file: Written = toml::from_str(&text).map_err(|e| 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)?;
        Ok(Self {
            server: file.server,
            channel: file.channel,
            credential,
            limits,
            agent,
            listen: file.listen,
        })
    }
}

/// The seat's credential from its `token_file` and `[lys]` fields: exactly one
/// of them, or neither for an open seat (ACCESS-010 R3), which reaches a
/// loopback exchange only and is refused here, before any dial.
fn credential(
    path: &Path,
    server: &str,
    token_file: Option<PathBuf>,
    lys: Option<crate::lys::LysTable>,
) -> Result<Credential, ConfigError> {
    let invalid = |message: String| ConfigError::Invalid {
        path: path.to_path_buf(),
        message,
    };
    let token_file = match (token_file, lys) {
        (None, Some(lys)) => return Ok(Credential::Lys(lys)),
        (None, None) if crate::seat::names_loopback(server) => return Ok(Credential::Open),
        (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)
}

#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;