haematite-cli 0.21.12

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! HAEMNET-003 R1: `haematite door --check` says, for each network store
//! entry, whether it was reached and whether the store answered hello, and
//! changes nothing; a network entry that is not TLS, is not trusted, names
//! no `server_name` or whose endpoint does not parse is refused by name at
//! `--check` and at start, before Lys is asked.
//!
//! The stores are real serve endpoints of one served store, under TLS with
//! serve's test-only certificate for `localhost`, or plain TCP on loopback.
//! The door trusts the test CA only where `SSL_CERT_FILE` names it.

use crate::support::{Fixture, TestResult};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use haematite::db::DatabaseConfig;
use haematite::store::NodeCacheBudget;
use haematite_cli::serve::pass::{Gate, Unreachable};
use haematite_cli::serve::service::Service;
use haematite_cli::serve::storage::Storage;
use haematite_cli::serve::transport::Limits;
use haematite_cli::serve::transport::network::{Network, NetworkConfig, TlsFiles};
use serde_json::{Value, json};
use std::error::Error;
use std::fs;
use std::net::SocketAddr;
use std::num::{NonZeroU32, NonZeroUsize};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::Arc;
use std::time::Duration;

fn certificate(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("src/serve/network_fixtures")
        .join(name)
}

fn keys() -> String {
    let key = ed25519_dalek::SigningKey::from_bytes(&[31; 32]);
    let x = URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes());
    json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": "check", "x": x, "alg": "EdDSA", "use": "sig"}]})
        .to_string()
}

/// One served store decided through Lys, at a network endpoint of its own:
/// TLS with the test certificate when `tls`, else plain TCP on loopback.
/// The endpoint serves on a thread the test process ends.
fn store(directory: &Path, tls: bool) -> Result<SocketAddr, Box<dyn Error>> {
    let storage = Storage::create(
        DatabaseConfig {
            data_dir: directory.join(if tls { "tls-store" } else { "plain-store" }),
            shard_count: 1,
            distributed: None,
            executor_threads: Some(1),
            node_cache_budget: Some(NodeCacheBudget::Unlimited),
            retention: haematite::Retention::KeepAll,
        },
        NodeCacheBudget::Unlimited,
        Duration::from_secs(1),
    )?;
    let service = Arc::new(
        Service::new(storage, NonZeroU32::MIN.saturating_add(9))?.with_lys(Gate::new(
            &keys(),
            "https://lys.example",
            "wards",
            Arc::new(Unreachable),
        )?),
    );
    let limits = Limits {
        max_frame: NonZeroU32::MIN.saturating_add(65_535),
        max_clients: NonZeroUsize::new(4).ok_or("zero clients")?,
        frame_timeout: Duration::from_secs(60),
    };
    let config = NetworkConfig {
        listen: "127.0.0.1:0".parse()?,
        tls: tls.then(|| TlsFiles {
            certificate_file: certificate("server.pem"),
            key_file: certificate("server.key"),
        }),
        max_connections: None,
    };
    let network = Network::bind(&config, &service, limits.max_clients)?;
    let address = network.address();
    std::thread::Builder::new()
        .name("check-store".to_owned())
        .spawn(move || {
            if let Err(error) = network.run(&service, limits) {
                eprintln!("check store ended: {error}");
            }
        })?;
    Ok(address)
}

fn entry(address: SocketAddr) -> Value {
    json!({"endpoint": address.to_string(), "server_name": "localhost"})
}

/// Run the door on `fixture`'s configuration, `--check` or to start, with
/// `SSL_CERT_FILE` naming the test CA when `trusted`. A start that is
/// refused exits; one that is not would serve, so every start here is one
/// the door must refuse.
fn door(fixture: &Fixture, check: bool, trusted: bool) -> Result<Output, Box<dyn Error>> {
    let path = fixture.root().join("door.json");
    fs::write(&path, serde_json::to_vec_pretty(&fixture.config)?)?;
    let mut command = Command::new(env!("CARGO_BIN_EXE_haematite"));
    command.arg("door");
    if check {
        command.arg("--check");
    }
    command
        .arg("--config")
        .arg(&path)
        .current_dir(fixture.root())
        .env_remove("SSL_CERT_FILE");
    if trusted {
        command.env("SSL_CERT_FILE", certificate("ca.pem"));
    }
    Ok(command.output()?)
}

fn text(output: &Output) -> String {
    format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn listing(path: &Path) -> std::io::Result<Vec<(std::ffi::OsString, Vec<u8>)>> {
    let mut entries = fs::read_dir(path)?
        .map(|entry| {
            let entry = entry?;
            let bytes = if entry.file_type()?.is_file() {
                fs::read(entry.path())?
            } else {
                Vec::new()
            };
            Ok((entry.file_name(), bytes))
        })
        .collect::<std::io::Result<Vec<_>>>()?;
    entries.sort();
    Ok(entries)
}

/// Refused by `name` at `--check` (exit 78) and at start (exit 1), and at
/// start before Lys was asked: the fixture's issuer answers nothing, so a
/// start that reached Lys would name `lys_could_not_be_asked` instead.
fn refused_both_ways(fixture: &Fixture, trusted: bool, name: &str) -> TestResult {
    let checked = door(fixture, true, trusted)?;
    assert_eq!(checked.status.code(), Some(78), "{}", text(&checked));
    assert!(text(&checked).contains(name), "--check: {}", text(&checked));
    let started = door(fixture, false, trusted)?;
    assert_eq!(started.status.code(), Some(1), "{}", text(&started));
    assert!(text(&started).contains(name), "start: {}", text(&started));
    assert!(
        !text(&started).contains("lys_could_not_be_asked"),
        "Lys was asked before the store was refused: {}",
        text(&started)
    );
    Ok(())
}

/// R1: a trusted network store is reached and answers hello, and `--check`
/// says so for it by name; the configuration and its folder are unchanged.
/// Fails today: a network entry is refused at parse.
#[test]
fn check_says_a_network_store_was_reached_and_answered_hello_and_changes_nothing() -> TestResult {
    let mut fixture = Fixture::new()?;
    let address = store(fixture.root(), true)?;
    fixture.config["stores"]["play"] = entry(address);
    let path = fixture.root().join("door.json");
    fs::write(&path, serde_json::to_vec_pretty(&fixture.config)?)?;
    let before = listing(fixture.root())?;
    let output = door(&fixture, true, true)?;
    assert_eq!(output.status.code(), Some(0), "{}", text(&output));
    assert!(
        text(&output).contains(&format!(
            "door_store_reached: store play: {address}: hello answered"
        )),
        "{}",
        text(&output)
    );
    assert_eq!(listing(fixture.root())?, before);
    Ok(())
}

/// R1: a store whose certificate this machine does not trust is refused by
/// name at `--check` and at start. Fails today: a network entry is refused
/// at parse, with words that name no network store.
#[test]
fn an_untrusted_network_store_is_refused_by_name_at_check_and_at_start() -> TestResult {
    let mut fixture = Fixture::new()?;
    let address = store(fixture.root(), true)?;
    fixture.config["stores"]["play"] = entry(address);
    refused_both_ways(&fixture, false, "door_store_tls_untrusted: store play")
}

/// R1: a plain-TCP endpoint is refused by name at `--check` and at start,
/// even with its server's authority trusted: a network store is reached only
/// over TLS. Fails today: a network entry is refused at parse.
#[test]
fn a_plain_tcp_network_store_is_refused_by_name_at_check_and_at_start() -> TestResult {
    let mut fixture = Fixture::new()?;
    let address = store(fixture.root(), false)?;
    fixture.config["stores"]["play"] = entry(address);
    refused_both_ways(&fixture, true, "door_store_not_tls: store play")
}

/// R1: an entry naming no `server_name`, or an endpoint that does not parse,
/// is refused by its own name at `--check` and at start. Fails today: each
/// is refused with the socket path's words.
#[test]
fn a_network_entry_without_server_name_or_a_parsed_endpoint_is_refused_by_name() -> TestResult {
    for (stored, name) in [
        (
            json!({"endpoint": "127.0.0.1:1"}),
            "door_store_server_name_missing: store play",
        ),
        (
            json!({"endpoint": "tcp://127.0.0.1:1", "server_name": "localhost"}),
            "door_store_endpoint_invalid: store play",
        ),
        (
            json!({"endpoint": "127.0.0.1:1", "server_name": "localhost", "tls": false}),
            "door_store_field_unknown: store play",
        ),
    ] {
        let mut fixture = Fixture::new()?;
        fixture.config["stores"]["play"] = stored;
        refused_both_ways(&fixture, true, name)?;
    }
    Ok(())
}