CH-J Tools通过 SSH 管理服务器
菜单
已发布的源代码

CH-J Tools

浏览特定应用版本的目录和文件。

下载源代码 ZIP
CH-J / Server Manager 1704 个文件
0.1.2-beta 添加时间: · 构建时间:
http.rs 添加时间: 未记录日期 31.3 KB · 604 行
代码 下载文件
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 with
4//! nghttp2/ngtcp2/nghttp3; manifest + SHA-256 of executable and CA bundle verified before
5//! every capability check). HTTP/3 is reported only after a completed transfer with
6//! `http_version == 3`, verified TLS and a final status ≥ 200 — never from Alt-Svc.
8use crate::core::diagnostics::common::{error_result, now_ms, parse_target, stats, DiagError, Options, Target};
9use crate::core::diagnostics::dns;
10use crate::core::diagnostics::process::run_process;
11use once_cell::sync::Lazy;
12use regex::Regex;
13use serde_json::{json, Map, Value};
14use sha2::{Digest, Sha256};
15use std::io::Read;
16use std::net::IpAddr;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use tokio_util::sync::CancellationToken;
21pub const MAX_BODY: usize = 2 * 1024 * 1024;
22static 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)]
27pub struct Runtime {
28 pub root: PathBuf,
29 pub executable: PathBuf,
30 pub ca_path: PathBuf,
31 pub platform: String,
32 pub arch: String,
35pub 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())
46pub 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 }
58pub 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)
85pub 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 }
115pub 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)
127pub 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 names
132 .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 )
150fn 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 }
166fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
167 haystack.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
170fn rfind(haystack: &[u8], needle: &[u8]) -> Option<usize> {
171 haystack.windows(needle.len()).rposition(|w| w == needle)
174pub struct CurlResult {
175 pub value: Value,
176 pub body: Vec<u8>,
179pub 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.0
309 },
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 })
321pub struct HttpDiagnostics {
322 pub resources: PathBuf,
323 /// Test-only CA (never configured in the application).
324 pub ca_path: Option<PathBuf>,
327impl 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 f64
451 }));
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.value
478 } 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 }
517pub 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 })
526fn 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)
536/// Bounded decoding; RFC 9110 deflate is zlib-wrapped, but raw DEFLATE is accepted too.
537pub 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 }
551#[cfg(test)]
552mod 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 machine
600 }
601 assert_eq!(capability["available"], true, "{capability}");
602 assert_eq!(capability["http3"], true);
603 }
SHA-25685a6d67fac8b36424a751b88325b35b47b9b044cbbdb0557928b393ab24c8cd7

CH-J Proprietary Software License 1.14 · 源代码依据 CH-J Proprietary Software License 1.14 提供。公开源代码不会改变许可条款,也不会授予额外权利。

压缩包 SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
文件