//! HTTP/HTTPS 1.1/2/3 measurements (port of `httpDiagnostics.js` + `httpRuntime.js`). //! //! All protocol versions use the same bundled curl runtime as 0.1.0 (pinned build with //! nghttp2/ngtcp2/nghttp3; manifest + SHA-256 of executable and CA bundle verified before //! every capability check). HTTP/3 is reported only after a completed transfer with //! `http_version == 3`, verified TLS and a final status ≥ 200 — never from Alt-Svc. use crate::core::diagnostics::common::{error_result, now_ms, parse_target, stats, DiagError, Options, Target}; use crate::core::diagnostics::dns; use crate::core::diagnostics::process::run_process; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::io::Read; use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio_util::sync::CancellationToken; pub const MAX_BODY: usize = 2 * 1024 * 1024; static PRIVATE_HEADERS: Lazy = Lazy::new(|| { Regex::new(r"(?i)^(?:set-cookie|cookie|authorization|proxy-authorization|www-authenticate|proxy-authenticate|authentication-info|proxy-authentication-info|set-cookie2)$").unwrap() }); #[derive(Clone, Debug)] pub struct Runtime { pub root: PathBuf, pub executable: PathBuf, pub ca_path: PathBuf, pub platform: String, pub arch: String, } pub fn node_platform_arch() -> (String, String) { let platform = crate::windows::platform_name().to_string(); let arch = match std::env::consts::ARCH { "x86_64" => "x64", "aarch64" => "arm64", "x86" => "ia32", other => other, }; (platform, arch.to_string()) } pub fn runtime_paths(resources: &Path) -> Runtime { let (platform, arch) = node_platform_arch(); let root = resources.join("native-runtime").join(format!("{platform}-{arch}")); Runtime { executable: root.join(if platform == "win32" { "curl.exe" } else { "curl" }), ca_path: root.join("ca-bundle.pem"), root, platform, arch, } } pub fn validate_runtime(resources: &Path) -> Result { let runtime = runtime_paths(resources); let check = || -> Result<(), std::io::Error> { let manifest: Value = serde_json::from_str(&std::fs::read_to_string(runtime.root.join("manifest.json"))?).map_err(|_| std::io::Error::other("manifest"))?; if manifest["platform"].as_str() != Some(&runtime.platform) || manifest["arch"].as_str() != Some(&runtime.arch) { return Err(std::io::Error::other("architecture")); } let exe_name = runtime.executable.file_name().unwrap().to_string_lossy().to_string(); for name in [exe_name.as_str(), "ca-bundle.pem"] { let expected = manifest["files"][name].as_str().unwrap_or(""); if expected.len() != 64 || hex::encode(Sha256::digest(std::fs::read(runtime.root.join(name))?)) != expected { return Err(std::io::Error::other("checksum")); } } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if std::fs::metadata(&runtime.executable)?.permissions().mode() & 0o111 == 0 { return Err(std::io::Error::other("not executable")); } } Ok(()) }; check().map_err(|e| DiagError::new(if e.kind() == std::io::ErrorKind::NotFound { "HTTP_RUNTIME_MISSING" } else { "HTTP_RUNTIME_INVALID" }))?; Ok(runtime) } pub async fn curl_capability(resources: &Path, cancel: &CancellationToken) -> Value { let (platform, arch) = node_platform_arch(); let runtime = match validate_runtime(resources) { Ok(runtime) => runtime, Err(error) => return json!({ "available": false, "code": error.code, "platform": platform, "arch": arch }), }; match run_process(&runtime.executable.to_string_lossy(), &["--disable".into(), "--version".into()], 3000, cancel, 256 * 1024, None, true).await { Ok(result) => { let output = String::from_utf8_lossy(&result.stdout).to_string(); let version = Regex::new(r"^curl (\d+)\.(\d+)\.(\d+)").unwrap().captures(&output); let feature = |name: &str| Regex::new(&format!(r"Features:.*\b{name}\b")).unwrap().is_match(&output); let (http2, http3, tls) = (feature("HTTP2"), feature("HTTP3"), feature("SSL")); let major_ok = version.as_ref().and_then(|v| v[1].parse::().ok()).map(|m| m >= 8).unwrap_or(false); let available = major_ok && http2 && http3 && tls && result.code.map(|c| c == 0).unwrap_or(true); let backend = Regex::new(r"(?:OpenSSL|LibreSSL|BoringSSL|AWS-LC)/[^ ]+").unwrap().find(output.lines().next().unwrap_or("")).map(|m| m.as_str().to_string()); json!({ "available": available, "version": version.map(|v| v[0].to_string()), "http2": http2, "http3": http3, "tls": tls, "platform": platform, "arch": arch, "tlsBackend": backend, "code": if available { Value::Null } else { json!("HTTP_RUNTIME_INCOMPATIBLE") }, "raw": output.chars().take(3000).collect::(), "executable": runtime.executable.to_string_lossy(), "caPath": runtime.ca_path.to_string_lossy() }) } Err(error) => { if cancel.is_cancelled() { return json!({ "available": false, "code": "CANCELLED" }); } json!({ "available": false, "code": if error.code == "ENOENT" { "HTTP_RUNTIME_MISSING" } else { "HTTP_RUNTIME_INVALID" }, "platform": platform, "arch": arch }) } } } pub fn safe_headers(headers: &Map) -> Value { let mut result = Map::new(); for (key, value) in headers { if key.starts_with(':') { continue; } let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string()); result.insert(key.to_lowercase(), json!(if PRIVATE_HEADERS.is_match(key) { "[redacted]".to_string() } else { text.chars().take(8192).collect::() })); } Value::Object(result) } pub fn security_headers(headers: &Value, https: bool) -> Value { let names = ["strict-transport-security", "content-security-policy", "x-content-type-options", "referrer-policy", "permissions-policy", "x-frame-options"]; let csp = headers["content-security-policy"].as_str().unwrap_or(""); Value::Array( names .iter() .map(|name| { let value = headers.get(*name).filter(|v| !v.is_null()).cloned(); let status = if value.is_some() { "present" } else if *name == "strict-transport-security" && !https { "not-applicable" } else if *name == "x-frame-options" && Regex::new(r"(?i)(?:^|;)\s*frame-ancestors\s").unwrap().is_match(csp) { "superseded-by-csp-frame-ancestors" } else { "missing" }; json!({ "name": name, "value": value, "status": status, "note": "Presence alone does not establish correct policy or website security." }) }) .collect(), ) } fn curl_failure(code: i32, protocol: &str) -> &'static str { match code { 1 | 92 => "HTTP_PROTOCOL_INCOMPATIBLE", 2 | 4 => "HTTP_RUNTIME_INCOMPATIBLE", 5 | 6 => "DNS_FAILED", 7 => "CONNECTION_REFUSED", 28 => if protocol == "3" { "HTTP3_UDP_TIMEOUT" } else { "TIMEOUT" }, 35 => "TLS_HANDSHAKE_FAILED", 51 | 58 | 60 => "TLS_CERT_INVALID", 63 => "BODY_LIMIT", 77 => "HTTP_RUNTIME_INVALID", 95 | 96 => "HTTP3_UNVERIFIED", _ => "HTTP_TRANSFER_FAILED", } } fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option { haystack.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from) } fn rfind(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).rposition(|w| w == needle) } pub struct CurlResult { pub value: Value, pub body: Vec, } pub async fn curl_request(url: &url::Url, address: &str, protocol: &str, options: &Options, encoding: &str, capability: &Value, ca_override: Option<&Path>, cancel: &CancellationToken) -> Result { let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?; parse_target(url.as_str())?; let client = json!({ "version": capability["version"], "http2": capability["http2"], "http3": capability["http3"], "tlsBackend": capability["tlsBackend"], "platform": capability["platform"], "arch": capability["arch"] }); if protocol == "3" && url.scheme() != "https" { return Ok(CurlResult { value: json!({ "status": "unavailable", "code": "HTTP3_REQUIRES_HTTPS" }), body: Vec::new() }); } if capability["available"] != true { return Ok(CurlResult { value: json!({ "status": "unavailable", "code": capability["code"], "capability": client }), body: Vec::new() }); } let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 }); let host = url.host_str().unwrap_or("").trim_start_matches('[').trim_end_matches(']').to_string(); let seconds = (options.timeout_ms as f64 / 1000.0).to_string(); let ca_path = ca_override.map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| capability["caPath"].as_str().unwrap_or("").to_string()); let mut args: Vec = vec![ "--disable".into(), "--silent".into(), "--show-error".into(), match protocol { "1.1" => "--http1.1", "2" => "--http2-prior-knowledge", _ => "--http3-only" }.into(), format!("--ipv{}", if ip.is_ipv4() { 4 } else { 6 }), "--noproxy".into(), "*".into(), "--proto".into(), "=http,https".into(), "--cacert".into(), ca_path, "--max-time".into(), seconds.clone(), "--connect-timeout".into(), seconds, "--max-filesize".into(), MAX_BODY.to_string(), "--output".into(), "-".into(), "--dump-header".into(), "-".into(), "--header".into(), format!("Accept-Encoding: {encoding}"), "--user-agent".into(), "CH-J-Server-Diagnostics/1".into(), ]; if host.parse::().is_err() { args.push("--resolve".into()); args.push(format!("{host}:{port}:{}", if ip.is_ipv6() { format!("[{address}]") } else { address.to_string() })); } args.extend(["--write-out".into(), "\nCHJ_METRICS:%{json}".into(), "--url".into(), url.to_string()]); let executable = capability["executable"].as_str().unwrap_or("").to_string(); let result = match run_process(&executable, &args, options.timeout_ms + 1000, cancel, MAX_BODY + 131_072, None, true).await { Ok(result) => result, Err(error) => { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } if error.code == "TIMEOUT" && protocol == "3" { return Ok(CurlResult { value: json!({ "status": "error", "code": "HTTP3_UDP_TIMEOUT", "capability": client }), body: Vec::new() }); } return Err(error); } }; if result.code != Some(0) { let code = result.code.unwrap_or(-1); return Ok(CurlResult { value: json!({ "status": "error", "code": curl_failure(code, protocol), "curlCode": code, "capability": client }), body: Vec::new() }); } let output = result.stdout; let invalid = || DiagError::new("HTTP_INVALID_RESULT"); let marker = rfind(&output, b"\nCHJ_METRICS:").ok_or_else(invalid)?; let metrics: Value = serde_json::from_slice(&output[marker + 13..]).map_err(|_| invalid())?; let http_version = match &metrics["http_version"] { Value::String(s) => s.clone(), other => other.to_string() }; if http_version != protocol { return Ok(CurlResult { value: json!({ "status": "unsupported", "code": if protocol == "3" { "HTTP3_FALLBACK_REJECTED" } else { "HTTP_PROTOCOL_INCOMPATIBLE" }, "negotiated": http_version, "capability": client }), body: Vec::new(), }); } for key in ["time_total", "time_starttransfer", "time_appconnect", "size_download", "speed_download", "response_code", "remote_port"] { if !metrics[key].as_f64().map(|v| v.is_finite() && v >= 0.0).unwrap_or(false) { return Err(invalid()); } } let response_code = metrics["response_code"].as_f64().unwrap_or(0.0) as u64; if response_code < 200 { return Err(invalid()); } if url.scheme() == "https" && metrics["ssl_verify_result"].as_i64() != Some(0) { return Err(DiagError::new("TLS_CERT_INVALID")); } if metrics["size_download"].as_f64().unwrap_or(0.0) > MAX_BODY as f64 { return Err(DiagError::new("BODY_LIMIT")); } let status_re = Regex::new(r"^HTTP/(1\.1|2|3)(?:\.0)?\s+(\d{3})\b").unwrap(); let mut cursor = 0usize; let mut head: Option = None; let mut body_start = 0usize; for _ in 0..8 { let end = find(&output, b"\r\n\r\n", cursor).ok_or_else(invalid)?; if end > 32768 || end >= marker { return Err(invalid()); } let candidate = String::from_utf8_lossy(&output[cursor..end]).to_string(); let caps = status_re.captures(&candidate).ok_or_else(invalid)?; let code: u64 = caps[2].parse().unwrap_or(0); if code >= 200 { if code != response_code { return Err(invalid()); } head = Some(candidate); body_start = end + 4; break; } cursor = end + 4; } let head = head.ok_or_else(invalid)?; let mut headers = Map::new(); for line in head.split("\r\n").skip(1) { if let Some(at) = line.find(':').filter(|i| *i > 0) { headers.insert(line[..at].to_lowercase(), json!(line[at + 1..].trim())); } } let body = output[body_start..marker].to_vec(); let num = |k: &str| metrics[k].as_f64().unwrap_or(0.0); let content_length = headers.get("content-length").and_then(Value::as_str).filter(|v| v.chars().all(|c| c.is_ascii_digit()) && !v.is_empty()).and_then(|v| v.parse::().ok()); let alt_svc = headers.get("alt-svc").cloned().unwrap_or(Value::Null); let value = json!({ "status": "success", "code": if protocol == "3" { json!("HTTP3_CONFIRMED") } else { Value::Null }, "negotiated": protocol, "alpn": match protocol { "1.1" => "http/1.1", "2" => "h2", _ => "h3" }, "statusCode": response_code, "serverIp": metrics["remote_ip"], "port": metrics["remote_port"], "headers": safe_headers(&headers), "altSvc": alt_svc, "downloadedBytes": metrics["size_download"], "contentType": metrics["content_type"], "contentLength": content_length, "bodyHash": hex::encode(Sha256::digest(&body)), "capability": client, "tls": if url.scheme() == "https" { json!({ "verified": true, "version": if protocol == "3" { json!("TLSv1.3") } else { Value::Null }, "backend": capability["tlsBackend"] }) } else { Value::Null }, "reused": false, "reuseReason": "Independent bundled-client process for every sample; connection reuse is not measured.", "timings": { "dnsMs": null, "tcpMs": if protocol == "3" { Value::Null } else { let v = num("time_connect") * 1000.0; if v != 0.0 { json!(v) } else { Value::Null } }, "tlsMs": if protocol == "3" || url.scheme() != "https" { Value::Null } else { json!((num("time_appconnect") - num("time_connect")) * 1000.0) }, "quicHandshakeMs": if protocol == "3" { json!(num("time_appconnect") * 1000.0) } else { Value::Null }, "ttfbMs": num("time_starttransfer") * 1000.0, "downloadMs": (num("time_total") - num("time_starttransfer")) * 1000.0, "totalMs": num("time_total") * 1000.0 }, "bytesPerSecond": metrics["speed_download"] }); let mut value = value; if protocol != "3" { if let Some(object) = value.as_object_mut() { object.remove("code"); } } Ok(CurlResult { value, body }) } pub struct HttpDiagnostics { pub resources: PathBuf, /// Test-only CA (never configured in the application). pub ca_path: Option, } impl HttpDiagnostics { async fn request(&self, target: &Target, address: &str, protocol: &str, options: &Options, cancel: &CancellationToken, capability: &Value, encoding: &str) -> Result { let mut url = url::Url::parse(&target.url).map_err(|_| DiagError::new("INVALID_URL"))?; if url.scheme() == "ws" { let _ = url.set_scheme("http"); } if url.scheme() == "wss" { let _ = url.set_scheme("https"); } let mut visited = std::collections::HashSet::new(); let mut redirects: Vec = Vec::new(); let started = now_ms(); let (mut redirect_ms, mut dns_ms, mut had_dns) = (0.0, 0.0, false); for hop in 0..=8 { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } parse_target(url.as_str())?; if !visited.insert(url.to_string()) { return Err(DiagError::new("REDIRECT_LOOP")); } let host = url.host_str().unwrap_or("").trim_start_matches('[').trim_end_matches(']').to_string(); let mut ip = address.to_string(); if host != target.host || (options.selected_ip.is_empty() && host.parse::().is_err()) { let dns_start = now_ms(); let family = if address.parse::().map(|i| i.is_ipv6()).unwrap_or(false) { "ipv6" } else { "ipv4" }; let list = dns::resolve(&host, &Options { mode: family.into(), ..options.clone() }, cancel).await?; dns_ms += now_ms() - dns_start; had_dns = true; ip = if host == target.host && list.iter().any(|e| e["address"].as_str() == Some(address)) { address.to_string() } else { list[0]["address"].as_str().unwrap_or("").to_string() }; } let before = now_ms() - started; let mut result = curl_request(&url, &ip, protocol, options, encoding, capability, self.ca_path.as_deref(), cancel).await?; if result.value["status"] != "success" { result.value["redirects"] = Value::Array(redirects); return Ok(result); } let status_code = result.value["statusCode"].as_u64().unwrap_or(0); if [301, 302, 303, 307, 308].contains(&status_code) { if let Some(location) = result.value["headers"]["location"].as_str() { if hop == 8 { return Err(DiagError::new("REDIRECT_LIMIT")); } let next = url.join(location).map_err(|_| DiagError::new("INVALID_URL"))?; parse_target(next.as_str())?; redirects.push(json!({ "from": url.as_str(), "to": next.as_str(), "statusCode": status_code, "crossDomain": next.host_str() != url.host_str(), "downgrade": url.scheme() == "https" && next.scheme() == "http" })); redirect_ms += result.value["timings"]["totalMs"].as_f64().unwrap_or(0.0); url = next; continue; } } let ttfb = result.value["timings"]["ttfbMs"].as_f64(); let headers = result.value["headers"].clone(); let value = &mut result.value; value["url"] = json!(url.as_str()); value["redirects"] = Value::Array(redirects.clone()); value["redirectCount"] = json!(redirects.len()); value["securityHeaders"] = security_headers(&headers, url.scheme() == "https"); let mut timings = value["timings"].clone(); timings["finalResponseTtfbMs"] = ttfb.map(Value::from).unwrap_or(Value::Null); timings["ttfbMs"] = ttfb.map(|t| json!(before + t)).unwrap_or(Value::Null); timings["dnsMs"] = if had_dns { json!(dns_ms) } else { Value::Null }; timings["redirectMs"] = json!(redirect_ms); timings["totalMs"] = json!(now_ms() - started); value["timings"] = timings; value["dnsReason"] = if had_dns { Value::Null } else { json!("Pinned IP or IP literal; no DNS operation in this request.") }; return Ok(result); } Err(DiagError::new("REDIRECT_LIMIT")) } pub async fn run(&self, target: &Target, address: &str, options: &Options, cancel: &CancellationToken, progress: Arc) -> Result { let capability = curl_capability(&self.resources, cancel).await; let mut targets = vec![target.clone()]; if options.compare_schemes { let mut url = url::Url::parse(&target.url).map_err(|_| DiagError::new("INVALID_URL"))?; let scheme = if url.scheme() == "http" { "https" } else { "http" }; let _ = url.set_scheme(scheme); let _ = url.set_port(None); targets.push(parse_target(url.as_str())?); } let mut comparisons = Vec::new(); for tested in &targets { let scheme = format!("{}:", url::Url::parse(&tested.url).map(|u| u.scheme().to_string()).unwrap_or_default()); for protocol in &options.protocols { let mut samples: Vec = Vec::new(); for iteration in 0..options.repetitions { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } let mut sample = match self.request(tested, address, protocol, options, cancel, &capability, "identity").await { Ok(result) => result.value, Err(error) => { if cancel.is_cancelled() { return Err(error); } error_result(&error) } }; let mut entry = json!({ "iteration": iteration }); if let (Some(e), Some(s)) = (entry.as_object_mut(), sample.as_object_mut()) { e.extend(std::mem::take(s)); } let mut event = json!({ "kind": "http-sample", "protocol": protocol, "address": address, "scheme": scheme, "iteration": iteration }); if let (Some(ev), Some(e)) = (event.as_object_mut(), entry.as_object()) { for (k, v) in e { if k != "iteration" { ev.insert(k.clone(), v.clone()); } } } progress(event); let stop = matches!(entry["status"].as_str(), Some("unavailable") | Some("unsupported")); sample = entry; samples.push(sample); if stop { break; } } let totals: Vec> = samples.iter().map(|s| s["timings"]["totalMs"].as_f64()).collect(); let errors = samples.iter().filter(|s| s["status"] != "success").count(); comparisons.push(json!({ "protocol": protocol, "url": tested.url, "address": address, "connection": "cold", "samples": samples, "statistics": stats(&totals), "errorRate": 100.0 * errors as f64 / samples.len().max(1) as f64 })); } } let ok = comparisons.iter().any(|c| c["samples"].as_array().map(|s| s.iter().any(|x| x["status"] == "success")).unwrap_or(false)); Ok(json!({ "status": if ok { "success" } else { "warning" }, "comparisons": comparisons, "note": "Network/server request timings; no page rendering or Lighthouse score is measured." })) } pub async fn compression(&self, target: &Target, address: &str, options: &Options, cancel: &CancellationToken, progress: Arc) -> Result { let capability = curl_capability(&self.resources, cancel).await; let mut results = Vec::new(); let mut identity: Option<(String, usize, String, u64)> = None; let cold = Options { warm: false, ..options.clone() }; for encoding in ["identity", "gzip", "deflate", "br", "zstd"] { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } let result = match self.request(target, address, "1.1", &cold, cancel, &capability, encoding).await { Err(error) => { if cancel.is_cancelled() { return Err(error); } error_result(&error) } Ok(response) => { let actual = response.value["headers"]["content-encoding"].as_str().unwrap_or("identity").trim().to_lowercase(); if response.value["status"] != "success" { response.value } else if actual != encoding { json!({ "status": "unsupported", "actualEncoding": actual, "downloadedBytes": response.value["downloadedBytes"], "reason": "Server did not use the requested content encoding." }) } else { match decode_body(&response.body, encoding) { Err(error) => error_result(&error), Ok(decoded) => { let hash = hex::encode(Sha256::digest(&decoded)); let url = response.value["url"].as_str().unwrap_or("").to_string(); let status = response.value["statusCode"].as_u64().unwrap_or(0); if encoding == "identity" { identity = Some((hash.clone(), decoded.len(), url.clone(), status)); } let comparable = identity.as_ref().map(|(h, _, u, s)| *h == hash && *u == url && *s == status).unwrap_or(false); let ratio = match (&identity, comparable) { (Some((_, bytes, _, _)), true) if *bytes > 0 => json!(response.value["downloadedBytes"].as_f64().unwrap_or(0.0) / *bytes as f64), _ => Value::Null, }; json!({ "status": "success", "actualEncoding": actual, "downloadedBytes": response.value["downloadedBytes"], "decodedBytes": decoded.len(), "decodedHash": hash, "comparable": comparable, "ratio": ratio, "statusCode": status }) } } } } }; let mut entry = json!({ "encoding": encoding }); if let (Some(e), Some(r)) = (entry.as_object_mut(), result.as_object()) { e.extend(r.clone()); } let mut event = json!({ "kind": "compression", "address": address }); if let (Some(ev), Some(e)) = (event.as_object_mut(), entry.as_object()) { ev.extend(e.clone()); } progress(event); results.push(entry); } let ok = results.iter().any(|r| r["encoding"] == "br" && r["status"] == "success"); Ok(json!({ "status": if ok { "success" } else { "warning" }, "results": results, "decoder": decoder_info(), "note": "Compression is confirmed by Content-Encoding and bounded decompression. Ratios require identical decoded content." })) } } pub fn decoder_info() -> Value { json!({ "runtime": "bundled", "implementation": "Rust (flate2/miniz_oxide, brotli, zstd)", "node": null, "electron": null, "zlib": "miniz_oxide", "brotli": "brotli (Rust)", "zstd": "zstd (libzstd)", "encodings": ["identity", "gzip", "deflate", "br", "zstd"] }) } fn bounded_read(mut reader: impl Read) -> Result, DiagError> { let mut out = Vec::new(); let limit = (MAX_BODY * 4) as u64; reader.by_ref().take(limit + 1).read_to_end(&mut out).map_err(|e| DiagError::with_message("Z_DATA_ERROR", e.to_string()))?; if out.len() as u64 > limit { return Err(DiagError::new("DECOMPRESSION_LIMIT")); } Ok(out) } /// Bounded decoding; RFC 9110 deflate is zlib-wrapped, but raw DEFLATE is accepted too. pub fn decode_body(body: &[u8], encoding: &str) -> Result, DiagError> { match encoding { "identity" => Ok(body.to_vec()), "gzip" => bounded_read(flate2::read::MultiGzDecoder::new(body)), "deflate" => { let zlib_header = body.len() > 1 && (body[0] & 0x0f) == 8 && ((u16::from(body[0]) << 8) | u16::from(body[1])) % 31 == 0; if zlib_header { bounded_read(flate2::read::ZlibDecoder::new(body)) } else { bounded_read(flate2::read::DeflateDecoder::new(body)) } } "br" => bounded_read(brotli::Decompressor::new(body, 4096)), "zstd" => bounded_read(zstd::stream::read::Decoder::new(body).map_err(|e| DiagError::with_message("ZSTD_ERROR", e.to_string()))?), _ => Err(DiagError::new("COMPRESSION_CLIENT_UNAVAILABLE")), } } #[cfg(test)] mod tests { use super::*; use std::io::Write; #[test] fn decoders_round_trip() { let text = b"CH-J Server Manager ".repeat(100); let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); gz.write_all(&text).unwrap(); assert_eq!(decode_body(&gz.finish().unwrap(), "gzip").unwrap(), text); let mut zl = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); zl.write_all(&text).unwrap(); assert_eq!(decode_body(&zl.finish().unwrap(), "deflate").unwrap(), text); let mut raw = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); raw.write_all(&text).unwrap(); assert_eq!(decode_body(&raw.finish().unwrap(), "deflate").unwrap(), text); let mut br = Vec::new(); brotli::BrotliCompress(&mut &text[..], &mut br, &Default::default()).unwrap(); assert_eq!(decode_body(&br, "br").unwrap(), text); assert_eq!(decode_body(&zstd::encode_all(&text[..], 3).unwrap(), "zstd").unwrap(), text); } #[test] fn bomb_is_bounded() { let zeros = vec![0u8; MAX_BODY * 5]; let compressed = zstd::encode_all(&zeros[..], 19).unwrap(); assert_eq!(decode_body(&compressed, "zstd").unwrap_err().code, "DECOMPRESSION_LIMIT"); } #[test] fn header_handling() { let mut headers = Map::new(); headers.insert("Set-Cookie".into(), json!("a=b")); headers.insert("Server".into(), json!("nginx")); let safe = safe_headers(&headers); assert_eq!(safe["set-cookie"], "[redacted]"); assert_eq!(safe["server"], "nginx"); let sec = security_headers(&json!({"content-security-policy": "default-src 'self'; frame-ancestors 'none'"}), false); assert_eq!(sec[0]["status"], "not-applicable"); assert_eq!(sec[5]["status"], "superseded-by-csp-frame-ancestors"); } #[tokio::test] async fn bundled_runtime_capability() { let resources = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources"); let capability = curl_capability(&resources, &CancellationToken::new()).await; if capability["code"] == "HTTP_RUNTIME_MISSING" { return; // runtime not built on this machine } assert_eq!(capability["available"], true, "{capability}"); assert_eq!(capability["http3"], true); } }