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;
#[derive(Debug)]
pub struct HttpRequest {
pub method: String,
pub route: String,
pub query: String,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
pub host_count: usize,
}
impl HttpRequest {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).map(String::as_str)
}
}
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();
let mut host_count = 0usize;
for line in lines {
if let Some((name, value)) = line.split_once(':') {
let name = name.trim().to_ascii_lowercase();
if name == "host" {
host_count += 1;
}
headers.insert(name, value.trim().to_string());
}
}
HttpRequest {
method,
route: route.to_string(),
query: query.to_string(),
headers,
body,
host_count,
}
}
pub fn mint_csrf_token() -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
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("");
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(())
}
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
}
pub(crate) 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}")
}
#[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(),
]
}
pub enum WriteOutcome {
Redirect(String),
RevealPlaintext(String),
Error {
status: &'static str,
message: String,
},
}
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");
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}"),
},
}
}
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::sendable_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(())
}
pub fn handle_reveal(request: &HttpRequest, db: &Path, root: &Path) -> WriteOutcome {
let form = parse_form(&request.body);
let (session, audit_id) = match (form.get("session"), form.get("audit_id")) {
(Some(s), Some(a)) if !s.is_empty() && !a.is_empty() => (s, a),
_ => {
return WriteOutcome::Error {
status: "400 Bad Request",
message: "session and audit_id are required".to_string(),
};
}
};
if let Err(outcome) = validate_reveal(db, session, audit_id) {
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 = [
"reveal".to_string(),
"--db".to_string(),
db.to_string_lossy().into_owned(),
"--root".to_string(),
root.to_string_lossy().into_owned(),
"--".to_string(),
audit_id.to_string(),
];
match Command::new(exe).args(&argv).output() {
Ok(out) if out.status.success() => {
WriteOutcome::RevealPlaintext(String::from_utf8_lossy(&out.stdout).into_owned())
}
Ok(out) => WriteOutcome::Error {
status: "502 Bad Gateway",
message: format!("reveal failed:\n{}", String::from_utf8_lossy(&out.stderr)),
},
Err(error) => WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("failed to run zynk: {error}"),
},
}
}
fn validate_reveal(db: &Path, session: &str, audit_id: &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),
})?;
let revealable: bool = connection
.query_row(
"SELECT EXISTS(
SELECT 1 FROM audit_records a
JOIN custody_vault v ON v.audit_id = a.audit_id
WHERE a.audit_id = ?1 AND a.session_id = ?2
AND a.payload_redaction_policy <> 'full')",
rusqlite::params![audit_id, session],
|row| row.get(0),
)
.map_err(|error| WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("reveal validation failed: {error}"),
})?;
if !revealable {
return Err(WriteOutcome::Error {
status: "404 Not Found",
message: format!("{audit_id} is not revealable in session {session}"),
});
}
Ok(())
}
pub fn decide_argv(
db: &Path,
root: &Path,
herdr_bin: &str,
decision_type: &str,
form: &HashMap<String, String>,
) -> CliResult<Vec<String>> {
let required = |field: &str| -> CliResult<&str> {
match form.get(field) {
Some(value) if !value.is_empty() => Ok(value.as_str()),
_ => Err(CliError::usage(format!("{field} is required"))),
}
};
let optional = |field: &str| -> Option<&str> {
form.get(field)
.map(String::as_str)
.filter(|value| !value.is_empty())
};
let session = required("session")?;
let mut argv: Vec<String> = vec![
"decide".into(),
decision_type.into(),
"--db".into(),
db.display().to_string(),
"--root".into(),
root.display().to_string(),
"--session-id".into(),
session.into(),
];
match decision_type {
"gate" => {
argv.push("--ref".into());
argv.push(required("ref")?.into());
argv.push("--verdict".into());
argv.push(required("verdict")?.into());
if let Some(note) = optional("note") {
argv.push("--note".into());
argv.push(note.into());
}
}
"conflict" => {
argv.push("--ref".into());
argv.push(required("ref")?.into());
argv.push("--resolution".into());
argv.push(required("resolution")?.into());
if let Some(note) = optional("note") {
argv.push("--note".into());
argv.push(note.into());
}
}
"mode" => {
argv.push("--to".into());
argv.push(required("to")?.into());
}
"interrupt" => {
if let Some(reason) = optional("reason") {
argv.push("--reason".into());
argv.push(reason.into());
}
}
"redirect" => {
argv.push("--to".into());
argv.push(required("to")?.into());
if let Some(reason) = optional("reason") {
argv.push("--reason".into());
argv.push(reason.into());
}
}
other => {
return Err(CliError::usage(format!("unknown decision type {other:?}")));
}
}
if let Some(notify) = optional("notify") {
let address = notify.split_once(':').map(|(_, a)| a).unwrap_or(notify);
argv.push("--notify-pane".into());
argv.push(address.into());
argv.push("--notify-to".into());
argv.push(notify.into());
argv.push("--herdr-bin".into());
argv.push(herdr_bin.into());
}
Ok(argv)
}
pub fn handle_decide(
request: &HttpRequest,
decision_type: &str,
db: &Path,
root: &Path,
herdr_bin: &str,
) -> WriteOutcome {
let form = parse_form(&request.body);
if let Err(outcome) = validate_decide(db, decision_type, &form) {
return outcome;
}
let argv = match decide_argv(db, root, herdr_bin, decision_type, &form) {
Ok(argv) => argv,
Err(error) => {
return WriteOutcome::Error {
status: "400 Bad Request",
message: error.message,
};
}
};
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}"),
};
}
};
match Command::new(exe).args(&argv).output() {
Ok(out) if out.status.success() => {
let session = form.get("session").map(String::as_str).unwrap_or("");
WriteOutcome::Redirect(format!("/?session={}", escape_url_component(session)))
}
Ok(out) => WriteOutcome::Error {
status: "502 Bad Gateway",
message: format!(
"decide 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}"),
},
}
}
fn validate_decide(
db: &Path,
decision_type: &str,
form: &HashMap<String, String>,
) -> Result<(), WriteOutcome> {
let session = match form.get("session") {
Some(session) if !session.is_empty() => session.as_str(),
_ => {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: "session is required".to_string(),
});
}
};
{
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 notify_target = form.get("notify").filter(|value| !value.is_empty());
let redirect_target = if decision_type == "redirect" {
form.get("to").filter(|value| !value.is_empty())
} else {
None
};
if notify_target.is_some() || redirect_target.is_some() {
let targets =
crate::db_dashboard::sendable_targets(&connection, session).map_err(|error| {
WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("target check failed: {}", error.message),
}
})?;
if let Some(notify) = notify_target {
if !targets.iter().any(|known| known == notify) {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: "unknown notify target — choose a known agent:address row"
.to_string(),
});
}
}
if let Some(to) = redirect_target {
if !targets.iter().any(|known| known == to) {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: "unknown redirect target — choose a known agent:address row"
.to_string(),
});
}
}
}
}
let required_kind = match decision_type {
"gate" => Some("gate"),
"conflict" => Some("conflict"),
_ => None,
};
if let Some(expected) = required_kind {
let raw = match form.get("ref").filter(|value| !value.is_empty()) {
Some(raw) => raw.as_str(),
None => {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: "ref is required".to_string(),
});
}
};
let wid: i64 = match raw.parse() {
Ok(wid) => wid,
Err(_) => {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: format!("ref must be a work_event id (got {raw:?})"),
});
}
};
match crate::db::work_event_kind(db, session, wid) {
Ok(Some(actual)) if actual == expected => {}
Ok(Some(actual)) => {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: format!("ref {wid} is a {actual} work-event, not a {expected}"),
});
}
Ok(None) => {
return Err(WriteOutcome::Error {
status: "404 Not Found",
message: format!("ref {wid} not found in session {session}"),
});
}
Err(error) => {
return Err(WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("ref check failed: {}", error.message),
});
}
}
}
if decision_type == "mode" {
let to = match form.get("to").filter(|value| !value.is_empty()) {
Some(to) => to.as_str(),
None => {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: "to is required".to_string(),
});
}
};
if !crate::decision::CANONICAL_MODES.contains(&to) {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: format!(
"mode must be one of {} (got {to:?})",
crate::decision::CANONICAL_MODES.join("/")
),
});
}
}
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",
);
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 decide_argv_threads_herdr_bin_only_on_notify() {
let mut form = HashMap::new();
form.insert("session".to_string(), "s1".to_string());
form.insert("ref".to_string(), "7".to_string());
form.insert("verdict".to_string(), "approve".to_string());
let argv = decide_argv(
Path::new("/db"),
Path::new("/root"),
"/custom",
"gate",
&form,
)
.unwrap();
assert!(
!argv.contains(&"--herdr-bin".to_string()),
"no notify target ⇒ no --herdr-bin flag: {argv:?}"
);
let ref_idx = argv.iter().position(|a| a == "--ref").unwrap();
assert_eq!(argv[ref_idx + 1], "7");
form.insert("notify".to_string(), "codex:w1-1".to_string());
let argv = decide_argv(
Path::new("/db"),
Path::new("/root"),
"/custom",
"gate",
&form,
)
.unwrap();
let bin_idx = argv
.iter()
.position(|a| a == "--herdr-bin")
.expect("notify target ⇒ --herdr-bin pinned");
assert_eq!(argv[bin_idx + 1], "/custom");
let pane_idx = argv.iter().position(|a| a == "--notify-pane").unwrap();
assert_eq!(argv[pane_idx + 1], "w1-1");
let to_idx = argv.iter().position(|a| a == "--notify-to").unwrap();
assert_eq!(argv[to_idx + 1], "codex:w1-1");
}
#[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");
}
#[test]
fn parse_request_counts_host_headers() {
let zero = parse_request("GET / HTTP/1.1\r\nX-Zynk-CSRF: abc", Vec::new());
assert_eq!(zero.host_count, 0);
let one = parse_request("GET / HTTP/1.1\r\nHost: 127.0.0.1:8787", Vec::new());
assert_eq!(one.host_count, 1);
let two = parse_request(
"GET / HTTP/1.1\r\nHost: evil.test\r\nHost: 127.0.0.1:8787",
Vec::new(),
);
assert_eq!(two.host_count, 2);
assert_eq!(two.header("host"), Some("127.0.0.1:8787"));
}
}