eggfetch-cli 0.2.2

Command-line HTTP client powered by the eggfetch-core engine
//! Private CLI presentation and machine-output helpers.

use std::time::Duration;

use serde_json::{json, Value};

pub(crate) fn version_string(v: http::Version) -> &'static str {
    match v {
        http::Version::HTTP_09 => "0.9",
        http::Version::HTTP_10 => "1.0",
        http::Version::HTTP_11 => "1.1",
        http::Version::HTTP_2 => "2",
        http::Version::HTTP_3 => "3",
        _ => "unknown",
    }
}

const SECRET_HEADER_NAMES: &[&str] = &[
    "authorization",
    "proxy-authorization",
    "cookie",
    "set-cookie",
];

pub(crate) fn is_secret_header(name: &str) -> bool {
    SECRET_HEADER_NAMES.contains(&name.to_ascii_lowercase().as_str())
}

pub(crate) fn is_binary_content_type(content_type: &str) -> bool {
    let ct = content_type.to_ascii_lowercase();
    ct.contains("octet-stream")
        || ct.contains("image/")
        || ct.contains("audio/")
        || ct.contains("video/")
        || ct.contains("application/pdf")
        || ct.contains("application/zip")
        || ct.contains("application/gzip")
}

pub(crate) fn format_header_value(name: &str, value: &str, redact: bool) -> String {
    if redact && is_secret_header(name) {
        if name.eq_ignore_ascii_case("authorization")
            || name.eq_ignore_ascii_case("proxy-authorization")
        {
            if let Some(space_pos) = value.find(' ') {
                let scheme = &value[..space_pos];
                format!("{scheme} <redacted>")
            } else {
                "<redacted>".to_owned()
            }
        } else {
            "<redacted>".to_owned()
        }
    } else {
        value.to_owned()
    }
}

pub(crate) fn format_headers(
    headers: &http::HeaderMap,
    verbose: bool,
    redact_secrets: bool,
) -> String {
    let mut out = String::new();
    for (name, value) in headers {
        if !verbose && name.as_str() == "set-cookie" {
            continue;
        }
        if let Ok(v) = value.to_str() {
            out.push_str(name.as_str());
            out.push_str(": ");
            out.push_str(&format_header_value(name.as_str(), v, redact_secrets));
            out.push_str("\r\n");
        }
    }
    out
}

pub(crate) fn format_headers_machine(headers: &http::HeaderMap) -> Vec<Value> {
    headers
        .iter()
        .map(|(name, value)| {
            let raw = value.to_str().unwrap_or("<binary>");
            json!([name.as_str(), format_header_value(name.as_str(), raw, true)])
        })
        .collect()
}

pub(crate) fn safe_url_for_display(url: &str) -> String {
    // Strip userinfo, query, and fragment so machine output never leaks
    // credentials or token material embedded in URLs.
    let authority_start = url.find("://").map_or(0, |i| i + 3);
    let (prefix, rest) = url.split_at(authority_start);
    // Drop `userinfo@` when present.
    let after_userinfo = match rest.find('@') {
        Some(at) => {
            // Only treat `@` before the first `/` as userinfo.
            let slash = rest.find('/').unwrap_or(rest.len());
            if at < slash {
                &rest[at + 1..]
            } else {
                rest
            }
        }
        None => rest,
    };
    // Drop path query/fragment.
    let host_end = after_userinfo
        .find(['/', '?', '#'])
        .unwrap_or(after_userinfo.len());
    let (host_port, path) = after_userinfo.split_at(host_end);
    let path_end = path.find(['?', '#']).unwrap_or(path.len());
    format!("{prefix}{host_port}{}", &path[..path_end])
}

/// Redact `user:pass@` userinfo from error/display strings.
///
/// Core errors may echo the request URL; routing them through here before
/// stdout/stderr/JSON output keeps credentials out of artifacts.
pub(crate) fn redact_credentials(message: &str) -> String {
    let mut result = message.to_owned();
    let mut search_from = 0;
    while let Some(scheme_pos) = result[search_from..].find("://") {
        let userinfo_start = search_from + scheme_pos + 3;
        let rest = &result[userinfo_start..];
        let Some(at_pos) = rest.find('@') else {
            break;
        };
        let terminator = [
            rest.find('/'),
            rest.find(' '),
            rest.find('"'),
            rest.find('\''),
        ]
        .into_iter()
        .flatten()
        .min()
        .unwrap_or(rest.len());
        if at_pos < terminator {
            result.replace_range(userinfo_start..=userinfo_start + at_pos, "<redacted>@");
            search_from = userinfo_start + "<redacted>@".len();
        } else {
            search_from = userinfo_start;
        }
        if search_from >= result.len() {
            break;
        }
    }
    result
}

pub(crate) fn build_json_response(
    response: &eggfetch_core::Response,
    elapsed: Duration,
    body_len: Option<usize>,
    include_body_b64: bool,
    body_b64: Option<&str>,
    errors: &[String],
) -> Value {
    let headers = format_headers_machine(response.headers());

    let history: Vec<Value> = response
        .history()
        .iter()
        .map(|entry| {
            json!({
                "status": entry.status().as_u16(),
                "url": safe_url_for_display(entry.url().as_str()),
                "version": version_string(entry.version()),
            })
        })
        .collect();

    let mut obj = json!({
        "url": safe_url_for_display(response.url().as_str()),
        "status": response.status().as_u16(),
        "version": version_string(response.version()),
        "headers": headers,
        "elapsed_ms": elapsed.as_millis(),
        "history": history,
        "body_length": body_len,
        "errors": errors,
    });

    if include_body_b64 {
        if let Some(b64) = body_b64 {
            obj["body_base64"] = json!(b64);
        }
    }

    obj
}

// Reduce a server-supplied filename to a safe single path component.
//
// Strips any directory components (`/` and `\`, including Windows
// separators), then rejects empty names, `.`, and `..` so a hostile

pub(crate) fn base64_encode(data: &[u8]) -> String {
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
    for chunk in data.chunks(3) {
        let b0 = u32::from(chunk[0]);
        let b1 = if chunk.len() > 1 {
            u32::from(chunk[1])
        } else {
            0
        };
        let b2 = if chunk.len() > 2 {
            u32::from(chunk[2])
        } else {
            0
        };
        let triple = (b0 << 16) | (b1 << 8) | b2;
        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
        if chunk.len() > 1 {
            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
        } else {
            result.push('=');
        }
        if chunk.len() > 2 {
            result.push(CHARS[(triple & 0x3F) as usize] as char);
        } else {
            result.push('=');
        }
    }
    result
}