eoka-cli 0.1.15

Browser automation CLI for AI agents and pentesting
use std::path::{Path, PathBuf};

use serde_json::{json, Map, Value};

use super::body::BodyCaptureExt;
use super::NetworkEntry;

pub(super) fn build_har(entries: &[NetworkEntry]) -> Value {
    let har_entries: Vec<Value> = entries
        .iter()
        .filter(|entry| entry.complete)
        .map(har_entry)
        .collect();
    json!({
        "log": {
            "version": "1.2",
            "creator": {
                "name": "eoka",
                "version": env!("CARGO_PKG_VERSION"),
            },
            "entries": har_entries,
        }
    })
}

pub(super) fn build_json_export(entries: &[NetworkEntry]) -> Value {
    json!({
        "version": 1,
        "creator": {
            "name": "eoka",
            "version": env!("CARGO_PKG_VERSION"),
        },
        "entries": entries.iter().map(NetworkEntry::full_json).collect::<Vec<_>>(),
    })
}

fn har_entry(entry: &NetworkEntry) -> Value {
    json!({
        "startedDateTime": har_time(entry),
        "time": entry.duration_ms(),
        "request": {
            "method": entry.method,
            "url": entry.url,
            "httpVersion": "HTTP/1.1",
            "cookies": request_cookies(entry),
            "headers": har_headers(&entry.request_headers),
            "queryString": har_query(&entry.url),
            "headersSize": header_size(entry.request_headers_text.as_deref()),
            "bodySize": request_body_size(entry),
            "postData": har_post_data(entry),
        },
        "response": {
            "status": entry.status.unwrap_or(0),
            "statusText": entry.status_text.clone().unwrap_or_default(),
            "httpVersion": entry.protocol.clone().unwrap_or_else(|| "HTTP/1.1".to_string()),
            "cookies": response_cookies(entry),
            "headers": har_headers(&entry.response_headers),
            "content": har_content(entry),
            "redirectURL": entry.redirect_url.clone().unwrap_or_default(),
            "headersSize": header_size(entry.response_headers_text.as_deref()),
            "bodySize": response_body_size(entry),
        },
        "cache": {},
        "timings": har_timings(entry),
        "serverIPAddress": entry.remote_ip,
        "_eoka": {
            "id": entry.id,
            "session_id": entry.session_id,
            "target_id": entry.target_id,
            "request_id": entry.request_id,
            "hop": entry.hop,
            "resource_type": entry.resource_type,
            "failure": entry.failure_text,
            "request_body_omitted": entry.request_post_data.as_ref().and_then(|body| body.omitted.clone()),
            "response_body_omitted": entry.response_body.as_ref().and_then(|body| body.omitted.clone()),
        }
    })
}

fn har_headers(headers: &Map<String, Value>) -> Vec<Value> {
    headers
        .iter()
        .map(|(name, value)| {
            json!({
                "name": name,
                "value": value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string()),
            })
        })
        .collect()
}

fn request_cookies(entry: &NetworkEntry) -> Vec<Value> {
    header_value(&entry.request_headers, "cookie")
        .map(parse_cookie_header)
        .unwrap_or_default()
}

fn response_cookies(entry: &NetworkEntry) -> Vec<Value> {
    let Some(header) = header_value(&entry.response_headers, "set-cookie") else {
        return Vec::new();
    };
    header
        .split(',')
        .filter_map(|cookie| cookie.split(';').next())
        .filter_map(cookie_pair)
        .collect()
}

fn parse_cookie_header(header: &str) -> Vec<Value> {
    header.split(';').filter_map(cookie_pair).collect()
}

fn cookie_pair(raw: &str) -> Option<Value> {
    let (name, value) = raw.trim().split_once('=')?;
    Some(json!({ "name": name.trim(), "value": value.trim() }))
}

fn header_size(text: Option<&str>) -> i64 {
    text.map(|text| text.len() as i64).unwrap_or(-1)
}

fn request_body_size(entry: &NetworkEntry) -> i64 {
    entry
        .request_post_data
        .as_ref()
        .map(|body| body.len() as i64)
        .unwrap_or(0)
}

fn response_body_size(entry: &NetworkEntry) -> i64 {
    entry
        .encoded_data_length
        .map(|length| length.round() as i64)
        .or_else(|| entry.response_body.as_ref().map(|body| body.len() as i64))
        .unwrap_or(-1)
}

fn har_timings(entry: &NetworkEntry) -> Value {
    let Some(timing) = entry.response_timing.as_ref().and_then(Value::as_object) else {
        return json!({
            "send": 0,
            "wait": entry.duration_ms(),
            "receive": 0,
        });
    };
    let send_start = timing
        .get("sendStart")
        .and_then(Value::as_f64)
        .unwrap_or(-1.0);
    let send_end = timing
        .get("sendEnd")
        .and_then(Value::as_f64)
        .unwrap_or(-1.0);
    let receive_headers_end = timing
        .get("receiveHeadersEnd")
        .and_then(Value::as_f64)
        .unwrap_or(-1.0);
    let wait = if send_end >= 0.0 && receive_headers_end >= send_end {
        receive_headers_end - send_end
    } else {
        entry.duration_ms()
    };
    json!({
        "blocked": -1,
        "dns": timing_span(timing, "dnsStart", "dnsEnd"),
        "connect": timing_span(timing, "connectStart", "connectEnd"),
        "ssl": timing_span(timing, "sslStart", "sslEnd"),
        "send": if send_start >= 0.0 && send_end >= send_start { send_end - send_start } else { 0.0 },
        "wait": wait,
        "receive": 0,
    })
}

fn timing_span(timing: &Map<String, Value>, start: &str, end: &str) -> f64 {
    let start = timing.get(start).and_then(Value::as_f64).unwrap_or(-1.0);
    let end = timing.get(end).and_then(Value::as_f64).unwrap_or(-1.0);
    if start >= 0.0 && end >= start {
        end - start
    } else {
        -1.0
    }
}

fn har_query(url: &str) -> Vec<Value> {
    url::Url::parse(url)
        .ok()
        .map(|url| {
            url.query_pairs()
                .map(|(name, value)| json!({ "name": name, "value": value }))
                .collect()
        })
        .unwrap_or_default()
}

fn har_post_data(entry: &NetworkEntry) -> Value {
    match entry
        .request_post_data
        .as_ref()
        .and_then(BodyCaptureExt::to_text)
    {
        Some(text) => json!({
            "mimeType": header_value(&entry.request_headers, "content-type")
                .unwrap_or("application/octet-stream"),
            "text": text,
        }),
        None => Value::Null,
    }
}

fn har_content(entry: &NetworkEntry) -> Value {
    let body = entry.response_body.as_ref();
    let mime_type = entry
        .mime_type
        .as_deref()
        .or_else(|| body.and_then(|body| body.mime_type.as_deref()))
        .unwrap_or("application/octet-stream");
    let text = body.and_then(BodyCaptureExt::to_text).unwrap_or_default();
    let encoding = body
        .filter(|body| body.base64_encoded)
        .map(|_| "base64".to_string());
    json!({
        "size": body.map(|body| body.len() as i64).unwrap_or(0),
        "mimeType": mime_type,
        "text": text,
        "encoding": encoding,
    })
}

fn har_time(entry: &NetworkEntry) -> String {
    let secs = entry.wall_time.unwrap_or_default();
    if secs == 0.0 {
        return "1970-01-01T00:00:00.000Z".to_string();
    }
    let millis = (secs * 1000.0).round() as i64;
    let whole = millis.div_euclid(1000);
    let ms = millis.rem_euclid(1000);
    format!(
        "{}.{:03}Z",
        chrono::DateTime::from_timestamp(whole, 0)
            .unwrap_or_default()
            .format("%Y-%m-%dT%H:%M:%S"),
        ms
    )
}

pub(super) fn atomic_write_json(path: &Path, value: &Value) -> Result<(), String> {
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let temp = temp_path(path);
    let json = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
    std::fs::write(&temp, json).map_err(|e| e.to_string())?;
    std::fs::rename(&temp, path).map_err(|e| e.to_string())?;
    Ok(())
}

fn temp_path(path: &Path) -> PathBuf {
    let mut temp = path.to_path_buf();
    let name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("network.har");
    temp.set_file_name(format!(".{}.{}.tmp", name, std::process::id()));
    temp
}

fn header_value<'a>(headers: &'a Map<String, Value>, lowercase_name: &str) -> Option<&'a str> {
    headers
        .get(lowercase_name)
        .or_else(|| {
            headers
                .iter()
                .find(|(name, _)| name.eq_ignore_ascii_case(lowercase_name))
                .map(|(_, value)| value)
        })
        .and_then(Value::as_str)
}