zynk 0.7.0

Portable protocol and helper CLI for multi-agent collaboration.
use crate::db_dashboard::{escape_url_component, percent_decode};
use crate::{CliError, CliResult};
use rand::Rng;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

/// A parsed HTTP request: request line + lower-cased header map + raw body.
/// ADR 031: the write path needs the headers (Host/Origin/X-Zynk-CSRF) and the
/// POST body, which the read-only server never parsed.
#[derive(Debug)]
pub struct HttpRequest {
    pub method: String,
    pub route: String,
    pub query: String,
    pub headers: HashMap<String, String>,
    pub body: Vec<u8>,
}

impl HttpRequest {
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers.get(name).map(String::as_str)
    }
}

/// Parse a request from the raw header text and the already-read body bytes.
pub fn parse_request(head: &str, body: Vec<u8>) -> HttpRequest {
    let mut lines = head.split("\r\n");
    let request_line = lines.next().unwrap_or("GET / HTTP/1.1");
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or("GET").to_string();
    let target = parts.next().unwrap_or("/");
    let (route, query) = target.split_once('?').unwrap_or((target, ""));
    let mut headers = HashMap::new();
    for line in lines {
        if let Some((name, value)) = line.split_once(':') {
            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
        }
    }
    HttpRequest {
        method,
        route: route.to_string(),
        query: query.to_string(),
        headers,
        body,
    }
}

/// A high-entropy per-serve CSRF token (hex of 32 random bytes).
pub fn mint_csrf_token() -> String {
    let bytes: [u8; 32] = rand::thread_rng().gen();
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

/// ADR 031 D4: authorize a browser write — exact Host (anti-DNS-rebind), exact
/// same-origin, the per-serve CSRF token in the `X-Zynk-CSRF` header, POST method.
/// (OPTIONS / non-POST are also rejected by the 405 arm; no CORS headers are ever
/// emitted, so a cross-origin request cannot preflight the custom header.)
pub fn authorize_write(request: &HttpRequest, authority: &str, csrf_token: &str) -> CliResult<()> {
    if request.method != "POST" {
        return Err(CliError::usage("writes require POST"));
    }
    if request.header("host") != Some(authority) {
        return Err(CliError::usage("Host mismatch"));
    }
    let expected_origin = format!("http://{authority}");
    if request.header("origin") != Some(expected_origin.as_str()) {
        return Err(CliError::usage("Origin mismatch"));
    }
    let presented = request.header("x-zynk-csrf").unwrap_or("");
    // Length-checked, constant-time-ish compare (tokens are fixed length).
    if presented.len() != csrf_token.len()
        || presented
            .bytes()
            .zip(csrf_token.bytes())
            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
            != 0
    {
        return Err(CliError::usage("CSRF token mismatch"));
    }
    Ok(())
}

/// Parse an `application/x-www-form-urlencoded` body into a field map.
pub fn parse_form(body: &[u8]) -> HashMap<String, String> {
    let text = String::from_utf8_lossy(body);
    let mut fields = HashMap::new();
    for pair in text.split('&') {
        if let Some((key, value)) = pair.split_once('=') {
            fields.insert(
                percent_decode(&key.replace('+', " ")),
                percent_decode(&value.replace('+', " ")),
            );
        }
    }
    fields
}

/// A short unique message id for a dashboard-originated send (`--mid` is required
/// by the audited send, send_herdr.rs).
fn mint_mid() -> String {
    let bytes: [u8; 8] = rand::thread_rng().gen();
    let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
    format!("op-{hex}")
}

/// ADR 031 D2/D3: the typed argv for the composer send. The browser supplies only
/// values (session/to/type/mid/body); every flag is chosen here. source =
/// `operator:dashboard` (D1), `command_origin=operator`, and the served
/// `--db`/`--root` plus the generated `--mid` are pinned. The audited send
/// requires `target_address == --pane`, so `--pane` is the address half of `--to`.
// The args are intentionally explicit (server-pinned db/root/herdr_bin + the
// validated form values + the generated mid); a struct would only add ceremony.
#[allow(clippy::too_many_arguments)]
pub fn send_argv(
    db: &Path,
    root: &Path,
    herdr_bin: &str,
    session: &str,
    to: &str,
    message_type: &str,
    mid: &str,
    body: &str,
) -> Vec<String> {
    let target_address = to.split_once(':').map(|(_, addr)| addr).unwrap_or(to);
    vec![
        "send".into(),
        "herdr".into(),
        "--herdr-bin".into(),
        herdr_bin.into(),
        "--pane".into(),
        target_address.into(),
        "--db".into(),
        db.display().to_string(),
        "--root".into(),
        root.display().to_string(),
        "--session-id".into(),
        session.into(),
        "--from".into(),
        "operator:dashboard".into(),
        "--to".into(),
        to.into(),
        "--command-origin".into(),
        "operator".into(),
        "--mid".into(),
        mid.into(),
        "--type".into(),
        message_type.into(),
        "--body".into(),
        body.into(),
    ]
}

/// The result of a browser write attempt.
pub enum WriteOutcome {
    /// PRG: redirect to this GET location after a successful write.
    Redirect(String),
    /// A failure to render (escaped by the caller) with this status + message.
    Error {
        status: &'static str,
        message: String,
    },
}

/// ADR 031 D2: build the typed argv from the validated form, spawn `current_exe()`
/// `zynk` (argv array, never a shell), capture stdout/stderr. On success, PRG-
/// redirect back to the session feed; on failure, return an Error whose message
/// includes the child output — which the caller HTML-escapes before rendering
/// (ADR 031 C1: the body can be hostile, so surfaced output is escaped/text-only).
/// Validation happens BEFORE the child spawns (D6).
pub fn handle_send(request: &HttpRequest, db: &Path, root: &Path, herdr_bin: &str) -> WriteOutcome {
    let form = parse_form(&request.body);
    let (session, to, body) = match (form.get("session"), form.get("to"), form.get("body")) {
        (Some(s), Some(t), Some(b)) if !s.is_empty() && !t.is_empty() && !b.is_empty() => (s, t, b),
        _ => {
            return WriteOutcome::Error {
                status: "400 Bad Request",
                message: "session, to, and body are required".to_string(),
            };
        }
    };
    let message_type = form
        .get("type")
        .map(String::as_str)
        .filter(|value| !value.is_empty())
        .unwrap_or("status-update");
    // ADR 031 D1: validate against the served DB BEFORE spawning — the session must
    // already exist (no browser-originated session creation) and the target must be
    // a known agent/address row (not free-typed). The read connection is dropped
    // inside the helper, before the child runs (the child owns the write).
    if let Err(outcome) = validate_send_target(db, session, to) {
        return outcome;
    }
    let exe = match std::env::current_exe() {
        Ok(exe) => exe,
        Err(error) => {
            return WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("cannot resolve zynk binary: {error}"),
            };
        }
    };
    let argv = send_argv(
        db,
        root,
        herdr_bin,
        session,
        to,
        message_type,
        &mint_mid(),
        body,
    );
    match Command::new(exe).args(&argv).output() {
        Ok(out) if out.status.success() => {
            WriteOutcome::Redirect(format!("/?session={}", escape_url_component(session)))
        }
        Ok(out) => WriteOutcome::Error {
            status: "502 Bad Gateway",
            message: format!(
                "send failed:\n{}\n{}",
                String::from_utf8_lossy(&out.stdout),
                String::from_utf8_lossy(&out.stderr)
            ),
        },
        Err(error) => WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("failed to run zynk: {error}"),
        },
    }
}

/// ADR 031 D1: confirm the posted session exists in the served DB and the target
/// is one of its known agent/address rows, before any child spawns. Returns the
/// rejection outcome on failure. The read connection is dropped before returning,
/// so it is never held while the child write runs.
fn validate_send_target(db: &Path, session: &str, to: &str) -> Result<(), WriteOutcome> {
    let connection = crate::db::open_read_database(db).map_err(|error| WriteOutcome::Error {
        status: "500 Internal Server Error",
        message: format!("cannot open dashboard db: {}", error.message),
    })?;
    match crate::db_dashboard::session_exists(&connection, session) {
        Ok(true) => {}
        Ok(false) => {
            return Err(WriteOutcome::Error {
                status: "404 Not Found",
                message: "unknown session — browser writes target an existing session".to_string(),
            });
        }
        Err(error) => {
            return Err(WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("session check failed: {}", error.message),
            });
        }
    }
    let targets = crate::db_dashboard::known_targets(&connection, session).map_err(|error| {
        WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("target check failed: {}", error.message),
        }
    })?;
    if !targets.iter().any(|known| known == to) {
        return Err(WriteOutcome::Error {
            status: "400 Bad Request",
            message: "unknown target — choose a known agent:address row".to_string(),
        });
    }
    Ok(())
}

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

    fn req(method: &str, host: &str, origin: Option<&str>, token: Option<&str>) -> HttpRequest {
        let mut h = format!("Host: {host}");
        if let Some(o) = origin {
            h.push_str(&format!("\r\nOrigin: {o}"));
        }
        if let Some(t) = token {
            h.push_str(&format!("\r\nX-Zynk-CSRF: {t}"));
        }
        parse_request(&format!("{method} /send HTTP/1.1\r\n{h}"), Vec::new())
    }

    #[test]
    fn authorize_write_accepts_exact_host_origin_token_post() {
        let authority = "127.0.0.1:8787";
        let r = req(
            "POST",
            authority,
            Some("http://127.0.0.1:8787"),
            Some("secret"),
        );
        assert!(authorize_write(&r, authority, "secret").is_ok());
    }

    #[test]
    fn authorize_write_rejects_bad_token_origin_host_method() {
        let a = "127.0.0.1:8787";
        let ok_origin = Some("http://127.0.0.1:8787");
        assert!(authorize_write(&req("POST", a, ok_origin, Some("nope")), a, "secret").is_err());
        assert!(authorize_write(
            &req("POST", a, Some("http://evil.test"), Some("secret")),
            a,
            "secret"
        )
        .is_err());
        assert!(authorize_write(
            &req("POST", "evil.test", ok_origin, Some("secret")),
            a,
            "secret"
        )
        .is_err());
        assert!(authorize_write(&req("GET", a, ok_origin, Some("secret")), a, "secret").is_err());
        assert!(authorize_write(&req("POST", a, None, Some("secret")), a, "secret").is_err());
    }

    #[test]
    fn mint_csrf_token_is_long_and_varies() {
        let a = mint_csrf_token();
        let b = mint_csrf_token();
        assert!(
            a.len() >= 32 && a.chars().all(|c| c.is_ascii_alphanumeric()),
            "{a}"
        );
        assert_ne!(a, b);
    }

    #[test]
    fn parse_form_decodes_fields() {
        let f = parse_form(b"session=s1&to=codex%3Aw1-1&type=status-update&body=hello+world");
        assert_eq!(f.get("session").map(String::as_str), Some("s1"));
        assert_eq!(f.get("to").map(String::as_str), Some("codex:w1-1"));
        assert_eq!(f.get("body").map(String::as_str), Some("hello world"));
    }

    #[test]
    fn send_argv_is_typed_and_unflaggable() {
        let argv = send_argv(
            Path::new("/db"),
            Path::new("/root"),
            "herdr",
            "s1",
            "codex:w1-1",
            "status-update",
            "m9",
            "--oops --no-audit",
        );
        // A body that looks like a flag must be a single positional --body value.
        let body_idx = argv.iter().position(|a| a == "--body").unwrap();
        assert_eq!(argv[body_idx + 1], "--oops --no-audit");
        assert!(argv.contains(&"--db".to_string()) && argv.contains(&"/db".to_string()));
        let origin_idx = argv.iter().position(|a| a == "--command-origin").unwrap();
        assert_eq!(argv[origin_idx + 1], "operator");
        let mid_idx = argv.iter().position(|a| a == "--mid").unwrap();
        assert_eq!(argv[mid_idx + 1], "m9");
    }

    #[test]
    fn parse_request_splits_line_headers_body() {
        let head = "POST /send?session=s HTTP/1.1\r\nHost: 127.0.0.1:8787\r\nX-Zynk-CSRF: abc";
        let req = parse_request(head, b"a=1&b=2".to_vec());
        assert_eq!(req.method, "POST");
        assert_eq!(req.route, "/send");
        assert_eq!(req.query, "session=s");
        assert_eq!(req.header("host"), Some("127.0.0.1:8787"));
        assert_eq!(req.header("x-zynk-csrf"), Some("abc"));
        assert_eq!(req.body, b"a=1&b=2");
    }
}