Skip to main content

futucli/cmd/daemon/doctor/
bundle.rs

1use 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}