futucli/cmd/daemon/doctor/
bundle.rs1use std::path::Path;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use anyhow::{Context, Result};
5use serde::Serialize;
6use serde_json::{Value, json};
7
8use super::DoctorReport;
9
10pub(super) fn write_doctor_bundle(report: &DoctorReport, dir: &Path) -> Result<()> {
11 std::fs::create_dir_all(dir).with_context(|| format!("create bundle dir {}", dir.display()))?;
12 let endpoints_dir = dir.join("endpoints");
13 std::fs::create_dir_all(&endpoints_dir)
14 .with_context(|| format!("create endpoints dir {}", endpoints_dir.display()))?;
15
16 let redacted_report = redacted_json(report)?;
17 write_json_file(&dir.join("doctor-report.json"), &redacted_report)?;
18
19 let findings_path = dir.join("doctor-findings.jsonl");
20 let mut findings = String::new();
21 for finding in &report.findings {
22 findings.push_str(&serde_json::to_string(finding)?);
23 findings.push('\n');
24 }
25 std::fs::write(&findings_path, redact_sensitive_text_for_bundle(&findings))
26 .with_context(|| format!("write {}", findings_path.display()))?;
27
28 let mut files = vec![
29 "doctor-report.json".to_string(),
30 "doctor-findings.jsonl".to_string(),
31 ];
32 for endpoint in &report.endpoints {
33 let base_name = bundle_file_stem(&endpoint.name);
34 if let Some(json) = &endpoint.json {
35 let mut value = json.clone();
36 redact_sensitive_json_for_bundle(&mut value);
37 let rel = format!("endpoints/{base_name}.json");
38 write_json_file(&dir.join(&rel), &value)?;
39 files.push(rel);
40 }
41 if let Some(text) = &endpoint.text_preview {
42 let rel = format!("endpoints/{base_name}.txt");
43 let path = dir.join(&rel);
44 std::fs::write(&path, redact_sensitive_text_for_bundle(text))
45 .with_context(|| format!("write {}", path.display()))?;
46 files.push(rel);
47 }
48 if let Some(error) = &endpoint.error {
49 let rel = format!("endpoints/{base_name}.error.txt");
50 let path = dir.join(&rel);
51 std::fs::write(&path, redact_sensitive_text_for_bundle(error))
52 .with_context(|| format!("write {}", path.display()))?;
53 files.push(rel);
54 }
55 }
56
57 let manifest = json!({
58 "schema_version": 1,
59 "generated_unix_secs": SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
60 "rest_url": report.rest_url,
61 "symbol": report.symbol,
62 "redaction": "sensitive keys and key=value tokens are replaced with <redacted>",
63 "files": files,
64 });
65 write_json_file(&dir.join("MANIFEST.json"), &manifest)?;
66 Ok(())
67}
68
69fn write_json_file(path: &Path, value: &Value) -> Result<()> {
70 let text = serde_json::to_string_pretty(value)?;
71 std::fs::write(path, text).with_context(|| format!("write {}", path.display()))
72}
73
74fn redacted_json<T: Serialize>(value: &T) -> Result<Value> {
75 let mut json = serde_json::to_value(value)?;
76 redact_sensitive_json_for_bundle(&mut json);
77 Ok(json)
78}
79
80fn bundle_file_stem(name: &str) -> String {
81 name.chars()
82 .map(|c| {
83 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
84 c
85 } else {
86 '-'
87 }
88 })
89 .collect()
90}
91
92pub(in crate::cmd::daemon) fn redact_sensitive_json_for_bundle(value: &mut Value) {
93 match value {
94 Value::Object(map) => {
95 for (key, child) in map.iter_mut() {
96 if is_sensitive_key(key) {
97 *child = Value::String("<redacted>".to_string());
98 } else {
99 redact_sensitive_json_for_bundle(child);
100 }
101 }
102 }
103 Value::Array(items) => {
104 for item in items {
105 redact_sensitive_json_for_bundle(item);
106 }
107 }
108 _ => {}
109 }
110}
111
112pub(in crate::cmd::daemon) fn redact_sensitive_text_for_bundle(text: &str) -> String {
113 text.split_whitespace()
114 .map(|token| {
115 let Some((key, _value)) = token.split_once('=') else {
116 return token.to_string();
117 };
118 let normalized = key.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
119 if is_sensitive_key(normalized) {
120 format!("{key}=<redacted>")
121 } else {
122 token.to_string()
123 }
124 })
125 .collect::<Vec<_>>()
126 .join(" ")
127}
128
129fn is_sensitive_key(key: &str) -> bool {
130 let k = key.to_ascii_lowercase();
131 if matches!(
132 k.as_str(),
133 "client_sig_proactive_refresh_enabled" | "client_sig_reactive_refresh_enabled"
134 ) {
135 return false;
136 }
137 k.contains("password")
138 || k == "pwd"
139 || k.contains("api_key")
140 || k.contains("token")
141 || k.contains("secret")
142 || k.contains("tgtgt")
143 || k.contains("device_sig")
144 || k.contains("dvs")
145 || k.contains("auth_code")
146 || k.contains("session_key")
147 || k.contains("client_key")
148 || k.contains("client_sig")
149 || k.contains("web_sig")
150}