liminal-cli 0.2.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
use std::path::PathBuf;
use std::time::Duration;

use super::{
    AUDIENCE_BYTES, Held, LysAgent, LysTable, PassError, RENEW_WITHIN_SECS, read_key, signature,
};

pub(super) fn environment(name: &str) -> Result<Option<String>, &'static str> {
    match std::env::var(name) {
        Ok(value) => Ok(Some(value)),
        Err(std::env::VarError::NotPresent) => Ok(None),
        Err(std::env::VarError::NotUnicode(_)) => Err(match name {
            "LYS_AGENT" => "LYS_AGENT is not Unicode",
            _ => "LYS_AGENT_PASS is not Unicode",
        }),
    }
}

pub(super) fn key_file<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<PathBuf, D::Error> {
    let path = <PathBuf as serde::Deserialize>::deserialize(deserializer)?;
    if path.as_os_str().is_empty() {
        return Err(serde::de::Error::custom(
            "lys.key_file must be an absolute path",
        ));
    }
    Ok(path)
}

pub(super) fn table_agent<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<String, D::Error> {
    let agent = <String as serde::Deserialize>::deserialize(deserializer)?;
    if agent.is_empty() {
        return Err(serde::de::Error::custom(
            "lys.agent must be a plain agent id",
        ));
    }
    Ok(agent)
}

pub(super) enum Proof {
    Signed(PathBuf),
    RunPass(String),
}

impl Proof {
    pub(super) fn resolve(
        table: &LysTable,
        env: impl Fn(&str) -> Result<Option<String>, &'static str>,
    ) -> Result<(String, Self), &'static str> {
        if !table.key_file.as_os_str().is_empty() {
            return Ok((table.agent.clone(), Self::Signed(table.key_file.clone())));
        }
        let agent = env("LYS_AGENT")?
            .filter(|value| !value.trim().is_empty())
            .ok_or("LYS_AGENT is missing or empty")?;
        if !table.agent.is_empty() && table.agent != agent {
            return Err("lys.agent differs from LYS_AGENT");
        }
        if agent.len() > 128
            || !agent
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        {
            return Err("LYS_AGENT must be a plain agent id");
        }
        let pass = env("LYS_AGENT_PASS")?
            .filter(|value| !value.trim().is_empty())
            .ok_or("LYS_AGENT_PASS is missing or empty")?;
        if !pass.is_ascii() || pass.bytes().any(|byte| byte.is_ascii_control()) {
            return Err("LYS_AGENT_PASS is not a valid header value");
        }
        Ok((agent, Self::RunPass(pass)))
    }

    pub(super) const fn renew_within(&self) -> u64 {
        match self {
            Self::Signed(_) => RENEW_WITHIN_SECS,
            Self::RunPass(_) => 0,
        }
    }
}

pub(super) struct PassRequest {
    pub(super) header: &'static str,
    pub(super) proof: String,
    pub(super) body: Vec<u8>,
}

impl PassRequest {
    pub(super) fn proof_header(&self) -> Result<ureq::http::HeaderValue, PassError> {
        let mut header = ureq::http::HeaderValue::from_bytes(self.proof.as_bytes())
            .map_err(|_| PassError("the Lys proof is not a valid header value".to_owned()))?;
        header.set_sensitive(true);
        Ok(header)
    }
}

pub(super) fn transport_reason(error: &ureq::Error) -> String {
    match error {
        ureq::Error::Io(error) => return format!("I/O: {}", error.kind()),
        ureq::Error::Timeout(timeout) => return format!("timeout: {timeout}"),
        ureq::Error::StatusCode(status) => return format!("HTTP {status}"),
        ureq::Error::Http(_) => "invalid HTTP request",
        ureq::Error::BadUri(_) => "bad issuer URI",
        ureq::Error::Protocol(_) => "HTTP protocol failure",
        ureq::Error::HostNotFound => "host not found",
        ureq::Error::RedirectFailed => "redirect refused",
        ureq::Error::InvalidProxyUrl => "invalid proxy URI",
        ureq::Error::ConnectionFailed => "connection failed",
        ureq::Error::BodyExceedsLimit(_) => "body exceeds limit",
        ureq::Error::TooManyRedirects => "too many redirects",
        ureq::Error::Tls(_) | ureq::Error::Rustls(_) => "TLS failure",
        ureq::Error::Pem(_) => "TLS certificate read failure",
        ureq::Error::RequireHttpsOnly(_) => "HTTPS required",
        ureq::Error::LargeResponseHeader(_, _) => "response header exceeds limit",
        ureq::Error::ConnectProxyFailed(_) => "proxy connection failed",
        ureq::Error::TlsRequired => "TLS required",
        ureq::Error::Other(_) => "other transport failure",
        ureq::Error::BodyStalled => "response body stalled",
        _ => "unclassified HTTP transport failure",
    }
    .to_owned()
}

impl LysAgent {
    pub(super) fn new_with_env(
        table: &LysTable,
        timeout: Duration,
        env: impl Fn(&str) -> Result<Option<String>, &'static str>,
    ) -> Result<Self, &'static str> {
        let (agent, proof) = Proof::resolve(table, env)?;
        Self::configured(table, timeout, agent, proof)
    }

    pub(super) fn pass_for_at(
        &self,
        audience: &str,
        now: u64,
        transport: impl FnOnce(&str, &PassRequest) -> Result<(u16, Vec<u8>), PassError>,
    ) -> Result<String, PassError> {
        if audience.is_empty()
            || audience.len() > AUDIENCE_BYTES
            || !audience
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        {
            return Err(PassError(
                "pass_for must name a plain audience, such as haematite".to_owned(),
            ));
        }
        let held = {
            let held = self.lock()?;
            held.get(audience)
                .filter(|current| {
                    current.expires_at > now.saturating_add(self.proof.renew_within())
                })
                .map(|current| current.pass.clone())
        };
        if let Some(pass) = held {
            return Ok(pass);
        }
        let fresh = self.ask_with(now, audience, transport)?;
        let pass = fresh.pass.clone();
        self.lock()?.insert(audience.to_owned(), fresh);
        Ok(pass)
    }

    /// Retires a run's held liminal pass after its named admission expiry.
    ///
    /// # Errors
    /// The held pass lock is poisoned.
    pub(crate) fn forget_expired_pass(&self) -> Result<bool, PassError> {
        if matches!(&self.proof, Proof::Signed(_)) {
            return Ok(false);
        }
        self.lock()?.remove(super::AUDIENCE);
        Ok(true)
    }

    fn ask_with(
        &self,
        now: u64,
        audience: &str,
        transport: impl FnOnce(&str, &PassRequest) -> Result<(u16, Vec<u8>), PassError>,
    ) -> Result<Held, PassError> {
        let body = serde_json::to_vec(&serde_json::json!({ "audience": audience }))
            .map_err(|_| PassError("the pass request could not be written".to_owned()))?;
        let (header, proof) = match &self.proof {
            Proof::Signed(path) => {
                let key = read_key(path)?;
                (
                    "lys-agent-signature",
                    signature(
                        &key,
                        &self.agent,
                        &self.path,
                        &body,
                        now.saturating_mul(1000),
                    )?,
                )
            }
            Proof::RunPass(pass) => ("lys-agent-pass", pass.clone()),
        };
        let request = PassRequest {
            header,
            proof,
            body,
        };
        let (status, body) = transport(&self.route, &request)?;
        Self::read_answer(status, &body, now, audience)
    }
}

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