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 {
let authority_start = url.find("://").map_or(0, |i| i + 3);
let (prefix, rest) = url.split_at(authority_start);
let after_userinfo = match rest.find('@') {
Some(at) => {
let slash = rest.find('/').unwrap_or(rest.len());
if at < slash {
&rest[at + 1..]
} else {
rest
}
}
None => rest,
};
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])
}
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
}
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
}