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
//! Bundles pin service settings; changed settings require a fresh destination.

use std::io::Write;
use std::path::{Path, PathBuf};

use super::agent::{Agent, NO_PASS};
use super::bundle::install_current;
use super::types::{Error, Report};

const USAGE: &str = concat!(
    "haematite install --bundle <new directory> --config <service.json> ",
    "[--lys-issuer <origin> --lys-agent <id> --key-file <path>] ",
    "[--lys-issuer <origin> --lys-credential-file <absolute path>]",
    "\nWith the Lys pair, the schema Lys holds for haematite is checked first ",
    "and a drift refuses before anything is written. ",
    "\nThe bundled serve.json is a snapshot. ",
    "Later socket or data_dir changes require a new bundle directory; ",
    "a repeat with changed inputs refuses bundle_conflict.",
);

/// Install without launching a service or moving its data.
///
/// # Errors
/// Refuses malformed arguments, invalid sources and incomplete or conflicting bundles.
pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
    let outcome = parse(args).and_then(|(directory, config, lys, agent)| {
        check_lys_schema(&mut std::io::stderr(), lys)?;
        if agent.is_none() {
            writeln!(std::io::stderr(), "bundle_no_pass: {NO_PASS}")
                .map_err(|error| Error::new("bundle_report_write_failed", &error))?;
        }
        install_current(&config, &directory, agent.as_ref(), || {
            let binary = std::env::current_exe()
                .map_err(|error| Error::new("bundle_source_invalid", &error))?;
            canonical_binary(&binary)
        })
    });
    write_outcome(&mut std::io::stdout(), &mut std::io::stderr(), outcome)
        .map_err(|error| error.to_string())
}

fn write_outcome(
    output: &mut impl Write,
    diagnostics: &mut impl Write,
    outcome: Result<Report, Error>,
) -> Result<(), Error> {
    match outcome {
        Ok(report) => write_stale_stages(diagnostics, &report.stale_stages)
            .and_then(|()| write_report(output, &report))
            .map_err(|error| error.with_stale_stages(report.stale_stages)),
        Err(error) => {
            if let Err(report_error) = write_stale_stages(diagnostics, &error.stale_stages) {
                return Err(error.with_cleanup(&report_error));
            }
            Err(error)
        }
    }
}

/// The deploy check of ACCESS-001 R1: with the Lys pair given, the schema
/// Lys holds must be this binary's schema.json, or the install refuses
/// `bundle_lys_schema_refused` before anything is written. Without the pair
/// the install says the check was not made; it is never reported as passed.
fn check_lys_schema(
    diagnostics: &mut impl Write,
    lys: Option<(String, PathBuf)>,
) -> Result<(), Error> {
    let Some((issuer, credential)) = lys else {
        return writeln!(
            diagnostics,
            "lys_schema_unchecked: no schema credential was given, so the registered schema \
             was not compared with this binary's"
        )
        .map_err(|error| Error::new("bundle_report_write_failed", &error));
    };
    let args = [
        "--issuer".to_owned(),
        issuer,
        "--credential-file".to_owned(),
        credential.display().to_string(),
        "--check".to_owned(),
    ];
    crate::serve::lys_schema::run(args.into_iter())
        .map_err(|refusal| Error::new("bundle_lys_schema_refused", &refusal))
}

fn write_stale_stages(output: &mut impl Write, stages: &[String]) -> Result<(), Error> {
    let stages = serde_json::to_string(stages)
        .map_err(|error| Error::new("bundle_encoding_failed", &error))?;
    writeln!(output, "bundle_stale_stages: {stages}")
        .map_err(|error| Error::new("bundle_report_write_failed", &error))
}

fn canonical_binary(path: &Path) -> Result<PathBuf, Error> {
    std::fs::canonicalize(path).map_err(|error| Error::new("bundle_source_invalid", &error))
}

fn write_report(output: &mut impl Write, report: &Report) -> Result<(), Error> {
    let report = serde_json::to_string(report)
        .map_err(|error| Error::new("bundle_encoding_failed", &error))?;
    writeln!(output, "{report}").map_err(|error| Error::new("bundle_report_write_failed", &error))
}

/// The bundle, the configuration and, when both are given, the Lys issuer
/// and credential path for the deploy check.
type Parsed = (PathBuf, PathBuf, Option<(String, PathBuf)>, Option<Agent>);

fn parse(args: impl Iterator<Item = String>) -> Result<Parsed, Error> {
    let mut args = args;
    let mut bundle = None;
    let mut config = None;
    let mut issuer = None;
    let mut credential = None;
    let mut agent = None;
    let mut key = None;
    while let Some(flag) = args.next() {
        let target = match flag.as_str() {
            "--bundle" => &mut bundle,
            "--config" => &mut config,
            "--lys-issuer" => &mut issuer,
            "--lys-credential-file" => &mut credential,
            "--lys-agent" => &mut agent,
            "--key-file" => &mut key,
            _ => return Err(Error::new("bundle_arguments_invalid", USAGE)),
        };
        if target.is_some() {
            return Err(Error::new("bundle_arguments_invalid", "duplicate option"));
        }
        let value = args
            .next()
            .filter(|value| !value.is_empty() && !value.starts_with("--"))
            .ok_or_else(|| Error::new("bundle_arguments_invalid", "option requires a path"))?;
        *target = Some(PathBuf::from(value));
    }
    let proof = if agent.is_some() || key.is_some() || credential.is_none() {
        Agent::parse(issuer.clone(), agent, key)?
    } else {
        None
    };
    let lys = match (issuer, credential) {
        (Some(issuer), Some(credential)) if credential.is_absolute() => {
            Some((issuer.display().to_string(), credential))
        }
        (_, None) => None,
        _ => return Err(Error::new("bundle_arguments_invalid", USAGE)),
    };
    Ok((
        bundle.ok_or_else(|| Error::new("bundle_arguments_invalid", USAGE))?,
        config.ok_or_else(|| Error::new("bundle_arguments_invalid", USAGE))?,
        lys,
        proof,
    ))
}

#[cfg(test)]
mod tests {
    use super::parse;

    #[test]
    fn arguments_require_each_path_once_and_do_not_consume_flags_as_values()
    -> Result<(), super::Error> {
        for values in [
            vec![],
            vec!["--bundle"],
            vec!["--bundle", "--config"],
            vec!["--bundle", "a", "--bundle", "b"],
            vec!["--bundle", "a", "--config", "b", "extra"],
            vec![
                "--bundle",
                "a",
                "--config",
                "b",
                "--lys-credential-file",
                "/c",
            ],
            vec![
                "--bundle",
                "a",
                "--config",
                "b",
                "--lys-issuer",
                "http://lys",
                "--lys-credential-file",
                "c",
            ],
        ] {
            assert_eq!(
                parse(values.into_iter().map(str::to_owned))
                    .err()
                    .map(|error| error.code),
                Some("bundle_arguments_invalid")
            );
        }
        let values = parse(
            ["--bundle", "a", "--config", "b"]
                .into_iter()
                .map(str::to_owned),
        )?;
        assert_eq!(values, ("a".into(), "b".into(), None, None));
        let values = parse(
            [
                "--bundle",
                "a",
                "--config",
                "b",
                "--lys-issuer",
                "http://lys",
                "--lys-credential-file",
                "/c",
            ]
            .into_iter()
            .map(str::to_owned),
        )?;
        assert_eq!(
            values,
            (
                "a".into(),
                "b".into(),
                Some(("http://lys".to_owned(), "/c".into())),
                None
            )
        );
        Ok(())
    }

    /// Without the Lys pair the install names the check it did not make, and
    /// with an unreachable Lys it refuses by name before anything is written.
    #[test]
    fn the_deploy_check_is_named_when_skipped_and_refuses_when_lys_cannot_be_asked()
    -> Result<(), Box<dyn std::error::Error>> {
        let mut diagnostics = Vec::new();
        super::check_lys_schema(&mut diagnostics, None)?;
        assert!(String::from_utf8(diagnostics)?.starts_with("lys_schema_unchecked"));
        let directory = tempfile::tempdir()?;
        let credential = directory.path().join("credential");
        std::fs::write(&credential, "lys-app.haematite.never-shown\n")?;
        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
        let issuer = format!("http://{}/", listener.local_addr()?);
        drop(listener);
        let refused = super::check_lys_schema(&mut Vec::new(), Some((issuer, credential)))
            .err()
            .ok_or("an unreachable Lys passed the deploy check")?;
        assert_eq!(refused.code, "bundle_lys_schema_refused");
        assert!(
            refused.message.starts_with("lys_could_not_be_asked"),
            "{}",
            refused.message
        );
        assert!(!refused.message.contains("never-shown"));
        Ok(())
    }
}

#[cfg(test)]
#[path = "cli_tests.rs"]
mod proof_tests;