1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use futu_core::diagnostic_text::update_check_unavailable_action;
7use futu_qot::types::market_state_label;
8use serde::Serialize;
9use serde_json::{Value, json};
10
11use crate::cmd::static_diag::summarize_static_status;
12use crate::cmd::version_check::{self, UpdateDecision, UpdateLevel};
13use crate::output::OutputFormat;
14
15pub async fn run_doctor(
17 rest_url: Option<&str>,
18 api_key: Option<&str>,
19 symbol: Option<&str>,
20 bundle_dir: Option<&Path>,
21 check_update: bool,
22 update_url: Option<&str>,
23 update_timeout_ms: u64,
24 output: OutputFormat,
25) -> Result<()> {
26 let base = super::resolve_rest_url(rest_url);
27 let client = reqwest::Client::builder()
28 .timeout(std::time::Duration::from_secs(5))
29 .build()
30 .context("build reqwest client")?;
31 let mut endpoints = Vec::new();
32 endpoints.push(
33 fetch_doctor_endpoint(
34 &client,
35 &base,
36 "readyz",
37 reqwest::Method::GET,
38 "/readyz",
39 api_key,
40 None,
41 )
42 .await,
43 );
44 endpoints.push(
45 fetch_doctor_endpoint(
46 &client,
47 &base,
48 "admin-status",
49 reqwest::Method::GET,
50 "/api/admin/status",
51 api_key,
52 None,
53 )
54 .await,
55 );
56 endpoints.push(
57 fetch_doctor_endpoint(
58 &client,
59 &base,
60 "push-subscriber-info",
61 reqwest::Method::GET,
62 "/api/push-subscriber-info",
63 api_key,
64 None,
65 )
66 .await,
67 );
68 endpoints.push(
69 fetch_doctor_endpoint(
70 &client,
71 &base,
72 "sub-info",
73 reqwest::Method::GET,
74 "/api/sub-info",
75 api_key,
76 None,
77 )
78 .await,
79 );
80 endpoints.push(
81 fetch_doctor_endpoint(
82 &client,
83 &base,
84 "used-quota",
85 reqwest::Method::POST,
86 "/api/used-quota",
87 api_key,
88 Some(json!({})),
89 )
90 .await,
91 );
92 endpoints.push(
93 fetch_doctor_endpoint(
94 &client,
95 &base,
96 "history-kl-quota",
97 reqwest::Method::POST,
98 "/api/history-kl-quota",
99 api_key,
100 Some(json!({"c2s": {"b_get_detail": false}})),
101 )
102 .await,
103 );
104 endpoints.push(
105 fetch_doctor_endpoint(
106 &client,
107 &base,
108 "metrics",
109 reqwest::Method::GET,
110 "/metrics",
111 api_key,
112 None,
113 )
114 .await,
115 );
116 if let Some(symbol) = symbol {
117 endpoints.push(
118 fetch_doctor_endpoint(
119 &client,
120 &base,
121 "market-state",
122 reqwest::Method::POST,
123 "/api/market-state",
124 api_key,
125 Some(json!({"symbols": [symbol]})),
126 )
127 .await,
128 );
129 endpoints.push(
130 fetch_doctor_endpoint(
131 &client,
132 &base,
133 "quote-capability",
134 reqwest::Method::GET,
135 &format!("/api/quote-capability?symbol={symbol}"),
136 api_key,
137 None,
138 )
139 .await,
140 );
141 }
142
143 let mut findings = analyze_doctor(&endpoints, symbol);
144 if check_update {
145 findings.extend(fetch_update_check_finding(update_url, update_timeout_ms).await);
146 }
147 let report = DoctorReport {
148 rest_url: base,
149 symbol: symbol.map(str::to_string),
150 endpoints,
151 findings,
152 };
153 if let Some(dir) = bundle_dir {
154 write_doctor_bundle(&report, dir)?;
155 eprintln!("# doctor bundle written to {}", dir.display());
156 }
157 print_doctor_report(&report, output)
158}
159
160async fn fetch_update_check_finding(url: Option<&str>, timeout_ms: u64) -> Option<DoctorFinding> {
161 match version_check::check_for_update(env!("CARGO_PKG_VERSION"), url, timeout_ms).await {
162 Ok(decision) => update_check_finding_from_decision(&decision),
163 Err(err) => Some(DoctorFinding {
164 level: "INFO",
165 area: "update",
166 message: "version update check unavailable".to_string(),
167 action: format!(
168 "{err}; {}",
169 update_check_unavailable_action().text_with_code()
170 ),
171 }),
172 }
173}
174
175#[derive(Debug, Serialize)]
176pub(super) struct DoctorReport {
177 rest_url: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 symbol: Option<String>,
180 endpoints: Vec<DoctorEndpoint>,
181 findings: Vec<DoctorFinding>,
182}
183
184#[derive(Debug, Serialize)]
185pub(super) struct DoctorEndpoint {
186 pub(super) name: String,
187 pub(super) method: String,
188 pub(super) path: String,
189 pub(super) ok: bool,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub(super) http_status: Option<u16>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub(super) json: Option<Value>,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub(super) text_preview: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub(super) error: Option<String>,
198}
199
200#[derive(Debug, Serialize)]
201pub(super) struct DoctorFinding {
202 pub(super) level: &'static str,
203 pub(super) area: &'static str,
204 pub(super) message: String,
205 pub(super) action: String,
206}
207
208async fn fetch_doctor_endpoint(
209 client: &reqwest::Client,
210 base: &str,
211 name: &str,
212 method: reqwest::Method,
213 path: &str,
214 api_key: Option<&str>,
215 json_body: Option<Value>,
216) -> DoctorEndpoint {
217 let method_text = method.as_str().to_string();
218 match super::request_raw(client, base, method, path, api_key, json_body).await {
219 Ok((status, body)) => {
220 let json = serde_json::from_str::<Value>(&body).ok();
221 let text_preview = if path == "/metrics" {
222 Some(select_metrics_preview(&body))
223 } else if json.is_none() {
224 Some(preview_text(&body, 1200))
225 } else {
226 None
227 };
228 DoctorEndpoint {
229 name: name.to_string(),
230 method: method_text,
231 path: path.to_string(),
232 ok: (200..300).contains(&status),
233 http_status: Some(status),
234 json,
235 text_preview,
236 error: None,
237 }
238 }
239 Err(err) => DoctorEndpoint {
240 name: name.to_string(),
241 method: method_text,
242 path: path.to_string(),
243 ok: false,
244 http_status: None,
245 json: None,
246 text_preview: None,
247 error: Some(err.to_string()),
248 },
249 }
250}
251
252pub(super) fn analyze_doctor(
253 endpoints: &[DoctorEndpoint],
254 symbol: Option<&str>,
255) -> Vec<DoctorFinding> {
256 let mut findings = Vec::new();
257 for ep in endpoints {
258 if !ep.ok {
259 findings.push(DoctorFinding {
260 level: if matches!(
261 ep.name.as_str(),
262 "metrics" | "used-quota" | "history-kl-quota"
263 ) {
264 "WARN"
265 } else {
266 "ERROR"
267 },
268 area: "endpoint",
269 message: format!(
270 "{} {} unavailable{}",
271 ep.method,
272 ep.path,
273 ep.http_status
274 .map(|s| format!(" (HTTP {s})"))
275 .unwrap_or_default()
276 ),
277 action: ep
278 .error
279 .clone()
280 .unwrap_or_else(|| "查看 daemon 日志与 API key scope".to_string()),
281 });
282 }
283 }
284
285 if endpoint(endpoints, "readyz").is_some_and(|ep| ep.ok) {
286 findings.push(DoctorFinding {
287 level: "OK",
288 area: "readyz",
289 message: "REST readiness endpoint is reachable".to_string(),
290 action: "gateway dispatch path is ready enough for REST diagnostics".to_string(),
291 });
292 }
293
294 let market_state = endpoint_json(endpoints, "market-state").and_then(first_market_state);
295 let market_active = market_state.map(is_market_state_active);
296 if let Some(state) = market_state {
297 findings.push(DoctorFinding {
298 level: "INFO",
299 area: "market-state",
300 message: format!(
301 "{} market_state={} ({})",
302 symbol.unwrap_or("<symbol>"),
303 market_state_label(state),
304 state
305 ),
306 action: "用该状态辅助判断订阅无新 push 是闭市正常还是开市断流".to_string(),
307 });
308 }
309
310 if let Some(push_json) = endpoint_json(endpoints, "push-subscriber-info")
311 && let Some(push_health) = push_json.get("push_health")
312 {
313 if push_health
314 .get("push_stream_healthy")
315 .and_then(Value::as_bool)
316 == Some(false)
317 {
318 findings.push(DoctorFinding {
319 level: "ERROR",
320 area: "push",
321 message: "push_stream_healthy=false".to_string(),
322 action:
323 "先看 consecutive_parse_errors / circuit breaker;必要时抓 daemon debug log"
324 .to_string(),
325 });
326 }
327 if push_health
328 .get("backend_connected")
329 .and_then(Value::as_bool)
330 == Some(false)
331 {
332 findings.push(DoctorFinding {
333 level: "ERROR",
334 area: "push",
335 message: "backend_connected=false".to_string(),
336 action: "request path TCP 未连接;先确认 broker runtime 重连状态".to_string(),
337 });
338 }
339 if push_health
340 .get("last_push_received_at_ms")
341 .and_then(Value::as_i64)
342 == Some(0)
343 {
344 findings.push(DoctorFinding {
345 level: "WARN",
346 area: "push",
347 message: "daemon has not observed any backend push yet".to_string(),
348 action: "确认已订阅行情或交易推送;仅启动未订阅时可正常为 0".to_string(),
349 });
350 }
351 }
352
353 if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
354 && let Some(warning) = admin_json
355 .get("login")
356 .and_then(|login| login.get("credential"))
357 .and_then(|credential| credential.get("expiry_warning"))
358 .and_then(Value::as_str)
359 {
360 findings.push(DoctorFinding {
361 level: "WARN",
362 area: "auth",
363 message: warning.to_string(),
364 action: "优先执行 futucli daemon-reload;若已过期则用密码/SMS 重新认证".to_string(),
365 });
366 }
367
368 if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
369 && admin_json
370 .get("cache")
371 .and_then(|cache| cache.get("static_data"))
372 .is_some()
373 && let Ok(report) = summarize_static_status(admin_json)
374 && (!report.ready
375 || report.stock_list_sync_failed_total > 0
376 || report.stock_list_sync_recoverable_retry_total > 0)
377 {
378 let level = if report.ready { "INFO" } else { "WARN" };
379 findings.push(DoctorFinding {
380 level,
381 area: "static-data",
382 message: format!(
383 "stock-list readiness={} ready={} reason={}",
384 report.stock_list_readiness, report.ready, report.reason
385 ),
386 action: format!("{}; inspect with futucli static-status", report.action),
387 });
388 }
389
390 if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
391 && let Some(auth_refresh) = admin_json
392 .get("login")
393 .and_then(|login| login.get("auth_refresh"))
394 {
395 let proactive = auth_refresh
396 .get("client_sig_proactive_refresh_enabled")
397 .and_then(Value::as_bool)
398 .unwrap_or(false);
399 let reactive = auth_refresh
400 .get("client_sig_reactive_refresh_enabled")
401 .and_then(Value::as_bool)
402 .unwrap_or(false);
403 if proactive || reactive {
404 findings.push(DoctorFinding {
405 level: "INFO",
406 area: "auth",
407 message: format!(
408 "client_sig refresh opt-in enabled: proactive={proactive}, reactive={reactive}"
409 ),
410 action: "确认这是有意开启的长跑恢复配置;默认关闭,建议先用非关键账户验证"
411 .to_string(),
412 });
413 }
414 }
415
416 let subscribed_sub_types = endpoint_json(endpoints, "sub-info")
417 .map(collect_sub_types)
418 .unwrap_or_default();
419 let push_watermarks = endpoint_json(endpoints, "push-subscriber-info")
420 .and_then(|v| v.get("push_health"))
421 .map(collect_sub_type_watermarks)
422 .unwrap_or_default();
423 if subscribed_sub_types.is_empty() {
424 findings.push(DoctorFinding {
425 level: "INFO",
426 area: "subscription",
427 message: "no subscribed SubType found in /api/sub-info".to_string(),
428 action: "若期待行情 push,先执行 futucli sub 或 REST /api/subscribe".to_string(),
429 });
430 } else {
431 for sub_type in subscribed_sub_types {
432 if !push_watermarks.contains_key(&sub_type) {
433 findings.push(missing_sub_type_finding(sub_type, market_active, symbol));
434 }
435 }
436 }
437
438 if let Some(metrics) = endpoint(endpoints, "metrics")
439 && metrics.ok
440 && !metrics
441 .text_preview
442 .as_deref()
443 .unwrap_or_default()
444 .contains("futu_gateway_push_health_stream_healthy")
445 {
446 findings.push(DoctorFinding {
447 level: "WARN",
448 area: "metrics",
449 message: "/metrics reachable but push health metrics were not found".to_string(),
450 action: "确认 daemon 版本包含 v1.4.113 push-health Prometheus renderer".to_string(),
451 });
452 }
453
454 if let Some(capability_json) = endpoint_json(endpoints, "quote-capability") {
455 findings.extend(quote_capability_decision_findings(capability_json));
456 }
457
458 findings
459}
460
461pub(super) fn update_check_finding_from_decision(
462 decision: &UpdateDecision,
463) -> Option<DoctorFinding> {
464 match decision.level {
465 UpdateLevel::UpToDate => None,
466 UpdateLevel::Info => Some(DoctorFinding {
467 level: "INFO",
468 area: "update",
469 message: decision.message.clone(),
470 action: decision.action.clone(),
471 }),
472 UpdateLevel::Recommended => Some(DoctorFinding {
473 level: "WARN",
474 area: "update",
475 message: decision.message.clone(),
476 action: decision.action.clone(),
477 }),
478 UpdateLevel::Critical => Some(DoctorFinding {
479 level: "ERROR",
480 area: "update",
481 message: decision.message.clone(),
482 action: decision.action.clone(),
483 }),
484 }
485}
486
487fn endpoint<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a DoctorEndpoint> {
488 endpoints.iter().find(|ep| ep.name == name)
489}
490
491fn endpoint_json<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a Value> {
492 endpoint(endpoints, name).and_then(|ep| ep.json.as_ref())
493}
494
495fn missing_sub_type_finding(
496 sub_type: i64,
497 market_active: Option<bool>,
498 symbol: Option<&str>,
499) -> DoctorFinding {
500 match market_active {
501 Some(true) => DoctorFinding {
502 level: "WARN",
503 area: "subscription",
504 message: format!(
505 "SubType {sub_type} is subscribed but has no regular push watermark while {} is active",
506 symbol.unwrap_or("the reference symbol")
507 ),
508 action: "开市仍无该类型 push:确认订阅参数、backend push 通道;必要时 /api/admin/reload 后重订阅".to_string(),
509 },
510 Some(false) => DoctorFinding {
511 level: "INFO",
512 area: "subscription",
513 message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
514 action: "参考 symbol 当前非连续交易时段;闭市/午休/盘前结束时部分 SubType 无新数据通常正常".to_string(),
515 },
516 None => DoctorFinding {
517 level: "INFO",
518 area: "subscription",
519 message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
520 action: "未传 --symbol,无法区分闭市正常与开市断流;可加 --symbol HK.00700 或查 /api/market-state".to_string(),
521 },
522 }
523}
524
525fn collect_sub_types(value: &Value) -> BTreeSet<i64> {
526 let mut out = BTreeSet::new();
527 collect_i64_key(value, "sub_type", &mut out);
528 out
529}
530
531fn collect_sub_type_watermarks(push_health: &Value) -> BTreeMap<i64, i64> {
532 let mut out = BTreeMap::new();
533 if let Some(items) = push_health
534 .get("last_push_by_sub_type")
535 .and_then(Value::as_array)
536 {
537 for item in items {
538 if let (Some(sub_type), Some(at_ms)) = (
539 item.get("sub_type").and_then(Value::as_i64),
540 item.get("last_push_received_at_ms").and_then(Value::as_i64),
541 ) {
542 out.insert(sub_type, at_ms);
543 }
544 }
545 }
546 out
547}
548
549fn collect_i64_key(value: &Value, key: &str, out: &mut BTreeSet<i64>) {
550 match value {
551 Value::Object(map) => {
552 if let Some(v) = map.get(key).and_then(Value::as_i64) {
553 out.insert(v);
554 }
555 for child in map.values() {
556 collect_i64_key(child, key, out);
557 }
558 }
559 Value::Array(items) => {
560 for child in items {
561 collect_i64_key(child, key, out);
562 }
563 }
564 _ => {}
565 }
566}
567
568fn quote_capability_decision_findings(value: &Value) -> Vec<DoctorFinding> {
569 let Some(decisions) = value.get("decisions").and_then(Value::as_array) else {
570 return Vec::new();
571 };
572 decisions
573 .iter()
574 .filter_map(|decision| {
575 let allowed = decision.get("allowed").and_then(Value::as_bool)?;
576 if allowed {
577 return None;
578 }
579 let topic = decision
580 .get("topic")
581 .and_then(Value::as_str)
582 .unwrap_or("unknown");
583 let reason = decision
584 .get("reason")
585 .and_then(Value::as_str)
586 .unwrap_or("blocked by quote capability decision");
587 let source = decision
588 .get("source")
589 .and_then(Value::as_str)
590 .unwrap_or("unknown");
591 let freshness = decision
592 .get("freshness")
593 .and_then(Value::as_str)
594 .unwrap_or("unknown");
595 let action = decision
596 .get("action")
597 .and_then(Value::as_str)
598 .unwrap_or("inspect futucli quote-rights and quote-capability")
599 .to_string();
600 Some(DoctorFinding {
601 level: "WARN",
602 area: "quote-capability",
603 message: format!(
604 "{topic} blocked: {reason} (source={source}, freshness={freshness})"
605 ),
606 action,
607 })
608 })
609 .collect()
610}
611
612fn first_market_state(value: &Value) -> Option<i32> {
613 let mut values = BTreeSet::new();
614 collect_i64_key(value, "market_state", &mut values);
615 values
616 .into_iter()
617 .next()
618 .and_then(|v| i32::try_from(v).ok())
619}
620
621fn is_market_state_active(state: i32) -> bool {
622 matches!(
623 state,
624 1 | 3 | 5 | 8 | 10 | 13 | 15 | 19 | 21 | 23 | 25 | 28 | 32 | 33 | 35 | 36 | 37
625 )
626}
627
628fn print_doctor_report(report: &DoctorReport, output: OutputFormat) -> Result<()> {
629 match output {
630 OutputFormat::Json => {
631 println!("{}", serde_json::to_string_pretty(report)?);
632 }
633 OutputFormat::Jsonl => {
634 println!("{}", serde_json::to_string(report)?);
635 }
636 OutputFormat::Table | OutputFormat::Markdown => {
637 println!("futucli doctor");
638 println!("REST: {}", report.rest_url);
639 if let Some(symbol) = &report.symbol {
640 println!("Symbol: {symbol}");
641 }
642 println!();
643 println!("Endpoints:");
644 for ep in &report.endpoints {
645 let status = ep
646 .http_status
647 .map(|s| s.to_string())
648 .unwrap_or_else(|| "-".to_string());
649 let mark = if ep.ok { "OK" } else { "FAIL" };
650 println!("- [{mark}] {} {} HTTP {}", ep.method, ep.path, status);
651 if let Some(error) = &ep.error {
652 println!(" error: {error}");
653 }
654 }
655 println!();
656 println!("Findings:");
657 if report.findings.is_empty() {
658 println!("- [OK] no actionable findings");
659 } else {
660 for finding in &report.findings {
661 println!(
662 "- [{}] {}: {}",
663 finding.level, finding.area, finding.message
664 );
665 println!(" next: {}", finding.action);
666 }
667 }
668 }
669 }
670 Ok(())
671}
672
673fn select_metrics_preview(body: &str) -> String {
674 let selected: Vec<&str> = body
675 .lines()
676 .filter(|line| {
677 line.contains("futu_gateway_push_health")
678 || line.contains("futu_gateway_broker_tcp")
679 || line.contains("futu_metrics_registry")
680 })
681 .collect();
682 if selected.is_empty() {
683 preview_text(body, 1200)
684 } else {
685 selected.join("\n")
686 }
687}
688
689fn preview_text(body: &str, max_chars: usize) -> String {
690 body.chars().take(max_chars).collect()
691}
692
693fn write_doctor_bundle(report: &DoctorReport, dir: &Path) -> Result<()> {
694 std::fs::create_dir_all(dir).with_context(|| format!("create bundle dir {}", dir.display()))?;
695 let endpoints_dir = dir.join("endpoints");
696 std::fs::create_dir_all(&endpoints_dir)
697 .with_context(|| format!("create endpoints dir {}", endpoints_dir.display()))?;
698
699 let redacted_report = redacted_json(report)?;
700 write_json_file(&dir.join("doctor-report.json"), &redacted_report)?;
701
702 let findings_path = dir.join("doctor-findings.jsonl");
703 let mut findings = String::new();
704 for finding in &report.findings {
705 findings.push_str(&serde_json::to_string(finding)?);
706 findings.push('\n');
707 }
708 std::fs::write(&findings_path, redact_sensitive_text_for_bundle(&findings))
709 .with_context(|| format!("write {}", findings_path.display()))?;
710
711 let mut files = vec![
712 "doctor-report.json".to_string(),
713 "doctor-findings.jsonl".to_string(),
714 ];
715 for endpoint in &report.endpoints {
716 let base_name = bundle_file_stem(&endpoint.name);
717 if let Some(json) = &endpoint.json {
718 let mut value = json.clone();
719 redact_sensitive_json_for_bundle(&mut value);
720 let rel = format!("endpoints/{base_name}.json");
721 write_json_file(&dir.join(&rel), &value)?;
722 files.push(rel);
723 }
724 if let Some(text) = &endpoint.text_preview {
725 let rel = format!("endpoints/{base_name}.txt");
726 let path = dir.join(&rel);
727 std::fs::write(&path, redact_sensitive_text_for_bundle(text))
728 .with_context(|| format!("write {}", path.display()))?;
729 files.push(rel);
730 }
731 if let Some(error) = &endpoint.error {
732 let rel = format!("endpoints/{base_name}.error.txt");
733 let path = dir.join(&rel);
734 std::fs::write(&path, redact_sensitive_text_for_bundle(error))
735 .with_context(|| format!("write {}", path.display()))?;
736 files.push(rel);
737 }
738 }
739
740 let manifest = json!({
741 "schema_version": 1,
742 "generated_unix_secs": SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
743 "rest_url": report.rest_url,
744 "symbol": report.symbol,
745 "redaction": "sensitive keys and key=value tokens are replaced with <redacted>",
746 "files": files,
747 });
748 write_json_file(&dir.join("MANIFEST.json"), &manifest)?;
749 Ok(())
750}
751
752fn write_json_file(path: &Path, value: &Value) -> Result<()> {
753 let text = serde_json::to_string_pretty(value)?;
754 std::fs::write(path, text).with_context(|| format!("write {}", path.display()))
755}
756
757fn redacted_json<T: Serialize>(value: &T) -> Result<Value> {
758 let mut json = serde_json::to_value(value)?;
759 redact_sensitive_json_for_bundle(&mut json);
760 Ok(json)
761}
762
763fn bundle_file_stem(name: &str) -> String {
764 name.chars()
765 .map(|c| {
766 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
767 c
768 } else {
769 '-'
770 }
771 })
772 .collect()
773}
774
775pub(super) fn redact_sensitive_json_for_bundle(value: &mut Value) {
776 match value {
777 Value::Object(map) => {
778 for (key, child) in map.iter_mut() {
779 if is_sensitive_key(key) {
780 *child = Value::String("<redacted>".to_string());
781 } else {
782 redact_sensitive_json_for_bundle(child);
783 }
784 }
785 }
786 Value::Array(items) => {
787 for item in items {
788 redact_sensitive_json_for_bundle(item);
789 }
790 }
791 _ => {}
792 }
793}
794
795pub(super) fn redact_sensitive_text_for_bundle(text: &str) -> String {
796 text.split_whitespace()
797 .map(|token| {
798 let Some((key, _value)) = token.split_once('=') else {
799 return token.to_string();
800 };
801 let normalized = key.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
802 if is_sensitive_key(normalized) {
803 format!("{key}=<redacted>")
804 } else {
805 token.to_string()
806 }
807 })
808 .collect::<Vec<_>>()
809 .join(" ")
810}
811
812fn is_sensitive_key(key: &str) -> bool {
813 let k = key.to_ascii_lowercase();
814 if matches!(
815 k.as_str(),
816 "client_sig_proactive_refresh_enabled" | "client_sig_reactive_refresh_enabled"
817 ) {
818 return false;
819 }
820 k.contains("password")
821 || k == "pwd"
822 || k.contains("api_key")
823 || k.contains("token")
824 || k.contains("secret")
825 || k.contains("tgtgt")
826 || k.contains("device_sig")
827 || k.contains("dvs")
828 || k.contains("auth_code")
829 || k.contains("session_key")
830 || k.contains("client_key")
831 || k.contains("client_sig")
832 || k.contains("web_sig")
833}