//! Diagnostics history and export (port of `historyStore.js`): schema 1 file //! `diagnostics/history.json`, max 30 reports / 10 MiB, scrubbed of bodies, credentials, //! URL queries and secrets; strict import validation; JSON/CSV/TXT export with CSV formula guard. use crate::core::diagnostics::common::{normalize_options, DiagError, TOOLS}; use crate::core::storage::atomic_write_json; use once_cell::sync::Lazy; use parking_lot::Mutex; use regex::Regex; use serde_json::{json, Map, Value}; use std::net::IpAddr; use std::path::{Path, PathBuf}; static SECRET_KEY: Lazy = Lazy::new(|| Regex::new(r"(?i)password|passphrase|privatekey|cookie|authoriz|authenticat|api.?key|secret|token").unwrap()); static URLISH: Lazy = Lazy::new(|| Regex::new(r"(?i)^(?:https?|wss?)://").unwrap()); fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() } pub fn scrub(value: &Value, key: &str, depth: usize) -> Result { if depth > 24 { return Err(DiagError::new("IMPORT_INVALID")); } if SECRET_KEY.is_match(key) { return Ok(json!("[redacted]")); } Ok(match value { Value::String(text) => { if key == "input" || URLISH.is_match(text) { let candidate = if text.contains("://") { text.clone() } else { format!("https://{text}") }; if let Ok(mut url) = url::Url::parse(&candidate) { let _ = url.set_username(""); let _ = url.set_password(None); url.set_query(None); url.set_fragment(None); return Ok(json!(url.to_string())); } } if key.eq_ignore_ascii_case("location") { return Ok(json!(truncate(text.split(['?', '#']).next().unwrap_or(""), 16000))); } json!(truncate(text, 16000)) } Value::Array(items) => Value::Array(items.iter().take(500).map(|v| scrub(v, "", depth + 1)).collect::>()?), Value::Object(map) => { let mut result = Map::new(); for (k, v) in map { if ["_body", "raw", "__proto__", "constructor", "prototype"].contains(&k.as_str()) { continue; } result.insert(k.clone(), scrub(v, k, depth + 1)?); } Value::Object(result) } Value::Number(n) if n.as_f64().map(|f| !f.is_finite()).unwrap_or(false) => Value::Null, other => other.clone(), }) } pub fn scrubbed(value: &Value) -> Value { scrub(value, "", 0).unwrap_or(Value::Null) } fn invalid() -> DiagError { DiagError::new("IMPORT_INVALID") } fn is_object(v: &Value) -> bool { v.is_object() } fn objects(v: &Value) -> bool { v.as_array().map(|a| a.len() <= 500 && a.iter().all(is_object)).unwrap_or(false) } pub fn validate_report(report: &Value) -> Result { let id_ok = report["id"].as_str().map(|s| s.len() == 32 && s.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))).unwrap_or(false); let created_ok = report["createdAt"].as_str().map(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()).unwrap_or(false); if !is_object(report) || !id_ok || !created_ok || !["completed", "cancelled", "failed"].contains(&report["status"].as_str().unwrap_or("")) || !is_object(&report["options"]) || !is_object(&report["options"]["target"]) || !is_object(&report["results"]) { return Err(invalid()); } let mut source = report["options"].clone(); source["target"] = report["options"]["target"]["url"].clone(); if let Some(ports) = report["options"]["ports"].as_array() { source["ports"] = json!(ports.iter().map(|p| p.to_string()).collect::>().join(",")); } let options = normalize_options(&source).map_err(|_| invalid())?; let addresses_ok = report["addresses"].as_array().map(|list| { list.len() <= 500 && list.iter().all(|a| { let family = a["family"].as_u64(); match a["address"].as_str().and_then(|s| s.parse::().ok()) { Some(IpAddr::V4(_)) => family == Some(4), Some(IpAddr::V6(_)) => family == Some(6), None => false, } }) }); if options.target.host != report["options"]["target"]["host"].as_str().unwrap_or("") || addresses_ok != Some(true) { return Err(invalid()); } let array_keys = ["comparisons", "samples", "routes", "hops", "probes", "queries", "records", "packets", "ports", "redirects", "securityHeaders", "chain"]; let mut nodes = 0usize; fn walk(value: &Value, depth: usize, nodes: &mut usize, array_keys: &[&str]) -> Result<(), DiagError> { *nodes += 1; if *nodes > 50_000 || depth > 24 { return Err(invalid()); } match value { Value::Array(items) => { if items.len() > 500 { return Err(invalid()); } for child in items { walk(child, depth + 1, nodes, array_keys)?; } } Value::Object(map) => { for (key, child) in map { if ["__proto__", "constructor", "prototype"].contains(&key.as_str()) { return Err(invalid()); } let ports_ok = key == "ports" && child.as_array().map(|a| a.iter().all(|p| p.as_i64().is_some())).unwrap_or(false); if array_keys.contains(&key.as_str()) && !ports_ok && !objects(child) { return Err(invalid()); } if (key == "addresses" || key == "errors") && !child.is_array() { return Err(invalid()); } walk(child, depth + 1, nodes, array_keys)?; } if let Some(groups) = map.get("comparisons").and_then(Value::as_array) { if groups.iter().any(|g| !objects(&g["samples"])) { return Err(invalid()); } } if let Some(routes) = map.get("routes").and_then(Value::as_array) { if routes.iter().any(|r| !objects(&r["hops"])) { return Err(invalid()); } } if map.contains_key("hop") { let addresses_ok = map.get("addresses").and_then(Value::as_array).map(|a| a.iter().all(Value::is_string)).unwrap_or(false); if !addresses_ok || !objects(map.get("probes").unwrap_or(&Value::Null)) { return Err(invalid()); } } } _ => {} } Ok(()) } walk(report, 0, &mut nodes, &array_keys)?; for (tool, result) in report["results"].as_object().into_iter().flatten() { if !TOOLS.contains(&tool.as_str()) { return Err(invalid()); } let ok = if tool == "dns" { is_object(result) || objects(result) } else { objects(result) }; if !ok { return Err(invalid()); } let entries = result.as_array().cloned().unwrap_or_default(); if tool == "tcp" && entries.iter().any(|e| !e["ports"].is_null() && !objects(&e["ports"])) { return Err(invalid()); } if (tool == "tls" || tool == "compression") && entries.iter().any(|e| !e["results"].is_null() && !objects(&e["results"])) { return Err(invalid()); } } if !report["progress"].is_null() && !objects(&report["progress"]) { return Err(invalid()); } let mut copy = report.clone(); copy["options"] = options.json; scrub(©, "", 0) } fn rows(report: &Value) -> Vec> { let mut result = Vec::new(); for (tool, entries) in report["results"].as_object().into_iter().flatten() { let list = if entries.is_array() { entries.as_array().cloned().unwrap_or_default() } else { vec![entries.clone()] }; for entry in list { let mut push = |pairs: Vec<(&str, Value)>| { let mut row = Map::new(); for (k, v) in pairs { row.insert(k.to_string(), v); } result.push(row); }; match tool.as_str() { "ping" => { for packet in entry["packets"].as_array().cloned().unwrap_or_default() { push(vec![("tool", json!(tool)), ("address", entry["address"].clone()), ("sequence", packet["sequence"].clone()), ("status", packet["status"].clone()), ("rttMs", packet["rttMs"].clone())]); } } "http" => { for group in entry["comparisons"].as_array().cloned().unwrap_or_default() { for sample in group["samples"].as_array().cloned().unwrap_or_default() { let t = &sample["timings"]; let c = &sample["capability"]; push(vec![ ("tool", json!(tool)), ("address", group["address"].clone()), ("protocol", group["protocol"].clone()), ("sequence", sample["iteration"].clone()), ("status", sample["status"].clone()), ("httpStatus", sample["statusCode"].clone()), ("dnsMs", t["dnsMs"].clone()), ("tcpMs", t["tcpMs"].clone()), ("tlsMs", t["tlsMs"].clone()), ("quicHandshakeMs", t["quicHandshakeMs"].clone()), ("code", sample["code"].clone()), ("clientVersion", c["version"].clone()), ("http2", c["http2"].clone()), ("http3", c["http3"].clone()), ("tlsBackend", c["tlsBackend"].clone()), ("tlsVerified", sample["tls"]["verified"].clone()), ("ttfbMs", t["ttfbMs"].clone()), ("totalMs", t["totalMs"].clone()), ("bytes", sample["downloadedBytes"].clone()), ]); } } } "tcp" => { for port in entry["ports"].as_array().cloned().unwrap_or_default() { push(vec![("tool", json!(tool)), ("address", port["address"].clone()), ("port", port["port"].clone()), ("status", port["status"].clone()), ("tcpMs", port["connectionMs"].clone())]); } } _ => push(vec![("tool", json!(tool)), ("address", entry["address"].clone()), ("status", entry["status"].clone()), ("detail", json!(entry.to_string()))]), } } } result } fn js_string(value: &Value) -> String { match value { Value::Null => String::new(), Value::String(s) => s.clone(), other => other.to_string(), } } pub fn export_report(report: &Value, format: &str) -> Result { let safe = scrubbed(report); match format { "json" => Ok(serde_json::to_string_pretty(&json!({ "schema": 1, "report": safe })).unwrap_or_default() + "\n"), "txt" => Ok(format!( "CH-J Server Diagnostics\n{}\n{}\n\n{}\n", js_string(&safe["createdAt"]), safe["options"]["target"]["host"].as_str().unwrap_or(""), serde_json::to_string_pretty(&safe).unwrap_or_default() )), "csv" => { let table = rows(&safe); let mut keys: Vec = Vec::new(); for row in &table { for key in row.keys() { if !keys.contains(key) { keys.push(key.clone()); } } } let formula = Regex::new(r"^\s*[=+@-]|^[\t\r\n]").unwrap(); let cell = |value: &Value| { let mut text = js_string(value); if formula.is_match(&text) { text = format!("'{text}"); } format!("\"{}\"", text.replace('"', "\"\"")) }; let mut lines = vec![keys.iter().map(|k| cell(&json!(k))).collect::>().join(",")]; for row in &table { lines.push(keys.iter().map(|k| cell(row.get(k).unwrap_or(&Value::Null))).collect::>().join(",")); } Ok(lines.join("\r\n") + "\r\n") } _ => Err(DiagError::new("INVALID_EXPORT_FORMAT")), } } pub struct DiagnosticsHistory { file: Option, items: Mutex>>, } impl DiagnosticsHistory { pub fn new(root: Option<&Path>) -> Self { Self { file: root.map(|r| r.join("diagnostics").join("history.json")), items: Mutex::new(None) } } fn load(&self) -> Result<(), DiagError> { let mut guard = self.items.lock(); if guard.is_some() { return Ok(()); } let Some(file) = &self.file else { *guard = Some(Vec::new()); return Ok(()); }; let meta = match std::fs::symlink_metadata(file) { Ok(meta) => meta, Err(_) => { *guard = Some(Vec::new()); return Ok(()); } }; if !meta.is_file() || meta.len() > 12 * 1024 * 1024 { return Err(DiagError::new("HISTORY_INVALID")); } let document: Value = serde_json::from_str(&std::fs::read_to_string(file).map_err(|_| DiagError::new("HISTORY_INVALID"))?).map_err(|_| DiagError::new("HISTORY_INVALID"))?; if document["schema"] != 1 || !document["items"].is_array() { return Err(DiagError::new("HISTORY_INVALID")); } let items = document["items"].as_array().cloned().unwrap_or_default().iter().take(30).map(validate_report).collect::, _>>()?; *guard = Some(items); Ok(()) } fn persist(&self, items: &[Value]) -> Result<(), DiagError> { if let Some(file) = &self.file { atomic_write_json(file, &json!({ "schema": 1, "items": items })).map_err(|e| DiagError::with_message("HISTORY_WRITE_FAILED", e.message))?; } Ok(()) } pub fn add(&self, report: &Value) -> Result<(), DiagError> { self.load()?; let mut guard = self.items.lock(); let items = guard.as_mut().expect("loaded"); items.retain(|i| i["id"] != report["id"]); items.insert(0, scrubbed(report)); items.truncate(30); while serde_json::to_vec(&*items).map(|v| v.len()).unwrap_or(0) > 10 * 1024 * 1024 { items.pop(); } let snapshot = items.clone(); drop(guard); self.persist(&snapshot) } pub fn list(&self) -> Result { self.load()?; let guard = self.items.lock(); Ok(Value::Array( guard .as_ref() .expect("loaded") .iter() .map(|i| json!({ "id": i["id"], "createdAt": i["createdAt"], "target": i["options"]["target"], "mode": i["options"]["mode"], "tools": i["options"]["tools"], "status": i["status"] })) .collect(), )) } pub fn get(&self, id: &str) -> Result { self.load()?; let guard = self.items.lock(); guard.as_ref().expect("loaded").iter().find(|i| i["id"] == id).map(scrubbed).ok_or_else(|| DiagError::new("HISTORY_NOT_FOUND")) } pub fn remove(&self, id: Option<&str>) -> Result { self.load()?; let snapshot = { let mut guard = self.items.lock(); let items = guard.as_mut().expect("loaded"); match id { Some(id) if !id.is_empty() => items.retain(|i| i["id"] != id), _ => items.clear(), } items.clone() }; self.persist(&snapshot)?; self.list() } pub fn import(&self, text: &str) -> Result { if text.len() > 2 * 1024 * 1024 { return Err(DiagError::new("IMPORT_LIMIT")); } let value: Value = serde_json::from_str(text).map_err(|_| invalid())?; if value["schema"] != 1 { return Err(invalid()); } let report = validate_report(&value["report"])?; self.add(&report)?; Ok(report["id"].as_str().unwrap_or("").to_string()) } } #[cfg(test)] mod tests { use super::*; fn report() -> Value { let options = normalize_options(&json!({"target": "https://user:pw@example.com/path?token=x"})).map(|o| o.json); let options = options.unwrap_or_else(|_| normalize_options(&json!({"target": "https://example.com/path?token=x"})).unwrap().json); json!({ "id": "a".repeat(32), "createdAt": "2026-10-24T10:00:00.000Z", "status": "completed", "options": options, "addresses": [{"address": "93.184.216.34", "family": 4}], "results": {"tcp": [{"address": "93.184.216.34", "ports": [{"address": "93.184.216.34", "port": 443, "status": "connected", "connectionMs": 1.5}]}], "http": [{"comparisons": [{"protocol": "3", "address": "93.184.216.34", "samples": [{"iteration": 0, "status": "success", "_body": "secret", "headers": {"authorization": "x"}}]}]}]}, "progress": [] }) } #[test] fn scrub_and_round_trip() { let safe = scrubbed(&report()); assert!(!safe.to_string().contains("token=x")); assert!(!safe.to_string().contains("\"_body\"")); assert_eq!(safe["results"]["http"][0]["comparisons"][0]["samples"][0]["headers"]["authorization"], "[redacted]"); let dir = tempfile::tempdir().unwrap(); let history = DiagnosticsHistory::new(Some(dir.path())); let exported = export_report(&safe, "json").unwrap(); let id = history.import(&exported).unwrap(); assert_eq!(id, "a".repeat(32)); assert_eq!(history.list().unwrap().as_array().unwrap().len(), 1); let reloaded = DiagnosticsHistory::new(Some(dir.path())); assert_eq!(reloaded.get(&id).unwrap()["status"], "completed"); assert!(history.import(r#"{"schema":1,"report":{"id":"x"}}"#).is_err()); } #[test] fn csv_formula_guard() { let mut r = report(); r["results"] = json!({"tcp": [{"ports": [{"address": "=cmd()", "port": 1, "status": "x"}]}]}); let csv = export_report(&r, "csv").unwrap(); assert!(csv.contains("\"'=cmd()\"")); } }