Julkaistu lähde
Lataa lähde ZIP
CH-J Tools
Selaile hakemistoja ja tiedostoja tietyn sovelluksen julkaisua varten.
CH-J / Server Manager
1704 tiedostot
0.1.2-beta
Lisätty: · Rakennettu:
1
//! Diagnostics history and export (port of `historyStore.js`): schema 1 file2
//! `diagnostics/history.json`, max 30 reports / 10 MiB, scrubbed of bodies, credentials,3
//! URL queries and secrets; strict import validation; JSON/CSV/TXT export with CSV formula guard.5
use crate::core::diagnostics::common::{normalize_options, DiagError, TOOLS};6
use crate::core::storage::atomic_write_json;7
use once_cell::sync::Lazy;8
use parking_lot::Mutex;9
use regex::Regex;10
use serde_json::{json, Map, Value};11
use std::net::IpAddr;12
use std::path::{Path, PathBuf};14
static SECRET_KEY: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)password|passphrase|privatekey|cookie|authoriz|authenticat|api.?key|secret|token").unwrap());15
static URLISH: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^(?:https?|wss?)://").unwrap());17
fn truncate(value: &str, max: usize) -> String {18
value.chars().take(max).collect()19
}21
pub fn scrub(value: &Value, key: &str, depth: usize) -> Result<Value, DiagError> {22
if depth > 24 {23
return Err(DiagError::new("IMPORT_INVALID"));24
}25
if SECRET_KEY.is_match(key) {26
return Ok(json!("[redacted]"));27
}28
Ok(match value {29
Value::String(text) => {30
if key == "input" || URLISH.is_match(text) {31
let candidate = if text.contains("://") { text.clone() } else { format!("https://{text}") };32
if let Ok(mut url) = url::Url::parse(&candidate) {33
let _ = url.set_username("");34
let _ = url.set_password(None);35
url.set_query(None);36
url.set_fragment(None);37
return Ok(json!(url.to_string()));38
}39
}40
if key.eq_ignore_ascii_case("location") {41
return Ok(json!(truncate(text.split(['?', '#']).next().unwrap_or(""), 16000)));42
}43
json!(truncate(text, 16000))44
}45
Value::Array(items) => Value::Array(items.iter().take(500).map(|v| scrub(v, "", depth + 1)).collect::<Result<_, _>>()?),46
Value::Object(map) => {47
let mut result = Map::new();48
for (k, v) in map {49
if ["_body", "raw", "__proto__", "constructor", "prototype"].contains(&k.as_str()) {50
continue;51
}52
result.insert(k.clone(), scrub(v, k, depth + 1)?);53
}54
Value::Object(result)55
}56
Value::Number(n) if n.as_f64().map(|f| !f.is_finite()).unwrap_or(false) => Value::Null,57
other => other.clone(),58
})59
}61
pub fn scrubbed(value: &Value) -> Value {62
scrub(value, "", 0).unwrap_or(Value::Null)63
}65
fn invalid() -> DiagError {66
DiagError::new("IMPORT_INVALID")67
}69
fn is_object(v: &Value) -> bool {70
v.is_object()71
}73
fn objects(v: &Value) -> bool {74
v.as_array().map(|a| a.len() <= 500 && a.iter().all(is_object)).unwrap_or(false)75
}77
pub fn validate_report(report: &Value) -> Result<Value, DiagError> {78
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);79
let created_ok = report["createdAt"].as_str().map(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()).unwrap_or(false);80
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"]) {81
return Err(invalid());82
}83
let mut source = report["options"].clone();84
source["target"] = report["options"]["target"]["url"].clone();85
if let Some(ports) = report["options"]["ports"].as_array() {86
source["ports"] = json!(ports.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(","));87
}88
let options = normalize_options(&source).map_err(|_| invalid())?;89
let addresses_ok = report["addresses"].as_array().map(|list| {90
list.len() <= 50091
&& list.iter().all(|a| {92
let family = a["family"].as_u64();93
match a["address"].as_str().and_then(|s| s.parse::<IpAddr>().ok()) {94
Some(IpAddr::V4(_)) => family == Some(4),95
Some(IpAddr::V6(_)) => family == Some(6),96
None => false,97
}98
})99
});100
if options.target.host != report["options"]["target"]["host"].as_str().unwrap_or("") || addresses_ok != Some(true) {101
return Err(invalid());102
}103
let array_keys = ["comparisons", "samples", "routes", "hops", "probes", "queries", "records", "packets", "ports", "redirects", "securityHeaders", "chain"];104
let mut nodes = 0usize;105
fn walk(value: &Value, depth: usize, nodes: &mut usize, array_keys: &[&str]) -> Result<(), DiagError> {106
*nodes += 1;107
if *nodes > 50_000 || depth > 24 {108
return Err(invalid());109
}110
match value {111
Value::Array(items) => {112
if items.len() > 500 {113
return Err(invalid());114
}115
for child in items {116
walk(child, depth + 1, nodes, array_keys)?;117
}118
}119
Value::Object(map) => {120
for (key, child) in map {121
if ["__proto__", "constructor", "prototype"].contains(&key.as_str()) {122
return Err(invalid());123
}124
let ports_ok = key == "ports" && child.as_array().map(|a| a.iter().all(|p| p.as_i64().is_some())).unwrap_or(false);125
if array_keys.contains(&key.as_str()) && !ports_ok && !objects(child) {126
return Err(invalid());127
}128
if (key == "addresses" || key == "errors") && !child.is_array() {129
return Err(invalid());130
}131
walk(child, depth + 1, nodes, array_keys)?;132
}133
if let Some(groups) = map.get("comparisons").and_then(Value::as_array) {134
if groups.iter().any(|g| !objects(&g["samples"])) {135
return Err(invalid());136
}137
}138
if let Some(routes) = map.get("routes").and_then(Value::as_array) {139
if routes.iter().any(|r| !objects(&r["hops"])) {140
return Err(invalid());141
}142
}143
if map.contains_key("hop") {144
let addresses_ok = map.get("addresses").and_then(Value::as_array).map(|a| a.iter().all(Value::is_string)).unwrap_or(false);145
if !addresses_ok || !objects(map.get("probes").unwrap_or(&Value::Null)) {146
return Err(invalid());147
}148
}149
}150
_ => {}151
}152
Ok(())153
}154
walk(report, 0, &mut nodes, &array_keys)?;155
for (tool, result) in report["results"].as_object().into_iter().flatten() {156
if !TOOLS.contains(&tool.as_str()) {157
return Err(invalid());158
}159
let ok = if tool == "dns" { is_object(result) || objects(result) } else { objects(result) };160
if !ok {161
return Err(invalid());162
}163
let entries = result.as_array().cloned().unwrap_or_default();164
if tool == "tcp" && entries.iter().any(|e| !e["ports"].is_null() && !objects(&e["ports"])) {165
return Err(invalid());166
}167
if (tool == "tls" || tool == "compression") && entries.iter().any(|e| !e["results"].is_null() && !objects(&e["results"])) {168
return Err(invalid());169
}170
}171
if !report["progress"].is_null() && !objects(&report["progress"]) {172
return Err(invalid());173
}174
let mut copy = report.clone();175
copy["options"] = options.json;176
scrub(©, "", 0)177
}179
fn rows(report: &Value) -> Vec<Map<String, Value>> {180
let mut result = Vec::new();181
for (tool, entries) in report["results"].as_object().into_iter().flatten() {182
let list = if entries.is_array() { entries.as_array().cloned().unwrap_or_default() } else { vec![entries.clone()] };183
for entry in list {184
let mut push = |pairs: Vec<(&str, Value)>| {185
let mut row = Map::new();186
for (k, v) in pairs {187
row.insert(k.to_string(), v);188
}189
result.push(row);190
};191
match tool.as_str() {192
"ping" => {193
for packet in entry["packets"].as_array().cloned().unwrap_or_default() {194
push(vec![("tool", json!(tool)), ("address", entry["address"].clone()), ("sequence", packet["sequence"].clone()), ("status", packet["status"].clone()), ("rttMs", packet["rttMs"].clone())]);195
}196
}197
"http" => {198
for group in entry["comparisons"].as_array().cloned().unwrap_or_default() {199
for sample in group["samples"].as_array().cloned().unwrap_or_default() {200
let t = &sample["timings"];201
let c = &sample["capability"];202
push(vec![203
("tool", json!(tool)), ("address", group["address"].clone()), ("protocol", group["protocol"].clone()), ("sequence", sample["iteration"].clone()),204
("status", sample["status"].clone()), ("httpStatus", sample["statusCode"].clone()), ("dnsMs", t["dnsMs"].clone()), ("tcpMs", t["tcpMs"].clone()),205
("tlsMs", t["tlsMs"].clone()), ("quicHandshakeMs", t["quicHandshakeMs"].clone()), ("code", sample["code"].clone()), ("clientVersion", c["version"].clone()),206
("http2", c["http2"].clone()), ("http3", c["http3"].clone()), ("tlsBackend", c["tlsBackend"].clone()), ("tlsVerified", sample["tls"]["verified"].clone()),207
("ttfbMs", t["ttfbMs"].clone()), ("totalMs", t["totalMs"].clone()), ("bytes", sample["downloadedBytes"].clone()),208
]);209
}210
}211
}212
"tcp" => {213
for port in entry["ports"].as_array().cloned().unwrap_or_default() {214
push(vec![("tool", json!(tool)), ("address", port["address"].clone()), ("port", port["port"].clone()), ("status", port["status"].clone()), ("tcpMs", port["connectionMs"].clone())]);215
}216
}217
_ => push(vec![("tool", json!(tool)), ("address", entry["address"].clone()), ("status", entry["status"].clone()), ("detail", json!(entry.to_string()))]),218
}219
}220
}221
result222
}224
fn js_string(value: &Value) -> String {225
match value {226
Value::Null => String::new(),227
Value::String(s) => s.clone(),228
other => other.to_string(),229
}230
}232
pub fn export_report(report: &Value, format: &str) -> Result<String, DiagError> {233
let safe = scrubbed(report);234
match format {235
"json" => Ok(serde_json::to_string_pretty(&json!({ "schema": 1, "report": safe })).unwrap_or_default() + "\n"),236
"txt" => Ok(format!(237
"CH-J Server Diagnostics\n{}\n{}\n\n{}\n",238
js_string(&safe["createdAt"]),239
safe["options"]["target"]["host"].as_str().unwrap_or(""),240
serde_json::to_string_pretty(&safe).unwrap_or_default()241
)),242
"csv" => {243
let table = rows(&safe);244
let mut keys: Vec<String> = Vec::new();245
for row in &table {246
for key in row.keys() {247
if !keys.contains(key) {248
keys.push(key.clone());249
}250
}251
}252
let formula = Regex::new(r"^\s*[=+@-]|^[\t\r\n]").unwrap();253
let cell = |value: &Value| {254
let mut text = js_string(value);255
if formula.is_match(&text) {256
text = format!("'{text}");257
}258
format!("\"{}\"", text.replace('"', "\"\""))259
};260
let mut lines = vec![keys.iter().map(|k| cell(&json!(k))).collect::<Vec<_>>().join(",")];261
for row in &table {262
lines.push(keys.iter().map(|k| cell(row.get(k).unwrap_or(&Value::Null))).collect::<Vec<_>>().join(","));263
}264
Ok(lines.join("\r\n") + "\r\n")265
}266
_ => Err(DiagError::new("INVALID_EXPORT_FORMAT")),267
}268
}270
pub struct DiagnosticsHistory {271
file: Option<PathBuf>,272
items: Mutex<Option<Vec<Value>>>,273
}275
impl DiagnosticsHistory {276
pub fn new(root: Option<&Path>) -> Self {277
Self { file: root.map(|r| r.join("diagnostics").join("history.json")), items: Mutex::new(None) }278
}280
fn load(&self) -> Result<(), DiagError> {281
let mut guard = self.items.lock();282
if guard.is_some() {283
return Ok(());284
}285
let Some(file) = &self.file else {286
*guard = Some(Vec::new());287
return Ok(());288
};289
let meta = match std::fs::symlink_metadata(file) {290
Ok(meta) => meta,291
Err(_) => {292
*guard = Some(Vec::new());293
return Ok(());294
}295
};296
if !meta.is_file() || meta.len() > 12 * 1024 * 1024 {297
return Err(DiagError::new("HISTORY_INVALID"));298
}299
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"))?;300
if document["schema"] != 1 || !document["items"].is_array() {301
return Err(DiagError::new("HISTORY_INVALID"));302
}303
let items = document["items"].as_array().cloned().unwrap_or_default().iter().take(30).map(validate_report).collect::<Result<Vec<_>, _>>()?;304
*guard = Some(items);305
Ok(())306
}308
fn persist(&self, items: &[Value]) -> Result<(), DiagError> {309
if let Some(file) = &self.file {310
atomic_write_json(file, &json!({ "schema": 1, "items": items })).map_err(|e| DiagError::with_message("HISTORY_WRITE_FAILED", e.message))?;311
}312
Ok(())313
}315
pub fn add(&self, report: &Value) -> Result<(), DiagError> {316
self.load()?;317
let mut guard = self.items.lock();318
let items = guard.as_mut().expect("loaded");319
items.retain(|i| i["id"] != report["id"]);320
items.insert(0, scrubbed(report));321
items.truncate(30);322
while serde_json::to_vec(&*items).map(|v| v.len()).unwrap_or(0) > 10 * 1024 * 1024 {323
items.pop();324
}325
let snapshot = items.clone();326
drop(guard);327
self.persist(&snapshot)328
}330
pub fn list(&self) -> Result<Value, DiagError> {331
self.load()?;332
let guard = self.items.lock();333
Ok(Value::Array(334
guard335
.as_ref()336
.expect("loaded")337
.iter()338
.map(|i| json!({ "id": i["id"], "createdAt": i["createdAt"], "target": i["options"]["target"], "mode": i["options"]["mode"], "tools": i["options"]["tools"], "status": i["status"] }))339
.collect(),340
))341
}343
pub fn get(&self, id: &str) -> Result<Value, DiagError> {344
self.load()?;345
let guard = self.items.lock();346
guard.as_ref().expect("loaded").iter().find(|i| i["id"] == id).map(scrubbed).ok_or_else(|| DiagError::new("HISTORY_NOT_FOUND"))347
}349
pub fn remove(&self, id: Option<&str>) -> Result<Value, DiagError> {350
self.load()?;351
let snapshot = {352
let mut guard = self.items.lock();353
let items = guard.as_mut().expect("loaded");354
match id {355
Some(id) if !id.is_empty() => items.retain(|i| i["id"] != id),356
_ => items.clear(),357
}358
items.clone()359
};360
self.persist(&snapshot)?;361
self.list()362
}364
pub fn import(&self, text: &str) -> Result<String, DiagError> {365
if text.len() > 2 * 1024 * 1024 {366
return Err(DiagError::new("IMPORT_LIMIT"));367
}368
let value: Value = serde_json::from_str(text).map_err(|_| invalid())?;369
if value["schema"] != 1 {370
return Err(invalid());371
}372
let report = validate_report(&value["report"])?;373
self.add(&report)?;374
Ok(report["id"].as_str().unwrap_or("").to_string())375
}376
}378
#[cfg(test)]379
mod tests {380
use super::*;382
fn report() -> Value {383
let options = normalize_options(&json!({"target": "https://user:pw@example.com/path?token=x"})).map(|o| o.json);384
let options = options.unwrap_or_else(|_| normalize_options(&json!({"target": "https://example.com/path?token=x"})).unwrap().json);385
json!({386
"id": "a".repeat(32), "createdAt": "2026-10-24T10:00:00.000Z", "status": "completed", "options": options,387
"addresses": [{"address": "93.184.216.34", "family": 4}],388
"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"}}]}]}]},389
"progress": []390
})391
}393
#[test]394
fn scrub_and_round_trip() {395
let safe = scrubbed(&report());396
assert!(!safe.to_string().contains("token=x"));397
assert!(!safe.to_string().contains("\"_body\""));398
assert_eq!(safe["results"]["http"][0]["comparisons"][0]["samples"][0]["headers"]["authorization"], "[redacted]");399
let dir = tempfile::tempdir().unwrap();400
let history = DiagnosticsHistory::new(Some(dir.path()));401
let exported = export_report(&safe, "json").unwrap();402
let id = history.import(&exported).unwrap();403
assert_eq!(id, "a".repeat(32));404
assert_eq!(history.list().unwrap().as_array().unwrap().len(), 1);405
let reloaded = DiagnosticsHistory::new(Some(dir.path()));406
assert_eq!(reloaded.get(&id).unwrap()["status"], "completed");407
assert!(history.import(r#"{"schema":1,"report":{"id":"x"}}"#).is_err());408
}410
#[test]411
fn csv_formula_guard() {412
let mut r = report();413
r["results"] = json!({"tcp": [{"ports": [{"address": "=cmd()", "port": 1, "status": "x"}]}]});414
let csv = export_report(&r, "csv").unwrap();415
assert!(csv.contains("\"'=cmd()\""));416
}417
}SHA-256
9bdee129aa42adcc8b87d6ad60cfb45630c3fbd6a206e4b94adb146ec93ffff1CH-J Proprietary Software License 1.14 · Lähde ilmoitetaan CH-J Proprietary Software License 1.14-koodilla. Sen saatavuus ei muuta lisenssiehtoja eikä myönnä lisäoikeuksia.
Arkisto SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f