haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
use super::{config::Seat, tools::Identity};
use serde::{Deserialize, Serialize};
use std::{
    fs::{File, OpenOptions},
    io::{Read as _, Write as _},
    os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
    path::{Path, PathBuf},
};

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Debt {
    issuer: String,
    agent: String,
    proof: String,
    bound: usize,
}
pub(super) struct Owed {
    path: PathBuf,
    debt: Debt,
    lock: File,
}
impl Owed {
    pub fn begin(seat: &Seat, identity: &Identity) -> anyhow::Result<Self> {
        let path = companion(&seat.path, "revoke-owed")?;
        let lock_path = companion(&seat.path, "revoke-lock")?;
        let lock = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .mode(0o600)
            .custom_flags(i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())?)
            .open(lock_path)?;
        private(&lock)?;
        rustix::fs::flock(&lock, rustix::fs::FlockOperation::NonBlockingLockExclusive)
            .map_err(|_| anyhow::anyhow!("assistant_seat_already_running"))?;
        match OpenOptions::new()
            .read(true)
            .custom_flags(i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())?)
            .open(&path)
        {
            Ok(file) => {
                private(&file)?;
                let limit = seat
                    .harness
                    .issuer_response_bytes
                    .checked_mul(2)
                    .and_then(|n| n.checked_add(4096))
                    .ok_or_else(|| anyhow::anyhow!("revoke_marker_bound_overflow"))?;
                let mut bytes = Vec::new();
                file.take(u64::try_from(limit)?.saturating_add(1))
                    .read_to_end(&mut bytes)?;
                anyhow::ensure!(bytes.len() <= limit, "revoke_marker_exceeds_bound");
                let debt: Debt = serde_json::from_slice(&bytes)
                    .map_err(|_| anyhow::anyhow!("revoke_marker_invalid"))?;
                debt.send()?;
                remove(&path)?;
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error.into()),
        }
        let debt = Debt {
            issuer: super::tools::origin(&seat.issuer)?.to_owned(),
            agent: identity.agent.clone(),
            proof: identity.launch.clone(),
            bound: seat.harness.issuer_response_bytes,
        };
        let mut file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(&path)?;
        file.write_all(&serde_json::to_vec(&debt)?)?;
        sync_file(&file)?;
        sync_file(&parent(&path)?)?;
        Ok(Self { path, debt, lock })
    }
    pub async fn close(self) -> anyhow::Result<()> {
        tokio::task::spawn_blocking(move || {
            self.debt.send()?;
            remove(&self.path)?;
            drop(self.lock);
            Ok(())
        })
        .await
        .map_err(|_| anyhow::anyhow!("revoke_worker_failed"))?
    }
}
impl Debt {
    fn send(&self) -> anyhow::Result<()> {
        let issuer = super::tools::origin(&self.issuer)?;
        anyhow::ensure!(
            !self.agent.is_empty()
                && !matches!(self.agent.as_str(), "." | "..")
                && self.agent.len() <= 128
                && self
                    .agent
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')),
            "revoke_agent_invalid"
        );
        anyhow::ensure!(
            self.bound > 0
                && !self.proof.is_empty()
                && self.proof.len() <= self.bound
                && self.proof.is_ascii()
                && !self.proof.bytes().any(|byte| byte.is_ascii_control()),
            "revoke_proof_invalid"
        );
        let mut proof = ureq::http::HeaderValue::from_str(&self.proof)
            .map_err(|_| anyhow::anyhow!("revoke_proof_invalid"))?;
        proof.set_sensitive(true);
        let mut response = super::tools::client()
            .post(format!("{issuer}/api/agents/{}/pass/revoke", self.agent))
            .header("lys-agent-pass", proof)
            .header("content-type", "application/json")
            .send(br#"{"audience":"haematite"}"#.as_slice())
            .map_err(|_| anyhow::anyhow!("assistant_revoke_unreachable"))?;
        let status = response.status().as_u16();
        let body = response
            .body_mut()
            .with_config()
            .limit(u64::try_from(self.bound)?)
            .read_to_vec()
            .map_err(|_| {
                anyhow::anyhow!("assistant_revoke_response_exceeds_bound_or_unreadable")
            })?;
        anyhow::ensure!(
            matches!(status, 200 | 204),
            "assistant_revoke_refused: HTTP {status}"
        );
        drop(body);
        Ok(())
    }
}
fn private(file: &File) -> anyhow::Result<()> {
    let meta = file.metadata()?;
    anyhow::ensure!(
        meta.is_file()
            && meta.uid() == rustix::process::geteuid().as_raw()
            && meta.permissions().mode() & 0o7777 == 0o600,
        "revoke_marker_requires_owner_and_mode_0600"
    );
    Ok(())
}
fn parent(path: &Path) -> anyhow::Result<File> {
    Ok(File::open(path.parent().ok_or_else(|| {
        anyhow::anyhow!("revoke_marker_parent_absent")
    })?)?)
}
fn remove(path: &Path) -> anyhow::Result<()> {
    std::fs::remove_file(path)?;
    sync_file(&parent(path)?)?;
    Ok(())
}

fn companion(path: &Path, suffix: &str) -> anyhow::Result<PathBuf> {
    let name = path
        .file_name()
        .ok_or_else(|| anyhow::anyhow!("seat_config_name_absent"))?;
    let mut name = name.to_os_string();
    name.push(format!(".{suffix}"));
    Ok(path.with_file_name(name))
}

fn sync_file(file: &File) -> anyhow::Result<()> {
    file.sync_all()?;
    Ok(())
}