liminal-cli 0.2.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;

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,
}

impl Limits {
    /// The reply timeout as a duration.
    #[must_use]
    pub const fn reply_timeout(&self) -> Duration {
        Duration::from_millis(self.reply_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");
        }
        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,
}

/// 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),
}

/// 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 is required and an unknown one is refused.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Written {
    server: String,
    channel: String,
    token_file: Option<PathBuf>,
    lys: Option<crate::lys::LysTable>,
    limits: Limits,
}

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(),
        })?;
        let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
            path: path.to_path_buf(),
            field,
        })?;
        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 token_file = match (file.token_file, file.lys) {
            (None, Some(lys)) => {
                return Ok(Self {
                    server: file.server,
                    channel: file.channel,
                    credential: Credential::Lys(lys),
                    limits,
                });
            }
            (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(ConfigError::Invalid {
                    path: path.to_path_buf(),
                    message: "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(Self {
            server: file.server,
            channel: file.channel,
            credential: Credential::Token(Token(token.to_owned())),
            limits,
        })
    }
}

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