Gepubliceerde bron
Download bron ZIP
CH-J Tools
Blader door mappen en bestanden voor een specifieke toepassing release.
CH-J / Server Manager
1704 bestanden
0.1.2-beta
Toegevoegd: · Gebouwd:
1
//! HTTP/HTTPS 1.1/2/3 measurements (port of `httpDiagnostics.js` + `httpRuntime.js`).2
//!3
//! All protocol versions use the same bundled curl runtime as 0.1.0 (pinned build with4
//! nghttp2/ngtcp2/nghttp3; manifest + SHA-256 of executable and CA bundle verified before5
//! every capability check). HTTP/3 is reported only after a completed transfer with6
//! `http_version == 3`, verified TLS and a final status ≥ 200 — never from Alt-Svc.8
use crate::core::diagnostics::common::{error_result, now_ms, parse_target, stats, DiagError, Options, Target};9
use crate::core::diagnostics::dns;10
use crate::core::diagnostics::process::run_process;11
use once_cell::sync::Lazy;12
use regex::Regex;13
use serde_json::{json, Map, Value};14
use sha2::{Digest, Sha256};15
use std::io::Read;16
use std::net::IpAddr;17
use std::path::{Path, PathBuf};18
use std::sync::Arc;19
use tokio_util::sync::CancellationToken;21
pub const MAX_BODY: usize = 2 * 1024 * 1024;22
static PRIVATE_HEADERS: Lazy<Regex> = Lazy::new(|| {23
Regex::new(r"(?i)^(?:set-cookie|cookie|authorization|proxy-authorization|www-authenticate|proxy-authenticate|authentication-info|proxy-authentication-info|set-cookie2)$").unwrap()24
});26
#[derive(Clone, Debug)]27
pub struct Runtime {28
pub root: PathBuf,29
pub executable: PathBuf,30
pub ca_path: PathBuf,31
pub platform: String,32
pub arch: String,33
}35
pub fn node_platform_arch() -> (String, String) {36
let platform = crate::windows::platform_name().to_string();37
let arch = match std::env::consts::ARCH {38
"x86_64" => "x64",39
"aarch64" => "arm64",40
"x86" => "ia32",41
other => other,42
};43
(platform, arch.to_string())44
}46
pub fn runtime_paths(resources: &Path) -> Runtime {47
let (platform, arch) = node_platform_arch();48
let root = resources.join("native-runtime").join(format!("{platform}-{arch}"));49
Runtime {50
executable: root.join(if platform == "win32" { "curl.exe" } else { "curl" }),51
ca_path: root.join("ca-bundle.pem"),52
root,53
platform,54
arch,55
}56
}58
pub fn validate_runtime(resources: &Path) -> Result<Runtime, DiagError> {59
let runtime = runtime_paths(resources);60
let check = || -> Result<(), std::io::Error> {61
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(runtime.root.join("manifest.json"))?).map_err(|_| std::io::Error::other("manifest"))?;62
if manifest["platform"].as_str() != Some(&runtime.platform) || manifest["arch"].as_str() != Some(&runtime.arch) {63
return Err(std::io::Error::other("architecture"));64
}65
let exe_name = runtime.executable.file_name().unwrap().to_string_lossy().to_string();66
for name in [exe_name.as_str(), "ca-bundle.pem"] {67
let expected = manifest["files"][name].as_str().unwrap_or("");68
if expected.len() != 64 || hex::encode(Sha256::digest(std::fs::read(runtime.root.join(name))?)) != expected {69
return Err(std::io::Error::other("checksum"));70
}71
}72
#[cfg(unix)]73
{74
use std::os::unix::fs::PermissionsExt;75
if std::fs::metadata(&runtime.executable)?.permissions().mode() & 0o111 == 0 {76
return Err(std::io::Error::other("not executable"));77
}78
}79
Ok(())80
};81
check().map_err(|e| DiagError::new(if e.kind() == std::io::ErrorKind::NotFound { "HTTP_RUNTIME_MISSING" } else { "HTTP_RUNTIME_INVALID" }))?;82
Ok(runtime)83
}85
pub async fn curl_capability(resources: &Path, cancel: &CancellationToken) -> Value {86
let (platform, arch) = node_platform_arch();87
let runtime = match validate_runtime(resources) {88
Ok(runtime) => runtime,89
Err(error) => return json!({ "available": false, "code": error.code, "platform": platform, "arch": arch }),90
};91
match run_process(&runtime.executable.to_string_lossy(), &["--disable".into(), "--version".into()], 3000, cancel, 256 * 1024, None, true).await {92
Ok(result) => {93
let output = String::from_utf8_lossy(&result.stdout).to_string();94
let version = Regex::new(r"^curl (\d+)\.(\d+)\.(\d+)").unwrap().captures(&output);95
let feature = |name: &str| Regex::new(&format!(r"Features:.*\b{name}\b")).unwrap().is_match(&output);96
let (http2, http3, tls) = (feature("HTTP2"), feature("HTTP3"), feature("SSL"));97
let major_ok = version.as_ref().and_then(|v| v[1].parse::<u32>().ok()).map(|m| m >= 8).unwrap_or(false);98
let available = major_ok && http2 && http3 && tls && result.code.map(|c| c == 0).unwrap_or(true);99
let backend = Regex::new(r"(?:OpenSSL|LibreSSL|BoringSSL|AWS-LC)/[^ ]+").unwrap().find(output.lines().next().unwrap_or("")).map(|m| m.as_str().to_string());100
json!({101
"available": available, "version": version.map(|v| v[0].to_string()), "http2": http2, "http3": http3, "tls": tls,102
"platform": platform, "arch": arch, "tlsBackend": backend, "code": if available { Value::Null } else { json!("HTTP_RUNTIME_INCOMPATIBLE") },103
"raw": output.chars().take(3000).collect::<String>(), "executable": runtime.executable.to_string_lossy(), "caPath": runtime.ca_path.to_string_lossy()104
})105
}106
Err(error) => {107
if cancel.is_cancelled() {108
return json!({ "available": false, "code": "CANCELLED" });109
}110
json!({ "available": false, "code": if error.code == "ENOENT" { "HTTP_RUNTIME_MISSING" } else { "HTTP_RUNTIME_INVALID" }, "platform": platform, "arch": arch })111
}112
}113
}115
pub fn safe_headers(headers: &Map<String, Value>) -> Value {116
let mut result = Map::new();117
for (key, value) in headers {118
if key.starts_with(':') {119
continue;120
}121
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());122
result.insert(key.to_lowercase(), json!(if PRIVATE_HEADERS.is_match(key) { "[redacted]".to_string() } else { text.chars().take(8192).collect::<String>() }));123
}124
Value::Object(result)125
}127
pub fn security_headers(headers: &Value, https: bool) -> Value {128
let names = ["strict-transport-security", "content-security-policy", "x-content-type-options", "referrer-policy", "permissions-policy", "x-frame-options"];129
let csp = headers["content-security-policy"].as_str().unwrap_or("");130
Value::Array(131
names132
.iter()133
.map(|name| {134
let value = headers.get(*name).filter(|v| !v.is_null()).cloned();135
let status = if value.is_some() {136
"present"137
} else if *name == "strict-transport-security" && !https {138
"not-applicable"139
} else if *name == "x-frame-options" && Regex::new(r"(?i)(?:^|;)\s*frame-ancestors\s").unwrap().is_match(csp) {140
"superseded-by-csp-frame-ancestors"141
} else {142
"missing"143
};144
json!({ "name": name, "value": value, "status": status, "note": "Presence alone does not establish correct policy or website security." })145
})146
.collect(),147
)148
}150
fn curl_failure(code: i32, protocol: &str) -> &'static str {151
match code {152
1 | 92 => "HTTP_PROTOCOL_INCOMPATIBLE",153
2 | 4 => "HTTP_RUNTIME_INCOMPATIBLE",154
5 | 6 => "DNS_FAILED",155
7 => "CONNECTION_REFUSED",156
28 => if protocol == "3" { "HTTP3_UDP_TIMEOUT" } else { "TIMEOUT" },157
35 => "TLS_HANDSHAKE_FAILED",158
51 | 58 | 60 => "TLS_CERT_INVALID",159
63 => "BODY_LIMIT",160
77 => "HTTP_RUNTIME_INVALID",161
95 | 96 => "HTTP3_UNVERIFIED",162
_ => "HTTP_TRANSFER_FAILED",163
}164
}166
fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {167
haystack.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)168
}170
fn rfind(haystack: &[u8], needle: &[u8]) -> Option<usize> {171
haystack.windows(needle.len()).rposition(|w| w == needle)172
}174
pub struct CurlResult {175
pub value: Value,176
pub body: Vec<u8>,177
}179
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<CurlResult, DiagError> {180
let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?;181
parse_target(url.as_str())?;182
let client = json!({ "version": capability["version"], "http2": capability["http2"], "http3": capability["http3"], "tlsBackend": capability["tlsBackend"], "platform": capability["platform"], "arch": capability["arch"] });183
if protocol == "3" && url.scheme() != "https" {184
return Ok(CurlResult { value: json!({ "status": "unavailable", "code": "HTTP3_REQUIRES_HTTPS" }), body: Vec::new() });185
}186
if capability["available"] != true {187
return Ok(CurlResult { value: json!({ "status": "unavailable", "code": capability["code"], "capability": client }), body: Vec::new() });188
}189
let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });190
let host = url.host_str().unwrap_or("").trim_start_matches('[').trim_end_matches(']').to_string();191
let seconds = (options.timeout_ms as f64 / 1000.0).to_string();192
let ca_path = ca_override.map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| capability["caPath"].as_str().unwrap_or("").to_string());193
let mut args: Vec<String> = vec![194
"--disable".into(), "--silent".into(), "--show-error".into(),195
match protocol { "1.1" => "--http1.1", "2" => "--http2-prior-knowledge", _ => "--http3-only" }.into(),196
format!("--ipv{}", if ip.is_ipv4() { 4 } else { 6 }),197
"--noproxy".into(), "*".into(), "--proto".into(), "=http,https".into(), "--cacert".into(), ca_path,198
"--max-time".into(), seconds.clone(), "--connect-timeout".into(), seconds, "--max-filesize".into(), MAX_BODY.to_string(),199
"--output".into(), "-".into(), "--dump-header".into(), "-".into(), "--header".into(), format!("Accept-Encoding: {encoding}"),200
"--user-agent".into(), "CH-J-Server-Diagnostics/1".into(),201
];202
if host.parse::<IpAddr>().is_err() {203
args.push("--resolve".into());204
args.push(format!("{host}:{port}:{}", if ip.is_ipv6() { format!("[{address}]") } else { address.to_string() }));205
}206
args.extend(["--write-out".into(), "\nCHJ_METRICS:%{json}".into(), "--url".into(), url.to_string()]);207
let executable = capability["executable"].as_str().unwrap_or("").to_string();208
let result = match run_process(&executable, &args, options.timeout_ms + 1000, cancel, MAX_BODY + 131_072, None, true).await {209
Ok(result) => result,210
Err(error) => {211
if cancel.is_cancelled() {212
return Err(DiagError::new("CANCELLED"));213
}214
if error.code == "TIMEOUT" && protocol == "3" {215
return Ok(CurlResult { value: json!({ "status": "error", "code": "HTTP3_UDP_TIMEOUT", "capability": client }), body: Vec::new() });216
}217
return Err(error);218
}219
};220
if result.code != Some(0) {221
let code = result.code.unwrap_or(-1);222
return Ok(CurlResult { value: json!({ "status": "error", "code": curl_failure(code, protocol), "curlCode": code, "capability": client }), body: Vec::new() });223
}224
let output = result.stdout;225
let invalid = || DiagError::new("HTTP_INVALID_RESULT");226
let marker = rfind(&output, b"\nCHJ_METRICS:").ok_or_else(invalid)?;227
let metrics: Value = serde_json::from_slice(&output[marker + 13..]).map_err(|_| invalid())?;228
let http_version = match &metrics["http_version"] { Value::String(s) => s.clone(), other => other.to_string() };229
if http_version != protocol {230
return Ok(CurlResult {231
value: json!({ "status": "unsupported", "code": if protocol == "3" { "HTTP3_FALLBACK_REJECTED" } else { "HTTP_PROTOCOL_INCOMPATIBLE" }, "negotiated": http_version, "capability": client }),232
body: Vec::new(),233
});234
}235
for key in ["time_total", "time_starttransfer", "time_appconnect", "size_download", "speed_download", "response_code", "remote_port"] {236
if !metrics[key].as_f64().map(|v| v.is_finite() && v >= 0.0).unwrap_or(false) {237
return Err(invalid());238
}239
}240
let response_code = metrics["response_code"].as_f64().unwrap_or(0.0) as u64;241
if response_code < 200 {242
return Err(invalid());243
}244
if url.scheme() == "https" && metrics["ssl_verify_result"].as_i64() != Some(0) {245
return Err(DiagError::new("TLS_CERT_INVALID"));246
}247
if metrics["size_download"].as_f64().unwrap_or(0.0) > MAX_BODY as f64 {248
return Err(DiagError::new("BODY_LIMIT"));249
}250
let status_re = Regex::new(r"^HTTP/(1\.1|2|3)(?:\.0)?\s+(\d{3})\b").unwrap();251
let mut cursor = 0usize;252
let mut head: Option<String> = None;253
let mut body_start = 0usize;254
for _ in 0..8 {255
let end = find(&output, b"\r\n\r\n", cursor).ok_or_else(invalid)?;256
if end > 32768 || end >= marker {257
return Err(invalid());258
}259
let candidate = String::from_utf8_lossy(&output[cursor..end]).to_string();260
let caps = status_re.captures(&candidate).ok_or_else(invalid)?;261
let code: u64 = caps[2].parse().unwrap_or(0);262
if code >= 200 {263
if code != response_code {264
return Err(invalid());265
}266
head = Some(candidate);267
body_start = end + 4;268
break;269
}270
cursor = end + 4;271
}272
let head = head.ok_or_else(invalid)?;273
let mut headers = Map::new();274
for line in head.split("\r\n").skip(1) {275
if let Some(at) = line.find(':').filter(|i| *i > 0) {276
headers.insert(line[..at].to_lowercase(), json!(line[at + 1..].trim()));277
}278
}279
let body = output[body_start..marker].to_vec();280
let num = |k: &str| metrics[k].as_f64().unwrap_or(0.0);281
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::<u64>().ok());282
let alt_svc = headers.get("alt-svc").cloned().unwrap_or(Value::Null);283
let value = json!({284
"status": "success",285
"code": if protocol == "3" { json!("HTTP3_CONFIRMED") } else { Value::Null },286
"negotiated": protocol,287
"alpn": match protocol { "1.1" => "http/1.1", "2" => "h2", _ => "h3" },288
"statusCode": response_code,289
"serverIp": metrics["remote_ip"],290
"port": metrics["remote_port"],291
"headers": safe_headers(&headers),292
"altSvc": alt_svc,293
"downloadedBytes": metrics["size_download"],294
"contentType": metrics["content_type"],295
"contentLength": content_length,296
"bodyHash": hex::encode(Sha256::digest(&body)),297
"capability": client,298
"tls": if url.scheme() == "https" { json!({ "verified": true, "version": if protocol == "3" { json!("TLSv1.3") } else { Value::Null }, "backend": capability["tlsBackend"] }) } else { Value::Null },299
"reused": false,300
"reuseReason": "Independent bundled-client process for every sample; connection reuse is not measured.",301
"timings": {302
"dnsMs": null,303
"tcpMs": if protocol == "3" { Value::Null } else { let v = num("time_connect") * 1000.0; if v != 0.0 { json!(v) } else { Value::Null } },304
"tlsMs": if protocol == "3" || url.scheme() != "https" { Value::Null } else { json!((num("time_appconnect") - num("time_connect")) * 1000.0) },305
"quicHandshakeMs": if protocol == "3" { json!(num("time_appconnect") * 1000.0) } else { Value::Null },306
"ttfbMs": num("time_starttransfer") * 1000.0,307
"downloadMs": (num("time_total") - num("time_starttransfer")) * 1000.0,308
"totalMs": num("time_total") * 1000.0309
},310
"bytesPerSecond": metrics["speed_download"]311
});312
let mut value = value;313
if protocol != "3" {314
if let Some(object) = value.as_object_mut() {315
object.remove("code");316
}317
}318
Ok(CurlResult { value, body })319
}321
pub struct HttpDiagnostics {322
pub resources: PathBuf,323
/// Test-only CA (never configured in the application).324
pub ca_path: Option<PathBuf>,325
}327
impl HttpDiagnostics {328
async fn request(&self, target: &Target, address: &str, protocol: &str, options: &Options, cancel: &CancellationToken, capability: &Value, encoding: &str) -> Result<CurlResult, DiagError> {329
let mut url = url::Url::parse(&target.url).map_err(|_| DiagError::new("INVALID_URL"))?;330
if url.scheme() == "ws" {331
let _ = url.set_scheme("http");332
}333
if url.scheme() == "wss" {334
let _ = url.set_scheme("https");335
}336
let mut visited = std::collections::HashSet::new();337
let mut redirects: Vec<Value> = Vec::new();338
let started = now_ms();339
let (mut redirect_ms, mut dns_ms, mut had_dns) = (0.0, 0.0, false);340
for hop in 0..=8 {341
if cancel.is_cancelled() {342
return Err(DiagError::new("CANCELLED"));343
}344
parse_target(url.as_str())?;345
if !visited.insert(url.to_string()) {346
return Err(DiagError::new("REDIRECT_LOOP"));347
}348
let host = url.host_str().unwrap_or("").trim_start_matches('[').trim_end_matches(']').to_string();349
let mut ip = address.to_string();350
if host != target.host || (options.selected_ip.is_empty() && host.parse::<IpAddr>().is_err()) {351
let dns_start = now_ms();352
let family = if address.parse::<IpAddr>().map(|i| i.is_ipv6()).unwrap_or(false) { "ipv6" } else { "ipv4" };353
let list = dns::resolve(&host, &Options { mode: family.into(), ..options.clone() }, cancel).await?;354
dns_ms += now_ms() - dns_start;355
had_dns = true;356
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() };357
}358
let before = now_ms() - started;359
let mut result = curl_request(&url, &ip, protocol, options, encoding, capability, self.ca_path.as_deref(), cancel).await?;360
if result.value["status"] != "success" {361
result.value["redirects"] = Value::Array(redirects);362
return Ok(result);363
}364
let status_code = result.value["statusCode"].as_u64().unwrap_or(0);365
if [301, 302, 303, 307, 308].contains(&status_code) {366
if let Some(location) = result.value["headers"]["location"].as_str() {367
if hop == 8 {368
return Err(DiagError::new("REDIRECT_LIMIT"));369
}370
let next = url.join(location).map_err(|_| DiagError::new("INVALID_URL"))?;371
parse_target(next.as_str())?;372
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" }));373
redirect_ms += result.value["timings"]["totalMs"].as_f64().unwrap_or(0.0);374
url = next;375
continue;376
}377
}378
let ttfb = result.value["timings"]["ttfbMs"].as_f64();379
let headers = result.value["headers"].clone();380
let value = &mut result.value;381
value["url"] = json!(url.as_str());382
value["redirects"] = Value::Array(redirects.clone());383
value["redirectCount"] = json!(redirects.len());384
value["securityHeaders"] = security_headers(&headers, url.scheme() == "https");385
let mut timings = value["timings"].clone();386
timings["finalResponseTtfbMs"] = ttfb.map(Value::from).unwrap_or(Value::Null);387
timings["ttfbMs"] = ttfb.map(|t| json!(before + t)).unwrap_or(Value::Null);388
timings["dnsMs"] = if had_dns { json!(dns_ms) } else { Value::Null };389
timings["redirectMs"] = json!(redirect_ms);390
timings["totalMs"] = json!(now_ms() - started);391
value["timings"] = timings;392
value["dnsReason"] = if had_dns { Value::Null } else { json!("Pinned IP or IP literal; no DNS operation in this request.") };393
return Ok(result);394
}395
Err(DiagError::new("REDIRECT_LIMIT"))396
}398
pub async fn run(&self, target: &Target, address: &str, options: &Options, cancel: &CancellationToken, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value, DiagError> {399
let capability = curl_capability(&self.resources, cancel).await;400
let mut targets = vec![target.clone()];401
if options.compare_schemes {402
let mut url = url::Url::parse(&target.url).map_err(|_| DiagError::new("INVALID_URL"))?;403
let scheme = if url.scheme() == "http" { "https" } else { "http" };404
let _ = url.set_scheme(scheme);405
let _ = url.set_port(None);406
targets.push(parse_target(url.as_str())?);407
}408
let mut comparisons = Vec::new();409
for tested in &targets {410
let scheme = format!("{}:", url::Url::parse(&tested.url).map(|u| u.scheme().to_string()).unwrap_or_default());411
for protocol in &options.protocols {412
let mut samples: Vec<Value> = Vec::new();413
for iteration in 0..options.repetitions {414
if cancel.is_cancelled() {415
return Err(DiagError::new("CANCELLED"));416
}417
let mut sample = match self.request(tested, address, protocol, options, cancel, &capability, "identity").await {418
Ok(result) => result.value,419
Err(error) => {420
if cancel.is_cancelled() {421
return Err(error);422
}423
error_result(&error)424
}425
};426
let mut entry = json!({ "iteration": iteration });427
if let (Some(e), Some(s)) = (entry.as_object_mut(), sample.as_object_mut()) {428
e.extend(std::mem::take(s));429
}430
let mut event = json!({ "kind": "http-sample", "protocol": protocol, "address": address, "scheme": scheme, "iteration": iteration });431
if let (Some(ev), Some(e)) = (event.as_object_mut(), entry.as_object()) {432
for (k, v) in e {433
if k != "iteration" {434
ev.insert(k.clone(), v.clone());435
}436
}437
}438
progress(event);439
let stop = matches!(entry["status"].as_str(), Some("unavailable") | Some("unsupported"));440
sample = entry;441
samples.push(sample);442
if stop {443
break;444
}445
}446
let totals: Vec<Option<f64>> = samples.iter().map(|s| s["timings"]["totalMs"].as_f64()).collect();447
let errors = samples.iter().filter(|s| s["status"] != "success").count();448
comparisons.push(json!({449
"protocol": protocol, "url": tested.url, "address": address, "connection": "cold", "samples": samples,450
"statistics": stats(&totals), "errorRate": 100.0 * errors as f64 / samples.len().max(1) as f64451
}));452
}453
}454
let ok = comparisons.iter().any(|c| c["samples"].as_array().map(|s| s.iter().any(|x| x["status"] == "success")).unwrap_or(false));455
Ok(json!({ "status": if ok { "success" } else { "warning" }, "comparisons": comparisons, "note": "Network/server request timings; no page rendering or Lighthouse score is measured." }))456
}458
pub async fn compression(&self, target: &Target, address: &str, options: &Options, cancel: &CancellationToken, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value, DiagError> {459
let capability = curl_capability(&self.resources, cancel).await;460
let mut results = Vec::new();461
let mut identity: Option<(String, usize, String, u64)> = None;462
let cold = Options { warm: false, ..options.clone() };463
for encoding in ["identity", "gzip", "deflate", "br", "zstd"] {464
if cancel.is_cancelled() {465
return Err(DiagError::new("CANCELLED"));466
}467
let result = match self.request(target, address, "1.1", &cold, cancel, &capability, encoding).await {468
Err(error) => {469
if cancel.is_cancelled() {470
return Err(error);471
}472
error_result(&error)473
}474
Ok(response) => {475
let actual = response.value["headers"]["content-encoding"].as_str().unwrap_or("identity").trim().to_lowercase();476
if response.value["status"] != "success" {477
response.value478
} else if actual != encoding {479
json!({ "status": "unsupported", "actualEncoding": actual, "downloadedBytes": response.value["downloadedBytes"], "reason": "Server did not use the requested content encoding." })480
} else {481
match decode_body(&response.body, encoding) {482
Err(error) => error_result(&error),483
Ok(decoded) => {484
let hash = hex::encode(Sha256::digest(&decoded));485
let url = response.value["url"].as_str().unwrap_or("").to_string();486
let status = response.value["statusCode"].as_u64().unwrap_or(0);487
if encoding == "identity" {488
identity = Some((hash.clone(), decoded.len(), url.clone(), status));489
}490
let comparable = identity.as_ref().map(|(h, _, u, s)| *h == hash && *u == url && *s == status).unwrap_or(false);491
let ratio = match (&identity, comparable) {492
(Some((_, bytes, _, _)), true) if *bytes > 0 => json!(response.value["downloadedBytes"].as_f64().unwrap_or(0.0) / *bytes as f64),493
_ => Value::Null,494
};495
json!({ "status": "success", "actualEncoding": actual, "downloadedBytes": response.value["downloadedBytes"], "decodedBytes": decoded.len(), "decodedHash": hash, "comparable": comparable, "ratio": ratio, "statusCode": status })496
}497
}498
}499
}500
};501
let mut entry = json!({ "encoding": encoding });502
if let (Some(e), Some(r)) = (entry.as_object_mut(), result.as_object()) {503
e.extend(r.clone());504
}505
let mut event = json!({ "kind": "compression", "address": address });506
if let (Some(ev), Some(e)) = (event.as_object_mut(), entry.as_object()) {507
ev.extend(e.clone());508
}509
progress(event);510
results.push(entry);511
}512
let ok = results.iter().any(|r| r["encoding"] == "br" && r["status"] == "success");513
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." }))514
}515
}517
pub fn decoder_info() -> Value {518
json!({519
"runtime": "bundled",520
"implementation": "Rust (flate2/miniz_oxide, brotli, zstd)",521
"node": null, "electron": null, "zlib": "miniz_oxide", "brotli": "brotli (Rust)", "zstd": "zstd (libzstd)",522
"encodings": ["identity", "gzip", "deflate", "br", "zstd"]523
})524
}526
fn bounded_read(mut reader: impl Read) -> Result<Vec<u8>, DiagError> {527
let mut out = Vec::new();528
let limit = (MAX_BODY * 4) as u64;529
reader.by_ref().take(limit + 1).read_to_end(&mut out).map_err(|e| DiagError::with_message("Z_DATA_ERROR", e.to_string()))?;530
if out.len() as u64 > limit {531
return Err(DiagError::new("DECOMPRESSION_LIMIT"));532
}533
Ok(out)534
}536
/// Bounded decoding; RFC 9110 deflate is zlib-wrapped, but raw DEFLATE is accepted too.537
pub fn decode_body(body: &[u8], encoding: &str) -> Result<Vec<u8>, DiagError> {538
match encoding {539
"identity" => Ok(body.to_vec()),540
"gzip" => bounded_read(flate2::read::MultiGzDecoder::new(body)),541
"deflate" => {542
let zlib_header = body.len() > 1 && (body[0] & 0x0f) == 8 && ((u16::from(body[0]) << 8) | u16::from(body[1])) % 31 == 0;543
if zlib_header { bounded_read(flate2::read::ZlibDecoder::new(body)) } else { bounded_read(flate2::read::DeflateDecoder::new(body)) }544
}545
"br" => bounded_read(brotli::Decompressor::new(body, 4096)),546
"zstd" => bounded_read(zstd::stream::read::Decoder::new(body).map_err(|e| DiagError::with_message("ZSTD_ERROR", e.to_string()))?),547
_ => Err(DiagError::new("COMPRESSION_CLIENT_UNAVAILABLE")),548
}549
}551
#[cfg(test)]552
mod tests {553
use super::*;554
use std::io::Write;556
#[test]557
fn decoders_round_trip() {558
let text = b"CH-J Tools ".repeat(100);559
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());560
gz.write_all(&text).unwrap();561
assert_eq!(decode_body(&gz.finish().unwrap(), "gzip").unwrap(), text);562
let mut zl = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());563
zl.write_all(&text).unwrap();564
assert_eq!(decode_body(&zl.finish().unwrap(), "deflate").unwrap(), text);565
let mut raw = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());566
raw.write_all(&text).unwrap();567
assert_eq!(decode_body(&raw.finish().unwrap(), "deflate").unwrap(), text);568
let mut br = Vec::new();569
brotli::BrotliCompress(&mut &text[..], &mut br, &Default::default()).unwrap();570
assert_eq!(decode_body(&br, "br").unwrap(), text);571
assert_eq!(decode_body(&zstd::encode_all(&text[..], 3).unwrap(), "zstd").unwrap(), text);572
}574
#[test]575
fn bomb_is_bounded() {576
let zeros = vec![0u8; MAX_BODY * 5];577
let compressed = zstd::encode_all(&zeros[..], 19).unwrap();578
assert_eq!(decode_body(&compressed, "zstd").unwrap_err().code, "DECOMPRESSION_LIMIT");579
}581
#[test]582
fn header_handling() {583
let mut headers = Map::new();584
headers.insert("Set-Cookie".into(), json!("a=b"));585
headers.insert("Server".into(), json!("nginx"));586
let safe = safe_headers(&headers);587
assert_eq!(safe["set-cookie"], "[redacted]");588
assert_eq!(safe["server"], "nginx");589
let sec = security_headers(&json!({"content-security-policy": "default-src 'self'; frame-ancestors 'none'"}), false);590
assert_eq!(sec[0]["status"], "not-applicable");591
assert_eq!(sec[5]["status"], "superseded-by-csp-frame-ancestors");592
}594
#[tokio::test]595
async fn bundled_runtime_capability() {596
let resources = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources");597
let capability = curl_capability(&resources, &CancellationToken::new()).await;598
if capability["code"] == "HTTP_RUNTIME_MISSING" {599
return; // runtime not built on this machine600
}601
assert_eq!(capability["available"], true, "{capability}");602
assert_eq!(capability["http3"], true);603
}604
}SHA-256
85a6d67fac8b36424a751b88325b35b47b9b044cbbdb0557928b393ab24c8cd7CH-J Proprietary Software License 1.14 · Bron wordt geleverd onder de CH-J Proprietary Software License 1.14. De beschikbaarheid verandert de licentievoorwaarden niet of verleent geen extra rechten.
Archief SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f