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
//! The door's configuration (ACCESS-002 R2).
//!
//! It names where the door listens, Studio's origin and files, the stores it
//! carries verbs to, and the Lys app it signs people in as. The app's secret
//! is named by its file; the door's Lys client reads it and hands it to
//! the sign-in helper.

use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::num::{NonZeroU64, NonZeroUsize};
use std::path::PathBuf;
use std::time::Duration;

use serde::Deserialize;

use super::listen::Limits;
use super::packed::Packed;
use super::route::Site;

/// The bound on a request's head, in bytes.
const HEAD_CAP: usize = 16 * 1024;
/// The bound on a request's body, in bytes.
const BODY_CAP: usize = 1024 * 1024;
/// The bound on each read and write of a browser's connection.
const READ_TIMEOUT: Duration = Duration::from_secs(10);

/// The whole of `haematite door`'s configuration file.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    /// Programs whose stdin pipes are owned by this door.
    #[serde(default)]
    pub children: Vec<super::children::Config>,
    /// A loopback address; TLS is the proxy's in front of it.
    pub listen: SocketAddr,
    /// Studio's origin, as the browser sends it.
    pub origin: String,
    /// The absolute folder holding Studio's built files.
    pub files: PathBuf,
    /// Each store by name, with its socket's absolute path.
    pub stores: BTreeMap<String, PathBuf>,
    /// The bound on each store connection's steps, in milliseconds.
    #[serde(default = "timeout")]
    pub timeout_ms: NonZeroU64,
    /// Sixty-four bounds the threads serving browser and seat connections together.
    #[serde(default = "connections")]
    pub max_connections: NonZeroUsize,
    /// The Lys app the door signs people in as.
    pub lys: Lys,
    /// The optional seat socket and its admission and replay bounds.
    pub assistant: Option<Assistant>,
}

/// Bounds on seats, attached streams and outstanding operations, counted separately.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Assistant {
    /// Private local transport; a relative path could select another install.
    pub socket: PathBuf,
    /// 256 KiB admits bounded tool items without unbounded line allocation.
    #[serde(default = "assistant_frame")]
    pub frame_bytes: NonZeroUsize,
    /// Two seats or tabs let a person work in two stores or windows.
    #[serde(default = "assistant_person")]
    pub max_per_person: NonZeroUsize,
    /// Thirty-two limits one store's assistant and stream memory.
    #[serde(default = "assistant_store")]
    pub max_per_store: NonZeroUsize,
    /// One frame bounds each replay page and its compressed sequence coverage.
    #[serde(default = "assistant_frame")]
    pub events_after_bytes: NonZeroUsize,
}

const fn assistant_frame() -> NonZeroUsize {
    NonZeroUsize::MIN.saturating_add(262_143)
}
const fn assistant_person() -> NonZeroUsize {
    NonZeroUsize::MIN.saturating_add(1)
}
const fn assistant_store() -> NonZeroUsize {
    NonZeroUsize::MIN.saturating_add(31)
}

/// The Lys app the door signs people in as.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lys {
    /// Lys's origin.
    pub issuer: String,
    /// The app's client id in Lys.
    pub client_id: String,
    /// The absolute path of the file holding the app's secret.
    pub secret_file: PathBuf,
    /// Where Lys sends the browser back: the origin's `/callback`.
    pub redirect: String,
}

const fn timeout() -> NonZeroU64 {
    NonZeroU64::MIN.saturating_add(29_999)
}

const fn connections() -> NonZeroUsize {
    NonZeroUsize::MIN.saturating_add(63)
}

impl Config {
    /// The configuration in `bytes`, checked whole.
    ///
    /// # Errors
    ///
    /// A message naming the field that is missing, unknown or unsafe.
    pub fn parse(bytes: &[u8]) -> Result<Self, String> {
        let config: Self = serde_json::from_slice(bytes)
            .map_err(|error| format!("the door configuration is not valid: {error}"))?;
        config.check()?;
        Ok(config)
    }

    fn check(&self) -> Result<(), String> {
        super::children::check(&self.children, &self.stores)?;
        if self
            .assistant
            .as_ref()
            .is_some_and(|assistant| !assistant.socket.is_absolute())
        {
            return Err("assistant.socket must be absolute".to_owned());
        }
        if !self.listen.ip().is_loopback() {
            return Err("listen must be a loopback address behind a TLS proxy".to_owned());
        }
        let origin =
            url::Url::parse(&self.origin).map_err(|_| "origin is not an address".to_owned())?;
        if origin.scheme() != "https" || origin.origin().ascii_serialization() != self.origin {
            return Err(
                "origin must be an https origin with no path, as the browser sends it".to_owned(),
            );
        }
        if !self.files.is_absolute() {
            return Err("files must be an absolute folder".to_owned());
        }
        if self.stores.is_empty() {
            return Err("stores must name at least one store".to_owned());
        }
        for (name, socket) in &self.stores {
            if name.is_empty()
                || !name
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
            {
                return Err(format!(
                    "store name {name:?} must be letters, digits, - or _"
                ));
            }
            if !socket.is_absolute() {
                return Err(format!("store socket for {name} must be an absolute path"));
            }
        }
        let issuer = url::Url::parse(&self.lys.issuer)
            .map_err(|_| "lys issuer is not an address".to_owned())?;
        if !matches!(issuer.scheme(), "http" | "https") {
            return Err("lys issuer must be an http or https address".to_owned());
        }
        if self.lys.client_id.is_empty() || self.lys.client_id.chars().any(char::is_control) {
            return Err("lys client_id must be a name with no control character".to_owned());
        }
        if !self.lys.secret_file.is_absolute() {
            return Err("lys secret_file must be an absolute path".to_owned());
        }
        if self.lys.redirect != format!("{}/callback", self.origin) {
            return Err("lys redirect must be the origin's /callback".to_owned());
        }
        Ok(())
    }

    /// What the door serves.
    #[must_use]
    pub fn site(&self) -> Site {
        Site {
            origin: self.origin.clone(),
            files: self.files.clone(),
            stores: self.stores.clone(),
            store_children: self
                .children
                .iter()
                .filter_map(|child| {
                    child.store.as_ref().map(|store| {
                        (
                            store.clone(),
                            (child.name.clone(), std::sync::Arc::clone(&child.given_up)),
                        )
                    })
                })
                .collect(),
            timeout: Duration::from_millis(self.timeout_ms.get()),
            packed: Packed::default(),
        }
    }

    /// The door's bounds on its connections.
    #[must_use]
    pub const fn limits(&self) -> Limits {
        Limits {
            max_connections: self.max_connections.get(),
            read_timeout: READ_TIMEOUT,
            head_cap: HEAD_CAP,
            body_cap: BODY_CAP,
        }
    }
}

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

/// Bounds read from the process configuration, with no environment mutation.
/// Defaults: 512 pending states, 12 hour sessions, 5 second connects and
/// 30 second whole Lys requests.
#[derive(Clone, Copy)]
pub struct SignInBounds {
    pub max_pending: NonZeroUsize,
    pub session_seconds: NonZeroU64,
    pub connect_ms: NonZeroU64,
    pub total_ms: NonZeroU64,
}

impl Default for SignInBounds {
    fn default() -> Self {
        Self {
            max_pending: NonZeroUsize::MIN.saturating_add(511),
            session_seconds: NonZeroU64::MIN.saturating_add(43_199),
            connect_ms: NonZeroU64::MIN.saturating_add(4_999),
            total_ms: timeout(),
        }
    }
}

impl SignInBounds {
    /// Read sign-in bounds from process configuration.
    ///
    /// # Errors
    ///
    /// Names an invalid bound, a connect bound beyond the total, or an unrepresentable timeout.
    pub fn from_env() -> Result<Self, String> {
        let defaults = Self::default();
        let max_pending = positive_env(
            "HAEMATITE_PENDING_SIGNINS",
            defaults.max_pending.get() as u64,
        )?;
        let bounds = Self {
            max_pending: usize::try_from(max_pending.get())
                .ok()
                .and_then(NonZeroUsize::new)
                .ok_or("HAEMATITE_PENDING_SIGNINS exceeds this machine's range")?,
            session_seconds: positive_env(
                "HAEMATITE_SESSION_SECONDS",
                defaults.session_seconds.get(),
            )?,
            connect_ms: positive_env(
                "HAEMATITE_LYS_CONNECT_TIMEOUT_MS",
                defaults.connect_ms.get(),
            )?,
            total_ms: positive_env("HAEMATITE_LYS_TIMEOUT_MS", defaults.total_ms.get())?,
        };
        if bounds.connect_ms > bounds.total_ms {
            return Err(
                "HAEMATITE_LYS_CONNECT_TIMEOUT_MS exceeds HAEMATITE_LYS_TIMEOUT_MS".to_owned(),
            );
        }
        if std::time::Instant::now()
            .checked_add(Duration::from_millis(bounds.total_ms.get()))
            .is_none()
        {
            return Err("HAEMATITE_LYS_TIMEOUT_MS exceeds the monotonic clock".to_owned());
        }
        Ok(bounds)
    }
}

pub(crate) fn positive_env(name: &str, default: u64) -> Result<NonZeroU64, String> {
    match std::env::var(name) {
        Ok(value) => value
            .parse()
            .map_err(|_| format!("{name} must be a positive integer")),
        Err(std::env::VarError::NotPresent) => {
            NonZeroU64::new(default).ok_or_else(|| format!("{name} has a zero default"))
        }
        Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be a positive integer")),
    }
}