haematite-cli 0.33.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! The foreground door and its read-only configuration check share one loader.

use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Instant;

use super::config::Config;
use super::listen::serve;
use super::local_signin::{Local, NoLys, link};
use super::lys::LysSignIn;
use super::signin::{Door, Issuer};
use crate::client::network::Trust;

const CONFIG_BYTES: u64 = 1_048_576;
const USAGE: &str = "usage: haematite door [--check] --config <absolute door.json>";

/// Run the door command, reporting check refusals with exit 78.
pub fn dispatch(args: impl Iterator<Item = String>) -> ExitCode {
    let started = Instant::now();
    let (path, checking) = match arguments(args) {
        Ok(arguments) => arguments,
        Err(message) => {
            eprintln!("{message}");
            return ExitCode::FAILURE;
        }
    };
    match run_config(&path, checking, started) {
        Ok(()) => ExitCode::SUCCESS,
        Err(message) => {
            eprintln!("{message}");
            if checking {
                ExitCode::from(78)
            } else {
                ExitCode::FAILURE
            }
        }
    }
}

/// Check the configuration or run the door until its listener stops accepting.
///
/// # Errors
///
/// The usage, or the named reason the door could not check or start.
pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
    let started = Instant::now();
    let (path, checking) = arguments(args)?;
    run_config(&path, checking, started)
}

fn run_config(path: &Path, checking: bool, started: Instant) -> Result<(), String> {
    let config = load(path, checking)?;
    let networked = config
        .stores
        .values()
        .any(|store| matches!(store, super::config::Store::Network(_)));
    let trust = if networked {
        Trust::system()?
    } else {
        Trust::mozilla()?
    };
    // HAEMNET-003 R1: every network store is reached and asked hello, at
    // --check and at start, before Lys is asked; one reached that is not
    // verified TLS stops both by name.
    let reached = super::check::stores(&config, &trust);
    let say = |line: &String| {
        if checking {
            println!("{line}");
        } else {
            eprintln!("haematite door: {line}");
        }
    };
    let reached = match reached {
        Ok(lines) => lines,
        Err((lines, refused)) => {
            lines.iter().for_each(say);
            return Err(refused);
        }
    };
    reached.iter().for_each(say);
    if checking {
        super::check::configuration(&config)?;
        // ACCESS-004 R1: the stores it serves by name, studio among them.
        println!(
            "door_check_ok: {} children, {} stores: {}",
            config.children.len(),
            config.stores.len(),
            config
                .stores
                .keys()
                .map(String::as_str)
                .collect::<Vec<_>>()
                .join(", ")
        );
        return Ok(());
    }
    if let Some(lys) = &config.lys {
        let door = Arc::new(Door::new(LysSignIn::connect(lys)?));
        let assistant = super::assistant_socket::Listener::start(&door, &config)
            .map_err(|error| format!("{}: {}", error.code, error.message))?;
        open(&config, &trust, &door, started, None)?;
        drop(assistant);
        return Ok(());
    }
    // ACCESS-003 R2: no lys block, so no Lys is asked and no seat socket is
    // opened; the person at the keyboard signs in by the key printed below.
    let owner = crate::serve::contracts::local_owner()
        .map_err(|failure| format!("{}: {}", failure.code, failure.message))?;
    let (local, key) =
        Local::new(owner).map_err(|failure| format!("{}: {}", failure.code, failure.message))?;
    let door = Arc::new(Door::at_keyboard(NoLys, local));
    open(&config, &trust, &door, started, Some(&key))
}

/// Start the door's children, listen, and serve until the listener stops
/// accepting. A door with no lys block prints `key`'s link, once, on its
/// own standard output and nowhere else, once it listens.
fn open<I: Issuer>(
    config: &Config,
    trust: &Trust,
    door: &Arc<Door<I>>,
    started: Instant,
    key: Option<&str>,
) -> Result<(), String> {
    let children = super::children::Children::start(&config.children)?;
    let site = Arc::new(config.site(trust)?);
    let limits = config.limits();
    let listener = TcpListener::bind(config.listen)
        .map_err(|error| format!("the door could not listen on {}: {error}", config.listen))?;
    let listening = listener
        .local_addr()
        .map_err(|error| format!("the door could not read its listen address: {error}"))?;
    eprintln!(
        "haematite door: listening on {listening} for {} elapsed_ms={}",
        config.origin,
        started.elapsed().as_millis()
    );
    if let Some(key) = key {
        let mut out = std::io::stdout().lock();
        writeln!(out, "{}", link(&config.origin, key))
            .and_then(|()| out.flush())
            .map_err(|error| format!("the door could not print its sign-in link: {error}"))?;
    }
    serve(&listener, door, &site, &limits);
    drop(children);
    Ok(())
}

fn arguments(mut args: impl Iterator<Item = String>) -> Result<(PathBuf, bool), String> {
    let mut checking = false;
    let mut path = None;
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--check" if !checking => checking = true,
            "--config" if path.is_none() => path = Some(PathBuf::from(args.next().ok_or(USAGE)?)),
            _ => return Err(USAGE.to_owned()),
        }
    }
    let path = path.ok_or(USAGE)?;
    if !path.is_absolute() {
        return Err(USAGE.to_owned());
    }
    Ok((path, checking))
}

fn load(path: &Path, checking: bool) -> Result<Config, String> {
    let file = super::config_upgrade::open_source(path)?;
    let mut bytes = Vec::new();
    file.take(CONFIG_BYTES + 1)
        .read_to_end(&mut bytes)
        .map_err(|error| format!("the door configuration could not be read: {error}"))?;
    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > CONFIG_BYTES {
        return Err("the door configuration exceeds its byte limit".to_owned());
    }
    let plan = super::config_upgrade::plan(path, &bytes)?;
    if checking {
        super::config_upgrade::check_apply(path, &plan)?;
    } else {
        super::config_upgrade::apply(path, &bytes, &plan)?;
    }
    let config = Config::parse(&plan.bytes)?;
    if checking && !plan.names.is_empty() {
        println!(
            "door_config_upgrade_needed: give_up_after=3 for {}",
            plan.names.join(", ")
        );
    }
    Ok(config)
}