liminal-cli 0.3.5

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! Close asks the issuer once, without redirects, and never exposes the launch proof.

use std::fmt;
use std::time::Duration;
use zeroize::Zeroizing;

pub(super) const fn session_end(resumable: bool) -> &'static str {
    if resumable { "dormant" } else { "ended" }
}

pub(super) struct Revoke {
    route: String,
    pass: Zeroizing<String>,
    bound: usize,
    client: ureq::Agent,
}

impl fmt::Debug for Revoke {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("RunPassRevoke")
    }
}

impl Revoke {
    pub fn new(
        issuer: &str,
        agent: &str,
        pass: String,
        bound: usize,
        timeout_ms: u64,
    ) -> anyhow::Result<Self> {
        let pass = Zeroizing::new(pass);
        let issuer = validate_issuer(issuer)?;
        anyhow::ensure!(
            !agent.is_empty()
                && agent
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
            "revoke agent must be one segment"
        );
        anyhow::ensure!(
            bound > 0 && timeout_ms > 0,
            "revoke response and request bounds must be positive"
        );
        anyhow::ensure!(
            !pass.is_empty()
                && pass.is_ascii()
                && !pass.bytes().any(|byte| byte.is_ascii_control()),
            "LYS_AGENT_PASS is not a valid header value"
        );
        let client = ureq::Agent::new_with_config(
            ureq::Agent::config_builder()
                .proxy(None)
                .http_status_as_error(false)
                .max_redirects(0)
                .timeout_global(Some(Duration::from_millis(timeout_ms)))
                .build(),
        );
        Ok(Self {
            route: format!("{issuer}/api/agents/{agent}/pass"),
            pass,
            bound,
            client,
        })
    }

    pub fn send(self) -> anyhow::Result<()> {
        revoke_run_audience(
            &self.client,
            &self.route,
            &self.pass,
            u64::try_from(self.bound)?,
        )?;
        Ok(())
    }
}

pub(super) fn validate_issuer(issuer: &str) -> anyhow::Result<&str> {
    let issuer = issuer.trim_end_matches('/');
    let uri: ureq::http::Uri = issuer
        .parse()
        .map_err(|_| anyhow::anyhow!("lys.issuer is not an origin"))?;
    let authority = uri
        .authority()
        .ok_or_else(|| anyhow::anyhow!("lys.issuer has no authority"))?;
    anyhow::ensure!(
        !authority.as_str().contains('@')
            && uri.query().is_none()
            && matches!(uri.path(), "" | "/")
            && !issuer.contains('#'),
        "lys.issuer must be an origin without credentials, path, query or fragment"
    );
    anyhow::ensure!(
        uri.scheme_str() == Some("https")
            || (uri.scheme_str() == Some("http")
                && ["localhost", "127.0.0.1", "[::1]"].contains(&authority.host())),
        "lys.issuer must be https or loopback http"
    );
    Ok(issuer)
}

/// Revoke the audience using the run-pass owner's existing bounded client.
/// Redirects and proxies are refused before the proof crosses the boundary.
///
/// # Errors
/// Returns a named client, route, transport or issuer refusal without secret bytes.
pub fn revoke_run_audience(
    client: &ureq::Agent,
    pass_route: &str,
    pass: &str,
    response_bytes: u64,
) -> Result<(), crate::lys::PassError> {
    send_revoke(client, pass_route, pass, response_bytes)
        .map_err(|error| crate::lys::PassError(error.to_string()))
}

fn send_revoke(
    client: &ureq::Agent,
    pass_route: &str,
    pass: &str,
    response_bytes: u64,
) -> anyhow::Result<()> {
    anyhow::ensure!(
        !pass.is_empty() && pass.is_ascii() && !pass.bytes().any(|byte| byte.is_ascii_control()),
        "revoke proof is not a valid header value"
    );
    let config = client.config();
    anyhow::ensure!(
        config.max_redirects() == 0
            && config.proxy().is_none()
            && config
                .timeouts()
                .global
                .is_some_and(|duration| !duration.is_zero())
            && response_bytes > 0,
        "revoke needs a bounded client without redirects or a proxy"
    );
    let uri: ureq::http::Uri = pass_route
        .parse()
        .map_err(|_| anyhow::anyhow!("revoke route refused"))?;
    let authority = uri
        .authority()
        .ok_or_else(|| anyhow::anyhow!("revoke route has no authority"))?;
    let segments: Vec<_> = uri
        .path()
        .split('/')
        .filter(|segment| !segment.is_empty())
        .collect();
    anyhow::ensure!(
        !authority.as_str().contains('@')
            && uri.query().is_none()
            && segments.len() == 4
            && segments[0] == "api"
            && segments[1] == "agents"
            && segments[3] == "pass"
            && segments[2]
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
        "revoke route is not the agent pass endpoint"
    );
    anyhow::ensure!(
        uri.scheme_str() == Some("https")
            || (uri.scheme_str() == Some("http")
                && ["localhost", "127.0.0.1", "[::1]"].contains(&authority.host())),
        "revoke route must be https or loopback http"
    );
    let mut proof = ureq::http::HeaderValue::from_bytes(pass.as_bytes())
        .map_err(|_| anyhow::anyhow!("revoke proof is not a header value"))?;
    proof.set_sensitive(true);
    let mut response = client
        .post(format!("{pass_route}/revoke"))
        .header("lys-agent-pass", proof)
        .header("content-type", "application/json")
        .send(b"{\"audience\":\"liminal\"}".as_slice())
        .map_err(|_| anyhow::anyhow!("run pass revoke transport failed"))?;
    let status = response.status().as_u16();
    let body = Zeroizing::new(
        response
            .body_mut()
            .with_config()
            .limit(response_bytes)
            .read_to_vec()
            .map_err(|_| {
                anyhow::anyhow!(
                    "run pass revoke response could not be read within issuer_response_bytes"
                )
            })?,
    );
    if matches!(status, 200 | 204) {
        return Ok(());
    }
    let name = serde_json::from_slice::<serde_json::Value>(&body)
        .ok()
        .and_then(|value| {
            value
                .get("refusal")
                .and_then(serde_json::Value::as_str)
                .map(str::to_owned)
        })
        .filter(|name| {
            !name.is_empty()
                && !name.contains(pass)
                && name
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
        })
        .unwrap_or_else(|| "unnamed_refusal".to_owned());
    anyhow::bail!("run pass revoke refused: {name} (HTTP {status})")
}

/// Revoke the launch's audience pass at its owner's close boundary.
/// The request consumes a transient proof; diagnostics carry only the refusal name.
///
/// # Errors
/// Returns the named configuration, transport, response bound or issuer refusal.
pub fn revoke_run_pass(
    issuer: &str,
    agent: &str,
    pass: String,
    response_bytes: usize,
    timeout_ms: u64,
) -> anyhow::Result<()> {
    Revoke::new(issuer, agent, pass, response_bytes, timeout_ms)?.send()
}