haematite-cli 0.21.7

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use std::net::{IpAddr, TcpStream};
use std::time::{Duration, Instant};

use url::Url;

use super::lys_pass::LysAgent;

pub(super) struct Options {
    pub url: String,
    pub agent: LysAgent,
    pub timeout: Duration,
}

impl Options {
    pub fn parse(mut args: impl Iterator<Item = String>) -> Result<Self, String> {
        let mut remote = None;
        let mut pass_args = Vec::new();
        while let Some(arg) = args.next() {
            if arg == "--remote" {
                if remote.is_some() {
                    return Err("haematite_mcp_remote_arguments_invalid: repeated --remote".into());
                }
                remote = args.next();
                if remote.is_none() {
                    return Err(
                        "haematite_mcp_remote_arguments_invalid: --remote requires a URL".into(),
                    );
                }
            } else {
                pass_args.push(arg);
            }
        }
        let remote = remote.ok_or("haematite_mcp_remote_arguments_invalid: missing --remote")?;
        let url = validate(&remote, "haematite_mcp_remote_url_invalid")?;
        let issuer = pass_args
            .windows(2)
            .find(|pair| pair[0] == "--lys-agent")
            .map(|pair| pair[1].as_str())
            .ok_or(
                "haematite_mcp_remote_arguments_invalid: --lys-agent requires an issuer and agent",
            )?;
        let lys = validate(issuer, "haematite_mcp_lys_url_invalid")?;
        let timeout_ms = pass_args
            .windows(2)
            .find(|pair| pair[0] == "--timeout-ms")
            .map_or(Ok(10_000), |pair| pair[1].parse::<u64>())
            .map_err(|_| "haematite_mcp_remote_arguments_invalid: invalid timeout")?;
        let timeout = Duration::from_millis(timeout_ms);
        let agent = super::pass_header::parse(pass_args.clone().into_iter())?;
        probe(&url, &remote, timeout, "haematite_mcp_remote_unanswered")?;
        probe(&lys, issuer, timeout, "haematite_mcp_lys_unanswered")?;
        Ok(Self {
            url: remote,
            agent,
            timeout,
        })
    }
}

fn validate(value: &str, code: &str) -> Result<Url, String> {
    let invalid = || {
        format!(
            "{code}: requires HTTPS, or HTTP on 127.0.0.1 or localhost; no credentials or fragment"
        )
    };
    let uri: ureq::http::Uri = value.parse().map_err(|_| invalid())?;
    let authority = uri.authority().ok_or_else(invalid)?;
    let local = matches!(authority.host(), "127.0.0.1" | "localhost");
    if !value.is_ascii()
        || value.contains('#')
        || authority.as_str().contains('@')
        || !(uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && local))
    {
        return Err(invalid());
    }
    Url::parse(value).map_err(|_| invalid())
}

fn probe(url: &Url, address: &str, timeout: Duration, code: &str) -> Result<(), String> {
    let host = url.host_str().unwrap_or_default().trim_matches(['[', ']']);
    if host != "localhost" && !host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback()) {
        return Ok(());
    }
    let unanswered = || format!("{code}: {address} did not answer");
    let started = Instant::now();
    let addresses = url.socket_addrs(|| None).map_err(|_| unanswered())?;
    for socket in addresses {
        let remaining = timeout
            .checked_sub(started.elapsed())
            .ok_or_else(unanswered)?;
        if TcpStream::connect_timeout(&socket, remaining).is_ok() {
            return Ok(());
        }
    }
    Err(unanswered())
}