liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! One validated send has one key and one publish, with no text in its result.

use super::framing::INVALID_PARAMS;
use super::negotiate::Refusal;
use super::server::Server;
use super::tools::ToolResult;
use crate::network::Rail;
use serde_json::{Map, Value, json};
use std::io::Read as _;
use zeroize::Zeroize as _;

pub(super) fn entry() -> Value {
    json!({"name":"send","description":"Publish UTF-8 text once on this seat's grant and return the server's id or exact refusal.",
        "inputSchema":{"type":"object","required":["subject","text"],"properties":{
            "subject":{"type":"string"},"text":{"type":"string"},"key":{"type":"string","minLength":1}},"additionalProperties":false}})
}

pub(super) fn send<R: Rail>(
    server: &Server<R>,
    arguments: &Map<String, Value>,
) -> Result<ToolResult, Refusal> {
    if arguments
        .keys()
        .any(|key| !matches!(key.as_str(), "subject" | "text" | "key"))
    {
        return Err(invalid("send takes only subject, text and an optional key"));
    }
    let subject = super::tools::string(arguments, "subject")?;
    let text = super::tools::string(arguments, "text")?;
    let data = json!({"subject":subject});
    if let Err(words) = server.listen_config.subject(subject) {
        return Ok(ToolResult::failed(&words, data));
    }
    if text.len() > server.listen_config.content_bytes {
        return Ok(ToolResult::failed(
            &format!(
                "text is {} bytes; the bound is {}",
                text.len(),
                server.listen_config.content_bytes
            ),
            data,
        ));
    }
    let Some(source) = &server.channel_source else {
        return Ok(ToolResult::failed(
            "the ticket has no seat configuration for channel tools",
            data,
        ));
    };
    let key = match arguments.get("key") {
        Some(Value::String(key))
            if !key.is_empty() && key.len() <= server.listen_config.key_bytes =>
        {
            key.clone()
        }
        Some(_) => {
            return Err(invalid(&format!(
                "key must be a nonempty string of at most {} bytes",
                server.listen_config.key_bytes
            )));
        }
        None => match fresh_key(server.listen_config.key_bytes) {
            Ok(key) => key,
            Err(words) => return Ok(ToolResult::failed(&words, data)),
        },
    };
    Ok(
        match super::publish_wire::publish(source, subject, text, &key) {
            Ok(id) => ToolResult::ok(json!({"subject":subject,"id":id})),
            Err(words) => ToolResult::failed(&words, data),
        },
    )
}

fn fresh_key(bound: usize) -> Result<String, String> {
    // An operation id uses 256 bits of entropy. Its hex representation must
    // fit the configured key budget before any entropy or network is read.
    if bound < 64 {
        return Err(format!("a fresh key is 64 bytes; key_bytes is {bound}"));
    }
    let mut entropy = [0; 32];
    let result =
        std::fs::File::open("/dev/urandom").and_then(|mut source| source.read_exact(&mut entropy));
    let key = result
        .map(|()| hex::encode(entropy))
        .map_err(|error| format!("send key entropy failed: {}", error.kind()));
    entropy.zeroize();
    key
}

fn invalid(words: &str) -> Refusal {
    Refusal {
        code: INVALID_PARAMS,
        message: words.to_owned(),
        data: None,
    }
}