liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! AGT-006 R3: a seat names the central services by default, `liminal seat
//! init` writes them, and a seat on an unanswering loopback address is
//! refused by name before the MCP handshake.

use std::fs::OpenOptions;
use std::io::Write;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::time::Duration;

/// The central exchange's WebSocket door (AGT-006 R1).
pub const CENTRAL_SERVER: &str = "wss://liminal.ablative.com.au/ws";

/// The central Lys.
pub const CENTRAL_ISSUER: &str = "https://lys.ablative.com.au";

/// The channel a new seat's routed requests travel on.
const DEFAULT_CHANNEL: &str = "console.topology";

/// Why `liminal seat init` wrote nothing.
#[derive(Debug, thiserror::Error)]
pub enum SeatInitError {
    /// The agent id is empty or is not one line of printable characters.
    #[error("liminal_seat_agent_invalid: the agent id must be one line of printable characters")]
    Agent,
    /// The key file path cannot be written into the seat as text.
    #[error("liminal_seat_key_file_invalid: {0} is not a UTF-8 path")]
    KeyFile(String),
    /// The seat file exists or cannot be created; it is never overwritten.
    #[error("liminal_seat_unwritable: {path}: {message}")]
    Unwritable {
        /// The seat file asked for.
        path: String,
        /// The operating system's words.
        message: String,
    },
}

/// Writes a seat file at `out`, mode 0600, naming the central exchange and
/// Lys, the agent and its key file, and never a pass. An existing file is
/// refused, never replaced.
///
/// # Errors
/// An invalid agent id or key path, or a file that exists or cannot be made.
pub fn init(agent: &str, key_file: &Path, out: &Path) -> Result<(), SeatInitError> {
    if agent.is_empty()
        || agent
            .chars()
            .any(|c| c.is_control() || c == '"' || c == '\\')
    {
        return Err(SeatInitError::Agent);
    }
    let key = key_file
        .to_str()
        .filter(|k| !k.chars().any(|c| c.is_control() || c == '"' || c == '\\'))
        .ok_or_else(|| SeatInitError::KeyFile(key_file.display().to_string()))?;
    let text = format!(
        "# Written by liminal seat init (AGT-006 R3): the central exchange and Lys.\n\
         server = \"{CENTRAL_SERVER}\"\n\
         channel = \"{DEFAULT_CHANNEL}\"\n\
         \n\
         [lys]\n\
         issuer = \"{CENTRAL_ISSUER}\"\n\
         agent = \"{agent}\"\n\
         key_file = \"{key}\"\n\
         \n\
         [limits]\n\
         frame_bytes = 65536\n\
         record_bytes = 1048576\n\
         reply_timeout_ms = 10000\n\
         flow_steps = 8\n\
         request_bytes = 65536\n"
    );
    let unwritable = |error: std::io::Error| SeatInitError::Unwritable {
        path: out.display().to_string(),
        message: error.to_string(),
    };
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(out)
        .map_err(unwritable)?;
    file.write_all(text.as_bytes()).map_err(unwritable)?;
    file.sync_all().map_err(unwritable)
}

/// The `host:port` an address dials: a `host:port` as written, or a URL's
/// host and its port (its scheme's default when it names none).
fn dial_target(address: &str) -> Option<(String, u16)> {
    const SCHEMES: [(&str, u16); 4] = [
        ("wss://", 443),
        ("https://", 443),
        ("ws://", 80),
        ("http://", 80),
    ];
    let (rest, default_port) = SCHEMES
        .iter()
        .find_map(|(scheme, port)| address.strip_prefix(scheme).map(|rest| (rest, *port)))
        .unwrap_or((address, 0));
    let authority = rest.split('/').next()?;
    if let Some(bracketed) = authority.strip_prefix('[') {
        let (host, after) = bracketed.split_once(']')?;
        let port = match after.strip_prefix(':') {
            Some(port) => port.parse().ok()?,
            None if default_port > 0 => default_port,
            None => return None,
        };
        return Some((host.to_owned(), port));
    }
    match authority.rsplit_once(':') {
        Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
        None if default_port > 0 => Some((authority.to_owned(), default_port)),
        None => None,
    }
}

/// Whether `address` names this machine: a loopback host, with or without a
/// scheme and a path (ACCESS-010 R3: an open seat reaches loopback only).
#[must_use]
pub fn names_loopback(address: &str) -> bool {
    dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
}

/// Whether `host` names this machine.
fn is_loopback(host: &str) -> bool {
    host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}

/// Refuses an address on this machine that does not accept a connection
/// within `bound`, with `liminal_mcp_loopback_unanswered` and the address;
/// any other address is left to its own connection.
///
/// # Errors
/// The loopback address named, when nothing answers on it.
pub fn refuse_unanswered_loopback(address: &str, bound: Duration) -> Result<(), String> {
    let Some((host, port)) = dial_target(address) else {
        return Ok(());
    };
    if !is_loopback(&host) {
        return Ok(());
    }
    let unanswered = || format!("liminal_mcp_loopback_unanswered: nothing answers at {address}");
    let targets: Vec<SocketAddr> = (host.as_str(), port)
        .to_socket_addrs()
        .map_err(|_| unanswered())?
        .collect();
    if targets
        .iter()
        .any(|target| TcpStream::connect_timeout(target, bound).is_ok())
    {
        Ok(())
    } else {
        Err(unanswered())
    }
}

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